Skip to content

Commit a09aba8

Browse files
committed
Rewrite scip-java CLI to Kotlin
1 parent 12ac5eb commit a09aba8

48 files changed

Lines changed: 2871 additions & 2602 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build.sbt

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ lazy val V =
1111
val protobuf = "4.34.2"
1212
val scipBindings = "0.8.0"
1313
val scalaXml = "2.1.0"
14-
val moped = "0.2.0"
1514
val gradle = "8.10"
1615
val scala213 = "2.13.13"
1716
val scalameta = "4.9.3"
1817
val kotlinVersion = "2.2.0"
1918
val kotest = "4.6.3"
2019
val kctfork = "0.7.1"
20+
val clikt = "5.0.3"
21+
val kotlinxSerialization = "1.9.0"
2122
}
2223

2324
// sbt-git's bundled JGit can't read linked worktrees; shell out to
@@ -196,28 +197,29 @@ lazy val mavenPlugin = project
196197

197198
lazy val cli = project
198199
.in(file("scip-java"))
200+
.enablePlugins(KotlinPlugin, PackPlugin, DockerPlugin)
199201
.settings(
200202
moduleName := "scip-java",
203+
crossPaths := false,
204+
autoScalaLibrary := false,
205+
kotlinVersion := V.kotlinVersion,
206+
kotlincJvmTarget := "11",
207+
Compile / javacOptions ++= Seq("--release", "11"),
201208
(Compile / mainClass) := Some("com.sourcegraph.scip_java.ScipJava"),
202209
(run / baseDirectory) := (ThisBuild / baseDirectory).value,
203210
// ScipJava.main can call System.exit, so we always fork the JVM when
204211
// sbt invokes it directly (e.g. from the semanticdb-kotlinc snapshots
205212
// task) so it cannot kill the surrounding sbt process.
206213
Compile / run / fork := true,
207-
buildInfoKeys :=
208-
Seq[BuildInfoKey](
209-
version,
210-
sbtVersion,
211-
scalaVersion,
212-
"javacModuleOptions" -> javacModuleOptions,
213-
"semanticdbVersion" -> V.scalameta,
214-
"scala213" -> V.scala213
215-
),
216-
buildInfoPackage := "com.sourcegraph.scip_java",
214+
// Generate a tiny Java `BuildInfo` class replacing the previous
215+
// sbt-buildinfo-generated Scala object. Same shape as the Gradle plugin's
216+
// `GradlePluginBuildInfo` (introduced in the Gradle plugin Kotlin port).
217+
Compile / sourceGenerators += scipJavaCliBuildInfoGenerator.taskValue,
217218
libraryDependencies ++=
218219
List(
219-
"org.scala-lang.modules" %% "scala-xml" % V.scalaXml,
220-
"org.scalameta" %% "moped" % V.moped,
220+
"com.github.ajalt.clikt" % "clikt-jvm" % V.clikt,
221+
"org.jetbrains.kotlinx" % "kotlinx-serialization-json-jvm" %
222+
V.kotlinxSerialization,
221223
"org.jetbrains.kotlin" % "kotlin-compiler-embeddable" % V.kotlinVersion,
222224
"org.jetbrains.kotlin" % "kotlin-scripting-common" % V.kotlinVersion,
223225
"org.jetbrains.kotlin" % "kotlin-scripting-jvm" % V.kotlinVersion,
@@ -289,9 +291,52 @@ lazy val cli = project
289291
docker / dockerfile :=
290292
NativeDockerfile((ThisBuild / baseDirectory).value / "Dockerfile")
291293
)
292-
.enablePlugins(PackPlugin, DockerPlugin, BuildInfoPlugin)
293294
.dependsOn(scip)
294295

296+
// Source-generator for the CLI's build-info Java class. Replaces the
297+
// sbt-buildinfo-generated Scala BuildInfo object so the CLI module stays
298+
// Kotlin/Java-only (and the generated class is straightforward to consume
299+
// from Kotlin).
300+
lazy val scipJavaCliBuildInfoGenerator = Def.task {
301+
val out =
302+
(Compile / sourceManaged).value / "com" / "sourcegraph" / "scip_java" /
303+
"BuildInfo.java"
304+
IO.createDirectory(out.getParentFile)
305+
val optionsLiteral = javacModuleOptions
306+
.map(javaStringLiteral)
307+
.mkString("Arrays.asList(", ", ", ")")
308+
val versionLiteral = javaStringLiteral(version.value)
309+
val contents =
310+
s"""package com.sourcegraph.scip_java;
311+
|
312+
|import java.util.Arrays;
313+
|import java.util.Collections;
314+
|import java.util.List;
315+
|
316+
|public final class BuildInfo {
317+
| private BuildInfo() {}
318+
| public static final String version = $versionLiteral;
319+
| public static final List<String> javacModuleOptions =
320+
| Collections.unmodifiableList($optionsLiteral);
321+
|}
322+
|""".stripMargin
323+
IO.write(out, contents)
324+
Seq(out)
325+
}
326+
327+
def javaStringLiteral(value: String): String = {
328+
val escaped = value.flatMap {
329+
case '\\' => "\\\\"
330+
case '"' => "\\\""
331+
case '\n' => "\\n"
332+
case '\r' => "\\r"
333+
case '\t' => "\\t"
334+
case c if c.isControl => f"\\u${c.toInt}%04x"
335+
case c => c.toString
336+
}
337+
"\"" + escaped + "\""
338+
}
339+
295340
// Task key for regenerating the SCIP/SemanticDB golden snapshots emitted by
296341
// the semanticdb-kotlinc compiler plugin over the Kotlin minimized fixtures.
297342
// We deliberately do NOT call this `snapshots` to avoid colliding with the
@@ -615,8 +660,8 @@ val testSettings = List(
615660
libraryDependencies ++=
616661
List(
617662
"org.scalameta" %% "munit" % "0.7.29",
618-
"org.scalameta" %% "moped-testkit" % V.moped,
619663
"org.scalameta" %% "scalameta" % V.scalameta,
664+
"com.lihaoyi" %% "os-lib" % "0.9.3",
620665
"com.lihaoyi" %% "pprint" % "0.6.6"
621666
)
622667
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.sourcegraph.io
2+
3+
import java.nio.file.Path
4+
import java.nio.file.Paths
5+
6+
object AbsolutePath {
7+
@JvmStatic
8+
fun systemWorkingDirectory(): Path = Paths.get(".").toAbsolutePath().normalize()
9+
10+
@JvmStatic
11+
fun of(path: Path): Path = of(path, systemWorkingDirectory())
12+
13+
@JvmStatic
14+
fun of(path: Path, cwd: Path): Path =
15+
when {
16+
path.isAbsolute -> path
17+
cwd.isAbsolute -> cwd.resolve(path)
18+
else -> systemWorkingDirectory().resolve(cwd).resolve(path)
19+
}
20+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.sourcegraph.io
2+
3+
import java.io.IOException
4+
import java.nio.file.FileVisitResult
5+
import java.nio.file.Files
6+
import java.nio.file.NoSuchFileException
7+
import java.nio.file.Path
8+
import java.nio.file.SimpleFileVisitor
9+
import java.nio.file.attribute.BasicFileAttributes
10+
import java.util.function.Predicate
11+
12+
/**
13+
* File visitor that recursively deletes a directory tree.
14+
*
15+
* Optionally accepts a predicate that decides whether a given file or
16+
* directory should be deleted. Empty directories are deleted on the
17+
* post-visit pass.
18+
*/
19+
class DeleteVisitor
20+
@JvmOverloads
21+
constructor(
22+
private val deleteFile: Predicate<Path> = Predicate { true }
23+
) : SimpleFileVisitor<Path>() {
24+
25+
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult =
26+
if (!deleteFile.test(dir)) FileVisitResult.SKIP_SUBTREE
27+
else super.preVisitDirectory(dir, attrs)
28+
29+
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
30+
if (deleteFile.test(file)) {
31+
Files.deleteIfExists(file)
32+
}
33+
return super.visitFile(file, attrs)
34+
}
35+
36+
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
37+
Files.list(dir).use { stream ->
38+
if (!stream.iterator().hasNext()) {
39+
Files.deleteIfExists(dir)
40+
}
41+
}
42+
return super.postVisitDirectory(dir, exc)
43+
}
44+
45+
override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult =
46+
FileVisitResult.CONTINUE
47+
48+
companion object {
49+
@JvmStatic
50+
fun deleteRecursively(path: Path): Int {
51+
return try {
52+
Files.walkFileTree(path, DeleteVisitor())
53+
0
54+
} catch (_: NoSuchFileException) {
55+
0
56+
} catch (e: IOException) {
57+
e.printStackTrace()
58+
1
59+
}
60+
}
61+
}
62+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.sourcegraph.scip_java
2+
3+
import com.sourcegraph.io.AbsolutePath
4+
import java.io.PrintStream
5+
import java.nio.file.Path
6+
7+
/**
8+
* Captures the per-invocation environment of a scip-java CLI run.
9+
*
10+
* Tests inject a custom environment to redirect stdout/stderr into a
11+
* byte buffer, point the working directory at a temporary fixture
12+
* directory, and so on.
13+
*/
14+
data class CliEnvironment(
15+
val workingDirectory: Path = AbsolutePath.systemWorkingDirectory(),
16+
val environmentVariables: Map<String, String> = System.getenv(),
17+
val standardOutput: PrintStream = System.out,
18+
val standardError: PrintStream = System.err,
19+
val isProgressBarEnabled: Boolean = System.console() != null,
20+
) {
21+
fun withWorkingDirectory(cwd: Path): CliEnvironment = copy(workingDirectory = cwd)
22+
23+
fun withStandardOutput(out: PrintStream): CliEnvironment = copy(standardOutput = out)
24+
25+
fun withStandardError(err: PrintStream): CliEnvironment = copy(standardError = err)
26+
27+
fun withEnvironmentVariables(vars: Map<String, String>): CliEnvironment =
28+
copy(environmentVariables = vars)
29+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.sourcegraph.scip_java
2+
3+
import java.util.concurrent.atomic.AtomicInteger
4+
5+
/**
6+
* Minimal reporter that mirrors the moped `ConsoleReporter` API surface that
7+
* scip-java actually uses (info/warning/error/debug/hasErrors/exitCode).
8+
*
9+
* `info` is written to stdout to match the previous behaviour of the
10+
* default moped reporter; `warning` and `error` go to stderr.
11+
*/
12+
class CliReporter(private val env: CliEnvironment) {
13+
private val errorCount = AtomicInteger()
14+
15+
fun info(message: String) {
16+
env.standardOutput.println(message)
17+
}
18+
19+
fun warning(message: String) {
20+
env.standardError.println("warning: $message")
21+
}
22+
23+
fun error(message: String) {
24+
errorCount.incrementAndGet()
25+
env.standardError.println("error: $message")
26+
}
27+
28+
/**
29+
* Debug messages are dropped to avoid leaking noise into snapshot tests.
30+
*/
31+
@Suppress("UNUSED_PARAMETER")
32+
fun debug(message: String) {
33+
// intentional no-op
34+
}
35+
36+
fun error(e: Throwable) {
37+
errorCount.incrementAndGet()
38+
e.printStackTrace(env.standardError)
39+
}
40+
41+
fun hasErrors(): Boolean = errorCount.get() > 0
42+
43+
fun exitCode(): Int = if (hasErrors()) 1 else 0
44+
45+
fun reset() {
46+
errorCount.set(0)
47+
}
48+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.sourcegraph.scip_java
2+
3+
import com.sourcegraph.scip_java.buildtools.ProcessResult
4+
import java.nio.charset.StandardCharsets
5+
import java.nio.file.Files
6+
import java.nio.file.Path
7+
import java.nio.file.StandardCopyOption
8+
9+
object Embedded {
10+
11+
@JvmStatic
12+
fun semanticdbJar(tmpDir: Path): Path = copyFile(tmpDir, "semanticdb-plugin.jar")
13+
14+
@JvmStatic
15+
fun gradlePluginJar(tmpDir: Path): Path = copyFile(tmpDir, "gradle-plugin.jar")
16+
17+
@JvmStatic
18+
fun semanticdbKotlincJar(tmpDir: Path): Path = copyFile(tmpDir, "semanticdb-kotlinc.jar")
19+
20+
private fun javacErrorpath(tmp: Path): Path = tmp.resolve("errorpath.txt")
21+
22+
@JvmStatic
23+
fun customJavac(sourceroot: Path, targetroot: Path, tmp: Path): Path {
24+
val bin = tmp.resolve("bin")
25+
val javac = bin.resolve("javac")
26+
val java = bin.resolve("java")
27+
val pluginpath = semanticdbJar(tmp)
28+
val errorpath = javacErrorpath(tmp)
29+
val javacopts = targetroot.resolve("javacopts.txt")
30+
Files.createDirectories(targetroot)
31+
Files.createDirectories(bin)
32+
Files.write(
33+
java,
34+
("#!/usr/bin/env bash\n" +
35+
"java \"\$@\"\n").toByteArray(StandardCharsets.UTF_8),
36+
)
37+
val newJavacopts = tmp.resolve("javac_newarguments")
38+
// --add-exports flags required to access internal javac APIs from our
39+
// SemanticDB plugin. Always set; Java 11+ is the supported baseline.
40+
val javacModuleOptions = BuildInfo.javacModuleOptions.joinToString(" ")
41+
val injectSemanticdbArguments =
42+
listOf(
43+
"java",
44+
"-Dsemanticdb.errorpath=$errorpath",
45+
"-Dsemanticdb.pluginpath=$pluginpath",
46+
"-Dsemanticdb.sourceroot=$sourceroot",
47+
"-Dsemanticdb.targetroot=$targetroot",
48+
"-Dsemanticdb.output=\$NEW_JAVAC_OPTS",
49+
"-Dsemanticdb.old-output=$javacopts",
50+
"-classpath $pluginpath",
51+
"com.sourcegraph.semanticdb_javac.InjectSemanticdbOptions",
52+
"\"\$@\"",
53+
).joinToString(" ")
54+
val script = buildString {
55+
append("#!/usr/bin/env bash\n")
56+
append("set -eu\n")
57+
append("LAUNCHER_ARGS=()\n")
58+
append("NEW_JAVAC_OPTS=\"$newJavacopts-\$RANDOM\"\n")
59+
append("for arg in \"\$@\"; do\n")
60+
append(" if [[ \$arg == -J* ]]; then\n")
61+
append(" LAUNCHER_ARGS+=(\"\$arg\")\n")
62+
append(" fi\n")
63+
append("done\n")
64+
append(injectSemanticdbArguments).append('\n')
65+
append("if [ \${#LAUNCHER_ARGS[@]} -eq 0 ]; then\n")
66+
append(" javac $javacModuleOptions \"@\$NEW_JAVAC_OPTS\"\n")
67+
append("else\n")
68+
append(" javac $javacModuleOptions \"@\$NEW_JAVAC_OPTS\" \"\${LAUNCHER_ARGS[@]}\"\n")
69+
append("fi\n")
70+
}
71+
Files.write(javac, script.toByteArray(StandardCharsets.UTF_8))
72+
javac.toFile().setExecutable(true)
73+
java.toFile().setExecutable(true)
74+
return javac
75+
}
76+
77+
/**
78+
* The custom javac wrapper reports errors to a specific file if unexpected
79+
* errors happen. The javac wrapper gets invoked by builds tools like
80+
* Gradle/Maven, which hide the actual errors from the script because they
81+
* assume the standard output is from javac. This file is used a side-channel
82+
* to avoid relying on the error reporting from Gradle/Maven.
83+
*/
84+
@JvmStatic
85+
fun reportUnexpectedJavacErrors(reporter: CliReporter, tmp: Path): ProcessResult? {
86+
val errorpath = javacErrorpath(tmp)
87+
if (!Files.isRegularFile(errorpath)) return null
88+
reporter.error("unexpected javac compile errors")
89+
Files.readAllLines(errorpath).forEach { reporter.error(it) }
90+
return ProcessResult(1)
91+
}
92+
93+
/** Returns the string contents of the scip_java.bzl file on disk. */
94+
@JvmStatic
95+
fun bazelAspectFile(tmpDir: Path): String {
96+
val tmpFile = copyFile(tmpDir, "scip-java/scip_java.bzl")
97+
val contents = String(Files.readAllBytes(tmpFile), StandardCharsets.UTF_8)
98+
Files.deleteIfExists(tmpFile)
99+
return contents
100+
}
101+
102+
private fun copyFile(tmpDir: Path, filename: String): Path {
103+
val input =
104+
Embedded::class.java.getResourceAsStream("/$filename")
105+
?: error("missing embedded resource: /$filename")
106+
val out = tmpDir.resolve(filename)
107+
Files.createDirectories(out.parent)
108+
input.use { Files.copy(it, out, StandardCopyOption.REPLACE_EXISTING) }
109+
return out
110+
}
111+
}

0 commit comments

Comments
 (0)