Emit javac -source/-target from kotlinc jvm_target (#1648)

For mixed-sources targets ensure java sources are compiled for the same jvm_target as the kotlin sources.
Both java and kotlin compiler invocations will produce bytecode of the same version.

Co-authored-by: Eugene Zhuravlev <eugene.zhuravlev@jetbrains.com>
diff --git a/kotlin/internal/jvm/compile.bzl b/kotlin/internal/jvm/compile.bzl
index 9691e49..9d2a32e 100644
--- a/kotlin/internal/jvm/compile.bzl
+++ b/kotlin/internal/jvm/compile.bzl
@@ -1031,6 +1031,13 @@
         # annotation processors in `deps` also.
         if len(srcs.kt) > 0:
             javac_opts.append("-proc:none")
+
+        # Compile the Java half for the same effective jvm_target, the kotlin part is compiled for.
+        kotlinc_options = ctx.attr.kotlinc_opts[KotlincOptions] if ctx.attr.kotlinc_opts else toolchains.kt.kotlinc_options
+        jvm_target = kotlinc_options.jvm_target if (kotlinc_options and kotlinc_options.jvm_target) else toolchains.kt.jvm_target
+        if jvm_target:
+            javac_opts.extend(_utils.javac_jvm_target_flags(jvm_target))
+
         java_info = java_common.compile(
             ctx,
             source_files = srcs.java,
diff --git a/kotlin/internal/utils/utils.bzl b/kotlin/internal/utils/utils.bzl
index eecf791..8d7bd20 100644
--- a/kotlin/internal/utils/utils.bzl
+++ b/kotlin/internal/utils/utils.bzl
@@ -47,8 +47,19 @@
 
     return args
 
+def _javac_jvm_target_flags(jvm_target):
+    """Derive javac `-source`/`-target` flags from a kotlinc `jvm_target`.
+    """
+    stripped = jvm_target.strip()
+    dot = stripped.rfind(".")
+    target_version = stripped if dot < 0 else stripped[dot + 1:]
+    if not target_version.isdigit():
+        fail("kotlinc jvm_target '{}' is not a valid JVM target version (expected a number like '8', '11', '17', or the legacy '1.8' form)".format(jvm_target))
+    return ["-source", target_version, "-target", target_version]
+
 utils = struct(
     add_dicts = dicts.add,
     init_args = _init_builder_args,
     derive_module_name = _derive_module_name,
+    javac_jvm_target_flags = _javac_jvm_target_flags,
 )
diff --git a/src/test/data/jvm/jvm_target/BUILD b/src/test/data/jvm/jvm_target/BUILD
new file mode 100644
index 0000000..264efb4
--- /dev/null
+++ b/src/test/data/jvm/jvm_target/BUILD
@@ -0,0 +1,26 @@
+load("//kotlin:core.bzl", "kt_kotlinc_options")
+load("//kotlin:jvm.bzl", "kt_jvm_library")
+load(":java_language_version.bzl", "java_language_version_env")
+
+package(default_visibility = ["//visibility:public"])
+
+kt_kotlinc_options(
+    name = "jvm8_opts",
+    jvm_target = "1.8",
+)
+
+kt_jvm_library(
+    name = "mixed_jvm8",
+    srcs = [
+        "Mixed.kt",
+        "MixedJava.java",
+    ],
+    kotlinc_opts = ":jvm8_opts",
+)
+
+# Pin java_language_version so the test is independent of the repository's global Java config.
+java_language_version_env(
+    name = "mixed_jvm8_java11",
+    java_language_version = "11",
+    target = ":mixed_jvm8",
+)
diff --git a/src/test/data/jvm/jvm_target/Mixed.kt b/src/test/data/jvm/jvm_target/Mixed.kt
new file mode 100644
index 0000000..53cc07c
--- /dev/null
+++ b/src/test/data/jvm/jvm_target/Mixed.kt
@@ -0,0 +1,5 @@
+package mixed
+
+class Mixed {
+  fun greet(): String = MixedJava.greet()
+}
diff --git a/src/test/data/jvm/jvm_target/MixedJava.java b/src/test/data/jvm/jvm_target/MixedJava.java
new file mode 100644
index 0000000..4241739
--- /dev/null
+++ b/src/test/data/jvm/jvm_target/MixedJava.java
@@ -0,0 +1,7 @@
+package mixed;
+
+public final class MixedJava {
+  public static String greet() {
+    return "hi";
+  }
+}
diff --git a/src/test/data/jvm/jvm_target/java_language_version.bzl b/src/test/data/jvm/jvm_target/java_language_version.bzl
new file mode 100644
index 0000000..8bfc2e0
--- /dev/null
+++ b/src/test/data/jvm/jvm_target/java_language_version.bzl
@@ -0,0 +1,31 @@
+"""Build a target with `//command_line_option:java_language_version` pinned to a fixed value."""
+
+def _pin_java_language_version_impl(_settings, attr):
+    return {"//command_line_option:java_language_version": attr.java_language_version}
+
+_pin_java_language_version = transition(
+    implementation = _pin_java_language_version_impl,
+    inputs = [],
+    outputs = ["//command_line_option:java_language_version"],
+)
+
+def _java_language_version_env_impl(ctx):
+    dep = ctx.attr.target
+    if type(dep) == "list":
+        dep = dep[0]
+    return [DefaultInfo(
+        files = dep[DefaultInfo].files,
+        runfiles = dep[DefaultInfo].default_runfiles,
+    )]
+
+java_language_version_env = rule(
+    doc = "Forwards `target`, built with --java_language_version pinned to `java_language_version`.",
+    implementation = _java_language_version_env_impl,
+    attrs = {
+        "java_language_version": attr.string(mandatory = True),
+        "target": attr.label(mandatory = True, cfg = _pin_java_language_version),
+        "_allowlist_function_transition": attr.label(
+            default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+        ),
+    },
+)
diff --git a/src/test/kotlin/io/bazel/kotlin/BUILD b/src/test/kotlin/io/bazel/kotlin/BUILD
index 5ea88e0..e49429e 100644
--- a/src/test/kotlin/io/bazel/kotlin/BUILD
+++ b/src/test/kotlin/io/bazel/kotlin/BUILD
@@ -72,9 +72,16 @@
     data = ["//src/test/data/jvm/ksp"],
 )
 
+kt_rules_e2e_test(
+    name = "JavacJvmTargetAssertionTest",
+    srcs = ["JavacJvmTargetAssertionTest.kt"],
+    data = ["//src/test/data/jvm/jvm_target:mixed_jvm8_java11"],
+)
+
 test_suite(
     name = "assertion_tests",
     tests = [
+        "JavacJvmTargetAssertionTest",
         "KotlinJvm13Test",
         "KotlinJvmAssociatesBasicVisibilityTest",
         "KotlinJvmBasicAssertionTest",
diff --git a/src/test/kotlin/io/bazel/kotlin/JavacJvmTargetAssertionTest.kt b/src/test/kotlin/io/bazel/kotlin/JavacJvmTargetAssertionTest.kt
new file mode 100644
index 0000000..1e831f6
--- /dev/null
+++ b/src/test/kotlin/io/bazel/kotlin/JavacJvmTargetAssertionTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2018 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.
+ */
+package io.bazel.kotlin
+
+import org.junit.Test
+import kotlin.test.assertEquals
+
+class JavacJvmTargetAssertionTest : KotlinAssertionTestCase("src/test/data/jvm/jvm_target") {
+  @Test
+  fun javaHalfIsCompiledForTheKotlincJvmTarget() {
+    jarTestCase(
+      "mixed_jvm8.jar",
+      description = "In a mixed Kotlin/Java target the Java sources must compile to the same " +
+        "bytecode version as the kotlinc jvm_target",
+    ) {
+      // The Java half must follow the kotlinc jvm_target (1.8 -> major 52), not the pinned default.
+      assertEquals(
+        52,
+        classFileMajorVersion("mixed/MixedJava.class"),
+        "Java half was not compiled for jvm_target=1.8 (it followed java_language_version instead)",
+      )
+      // Both halves must target the same platform.
+      assertEquals(
+        classFileMajorVersion("mixed/Mixed.class"),
+        classFileMajorVersion("mixed/MixedJava.class"),
+        "Kotlin and Java halves produced different bytecode versions",
+      )
+    }
+  }
+}
diff --git a/src/test/kotlin/io/bazel/kotlin/KotlinAssertionTestCase.kt b/src/test/kotlin/io/bazel/kotlin/KotlinAssertionTestCase.kt
index 0715682..a4d7bd9 100644
--- a/src/test/kotlin/io/bazel/kotlin/KotlinAssertionTestCase.kt
+++ b/src/test/kotlin/io/bazel/kotlin/KotlinAssertionTestCase.kt
@@ -87,6 +87,16 @@
     }
   }
 
+  /** Class-file major version (JVMS 4.1) of a `.class` entry. */
+  protected fun JarFile.classFileMajorVersion(entry: String): Int {
+    val classEntry = checkNotNull(getJarEntry(entry)) { "no entry $entry in jar $name" }
+    java.io.DataInputStream(getInputStream(classEntry)).use { input ->
+      input.readInt() // 0xCAFEBABE magic
+      input.readUnsignedShort() // minor version
+      return input.readUnsignedShort() // major version
+    }
+  }
+
   /**
    * Validated the entry is compressed and has the DOS epoch for it's timestamp.
    */
diff --git a/src/test/starlark/internal/jvm/BUILD.bazel b/src/test/starlark/internal/jvm/BUILD.bazel
index e618755..a522231 100644
--- a/src/test/starlark/internal/jvm/BUILD.bazel
+++ b/src/test/starlark/internal/jvm/BUILD.bazel
@@ -1,9 +1,12 @@
 load(":export_only_module_name_test.bzl", "export_only_module_name_test_suite")
+load(":javac_jvm_target_test.bzl", "javac_jvm_target_test_suite")
 load(":jvm_deps_tests.bzl", "jvm_deps_test_suite")
 load(":kt_jvm_binary_env_test.bzl", "kt_jvm_binary_env_test_suite")
 
 export_only_module_name_test_suite(name = "export_only_module_name_tests")
 
+javac_jvm_target_test_suite(name = "javac_jvm_target_tests")
+
 jvm_deps_test_suite(name = "jvm_tests")
 
 kt_jvm_binary_env_test_suite(name = "kt_jvm_binary_env_tests")
diff --git a/src/test/starlark/internal/jvm/javac_jvm_target_test.bzl b/src/test/starlark/internal/jvm/javac_jvm_target_test.bzl
new file mode 100644
index 0000000..c8d5499
--- /dev/null
+++ b/src/test/starlark/internal/jvm/javac_jvm_target_test.bzl
@@ -0,0 +1,26 @@
+"""Unit tests for `utils.javac_jvm_target_flags` (kotlinc jvm_target -> javac -source/-target flags)."""
+
+load("@bazel_skylib//lib:unittest.bzl", "asserts", "unittest")
+load("//kotlin/internal/utils:utils.bzl", "utils")
+
+def _normalization_test_impl(ctx):
+    env = unittest.begin(ctx)
+
+    # '1.8'-style targets normalize to '8'; '1.6' -> '6'.
+    asserts.equals(env, ["-source", "8", "-target", "8"], utils.javac_jvm_target_flags("1.8"))
+    asserts.equals(env, ["-source", "6", "-target", "6"], utils.javac_jvm_target_flags("1.6"))
+
+    # Already-bare numeric targets pass through unchanged.
+    asserts.equals(env, ["-source", "8", "-target", "8"], utils.javac_jvm_target_flags("8"))
+    asserts.equals(env, ["-source", "11", "-target", "11"], utils.javac_jvm_target_flags("11"))
+    asserts.equals(env, ["-source", "17", "-target", "17"], utils.javac_jvm_target_flags("17"))
+
+    return unittest.end(env)
+
+normalization_test = unittest.make(_normalization_test_impl)
+
+def javac_jvm_target_test_suite(name):
+    unittest.suite(
+        name,
+        normalization_test,
+    )