Add `incompatible_language_version_bootclasspath` flag

Copybara Import from https://github.com/bazelbuild/rules_java/pull/182

BEGIN_PUBLIC
Add `incompatible_language_version_bootclasspath` flag (#182)

With `--@rules_java//java:incompatible_language_version_bootclasspath`, the bootclasspath used for Java compilation is now determined based on the numeric version specified in `--java_language_version` and the type specified in `--java_runtime_version` rather than just using the target runtime.

For example, with `--java_language_version=8` and
`--java_runtime_version=remotejdk_21`, the bootclasspath would be extracted from `remotejdk_8`.

For unversioned runtime versions such as `local_jdk`, the behavior doesn't change.

If a matching runtime is not available, analysis fails with a customized error message explaining the various options to the user.

Work towards https://github.com/bazelbuild/bazel/discussions/21769

Closes #182
END_PUBLIC

COPYBARA_INTEGRATE_REVIEW=https://github.com/bazelbuild/rules_java/pull/182 from fmeum:java-language-version de275069b6c98919264f2bfce791dde56eaefcde
PiperOrigin-RevId: 703396804
Change-Id: Ieb1def97a5eee59763336eb61deaf584f341a1d4
diff --git a/test/analysis/bootclasspath_tests.bzl b/test/analysis/bootclasspath_tests.bzl
index c862d83..5eb436a 100644
--- a/test/analysis/bootclasspath_tests.bzl
+++ b/test/analysis/bootclasspath_tests.bzl
@@ -2,6 +2,7 @@
 
 load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite")
 load("@rules_testing//lib:truth.bzl", "subjects")
+load("//java/common:java_common.bzl", "java_common")
 
 def _test_utf_8_environment(name):
     analysis_test(
@@ -16,10 +17,61 @@
         env_subject.keys().contains("LC_CTYPE")
         env_subject.get("LC_CTYPE", factory = subjects.str).contains("UTF-8")
 
+def _test_incompatible_language_version_bootclasspath_disabled(name):
+    analysis_test(
+        name = name,
+        impl = _test_incompatible_language_version_bootclasspath_disabled_impl,
+        target = Label("//toolchains:platformclasspath"),
+        config_settings = {
+            "//command_line_option:java_language_version": "11",
+            "//command_line_option:java_runtime_version": "remotejdk_17",
+            str(Label("//toolchains:incompatible_language_version_bootclasspath")): False,
+        },
+    )
+
+def _test_incompatible_language_version_bootclasspath_disabled_impl(env, target):
+    system_path = target[java_common.BootClassPathInfo]._system_path
+    env.expect.that_str(system_path).contains("remotejdk17_")
+
+def _test_incompatible_language_version_bootclasspath_enabled_versioned(name):
+    analysis_test(
+        name = name,
+        impl = _test_incompatible_language_version_bootclasspath_enabled_versioned_impl,
+        target = Label("//toolchains:platformclasspath"),
+        config_settings = {
+            "//command_line_option:java_language_version": "11",
+            "//command_line_option:java_runtime_version": "remotejdk_17",
+            str(Label("//toolchains:incompatible_language_version_bootclasspath")): True,
+        },
+    )
+
+def _test_incompatible_language_version_bootclasspath_enabled_versioned_impl(env, target):
+    system_path = target[java_common.BootClassPathInfo]._system_path
+    env.expect.that_str(system_path).contains("remotejdk11_")
+
+def _test_incompatible_language_version_bootclasspath_enabled_unversioned(name):
+    analysis_test(
+        name = name,
+        impl = _test_incompatible_language_version_bootclasspath_enabled_unversioned_impl,
+        target = Label("//toolchains:platformclasspath"),
+        config_settings = {
+            "//command_line_option:java_language_version": "11",
+            "//command_line_option:java_runtime_version": "local_jdk",
+            str(Label("//toolchains:incompatible_language_version_bootclasspath")): True,
+        },
+    )
+
+def _test_incompatible_language_version_bootclasspath_enabled_unversioned_impl(env, target):
+    system_path = target[java_common.BootClassPathInfo]._system_path
+    env.expect.that_str(system_path).contains("local_jdk")
+
 def bootclasspath_tests(name):
     test_suite(
         name = name,
         tests = [
             _test_utf_8_environment,
+            _test_incompatible_language_version_bootclasspath_disabled,
+            _test_incompatible_language_version_bootclasspath_enabled_versioned,
+            _test_incompatible_language_version_bootclasspath_enabled_unversioned,
         ],
     )
diff --git a/toolchains/BUILD b/toolchains/BUILD
index e7f4d84..99c4456 100644
--- a/toolchains/BUILD
+++ b/toolchains/BUILD
@@ -1,10 +1,15 @@
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "string_setting")
 load("@rules_cc//cc:cc_library.bzl", "cc_library")
 load(
+    ":bootclasspath.bzl",
+    "bootclasspath",
+    "language_version_bootstrap_runtime",
+)
+load(
     ":default_java_toolchain.bzl",
     "DEFAULT_TOOLCHAIN_CONFIGURATION",
     "PREBUILT_TOOLCHAIN_CONFIGURATION",
-    "bootclasspath",
     "default_java_toolchain",
     "java_runtime_files",
 )
@@ -31,6 +36,15 @@
     srcs = glob(["*.bzl"]),
 )
 
+# If enabled, the bootclasspath for Java compilation will be extracted from a Java runtime matching
+# the version specified with `--java_language_version` rather than the runtime specified with
+# `--java_runtime_version`.
+bool_flag(
+    name = "incompatible_language_version_bootclasspath",
+    build_setting_default = False,
+    visibility = ["//visibility:private"],
+)
+
 # A single binary distribution of a JDK (e.g., OpenJDK 17 for Windows arm64) provides three
 # different types of toolchains from the perspective of Bazel:
 
@@ -283,15 +297,46 @@
     }),
 )
 
+string_setting(
+    name = "java_language_version",
+    build_setting_default = "",
+    visibility = ["//visibility:private"],
+)
+
+string_setting(
+    name = "java_runtime_version",
+    build_setting_default = "",
+    visibility = ["//visibility:private"],
+)
+
+language_version_bootstrap_runtime(
+    name = "language_version_bootstrap_runtime",
+    java_language_version = ":java_language_version",
+    java_runtime_version = ":java_runtime_version",
+    visibility = ["//visibility:private"],
+)
+
 utf8_environment(
     name = "utf8_environment",
     visibility = ["//visibility:private"],
 )
 
+config_setting(
+    name = "incompatible_language_version_bootclasspath_enabled",
+    flag_values = {
+        ":incompatible_language_version_bootclasspath": "True",
+    },
+    visibility = ["//visibility:private"],
+)
+
 bootclasspath(
     name = "platformclasspath",
     src = "DumpPlatformClassPath.java",
     java_runtime_alias = ":current_java_runtime",
+    language_version_bootstrap_runtime = select({
+        ":incompatible_language_version_bootclasspath_enabled": ":language_version_bootstrap_runtime",
+        "//conditions:default": None,
+    }),
 )
 
 default_java_toolchain(
diff --git a/toolchains/bootclasspath.bzl b/toolchains/bootclasspath.bzl
new file mode 100644
index 0000000..b6f57e0
--- /dev/null
+++ b/toolchains/bootclasspath.bzl
@@ -0,0 +1,246 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Rules for extracting a platform classpath from Java runtimes."""
+
+load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
+load("//java/common:java_common.bzl", "java_common")
+load(":utf8_environment.bzl", "Utf8EnvironmentInfo")
+
+visibility("private")
+
+# TODO: This provider and is only necessary since --java_{language,runtime}_version
+# are not available directly to Starlark.
+_JavaVersionsInfo = provider(
+    "Exposes the --java_{language,runtime}_version value as extracted from a transition to a dependant.",
+    fields = {
+        "java_language_version": "The value of --java_language_version",
+        "java_runtime_version": "The value of --java_runtime_version",
+    },
+)
+
+def _language_version_bootstrap_runtime(ctx):
+    providers = [
+        _JavaVersionsInfo(
+            java_language_version = ctx.attr.java_language_version[BuildSettingInfo].value,
+            java_runtime_version = ctx.attr.java_runtime_version[BuildSettingInfo].value,
+        ),
+    ]
+
+    bootstrap_runtime = ctx.toolchains["@bazel_tools//tools/jdk:bootstrap_runtime_toolchain_type"]
+    if bootstrap_runtime:
+        providers.append(bootstrap_runtime.java_runtime)
+
+    return providers
+
+language_version_bootstrap_runtime = rule(
+    implementation = _language_version_bootstrap_runtime,
+    attrs = {
+        "java_language_version": attr.label(
+            providers = [BuildSettingInfo],
+        ),
+        "java_runtime_version": attr.label(
+            providers = [BuildSettingInfo],
+        ),
+    },
+    toolchains = [
+        config_common.toolchain_type("@bazel_tools//tools/jdk:bootstrap_runtime_toolchain_type", mandatory = False),
+    ],
+)
+
+def _get_bootstrap_runtime_version(*, java_language_version, java_runtime_version):
+    """Returns the runtime version to use for bootstrapping the given language version.
+
+    If the runtime version is not versioned, e.g. "local_jdk", it is used as is.
+    Otherwise, the language version replaces the numeric part of the runtime version, e.g.,
+    "remotejdk_17" becomes "remotejdk_8".
+    """
+    prefix, separator, version = java_runtime_version.rpartition("_")
+    if version and version.isdigit():
+        new_version = java_language_version
+    else:
+        # The runtime version is not versioned, e.g. "local_jdk". Use it as is.
+        new_version = version
+
+    return prefix + separator + new_version
+
+def _bootclasspath_transition_impl(settings, _):
+    java_language_version = settings["//command_line_option:java_language_version"]
+    java_runtime_version = settings["//command_line_option:java_runtime_version"]
+
+    return {
+        "//command_line_option:java_runtime_version": _get_bootstrap_runtime_version(
+            java_language_version = java_language_version,
+            java_runtime_version = java_runtime_version,
+        ),
+        "//toolchains:java_language_version": java_language_version,
+        "//toolchains:java_runtime_version": java_runtime_version,
+    }
+
+_bootclasspath_transition = transition(
+    implementation = _bootclasspath_transition_impl,
+    inputs = [
+        "//command_line_option:java_language_version",
+        "//command_line_option:java_runtime_version",
+    ],
+    outputs = [
+        "//command_line_option:java_runtime_version",
+        "//toolchains:java_language_version",
+        "//toolchains:java_runtime_version",
+    ],
+)
+
+_JAVA_BOOTSTRAP_RUNTIME_TOOLCHAIN_TYPE = Label("@bazel_tools//tools/jdk:bootstrap_runtime_toolchain_type")
+
+# Opt the Java bootstrap actions into path mapping:
+# https://github.com/bazelbuild/bazel/commit/a239ea84832f18ee8706682145e9595e71b39680
+_SUPPORTS_PATH_MAPPING = {"supports-path-mapping": "1"}
+
+def _java_home(java_executable):
+    return java_executable.dirname[:-len("/bin")]
+
+def _bootclasspath_impl(ctx):
+    exec_javabase = ctx.attr.java_runtime_alias[java_common.JavaRuntimeInfo]
+    env = ctx.attr._utf8_environment[Utf8EnvironmentInfo].environment
+
+    class_dir = ctx.actions.declare_directory("%s_classes" % ctx.label.name)
+
+    args = ctx.actions.args()
+    args.add("-source")
+    args.add("8")
+    args.add("-target")
+    args.add("8")
+    args.add("-Xlint:-options")
+    args.add("-J-XX:-UsePerfData")
+    args.add("-d")
+    args.add_all([class_dir], expand_directories = False)
+    args.add(ctx.file.src)
+
+    ctx.actions.run(
+        executable = "%s/bin/javac" % exec_javabase.java_home,
+        mnemonic = "JavaToolchainCompileClasses",
+        inputs = [ctx.file.src] + ctx.files.java_runtime_alias,
+        outputs = [class_dir],
+        arguments = [args],
+        env = env,
+        execution_requirements = _SUPPORTS_PATH_MAPPING,
+    )
+
+    bootclasspath = ctx.outputs.output_jar
+
+    args = ctx.actions.args()
+    args.add("-XX:+IgnoreUnrecognizedVMOptions")
+    args.add("-XX:-UsePerfData")
+    args.add("--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED")
+    args.add("--add-exports=jdk.compiler/com.sun.tools.javac.platform=ALL-UNNAMED")
+    args.add("--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED")
+    args.add_all("-cp", [class_dir], expand_directories = False)
+    args.add("DumpPlatformClassPath")
+    args.add(bootclasspath)
+
+    if ctx.attr.language_version_bootstrap_runtime:
+        # The attribute is subject to a split transition.
+        language_version_bootstrap_runtime = ctx.attr.language_version_bootstrap_runtime[0]
+        if java_common.JavaRuntimeInfo in language_version_bootstrap_runtime:
+            any_javabase = language_version_bootstrap_runtime[java_common.JavaRuntimeInfo]
+        else:
+            java_versions_info = language_version_bootstrap_runtime[_JavaVersionsInfo]
+            bootstrap_runtime_version = _get_bootstrap_runtime_version(
+                java_language_version = java_versions_info.java_language_version,
+                java_runtime_version = java_versions_info.java_runtime_version,
+            )
+            is_exec = "-exec" in ctx.bin_dir.path
+            tool_prefix = "tool_" if is_exec else ""
+            fail("""
+No Java runtime found to extract the bootclasspath from for --{tool_prefix}java_language_version={language_version} and --{tool_prefix}java_runtime_version={runtime_version}.
+You can:
+
+    * register a Java runtime with name "{bootstrap_runtime_version}" to provide the bootclasspath or
+    * set --java_language_version to the Java version of an available runtime.
+
+Rerun with --toolchain_resolution_debug='@bazel_tools//tools/jdk:bootstrap_runtime_toolchain_type' to see more details about toolchain resolution.
+""".format(
+                language_version = java_versions_info.java_language_version,
+                runtime_version = java_versions_info.java_runtime_version,
+                bootstrap_runtime_version = bootstrap_runtime_version,
+                tool_prefix = tool_prefix,
+            ))
+    else:
+        any_javabase = ctx.toolchains[_JAVA_BOOTSTRAP_RUNTIME_TOOLCHAIN_TYPE].java_runtime
+    any_javabase_files = any_javabase.files.to_list()
+
+    # If possible, add the Java executable to the command line as a File so that it can be path
+    # mapped.
+    java_executable = [f for f in any_javabase_files if f.path == any_javabase.java_executable_exec_path]
+    if len(java_executable) == 1:
+        args.add_all(java_executable, map_each = _java_home)
+    else:
+        args.add(any_javabase.java_home)
+
+    system_files = ("release", "modules", "jrt-fs.jar")
+    system = [f for f in any_javabase_files if f.basename in system_files]
+    if len(system) != len(system_files):
+        system = None
+
+    inputs = depset([class_dir] + ctx.files.java_runtime_alias, transitive = [any_javabase.files])
+    ctx.actions.run(
+        executable = str(exec_javabase.java_executable_exec_path),
+        mnemonic = "JavaToolchainCompileBootClasspath",
+        inputs = inputs,
+        outputs = [bootclasspath],
+        arguments = [args],
+        env = env,
+        execution_requirements = _SUPPORTS_PATH_MAPPING,
+    )
+    return [
+        DefaultInfo(files = depset([bootclasspath])),
+        java_common.BootClassPathInfo(
+            bootclasspath = [bootclasspath],
+            system = system,
+        ),
+        OutputGroupInfo(jar = [bootclasspath]),
+    ]
+
+_bootclasspath = rule(
+    implementation = _bootclasspath_impl,
+    attrs = {
+        "java_runtime_alias": attr.label(
+            cfg = "exec",
+            providers = [java_common.JavaRuntimeInfo],
+        ),
+        "language_version_bootstrap_runtime": attr.label(
+            cfg = _bootclasspath_transition,
+        ),
+        "output_jar": attr.output(mandatory = True),
+        "src": attr.label(
+            cfg = "exec",
+            allow_single_file = True,
+        ),
+        "_allowlist_function_transition": attr.label(
+            default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+        ),
+        "_utf8_environment": attr.label(
+            default = ":utf8_environment",
+            cfg = "exec",
+        ),
+    },
+    toolchains = [_JAVA_BOOTSTRAP_RUNTIME_TOOLCHAIN_TYPE],
+)
+
+def bootclasspath(name, **kwargs):
+    _bootclasspath(
+        name = name,
+        output_jar = name + ".jar",
+        **kwargs
+    )
diff --git a/toolchains/default_java_toolchain.bzl b/toolchains/default_java_toolchain.bzl
index 5aaa06b..4ec8961 100644
--- a/toolchains/default_java_toolchain.bzl
+++ b/toolchains/default_java_toolchain.bzl
@@ -14,9 +14,8 @@
 
 """Rules for defining default_java_toolchain"""
 
-load("//java/common:java_common.bzl", "java_common")
 load("//java/toolchains:java_toolchain.bzl", "java_toolchain")
-load(":utf8_environment.bzl", "Utf8EnvironmentInfo")
+load(":bootclasspath.bzl", _bootclasspath = "bootclasspath")
 
 # JVM options, without patching java.compiler and jdk.compiler modules.
 BASE_JDK9_JVM_OPTS = [
@@ -213,112 +212,4 @@
             tags = ["manual"],
         )
 
-_JAVA_BOOTSTRAP_RUNTIME_TOOLCHAIN_TYPE = Label("@bazel_tools//tools/jdk:bootstrap_runtime_toolchain_type")
-
-# Opt the Java bootstrap actions into path mapping:
-# https://github.com/bazelbuild/bazel/commit/a239ea84832f18ee8706682145e9595e71b39680
-_SUPPORTS_PATH_MAPPING = {"supports-path-mapping": "1"}
-
-def _java_home(java_executable):
-    return java_executable.dirname[:-len("/bin")]
-
-def _bootclasspath_impl(ctx):
-    exec_javabase = ctx.attr.java_runtime_alias[java_common.JavaRuntimeInfo]
-    env = ctx.attr._utf8_environment[Utf8EnvironmentInfo].environment
-
-    class_dir = ctx.actions.declare_directory("%s_classes" % ctx.label.name)
-
-    args = ctx.actions.args()
-    args.add("-source")
-    args.add("8")
-    args.add("-target")
-    args.add("8")
-    args.add("-Xlint:-options")
-    args.add("-J-XX:-UsePerfData")
-    args.add("-d")
-    args.add_all([class_dir], expand_directories = False)
-    args.add(ctx.file.src)
-
-    ctx.actions.run(
-        executable = "%s/bin/javac" % exec_javabase.java_home,
-        mnemonic = "JavaToolchainCompileClasses",
-        inputs = [ctx.file.src] + ctx.files.java_runtime_alias,
-        outputs = [class_dir],
-        arguments = [args],
-        env = env,
-        execution_requirements = _SUPPORTS_PATH_MAPPING,
-    )
-
-    bootclasspath = ctx.outputs.output_jar
-
-    args = ctx.actions.args()
-    args.add("-XX:+IgnoreUnrecognizedVMOptions")
-    args.add("-XX:-UsePerfData")
-    args.add("--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED")
-    args.add("--add-exports=jdk.compiler/com.sun.tools.javac.platform=ALL-UNNAMED")
-    args.add("--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED")
-    args.add_all("-cp", [class_dir], expand_directories = False)
-    args.add("DumpPlatformClassPath")
-    args.add(bootclasspath)
-
-    any_javabase = ctx.toolchains[_JAVA_BOOTSTRAP_RUNTIME_TOOLCHAIN_TYPE].java_runtime
-    any_javabase_files = any_javabase.files.to_list()
-
-    # If possible, add the Java executable to the command line as a File so that it can be path
-    # mapped.
-    java_executable = [f for f in any_javabase_files if f.path == any_javabase.java_executable_exec_path]
-    if len(java_executable) == 1:
-        args.add_all(java_executable, map_each = _java_home)
-    else:
-        args.add(any_javabase.java_home)
-
-    system_files = ("release", "modules", "jrt-fs.jar")
-    system = [f for f in any_javabase_files if f.basename in system_files]
-    if len(system) != len(system_files):
-        system = None
-
-    inputs = depset([class_dir] + ctx.files.java_runtime_alias, transitive = [any_javabase.files])
-    ctx.actions.run(
-        executable = str(exec_javabase.java_executable_exec_path),
-        mnemonic = "JavaToolchainCompileBootClasspath",
-        inputs = inputs,
-        outputs = [bootclasspath],
-        arguments = [args],
-        env = env,
-        execution_requirements = _SUPPORTS_PATH_MAPPING,
-    )
-    return [
-        DefaultInfo(files = depset([bootclasspath])),
-        java_common.BootClassPathInfo(
-            bootclasspath = [bootclasspath],
-            system = system,
-        ),
-        OutputGroupInfo(jar = [bootclasspath]),
-    ]
-
-_bootclasspath = rule(
-    implementation = _bootclasspath_impl,
-    attrs = {
-        "java_runtime_alias": attr.label(
-            cfg = "exec",
-            providers = [java_common.JavaRuntimeInfo],
-        ),
-        "output_jar": attr.output(mandatory = True),
-        "src": attr.label(
-            cfg = "exec",
-            allow_single_file = True,
-        ),
-        "_utf8_environment": attr.label(
-            default = ":utf8_environment",
-            cfg = "exec",
-        ),
-    },
-    toolchains = [_JAVA_BOOTSTRAP_RUNTIME_TOOLCHAIN_TYPE],
-)
-
-def bootclasspath(name, **kwargs):
-    _bootclasspath(
-        name = name,
-        output_jar = name + ".jar",
-        **kwargs
-    )
+bootclasspath = _bootclasspath