diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d1286f3e9..d062c2932 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,11 @@ [versions] clikt = "5.1.0" gradle-api = "8.11.1" +gradle-tooling-api = "7.3-20210825160000+0000" junit-jupiter = "5.11.4" -kctfork = "0.7.1" +kctfork = "0.12.1" kotest = "6.2.1" -kotlin = "2.2.0" +kotlin = "2.3.20" kotlinx-serialization = "1.11.0" lombok = "1.18.46" maven-plugin-annotations = "3.15.2" @@ -21,9 +22,11 @@ vanniktech-maven-publish = "0.37.0" clikt-jvm = { module = "com.github.ajalt.clikt:clikt-jvm", version.ref = "clikt" } gradle-api = { module = "dev.gradleplugins:gradle-api", version.ref = "gradle-api" } gradle-test-kit = { module = "dev.gradleplugins:gradle-test-kit", version.ref = "gradle-api" } +gradle-tooling-api = { module = "org.gradle:gradle-tooling-api", version.ref = "gradle-tooling-api" } kctfork-core = { module = "dev.zacsweers.kctfork:core", version.ref = "kctfork" } kotest-assertions-core = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" } kotlin-compiler-embeddable = { module = "org.jetbrains.kotlin:kotlin-compiler-embeddable", version.ref = "kotlin" } +kotlin-gradle-plugin-api = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin-api", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-scripting-common = { module = "org.jetbrains.kotlin:kotlin-scripting-common", version.ref = "kotlin" } kotlin-scripting-dependencies = { module = "org.jetbrains.kotlin:kotlin-scripting-dependencies", version.ref = "kotlin" } diff --git a/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java b/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java index ed223b729..6edc7ae9a 100644 --- a/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java +++ b/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java @@ -3,33 +3,64 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import org.gradle.api.InvalidUserCodeException; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.Task; import org.gradle.api.artifacts.Configuration; +import org.gradle.api.initialization.Settings; +import org.gradle.api.internal.GradleInternal; import org.gradle.api.tasks.compile.JavaCompile; public class ScipGradlePlugin implements Plugin { @Override public void apply(Project project) { + Map extra = project.getExtensions().getExtraProperties().getProperties(); + boolean kotlinGraphEnabled = + Boolean.parseBoolean(String.valueOf(extra.getOrDefault("scipKotlinGraphEnabled", false))); + if (kotlinGraphEnabled) { + project + .getPluginManager() + .withPlugin( + "org.jetbrains.kotlin.jvm", + ignored -> project.getPlugins().apply("org.scip-code.kotlin-graph")); + project + .getPluginManager() + .withPlugin( + "org.jetbrains.kotlin.multiplatform", + ignored -> + project + .getLogger() + .warn( + "scip-java: Kotlin graph exporter declines multiplatform project '{}'; only Kotlin/JVM is supported", + project.getPath())); + project + .getPluginManager() + .withPlugin( + "com.android.base", + ignored -> + project + .getLogger() + .warn( + "scip-java: Kotlin graph exporter declines Android project '{}'", + project.getPath())); + } project.afterEvaluate(this::configureProject); } private void configureProject(Project project) { - // Inject Maven Central/local so the indexer (and plugins like protobuf that - // resolve their own artifacts) can resolve dependencies even when the build - // being indexed doesn't declare any repositories of its own. - try { + // See https://github.com/gradle/gradle/issues/27260 + Settings settings = ((GradleInternal) (project.getGradle())).getSettings(); + + if (settings.getDependencyResolutionManagement().getRepositories().isEmpty()) { + // Inject Maven Central/local so the indexer (and plugins like protobuf that + // resolve their own artifacts) can resolve dependencies even when the build + // being indexed doesn't declare any repositories of its own. project.getRepositories().add(project.getRepositories().mavenCentral()); project.getRepositories().add(project.getRepositories().mavenLocal()); - } catch (InvalidUserCodeException exc) { - // FAIL_ON_PROJECT_REPOS forbids project repositories; they are declared - // in settings instead, so the injection isn't needed (issue #847). - project - .getLogger() - .info("scip-java: not injecting Maven Central/local repositories: " + exc.getMessage()); + } else { + // repositories are declared in settings instead, so the injection isn't needed (issue #847). + project.getLogger().info("scip-java: not injecting Maven Central/local repositories"); } Map extraProperties = @@ -37,6 +68,9 @@ private void configureProject(Project project) { String targetRoot = requiredExtra(extraProperties, "scipTarget").toString(); String sourceRoot = project.getRootDir().toString(); + boolean kotlinGraphEnabled = + Boolean.parseBoolean( + String.valueOf(extraProperties.getOrDefault("scipKotlinGraphEnabled", false))); // Compilation tasks we need to trigger to index all the sources we care // about. Built up as we detect the java and kotlin plugins. @@ -111,11 +145,13 @@ private void configureProject(Project project) { } // The CLI's init script provides the path of the embedded scip-kotlinc jar. - Object scipKotlinc = requiredExtra(extraProperties, "scipKotlincJar"); - project - .getTasks() - .configureEach( - task -> configureKotlinCompileTask(task, scipKotlinc, sourceRoot, targetRoot)); + if (!kotlinGraphEnabled) { + Object scipKotlinc = requiredExtra(extraProperties, "scipKotlincJar"); + project + .getTasks() + .configureEach( + task -> configureKotlinCompileTask(task, scipKotlinc, sourceRoot, targetRoot)); + } } project.getTasks().create("scipCompileAll").dependsOn(triggers); diff --git a/scip-java/build.gradle.kts b/scip-java/build.gradle.kts index 835e24adc..d94139f41 100644 --- a/scip-java/build.gradle.kts +++ b/scip-java/build.gradle.kts @@ -1,6 +1,7 @@ import org.scip_code.scip_java.buildlogic.JavacInternals import org.scip_code.scip_java.buildlogic.registerGeneratedFile import org.scip_code.scip_java.buildlogic.shadowJarArtifact +import org.gradle.jvm.application.tasks.CreateStartScripts plugins { id("scip.java-base") @@ -13,6 +14,8 @@ description = "Java and Kotlin indexer for SCIP" val javacShadowJar = shadowJarArtifact(":scip-javac", "javacShadowJar") val gradlePluginShadowJar = shadowJarArtifact(":scip-gradle-plugin", "gradlePluginShadowJar") +val kotlinGradlePluginShadowJar = + shadowJarArtifact(":scip-kotlin-gradle-plugin", "kotlinGradlePluginShadowJar") val kotlincShadowJar = shadowJarArtifact(":scip-kotlinc", "kotlincShadowJar") dependencies { @@ -25,6 +28,7 @@ dependencies { implementation(libs.kotlin.scripting.dependencies) implementation(libs.kotlin.scripting.dependencies.maven) implementation(libs.kotlinx.serialization.json.jvm) + implementation(libs.gradle.tooling.api) testImplementation(libs.kotlin.test) testImplementation(libs.kotlin.test.junit5) @@ -39,6 +43,23 @@ application { mainClass.set("org.scip_code.scip_java.ScipJava") } +// Expanding one absolute distribution path for every runtime jar can push the +// generated batch file past cmd.exe's 8,191-character command-line limit. Java +// expands a classpath wildcard itself, after cmd.exe has parsed the short +// command, while the distribution still copies the exact runtime classpath. +tasks.named("startScripts") { + doLast { + val script = windowsScript.readText() + val classpath = Regex("(?m)^set CLASSPATH=.*$") + check(classpath.containsMatchIn(script)) { + "generated Windows launcher has no classpath assignment" + } + windowsScript.writeText( + script.replace(classpath) { "set CLASSPATH=%APP_HOME%\\lib\\*" }, + ) + } +} + val generateEmbeddedResources = tasks.register("generateEmbeddedResources") { from(javacShadowJar) { rename { "scip-plugin.jar" } @@ -46,6 +67,9 @@ val generateEmbeddedResources = tasks.register("generateEmbeddedResources" from(gradlePluginShadowJar) { rename { "gradle-plugin.jar" } } + from(kotlinGradlePluginShadowJar) { + rename { "kotlin-gradle-plugin.jar" } + } from(kotlincShadowJar) { rename { "scip-kotlinc.jar" } } diff --git a/scip-java/src/main/kotlin/org/scip_code/scip_java/CliEnvironment.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/CliEnvironment.kt index e9d821e81..b95daec2c 100644 --- a/scip-java/src/main/kotlin/org/scip_code/scip_java/CliEnvironment.kt +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/CliEnvironment.kt @@ -1,5 +1,6 @@ package org.scip_code.scip_java +import java.io.InputStream import java.io.PrintStream import java.nio.file.Path import java.nio.file.Paths @@ -13,11 +14,14 @@ import java.nio.file.Paths data class CliEnvironment( val workingDirectory: Path = Paths.get("").toAbsolutePath(), val environmentVariables: Map = System.getenv(), + val standardInput: InputStream = System.`in`, val standardOutput: PrintStream = System.out, val standardError: PrintStream = System.err, ) { fun withWorkingDirectory(cwd: Path): CliEnvironment = copy(workingDirectory = cwd) + fun withStandardInput(input: InputStream): CliEnvironment = copy(standardInput = input) + fun withStandardOutput(out: PrintStream): CliEnvironment = copy(standardOutput = out) fun withStandardError(err: PrintStream): CliEnvironment = copy(standardError = err) diff --git a/scip-java/src/main/kotlin/org/scip_code/scip_java/Embedded.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/Embedded.kt index caf9b4f50..5d1307f88 100644 --- a/scip-java/src/main/kotlin/org/scip_code/scip_java/Embedded.kt +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/Embedded.kt @@ -25,6 +25,8 @@ object Embedded { fun gradlePluginJar(tmpDir: Path): Path = copyFile(tmpDir, "gradle-plugin.jar") + fun kotlinGradlePluginJar(tmpDir: Path): Path = copyFile(tmpDir, "kotlin-gradle-plugin.jar") + fun scipKotlincJar(tmpDir: Path): Path = copyFile(tmpDir, "scip-kotlinc.jar") private fun javacErrorpath(tmp: Path): Path = tmp.resolve("errorpath.txt") diff --git a/scip-java/src/main/kotlin/org/scip_code/scip_java/ScipJavaApp.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/ScipJavaApp.kt index 77a51fd39..278ac96f0 100644 --- a/scip-java/src/main/kotlin/org/scip_code/scip_java/ScipJavaApp.kt +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/ScipJavaApp.kt @@ -16,6 +16,7 @@ import org.scip_code.scip_java.buildtools.ProcessResult import org.scip_code.scip_java.buildtools.ProcessRunner import org.scip_code.scip_java.commands.AggregateCommand import org.scip_code.scip_java.commands.IndexCommand +import org.scip_code.scip_java.commands.KotlinGraphServerCommand import org.scip_code.scip_java.commands.SnapshotCommand /** @@ -66,7 +67,12 @@ class ScipJavaApp { val processedArgs = applyGlobalCwd(rewriteNestedOptions(args)) val root = RootCommand(this) root.versionOption(ScipJava.version, names = setOf("--version", "-v")) - root.subcommands(IndexCommand(), AggregateCommand(), SnapshotCommand()) + root.subcommands( + IndexCommand(), + AggregateCommand(), + SnapshotCommand(), + KotlinGraphServerCommand(), + ) return try { root.parse(processedArgs) // Commands signal failure only by throwing; reaching here is a clean exit. diff --git a/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/GradleBuildTool.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/GradleBuildTool.kt index 900287442..6e446aa13 100644 --- a/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/GradleBuildTool.kt +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/GradleBuildTool.kt @@ -1,11 +1,14 @@ package org.scip_code.scip_java.buildtools import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +import java.nio.file.StandardCopyOption import org.scip_code.scip_java.Embedded import org.scip_code.scip_java.commands.IndexCommand +import org.scip_code.scip_java.commands.KotlinGraphAggregateRunner class GradleBuildTool(index: IndexCommand) : BuildTool("Gradle", index) { @@ -16,6 +19,11 @@ class GradleBuildTool(index: IndexCommand) : BuildTool("Gradle", index) { override fun generateScip(): Int { val gradleResult = runBuild() + val graphOutput = index.kotlinGraphOutput + if (graphOutput != null) { + if (gradleResult.exitCode != 0) return gradleResult.exitCode + return KotlinGraphAggregateRunner.run(graphOutput, listOf(targetroot()), index.app) + } if (gradleResult.exitCode == 0) { val missing = reportMissingScipOutput() if (missing != 0) return missing @@ -74,11 +82,15 @@ This means our SCIP compiler plugin was not attached to one or more JavaCompile private val defaultTargetroot: Path = Paths.get("build", "scip-targetroot") private fun runBuild(): ProcessResult { - val gradleWrapper = index.workingDirectory.resolve("gradlew") + val windows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) + val gradleWrapper = + index.workingDirectory.resolve(if (windows) "gradlew.bat" else "gradlew") val gradleCommand = - if (Files.isRegularFile(gradleWrapper) && Files.isExecutable(gradleWrapper)) + if ( + Files.isRegularFile(gradleWrapper) && (windows || Files.isExecutable(gradleWrapper)) + ) gradleWrapper.toString() - else "gradle" + else if (windows) "gradle.bat" else "gradle" return TemporaryFiles.withDirectory(index) { tmp -> runCompileCommand(tmp, gradleCommand) } } @@ -86,47 +98,81 @@ This means our SCIP compiler plugin was not attached to one or more JavaCompile val script = initScript(tmp).toString() val cmd = mutableListOf() cmd += gradleCommand - cmd += "--no-daemon" cmd += "--init-script" cmd += script - cmd += "-Pkotlin.compiler.execution.strategy=in-process" cmd += "-Dscip.targetroot=${targetroot()}" - cmd += index.finalBuildCommand(listOf("clean", "scipPrintDependencies", "scipCompileAll")) - - targetroot().toFile().deleteRecursively() + if (index.kotlinGraphOutput == null) { + cmd += "--no-daemon" + cmd += "-Pkotlin.compiler.execution.strategy=in-process" + cmd += + index.finalBuildCommand(listOf("clean", "scipPrintDependencies", "scipCompileAll")) + targetroot().toFile().deleteRecursively() + } else { + cmd += "-Pkotlin.build.report.output=json" + cmd += + "-Pkotlin.build.report.json.directory=${targetroot().resolve("META-INF/kotlin-build-reports")}" + cmd += index.finalBuildCommand(listOf("samchonCommitKotlinGraph")) + } val result = index.app.runProcess(cmd, env = mapOf("TERM" to "dumb")) return Embedded.reportUnexpectedJavacErrors(index.app.reporter, tmp) ?: result } private fun initScript(tmp: Path): Path { + if (index.kotlinGraphOutput != null) { + return KotlinGraphGradleIntegration.prepare(index.workingDirectory, targetroot(), tmp) + .initScript + } val pluginpath = Embedded.scipJar(tmp) val gradlePluginPath = Embedded.gradlePluginJar(tmp) val scipKotlincPath = Embedded.scipKotlincJar(tmp) val dependenciesPath = targetroot().resolve("dependencies.txt") Files.deleteIfExists(dependenciesPath) + fun scriptPath(path: Path): String = path.toString().replace('\\', '/') val script = """ initscript { + repositories { + mavenCentral() + } dependencies{ - classpath(files("${gradlePluginPath}")) + classpath(files("${scriptPath(gradlePluginPath)}")) } } import org.scip_code.scip_java.gradle.ScipGradlePlugin allprojects { - project.ext["scipTarget"] = "${targetroot()}" - project.ext["javacPluginJar"] = "$pluginpath" - project.ext["dependenciesOut"] = "$dependenciesPath" - project.ext["scipKotlincJar"] = "$scipKotlincPath" + project.ext["scipTarget"] = "${scriptPath(targetroot())}" + project.ext["javacPluginJar"] = "${scriptPath(pluginpath)}" + project.ext["dependenciesOut"] = "${scriptPath(dependenciesPath)}" + project.ext["scipKotlincJar"] = "${scriptPath(scipKotlincPath)}" + project.ext["scipKotlinGraphEnabled"] = false apply plugin: ScipGradlePlugin } """ .trimIndent() val out = tmp.resolve("init-script.gradle") - Files.write(out, script.toByteArray(StandardCharsets.UTF_8)) + writeIfChanged(out, script.toByteArray(StandardCharsets.UTF_8)) return out } + + private fun writeIfChanged(output: Path, bytes: ByteArray) { + if (Files.isRegularFile(output) && Files.readAllBytes(output).contentEquals(bytes)) return + Files.createDirectories(output.parent) + val temporary = + output.resolveSibling("${output.fileName}.tmp-${ProcessHandle.current().pid()}") + Files.write(temporary, bytes) + try { + Files.move( + temporary, + output, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, output, StandardCopyOption.REPLACE_EXISTING) + } + } } diff --git a/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/KotlinGraphGradleIntegration.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/KotlinGraphGradleIntegration.kt new file mode 100644 index 000000000..2a52edbee --- /dev/null +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/KotlinGraphGradleIntegration.kt @@ -0,0 +1,121 @@ +package org.scip_code.scip_java.buildtools + +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.HexFormat +import org.scip_code.scip_java.Embedded + +/** Persistent, content-addressed files shared by one-shot and resident Kotlin graph builds. */ +object KotlinGraphGradleIntegration { + data class Prepared(val targetRoot: Path, val initScript: Path) + + fun prepare(projectRoot: Path, targetRoot: Path, temporary: Path): Prepared { + val kotlinc = Files.readAllBytes(Embedded.scipKotlincJar(temporary)) + val javac = Files.readAllBytes(Embedded.scipJar(temporary)) + val gradle = Files.readAllBytes(Embedded.gradlePluginJar(temporary)) + val kotlinGradle = Files.readAllBytes(Embedded.kotlinGradlePluginJar(temporary)) + val bundle = contentDigest(kotlinc, javac, gradle, kotlinGradle) + val tools = + targetRoot.resolve("META-INF/kotlin-graph-tools").resolve(bundle).toAbsolutePath() + val repository = tools.resolve("repository") + val artifact = + repository.resolve( + "org/scip-code/scip-kotlinc-k2-graph/2.3.20-e940c188/scip-kotlinc-k2-graph-2.3.20-e940c188.jar" + ) + writeIfChanged(artifact, kotlinc) + val pom = + """ + + 4.0.0 + org.scip-code + scip-kotlinc-k2-graph + 2.3.20-e940c188 + + """ + .trimIndent() + "\n" + writeIfChanged( + artifact.resolveSibling("scip-kotlinc-k2-graph-2.3.20-e940c188.pom"), + pom.toByteArray(StandardCharsets.UTF_8), + ) + val persistentJavac = tools.resolve("embedded/scip-plugin.jar") + val persistentGradle = tools.resolve("embedded/gradle-plugin.jar") + val persistentKotlinGradle = tools.resolve("embedded/kotlin-gradle-plugin.jar") + writeIfChanged(persistentJavac, javac) + writeIfChanged(persistentGradle, gradle) + writeIfChanged(persistentKotlinGradle, kotlinGradle) + + fun scriptPath(path: Path): String = path.toString().replace('\\', '/') + val sourceRoot = projectRoot.toAbsolutePath().normalize() + val script = + """ + initscript { + repositories { + mavenCentral() + } + dependencies{ + classpath(files("${scriptPath(persistentGradle)}")) + } + } + + import org.scip_code.scip_java.gradle.ScipGradlePlugin + + settingsEvaluated { settings -> + settings.dependencyResolutionManagement.repositories.maven { + url = new File("${scriptPath(repository)}") + } + } + + allprojects { + buildscript { + dependencies { + classpath(files("${scriptPath(persistentKotlinGradle)}")) + } + } + project.ext["scipTarget"] = "${scriptPath(targetRoot)}" + project.ext["javacPluginJar"] = "${scriptPath(persistentJavac)}" + project.ext["dependenciesOut"] = "${scriptPath(targetRoot.resolve("dependencies.txt"))}" + project.ext["scipKotlincJar"] = "${scriptPath(artifact)}" + project.ext["scipKotlinGraphEnabled"] = true + project.ext["scipKotlincGraphJar"] = "${scriptPath(artifact)}" + project.ext["scipKotlincGraphRepository"] = "${scriptPath(repository)}" + apply plugin: ScipGradlePlugin + } + """ + .trimIndent() + "\n" + val initScript = tools.resolve("init-script.gradle") + writeIfChanged(initScript, script.toByteArray(StandardCharsets.UTF_8)) + return Prepared(targetRoot.toAbsolutePath().normalize(), initScript) + } + + private fun contentDigest(vararg inputs: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256") + for (input in inputs) { + digest.update(input.size.toString().toByteArray(StandardCharsets.UTF_8)) + digest.update(':'.code.toByte()) + digest.update(input) + } + return HexFormat.of().formatHex(digest.digest()) + } + + private fun writeIfChanged(output: Path, bytes: ByteArray) { + if (Files.isRegularFile(output) && Files.readAllBytes(output).contentEquals(bytes)) return + Files.createDirectories(output.parent) + val temporary = + output.resolveSibling("${output.fileName}.tmp-${ProcessHandle.current().pid()}") + Files.write(temporary, bytes) + try { + Files.move( + temporary, + output, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, output, StandardCopyOption.REPLACE_EXISTING) + } + } +} diff --git a/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/IndexCommand.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/IndexCommand.kt index 087df3913..0f03d1c8f 100644 --- a/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/IndexCommand.kt +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/IndexCommand.kt @@ -60,6 +60,13 @@ class IndexCommand : CliktCommand(name = "index") { ) .path() + val kotlinGraphOutput: Path? by + option( + "--kotlin-graph-output", + help = "Write a compiler-owned Kotlin graph snapshot instead of a SCIP index.", + ) + .path() + val buildTool: String? by option( "--build-tool", diff --git a/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphAggregateRunner.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphAggregateRunner.kt new file mode 100644 index 000000000..3418da4c7 --- /dev/null +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphAggregateRunner.kt @@ -0,0 +1,344 @@ +package org.scip_code.scip_java.commands + +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.HexFormat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import org.scip_code.scip_java.ScipJava +import org.scip_code.scip_java.ScipJavaApp + +/** Aggregates only committed Kotlin compiler graph generations into one atomic artifact. */ +object KotlinGraphAggregateRunner { + private const val shardSuffix = ".graph.json" + private val sha256 = Regex("[0-9a-f]{64}") + + fun run(output: Path, targetroots: List, app: ScipJavaApp): Int { + val project = app.env.workingDirectory.toAbsolutePath().normalize() + val roots = targetroots.map { project.resolve(it).normalize() } + val targets = mutableListOf() + val names = mutableSetOf() + try { + for (root in roots) { + for (target in committedTargets(root, app)) { + val name = target["name"]!!.jsonPrimitive.content + if (!names.add(name)) { + return fail(app, "scip-java: duplicate committed graph target '$name'") + } + targets += target + } + } + } catch (_: KotlinGraphAggregationException) { + return 1 + } + if (targets.isEmpty()) { + app.error("scip-java: no committed Kotlin/JVM graph shards were produced") + return 1 + } + targets.sortBy { it["name"]!!.jsonPrimitive.content } + val artifact = + JsonObject( + linkedMapOf( + "schemaVersion" to JsonPrimitive(1), + "projectRoot" to JsonPrimitive(project.toString()), + "producer" to + JsonObject( + linkedMapOf( + "name" to JsonPrimitive("scip-kotlinc-k2-graph"), + "version" to JsonPrimitive(ScipJava.version), + "protocolVersion" to JsonPrimitive(1), + "capabilities" to + JsonObject( + linkedMapOf( + "atomicGenerations" to JsonPrimitive(true), + "incremental" to JsonPrimitive(true), + "diagnostics" to JsonPrimitive(true), + ) + ), + ) + ), + "targets" to JsonArray(targets), + ) + ) + writeAtomic(project.resolve(output), artifact.toString() + "\n") + return 0 + } + + private fun committedTargets(root: Path, app: ScipJavaApp): List { + val storeRoot = root.resolve("META-INF/kotlin-graph-store") + val targetsRoot = storeRoot.resolve("targets") + if (!Files.isDirectory(targetsRoot)) return emptyList() + val manifest = storeRoot.resolve("MANIFEST") + if (!Files.isRegularFile(manifest)) { + fail(app, "scip-java: Kotlin graph store has no committed MANIFEST at $manifest") + } + val seen = mutableSetOf() + return Files.readAllLines(manifest, StandardCharsets.UTF_8) + .filter { it.isNotEmpty() } + .flatMap { line -> + val fields = line.split(' ') + if (fields.size != 2 || !sha256.matches(fields[0]) || !sha256.matches(fields[1])) { + fail(app, "scip-java: invalid Kotlin graph MANIFEST entry at $manifest") + } + if (!seen.add(fields[0])) { + fail(app, "scip-java: duplicate Kotlin graph MANIFEST target at $manifest") + } + committedGeneration(targetsRoot.resolve(fields[0]), fields[1], app) + } + } + + private fun committedGeneration( + targetRoot: Path, + manifestGeneration: String?, + app: ScipJavaApp, + ): List { + val current = targetRoot.resolve("CURRENT") + val generation = + manifestGeneration ?: Files.readString(current, StandardCharsets.UTF_8).trim() + if (!sha256.matches(generation)) { + fail(app, "scip-java: invalid Kotlin graph CURRENT pointer at $current") + } + val committed = targetRoot.resolve("generations").resolve(generation).normalize() + if (!committed.startsWith(targetRoot) || !Files.isDirectory(committed)) { + fail(app, "scip-java: Kotlin graph CURRENT pointer names no generation at $current") + } + if (directoryDigest(committed) != generation) { + fail(app, "scip-java: committed Kotlin graph generation digest mismatch at $committed") + } + val shards = readShards(committed, app) + val sourcesFile = committed.resolve("SOURCES") + if (!Files.isRegularFile(sourcesFile)) { + fail(app, "scip-java: committed Kotlin graph generation has no SOURCES: $committed") + } + val sources = + Files.readAllLines(sourcesFile, StandardCharsets.UTF_8).filter { it.isNotEmpty() } + val shardSources = + shards.map { it["source"]!!.jsonPrimitive.content }.distinct().sortedWith(::compareUtf8) + if (sources.sortedWith(::compareUtf8) != shardSources) { + fail(app, "scip-java: committed Kotlin graph generation SOURCES mismatch at $committed") + } + val universeFile = committed.resolve("UNIVERSE") + if (!Files.isRegularFile(universeFile)) { + fail(app, "scip-java: committed Kotlin graph generation has no UNIVERSE: $committed") + } + val universeBytes = Files.readAllBytes(universeFile) + if (universeBytes.isEmpty()) { + fail( + app, + "scip-java: committed Kotlin graph generation has an empty UNIVERSE: $committed", + ) + } + val universe = byteDigest(universeBytes) + + val targetFile = committed.resolve("TARGET") + val targetsFile = committed.resolve("TARGETS") + if (Files.isRegularFile(targetsFile)) { + if (Files.isRegularFile(targetFile)) { + fail( + app, + "scip-java: committed Kotlin graph generation has TARGET and TARGETS: $committed", + ) + } + val names = + Files.readAllLines(targetsFile, StandardCharsets.UTF_8) + .filter { it.isNotEmpty() } + .sortedWith(::compareUtf8) + if (names.any(String::isEmpty) || names.distinct().size != names.size) { + fail(app, "scip-java: invalid committed Kotlin graph TARGETS at $committed") + } + val grouped = shards.groupBy { it["target"]!!.jsonPrimitive.content } + if (names != grouped.keys.sortedWith(::compareUtf8)) { + fail( + app, + "scip-java: committed Kotlin graph generation TARGETS mismatch at $committed", + ) + } + return names.map { name -> target(name, generation, universe, grouped.getValue(name)) } + } + if (!Files.isRegularFile(targetFile)) { + fail( + app, + "scip-java: committed Kotlin graph generation has no TARGET or TARGETS: $committed", + ) + } + val name = Files.readString(targetFile, StandardCharsets.UTF_8).trim() + if (name.isEmpty()) { + fail( + app, + "scip-java: committed Kotlin graph generation has an empty TARGET: $committed", + ) + } + if (shards.isEmpty() && sources.isEmpty()) return emptyList() + if (shards.any { it["target"]!!.jsonPrimitive.content != name }) { + fail(app, "scip-java: committed Kotlin graph generation target mismatch at $committed") + } + return listOf(target(name, generation, universe, shards)) + } + + private fun readShards(root: Path, app: ScipJavaApp): List = + Files.walk(root).use { paths -> + paths + .filter(Files::isRegularFile) + .filter { it.fileName.toString().endsWith(shardSuffix) } + .sorted() + .map { path -> parseShard(path, app) } + .filter { it != null } + .map { it!! } + .toList() + } + + private fun parseShard(path: Path, app: ScipJavaApp): JsonObject? { + val parsed = + runCatching { Json.parseToJsonElement(Files.readString(path, StandardCharsets.UTF_8)) } + .getOrElse { + fail(app, "scip-java: malformed Kotlin graph shard $path: ${it.message}") + } + if (parsed !is JsonObject || parsed["schemaVersion"]?.jsonPrimitive?.intOrNull != 1) { + fail(app, "scip-java: unsupported Kotlin graph shard schema at $path") + } + val target = parsed["target"]?.jsonPrimitive?.content + val source = parsed["source"]?.jsonPrimitive?.content + val checkerDigest = parsed["checkerDigest"]?.jsonPrimitive?.content + val diskDigest = parsed["diskDigest"]?.jsonPrimitive?.content + if ( + target.isNullOrEmpty() || + source.isNullOrEmpty() || + checkerDigest == null || + diskDigest == null || + !sha256.matches(checkerDigest) || + (diskDigest.isNotEmpty() && !sha256.matches(diskDigest)) + ) { + fail(app, "scip-java: Kotlin graph shard has invalid metadata at $path") + } + if (diskDigest.isNotEmpty()) { + val project = app.env.workingDirectory.toAbsolutePath().normalize() + val sourcePath = project.resolve(source).normalize() + if ( + !sourcePath.startsWith(project) || + !Files.isRegularFile(sourcePath) || + byteDigest(Files.readAllBytes(sourcePath)) != diskDigest + ) { + fail(app, "scip-java: Kotlin graph source moved after compilation: $source") + } + } + return parsed + } + + private fun target( + name: String, + generation: String, + universe: String, + shards: List, + ): JsonObject = + JsonObject( + linkedMapOf( + "name" to JsonPrimitive(name), + "generation" to JsonPrimitive(generation), + "universe" to JsonPrimitive(universe), + "coverage" to coverage(shards), + "shards" to JsonArray(shards), + ) + ) + + private fun coverage(shards: List): JsonObject { + val unresolved = + shards + .flatMap { shard -> + shard["unresolved"]?.let { (it as? JsonArray)?.toList() }.orEmpty() + } + .mapNotNull { row -> (row as? JsonObject)?.get("family")?.jsonPrimitive?.content } + .toSet() + val states = + linkedMapOf( + "contains" to "partial", + "exports" to "partial", + "imports" to "complete", + "calls" to "complete", + "accesses" to "complete", + "instantiates" to "complete", + "type_ref" to "partial", + "extends" to "complete", + "implements" to "complete", + "overrides" to "complete", + "dispatches" to "partial", + "decorates" to "complete", + "renders" to "unsupported", + "tests" to "partial", + "references" to "partial", + ) + for (family in unresolved) { + if (states[family] == "complete") states[family] = "partial" + } + return JsonObject(states.mapValues { JsonPrimitive(it.value) }) + } + + private fun digest(values: List): String { + val digest = MessageDigest.getInstance("SHA-256") + for (value in values) { + val bytes = value.toString().toByteArray(StandardCharsets.UTF_8) + digest.update(bytes.size.toString().toByteArray(StandardCharsets.UTF_8)) + digest.update(':'.code.toByte()) + digest.update(bytes) + } + return HexFormat.of().formatHex(digest.digest()) + } + + private fun byteDigest(value: ByteArray): String = + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)) + + private fun directoryDigest(root: Path): String { + val digest = MessageDigest.getInstance("SHA-256") + Files.walk(root).use { paths -> + for (file in paths.filter(Files::isRegularFile).sorted().toList()) { + val relative = root.relativize(file).toString().replace('\\', '/') + update(digest, relative.toByteArray(StandardCharsets.UTF_8)) + update(digest, Files.readAllBytes(file)) + } + } + return HexFormat.of().formatHex(digest.digest()) + } + + private fun update(digest: MessageDigest, value: ByteArray) { + digest.update(value.size.toString().toByteArray(StandardCharsets.UTF_8)) + digest.update(':'.code.toByte()) + digest.update(value) + } + + private fun compareUtf8(left: String, right: String): Int = + java.util.Arrays.compareUnsigned( + left.toByteArray(StandardCharsets.UTF_8), + right.toByteArray(StandardCharsets.UTF_8), + ) + + private fun writeAtomic(output: Path, text: String) { + Files.createDirectories(output.parent) + val temporary = + output.resolveSibling("${output.fileName}.tmp-${ProcessHandle.current().pid()}") + Files.writeString(temporary, text, StandardCharsets.UTF_8) + try { + Files.move( + temporary, + output, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, output, StandardCopyOption.REPLACE_EXISTING) + } + } + + private fun fail(app: ScipJavaApp, message: String): Nothing { + app.error(message) + throw KotlinGraphAggregationException() + } + + private class KotlinGraphAggregationException : RuntimeException() +} diff --git a/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphServerCommand.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphServerCommand.kt new file mode 100644 index 000000000..328aa3eae --- /dev/null +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphServerCommand.kt @@ -0,0 +1,151 @@ +package org.scip_code.scip_java.commands + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.requireObject +import java.net.URI +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import org.gradle.tooling.GradleConnector +import org.gradle.tooling.ProjectConnection +import org.scip_code.scip_java.ScipJavaApp +import org.scip_code.scip_java.buildtools.KotlinGraphGradleIntegration + +/** Resident NDJSON endpoint that reuses one Gradle Tooling API connection and its daemon. */ +class KotlinGraphServerCommand : CliktCommand(name = "kotlin-graph-server") { + private val app by requireObject() + + override fun help(context: com.github.ajalt.clikt.core.Context): String = + "Serve compiler-owned Kotlin graph generations over NDJSON." + + override fun run() { + // Gradle's Tooling API writes distribution-download progress directly + // to System.out before a BuildLauncher can redirect its output. Keep + // stdout reserved for protocol frames even on the first cold launch. + val systemOutput = System.out + System.setOut(app.env.standardError) + try { + serve() + } finally { + System.setOut(systemOutput) + } + } + + private fun serve() { + val project = app.env.workingDirectory.toAbsolutePath().normalize() + val targetRoot = project.resolve("build/scip-targetroot") + val temporary = Files.createTempDirectory("scip-java-kotlin-graph") + val prepared = + try { + KotlinGraphGradleIntegration.prepare(project, targetRoot, temporary) + } finally { + temporary.toFile().deleteRecursively() + } + val connector = + GradleConnector.newConnector() + .forProjectDirectory(project.toFile()) + .useDistribution(gradleDistribution(project)) + val connection: ProjectConnection = connector.connect() + try { + app.env.standardInput.bufferedReader(StandardCharsets.UTF_8).useLines { lines -> + for (line in lines) { + if (line.isBlank()) continue + val request = parseRequest(line, project) + val failure = + runCatching { + connection + .newBuild() + .forTasks("samchonCommitKotlinGraph") + .withArguments( + "--init-script", + prepared.initScript.toString(), + "-Dscip.targetroot=${prepared.targetRoot}", + "-Pkotlin.build.report.output=json", + "-Pkotlin.build.report.json.directory=${prepared.targetRoot.resolve("META-INF/kotlin-build-reports")}", + ) + .setStandardOutput(app.env.standardError) + .setStandardError(app.env.standardError) + .run() + check( + KotlinGraphAggregateRunner.run( + request.output, + listOf(prepared.targetRoot), + app, + ) == 0 + ) { + "Kotlin graph aggregation failed" + } + } + .exceptionOrNull() + respond(request.id, failure) + } + } + } finally { + connection.close() + } + } + + private fun parseRequest(line: String, project: Path): Request { + val value = + Json.parseToJsonElement(line) as? JsonObject + ?: error("Kotlin graph server request must be a JSON object") + val id = + value["id"]?.jsonPrimitive?.intOrNull + ?: error("Kotlin graph server request has no integer id") + check(value["protocolVersion"]?.jsonPrimitive?.intOrNull == PROTOCOL_VERSION) { + "Kotlin graph server protocol mismatch" + } + val text = + value["output"]?.jsonPrimitive?.content + ?: error("Kotlin graph server request has no output") + val output = project.resolve(text).normalize() + check(output.isAbsolute) { "Kotlin graph server output must be absolute" } + return Request(id, output) + } + + private fun respond(id: Int, failure: Throwable?) { + val response = + linkedMapOf( + "id" to JsonPrimitive(id), + "protocolVersion" to JsonPrimitive(PROTOCOL_VERSION), + "ok" to JsonPrimitive(failure == null), + ) + if (failure != null) { + response["error"] = JsonPrimitive(message(failure)) + } + app.env.standardOutput.println(JsonObject(response)) + app.env.standardOutput.flush() + } + + private fun message(failure: Throwable): String { + var current = failure + while (current.cause != null && current.cause !== current) current = current.cause!! + return (current.message ?: current::class.java.name).take(MAX_ERROR_CHARS) + } + + private fun gradleDistribution(project: Path): URI { + val wrapper = project.resolve("gradle/wrapper/gradle-wrapper.properties") + if (!Files.isRegularFile(wrapper)) return URI(DEFAULT_GRADLE_DISTRIBUTION) + val properties = Properties() + Files.newInputStream(wrapper).use(properties::load) + return URI( + properties.getProperty("distributionUrl") + ?: error("Gradle wrapper properties have no distributionUrl") + ) + } + + private data class Request(val id: Int, val output: Path) + + private companion object { + const val PROTOCOL_VERSION = 1 + const val MAX_ERROR_CHARS = 16 * 1024 + const val DEFAULT_GRADLE_DISTRIBUTION = + "https://services.gradle.org/distributions/gradle-9.4.1-bin.zip" + } +} diff --git a/scip-java/src/test/kotlin/tests/BuildToolHarness.kt b/scip-java/src/test/kotlin/tests/BuildToolHarness.kt index 2ea39aa09..8e8992e3f 100644 --- a/scip-java/src/test/kotlin/tests/BuildToolHarness.kt +++ b/scip-java/src/test/kotlin/tests/BuildToolHarness.kt @@ -1,5 +1,6 @@ package tests +import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.PrintStream import java.nio.charset.StandardCharsets @@ -25,13 +26,19 @@ import org.scip_code.scip_java.buildtools.ClasspathEntry abstract class BuildToolHarness { /** Run `scip-java` in-process with stdout/stderr redirected into a buffer. */ - private fun runScipJava(workingDirectory: Path, arguments: List): Pair { + protected fun runScipJava( + workingDirectory: Path, + arguments: List, + standardInput: String = "", + ): Pair { val buffer = ByteArrayOutputStream() val stream = PrintStream(buffer, true, StandardCharsets.UTF_8.name()) val app = ScipJavaApp() app.env = CliEnvironment( workingDirectory = workingDirectory, + standardInput = + ByteArrayInputStream(standardInput.toByteArray(StandardCharsets.UTF_8)), standardOutput = stream, standardError = stream, ) @@ -39,6 +46,42 @@ abstract class BuildToolHarness { return exit to buffer.toString(StandardCharsets.UTF_8.name()) } + /** Run a line protocol with stdout isolated from diagnostics and tool output. */ + protected fun runScipJavaProtocol( + workingDirectory: Path, + arguments: List, + standardInput: String, + ): ProtocolRun { + val outputBuffer = ByteArrayOutputStream() + val errorBuffer = ByteArrayOutputStream() + val output = PrintStream(outputBuffer, true, StandardCharsets.UTF_8.name()) + val error = PrintStream(errorBuffer, true, StandardCharsets.UTF_8.name()) + val app = ScipJavaApp() + app.env = + CliEnvironment( + workingDirectory = workingDirectory, + standardInput = + ByteArrayInputStream(standardInput.toByteArray(StandardCharsets.UTF_8)), + standardOutput = output, + standardError = error, + ) + val systemOutput = System.out + System.setOut(output) + val exit = + try { + app.run(arguments) + } finally { + System.setOut(systemOutput) + } + return ProtocolRun( + exit, + outputBuffer.toString(StandardCharsets.UTF_8.name()), + errorBuffer.toString(StandardCharsets.UTF_8.name()), + ) + } + + protected data class ProtocolRun(val exit: Int, val output: String, val error: String) + private fun listScipShards(targetroot: Path): List { if (!Files.isDirectory(targetroot)) return emptyList() Files.walk(targetroot).use { stream -> @@ -52,7 +95,7 @@ abstract class BuildToolHarness { * canonical paths into their output, so the sourceroot must be canonical too or path prefix * checks fail. */ - private fun newTempBase(): Path = Files.createTempDirectory("buildtools").toRealPath() + protected fun newTempBase(): Path = Files.createTempDirectory("buildtools").toRealPath() /** * Materialize a test project from the `fixtures/` directory on the test classpath into diff --git a/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt new file mode 100644 index 000000000..e14cda8ef --- /dev/null +++ b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt @@ -0,0 +1,301 @@ +package tests + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class KotlinGraphGradleBuildToolTest : BuildToolHarness() { + @Test + fun graphModeReusesGradleStateAndPublishesOnlySuccessfulGenerations() { + val base = newTempBase() + try { + val workingDirectory = Files.createDirectories(base.resolve("workingDirectory")) + val cacheDirectory = Files.createDirectories(base.resolve("cache")) + val buildScript = workingDirectory.resolve("build.gradle") + Files.write(buildScript, ByteArray(0)) + val wrapperCommand = + if (System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) { + listOf("cmd.exe", "/c", "gradle.bat", "wrapper", "--gradle-version", "9.4.1") + } else { + listOf("gradle", "wrapper", "--gradle-version", "9.4.1") + } + exec(wrapperCommand, workingDirectory) + copyFixture("gradle/kotlin-graph", workingDirectory) + + val targetRoot = workingDirectory.resolve("targetroot") + fun run(output: String): Pair = + runScipJava( + workingDirectory, + listOf( + "index", + "--temporary-directory", + cacheDirectory.toString(), + "--targetroot", + targetRoot.toString(), + "--kotlin-graph-output", + workingDirectory.resolve(output).toString(), + "--build-tool", + "gradle", + "--", + "--build-cache", + "--configuration-cache", + "samchonCommitKotlinGraph", + ), + ) + + val (firstExit, firstLog) = run("first.json") + assertEquals(0, firstExit, firstLog) + val first = Files.readAllBytes(workingDirectory.resolve("first.json")) + assertGraphContract(first) + val firstUniverse = mainUniverse(first) + + val (secondExit, secondLog) = run("second.json") + assertEquals(0, secondExit, secondLog) + assertTrue(secondLog.contains("Reusing configuration cache"), secondLog) + assertContentEquals(first, Files.readAllBytes(workingDirectory.resolve("second.json"))) + val reports = targetRoot.resolve("META-INF/kotlin-build-reports") + assertBuildReportRecordedNonIncrementalReason(reports) + + val source = workingDirectory.resolve("src/main/kotlin/example/GraphFixture.kt") + val original = Files.readString(source, StandardCharsets.UTF_8) + Files.writeString(source, original.replace("value.uppercase()", "value.lowercase()")) + val (editedExit, editedLog) = run("edited.json") + assertEquals(0, editedExit, editedLog) + assertTrue(editedLog.contains("Reusing configuration cache"), editedLog) + val edited = Files.readAllBytes(workingDirectory.resolve("edited.json")) + assertFalse(first.contentEquals(edited)) + assertEquals( + firstUniverse, + mainUniverse(edited), + "a source body edit must not move the target/classpath universe", + ) + + val manifest = targetRoot.resolve("META-INF/kotlin-graph-store/MANIFEST") + val committed = Files.readAllBytes(manifest) + Files.writeString(source, "$original\nfun broken(: Unit = Unit\n") + val failedOutput = workingDirectory.resolve("failed.json") + val (failedExit, _) = run("failed.json") + assertNotEquals(0, failedExit) + assertContentEquals(committed, Files.readAllBytes(manifest)) + assertFalse(Files.exists(failedOutput)) + + Files.writeString(source, original) + val (recoveredExit, recoveredLog) = run("recovered.json") + assertEquals(0, recoveredExit, recoveredLog) + assertContentEquals( + first, + Files.readAllBytes(workingDirectory.resolve("recovered.json")), + ) + + val residentFirst = workingDirectory.resolve("resident-first.json") + val residentSecond = workingDirectory.resolve("resident-second.json") + val requests = + listOf(residentFirst, residentSecond).mapIndexed { index, output -> + """{"id":${index + 1},"protocolVersion":1,"output":${JsonPrimitive(output.toString())}}""" + } + val resident = + runScipJavaProtocol( + workingDirectory, + listOf("kotlin-graph-server"), + requests.joinToString("\n", postfix = "\n"), + ) + assertEquals(0, resident.exit, resident.error) + val responses = + resident.output + .lineSequence() + .filter { it.isNotBlank() } + .map(Json::parseToJsonElement) + .map { it.jsonObject } + .toList() + assertEquals( + listOf(1, 2), + responses.map { it.getValue("id").jsonPrimitive.content.toInt() }, + ) + assertTrue(responses.all { it.getValue("ok").jsonPrimitive.boolean }, resident.error) + assertContentEquals( + Files.readAllBytes(residentFirst), + Files.readAllBytes(residentSecond), + ) + + val created = workingDirectory.resolve("src/main/kotlin/example/Created.kt") + Files.writeString(created, "package example\nclass Created\n") + val (createdExit, createdLog) = run("created.json") + assertEquals(0, createdExit, createdLog) + assertEquals( + 4, + shardCount(Files.readAllBytes(workingDirectory.resolve("created.json"))), + ) + + deleteEventually(created) + val (deletedExit, deletedLog) = run("deleted.json") + assertEquals(0, deletedExit, deletedLog) + assertContentEquals(first, Files.readAllBytes(workingDirectory.resolve("deleted.json"))) + + val originalBuild = Files.readString(buildScript, StandardCharsets.UTF_8) + Files.writeString(buildScript, originalBuild.replace("2.3.20", "2.2.21")) + val (mismatchExit, mismatchLog) = run("mismatch.json") + assertNotEquals(0, mismatchExit) + assertTrue( + mismatchLog.contains( + "Kotlin graph exporter supports Kotlin Gradle Plugin 2.3.20 exactly" + ), + mismatchLog, + ) + + Files.writeString( + buildScript, + """ + plugins { + id 'org.jetbrains.kotlin.multiplatform' version '2.3.20' + } + repositories { mavenCentral() } + kotlin { jvm() } + """ + .trimIndent(), + ) + val (multiplatformExit, multiplatformLog) = run("multiplatform.json") + assertNotEquals(0, multiplatformExit) + assertTrue( + multiplatformLog.contains( + "Kotlin graph exporter declines multiplatform project ':'; only Kotlin/JVM is supported" + ), + multiplatformLog, + ) + } finally { + base.toFile().deleteRecursively() + } + } + + private fun assertGraphContract(bytes: ByteArray) { + val graph = Json.parseToJsonElement(bytes.toString(StandardCharsets.UTF_8)).jsonObject + val producer = graph.getValue("producer").jsonObject + assertEquals("scip-kotlinc-k2-graph", producer.getValue("name").jsonPrimitive.content) + val capabilities = producer.getValue("capabilities").jsonObject + assertTrue(capabilities.getValue("atomicGenerations").jsonPrimitive.boolean) + assertTrue(capabilities.getValue("incremental").jsonPrimitive.boolean) + assertTrue(capabilities.getValue("diagnostics").jsonPrimitive.boolean) + + val targets = graph.getValue("targets").jsonArray.map { it.jsonObject } + assertEquals( + listOf(":|jvm|main", ":|jvm|test"), + targets.map { it.getValue("name").jsonPrimitive.content }, + ) + val target = targets.single { it.getValue("name").jsonPrimitive.content == ":|jvm|main" } + val shards = target.getValue("shards").jsonArray + assertEquals(2, shards.size) + val shard = + shards + .map { it.jsonObject } + .single { it.getValue("source").jsonPrimitive.content.endsWith("GraphFixture.kt") } + val facts = + shard + .getValue("edges") + .jsonArray + .map { it.jsonObject.getValue("kind").jsonPrimitive.content } + .toSet() + assertTrue( + facts.containsAll( + setOf( + "contains", + "exports", + "imports", + "calls", + "accesses", + "instantiates", + "type_ref", + "extends", + "implements", + "overrides", + "decorates", + "tests", + "references", + ) + ), + "missing compiler facts: $facts", + ) + val unresolved = + shard.getValue("unresolved").jsonArray.map { + it.jsonObject.getValue("family").jsonPrimitive.content + } + assertTrue("dispatches" in unresolved) + assertTrue(shard.getValue("diagnostics").jsonArray.isNotEmpty()) + val nodes = shard.getValue("nodes").jsonArray.map { it.jsonObject } + assertTrue(nodes.any { it.getValue("name").jsonPrimitive.content == "delegated" }) + assertTrue(nodes.any { it.getValue("name").jsonPrimitive.content == "suspended" }) + assertTrue(nodes.any { it.getValue("name").jsonPrimitive.content == "inlined" }) + assertTrue(nodes.any { it.getValue("name").jsonPrimitive.content == "Outcome" }) + assertTrue(nodes.all { it.getValue("origin").jsonPrimitive.content.isNotEmpty() }) + } + + private fun shardCount(bytes: ByteArray): Int = + Json.parseToJsonElement(bytes.toString(StandardCharsets.UTF_8)) + .jsonObject + .getValue("targets") + .jsonArray + .sumOf { it.jsonObject.getValue("shards").jsonArray.size } + + private fun mainUniverse(bytes: ByteArray): String = + Json.parseToJsonElement(bytes.toString(StandardCharsets.UTF_8)) + .jsonObject + .getValue("targets") + .jsonArray + .map { it.jsonObject } + .single { it.getValue("name").jsonPrimitive.content == ":|jvm|main" } + .getValue("universe") + .jsonPrimitive + .content + + private fun assertBuildReportRecordedNonIncrementalReason(reports: Path) { + val reportFiles = + Files.walk(reports).use { paths -> + paths + .filter(Files::isRegularFile) + .filter { it.fileName.toString().endsWith(".json") } + .toList() + } + assertTrue(reportFiles.isNotEmpty(), "Kotlin build reports were not captured") + val reasons = + reportFiles.flatMap { report -> + Json.parseToJsonElement(Files.readString(report, StandardCharsets.UTF_8)) + .jsonObject["buildOperationRecord"] + ?.jsonArray + .orEmpty() + .flatMap { operation -> + operation.jsonObject["icLogLines"] + ?.jsonArray + .orEmpty() + .map { it.jsonPrimitive.content } + .filter { + it.startsWith("Non-incremental compilation will be performed:") + } + } + } + assertTrue(reasons.isNotEmpty(), "Kotlin build reports recorded no non-incremental reason") + } + + private fun deleteEventually(path: Path) { + var failure: Exception? = null + repeat(20) { + try { + Files.deleteIfExists(path) + return + } catch (exception: Exception) { + failure = exception + Thread.sleep(100) + } + } + throw failure ?: IllegalStateException("unable to delete $path") + } +} diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-graph/build.gradle b/scip-java/src/test/resources/fixtures/gradle/kotlin-graph/build.gradle new file mode 100644 index 000000000..60edb44af --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-graph/build.gradle @@ -0,0 +1,33 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' version '2.3.20' + id 'org.jetbrains.kotlin.plugin.allopen' version '2.3.20' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' +} + +allOpen { + annotation('example.OpenForGraph') +} + +def generatedKotlin = layout.buildDirectory.dir('generated/sources/kotlin/main') +kotlin.sourceSets.main.kotlin.srcDir(generatedKotlin) + +tasks.register('generateKotlinGraphFixture') { + def output = generatedKotlin.map { it.file('example/Generated.kt') } + outputs.file(output) + doLast { + def file = output.get().asFile + file.parentFile.mkdirs() + file.text = 'package example\nclass Generated\n' + } +} + +tasks.named('compileKotlin') { + dependsOn('generateKotlinGraphFixture') +} diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-graph/src/main/kotlin/example/GraphFixture.kt b/scip-java/src/test/resources/fixtures/gradle/kotlin-graph/src/main/kotlin/example/GraphFixture.kt new file mode 100644 index 000000000..de990e91c --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-graph/src/main/kotlin/example/GraphFixture.kt @@ -0,0 +1,66 @@ +package example + +import org.junit.jupiter.api.Test as Spec + +annotation class Marker + +annotation class OpenForGraph + +open class Base + +sealed interface Outcome { + data class Value(val value: String) : Outcome +} + +interface Service { + fun execute(value: String): String +} + +@Marker +@OpenForGraph +class ServiceImpl(val state: String = "ready") : Base(), Service { + constructor(value: Int) : this(value.toString()) + + var mutable: String = state + get() = field + set(value) { + field = value + } + + val delegated: String by lazy { state } + + override fun execute(value: String): String = value.uppercase() + + fun readMutable(): String = mutable +} + +@Deprecated("diagnostic fixture") +fun old(): Unit = Unit + +fun String.describe(): String = "string:$this" + +fun Int.describe(): String = "integer:$this" + +fun identity(value: T): T = value + +suspend fun suspended(value: String): String = value + +inline fun inlined(value: T, block: (T) -> Unit): T { + block(value) + return value +} + +fun overloaded(value: String): String = value + +fun overloaded(value: Int): Int = value + +fun runFixture(service: Service): String { + old() + val created: Service = ServiceImpl(1) + val first = "value".describe() + val second = 7.describe() + return service.execute("${created.execute(first)}/$second") +} + +@Spec +fun testFixture(): Unit = Unit diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-graph/src/test/kotlin/example/GraphFixtureTest.kt b/scip-java/src/test/resources/fixtures/gradle/kotlin-graph/src/test/kotlin/example/GraphFixtureTest.kt new file mode 100644 index 000000000..246f967b1 --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-graph/src/test/kotlin/example/GraphFixtureTest.kt @@ -0,0 +1,8 @@ +package example + +import org.junit.jupiter.api.Test + +class GraphFixtureTest { + @Test + fun runs(): Unit = Unit +} diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-jvm-toolchains/build.gradle b/scip-java/src/test/resources/fixtures/gradle/kotlin-jvm-toolchains/build.gradle index 21b68107f..74aa8b743 100644 --- a/scip-java/src/test/resources/fixtures/gradle/kotlin-jvm-toolchains/build.gradle +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-jvm-toolchains/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.jetbrains.kotlin.jvm' version '2.2.0' + id 'org.jetbrains.kotlin.jvm' version '2.3.20' } java { toolchain { diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin2/build.gradle b/scip-java/src/test/resources/fixtures/gradle/kotlin2/build.gradle index d0a96fa86..eccb6c797 100644 --- a/scip-java/src/test/resources/fixtures/gradle/kotlin2/build.gradle +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin2/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.jetbrains.kotlin.jvm' version '2.2.0' + id 'org.jetbrains.kotlin.jvm' version '2.3.20' } kotlin { jvmToolchain(17) diff --git a/scip-kotlin-gradle-plugin/build.gradle.kts b/scip-kotlin-gradle-plugin/build.gradle.kts new file mode 100644 index 000000000..dae16868f --- /dev/null +++ b/scip-kotlin-gradle-plugin/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("scip.java-library") + id("scip.shadow-producer") +} + +description = "Gradle support plugin for the pinned Kotlin K2 graph exporter" + +dependencies { + compileOnly(libs.gradle.api) + compileOnly(libs.gradle.test.kit) + compileOnly(libs.kotlin.gradle.plugin.api) + implementation(libs.kotlinx.serialization.json.jvm) +} diff --git a/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGenerationCoordinator.java b/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGenerationCoordinator.java new file mode 100644 index 000000000..f7619b94c --- /dev/null +++ b/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGenerationCoordinator.java @@ -0,0 +1,156 @@ +package org.scip_code.scip_java.gradle; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.plugins.ExtraPropertiesExtension; +import org.gradle.api.tasks.TaskProvider; + +/** Coordinates task-owned candidates behind one build-wide atomic manifest. */ +final class KotlinGraphGenerationCoordinator { + private static final String EXTENSION = "scipKotlinGraphGenerationCoordinator"; + static final String COMMIT_TASK = "samchonCommitKotlinGraph"; + + private final Path targetRoot; + private final Path sourceRoot; + private final Task commitTask; + private final Map entries = new LinkedHashMap<>(); + + private KotlinGraphGenerationCoordinator(Project rootProject, Path targetRoot, Path sourceRoot) { + this.targetRoot = targetRoot.toAbsolutePath().normalize(); + this.sourceRoot = sourceRoot.toAbsolutePath().normalize(); + this.commitTask = rootProject.getTasks().maybeCreate(COMMIT_TASK); + this.commitTask.doLast(ignored -> commit()); + } + + static KotlinGraphGenerationCoordinator acquire( + Project project, Path targetRoot, Path sourceRoot) { + Project root = project.getRootProject(); + ExtraPropertiesExtension extra = root.getExtensions().getExtraProperties(); + if (extra.has(EXTENSION)) { + KotlinGraphGenerationCoordinator coordinator = + (KotlinGraphGenerationCoordinator) extra.get(EXTENSION); + coordinator.assertCompatible(targetRoot, sourceRoot); + return coordinator; + } + KotlinGraphGenerationCoordinator coordinator = + new KotlinGraphGenerationCoordinator(root, targetRoot, sourceRoot); + extra.set(EXTENSION, coordinator); + return coordinator; + } + + void register(TaskProvider task, KotlinGraphGenerationStore store) { + Entry prior = entries.putIfAbsent(store.targetKey(), new Entry(store)); + if (prior != null) { + throw new IllegalStateException("duplicate Kotlin graph target key " + store.targetKey()); + } + commitTask.dependsOn(task); + commitTask.mustRunAfter(task); + } + + private void commit() { + try { + List manifest = new ArrayList<>(); + for (Entry entry : entries.values()) { + String generation = entry.store.currentGenerationName(); + if (generation != null) { + manifest.add(new ManifestEntry(entry.store.targetKey(), generation, entry.store)); + } + } + manifest.sort(Comparator.comparing(ManifestEntry::targetKey)); + Path storeRoot = targetRoot.resolve("META-INF").resolve("kotlin-graph-store"); + Files.createDirectories(storeRoot); + Path output = storeRoot.resolve("MANIFEST"); + Path temporary = output.resolveSibling("MANIFEST.tmp-" + ProcessHandle.current().pid()); + StringBuilder text = new StringBuilder(); + for (ManifestEntry entry : manifest) { + text.append(entry.targetKey()).append(' ').append(entry.generation()).append('\n'); + } + Files.writeString(temporary, text, StandardCharsets.UTF_8); + move(temporary, output); + for (ManifestEntry entry : manifest) { + entry.store().pruneRetaining(entry.generation()); + } + pruneRemovedTargets( + storeRoot.resolve("targets"), + manifest.stream().map(ManifestEntry::targetKey).collect(Collectors.toSet())); + } catch (IOException exception) { + throw new UncheckedIOException( + "scip-java: unable to publish Kotlin graph manifest", exception); + } + } + + private void assertCompatible(Path targetRoot, Path sourceRoot) { + if (!this.targetRoot.equals(targetRoot.toAbsolutePath().normalize()) + || !this.sourceRoot.equals(sourceRoot.toAbsolutePath().normalize())) { + throw new IllegalStateException("scip-java: inconsistent Gradle graph roots"); + } + } + + private static void move(Path source, Path destination) throws IOException { + try { + Files.move( + source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static final class Entry { + private final KotlinGraphGenerationStore store; + + private Entry(KotlinGraphGenerationStore store) { + this.store = store; + } + } + + private static void pruneRemovedTargets(Path targetsRoot, Set retained) + throws IOException { + if (!Files.isDirectory(targetsRoot)) return; + try (var paths = Files.list(targetsRoot)) { + for (Path target : paths.filter(Files::isDirectory).toList()) { + if (!retained.contains(target.getFileName().toString())) deleteTree(target); + } + } + } + + private static void deleteTree(Path root) throws IOException { + Files.walkFileTree( + root, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path directory, IOException exception) + throws IOException { + if (exception != null) throw exception; + Files.delete(directory); + return FileVisitResult.CONTINUE; + } + }); + } + + private record ManifestEntry( + String targetKey, String generation, KotlinGraphGenerationStore store) {} +} diff --git a/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGenerationStore.java b/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGenerationStore.java new file mode 100644 index 000000000..7f979c6c5 --- /dev/null +++ b/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGenerationStore.java @@ -0,0 +1,558 @@ +package org.scip_code.scip_java.gradle; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Array; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import kotlinx.serialization.json.Json; +import kotlinx.serialization.json.JsonElement; +import kotlinx.serialization.json.JsonElementKt; +import kotlinx.serialization.json.JsonObject; +import kotlinx.serialization.json.JsonPrimitive; +import org.gradle.api.Task; +import org.gradle.api.provider.Provider; + +/** Task-owned immutable graph generations with an atomically replaced current pointer. */ +final class KotlinGraphGenerationStore { + private static final String SHARD_SUFFIX = ".graph.json"; + private static final String SEEN_ROOT = ".seen"; + private static final String DECLARED_SOURCES = "DECLARED_SOURCES"; + private static final java.util.regex.Pattern SHA256 = + java.util.regex.Pattern.compile("[0-9a-f]{64}"); + + private final Path sourceRoot; + private final String target; + private final String targetKey; + private final Path storeRoot; + private final Path outputRoot; + private final Path staging; + private final Path generations; + private final Path current; + private final Path embeddedKotlincPlugin; + + KotlinGraphGenerationStore(Path targetRoot, Path sourceRoot, String target) { + this(targetRoot, sourceRoot, target, null); + } + + KotlinGraphGenerationStore( + Path targetRoot, Path sourceRoot, String target, Path embeddedKotlincPlugin) { + this.sourceRoot = sourceRoot.toAbsolutePath().normalize(); + this.target = target; + this.embeddedKotlincPlugin = + embeddedKotlincPlugin == null ? null : embeddedKotlincPlugin.toAbsolutePath().normalize(); + this.targetKey = digest(target); + this.storeRoot = + targetRoot.toAbsolutePath().normalize().resolve("META-INF").resolve("kotlin-graph-store"); + this.outputRoot = storeRoot.resolve("targets").resolve(targetKey); + this.staging = outputRoot.resolve("staging"); + this.generations = outputRoot.resolve("generations"); + this.current = outputRoot.resolve("CURRENT"); + } + + Path staging() { + return staging; + } + + Path outputRoot() { + return outputRoot; + } + + String targetKey() { + return targetKey; + } + + /** Start from the prior committed generation; no published pointer changes here. */ + void prepare() { + try { + deleteTree(staging); + Files.createDirectories(staging); + Path prior = currentGeneration(); + if (prior != null) copyTree(prior, staging); + deleteTree(staging.resolve(SEEN_ROOT)); + Files.createDirectories(staging.resolve(SEEN_ROOT)); + } catch (IOException exception) { + throw new UncheckedIOException("scip-java: unable to prepare graph generation", exception); + } + } + + /** Commit only after Gradle reports that the Kotlin compilation completed successfully. */ + void commit(Set taskSources) { + commit(taskSources, null); + } + + void commit(Set taskSources, List universe) { + try { + Set declared = new LinkedHashSet<>(); + for (java.io.File source : taskSources) { + declared.add(relativeSource(source.toPath().toAbsolutePath().normalize())); + } + Set active = new LinkedHashSet<>(declared); + Set previouslyDeclared = readLines(staging.resolve(DECLARED_SOURCES)); + Path seen = staging.resolve(SEEN_ROOT); + if (Files.isDirectory(seen)) { + try (var paths = Files.walk(seen)) { + paths + .filter(Files::isRegularFile) + .map(seen::relativize) + .map(Path::toString) + .map(value -> value.replace(java.io.File.separatorChar, '/')) + .map(value -> value.substring(0, value.length() - ".seen".length())) + .forEach(active::add); + } + } + + List shards = graphShards(staging); + for (Path shard : shards) { + String source = shardSource(staging.relativize(shard)); + if (!active.contains(source) + && (previouslyDeclared.contains(source) || !sourceExists(source))) { + Files.deleteIfExists(shard); + } + } + deleteEmptyDirectories(staging); + deleteTree(staging.resolve(SEEN_ROOT)); + writeAtomic(staging.resolve("TARGET"), List.of(target)); + List orderedSources = new ArrayList<>(); + for (Path shard : graphShards(staging)) { + ShardMetadata metadata = shardMetadata(shard); + String expectedSource = shardSource(staging.relativize(shard)); + if (!metadata.source().equals(expectedSource)) { + throw new IOException("Kotlin graph shard source does not match its path: " + shard); + } + if (!metadata.target().equals(target)) { + throw new IOException( + "Kotlin graph shard target does not match its compilation: " + shard); + } + validateSource(metadata, shard); + orderedSources.add(metadata.source()); + } + orderedSources = new ArrayList<>(new LinkedHashSet<>(orderedSources)); + orderedSources.sort(KotlinGraphGenerationStore::compareUtf8); + writeAtomic(staging.resolve("SOURCES"), orderedSources); + List orderedDeclared = new ArrayList<>(declared); + orderedDeclared.sort(KotlinGraphGenerationStore::compareUtf8); + writeAtomic(staging.resolve(DECLARED_SOURCES), orderedDeclared); + if (universe != null) writeAtomic(staging.resolve("UNIVERSE"), universe); + if (!Files.isRegularFile(staging.resolve("UNIVERSE"))) { + writeAtomic(staging.resolve("UNIVERSE"), List.of("kotlin.version=2.3.20")); + } + + String generation = generationDigest(staging); + Files.createDirectories(generations); + Path committed = generations.resolve(generation); + if (Files.exists(committed)) { + deleteTree(staging); + } else { + move(staging, committed, false); + } + + Files.createDirectories(current.getParent()); + Path temporary = current.resolveSibling("CURRENT.tmp-" + ProcessHandle.current().pid()); + Files.writeString(temporary, generation + "\n", StandardCharsets.UTF_8); + move(temporary, current, true); + } catch (IOException exception) { + throw new UncheckedIOException("scip-java: unable to commit graph generation", exception); + } + } + + Set kotlinSources(Task task) { + Set sources = new LinkedHashSet<>(); + for (java.io.File file : task.getInputs().getFiles().getFiles()) { + Path path = file.toPath().toAbsolutePath().normalize(); + String name = path.getFileName().toString(); + if (path.startsWith(sourceRoot) + && Files.isRegularFile(path) + && (name.endsWith(".kt") || name.endsWith(".kts")) + && !name.endsWith(".gradle.kts")) { + sources.add(file); + } + } + return sources; + } + + List universe(Task task, List compilationRows) { + List rows = new ArrayList<>(); + rows.add("java.version=" + System.getProperty("java.version", "")); + rows.add("java.home=" + normalizedPath(Path.of(System.getProperty("java.home", "")))); + rows.add("kotlin.version=2.3.20"); + rows.addAll(compilationRows); + task.getInputs().getProperties().entrySet().stream() + .sorted(Map.Entry.comparingByKey(KotlinGraphGenerationStore::compareUtf8)) + .forEach( + property -> + rows.add( + "property[" + property.getKey() + "]=" + stableProperty(property.getValue()))); + Set sources = + kotlinSources(task).stream() + .map(java.io.File::toPath) + .map(Path::toAbsolutePath) + .map(Path::normalize) + .collect(java.util.stream.Collectors.toSet()); + List inputs = + task.getInputs().getFiles().getFiles().stream() + .map(java.io.File::toPath) + .map(Path::toAbsolutePath) + .map(Path::normalize) + .sorted(Comparator.comparing(Path::toString, KotlinGraphGenerationStore::compareUtf8)) + .toList(); + for (Path input : inputs) { + // Source membership belongs to the target universe; source contents do + // not. Each shard already binds the bytes its compiler read, and putting + // those bytes here turns an ordinary body edit into a classpath reload + // that prevents unchanged shards from being carried forward. + rows.add( + sources.contains(input) + ? "source=" + normalizedPath(input) + : "input=" + universeInputUnchecked(input)); + } + return rows; + } + + String universeInput(Path input) throws IOException { + Path normalized = input.toAbsolutePath().normalize(); + String digest = fileDigest(normalized); + String identity; + if (normalized.equals(embeddedKotlincPlugin)) { + // The compiler plugin is extracted into a fresh CLI temporary directory on every cold run. + // Its semantic identity is the embedded role plus exact bytes, not that random parent path. + identity = "embedded/scip-kotlinc.jar"; + } else if (normalized.startsWith(sourceRoot)) { + identity = normalizedPath(normalized); + } else { + // Ordinary compiler inputs retain path-to-content association. Basename-only identities let + // two same-named classpath entries exchange bytes without changing the universe. + identity = "external/" + normalizedPath(normalized); + } + return identity + ":" + digest; + } + + private String universeInputUnchecked(Path input) { + try { + return universeInput(input); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } + + String currentGenerationName() throws IOException { + Path generation = currentGeneration(); + return generation == null ? null : generation.getFileName().toString(); + } + + void pruneRetaining(String generation) throws IOException { + pruneGenerations(generation); + } + + private Path currentGeneration() throws IOException { + if (!Files.isRegularFile(current)) return null; + String generation = Files.readString(current, StandardCharsets.UTF_8).trim(); + if (!generation.matches("[0-9a-f]{64}")) { + throw new IOException("invalid graph CURRENT pointer for " + target); + } + Path resolved = generations.resolve(generation).normalize(); + if (!resolved.startsWith(generations) || !Files.isDirectory(resolved)) { + throw new IOException("graph CURRENT pointer names no committed generation for " + target); + } + return resolved; + } + + private String relativeSource(Path source) { + Path relative = source.startsWith(sourceRoot) ? sourceRoot.relativize(source) : source; + StringBuilder out = new StringBuilder(); + for (Path part : relative) { + if (!out.isEmpty()) out.append('/'); + out.append(part.getFileName()); + } + return out.toString(); + } + + private boolean sourceExists(String source) { + try { + Path path = Path.of(source); + Path absolute = path.isAbsolute() ? path.normalize() : sourceRoot.resolve(path).normalize(); + return Files.isRegularFile(absolute); + } catch (RuntimeException ignored) { + return false; + } + } + + private String normalizedPath(Path path) { + Path normalized = path.toAbsolutePath().normalize(); + Path value = normalized.startsWith(sourceRoot) ? sourceRoot.relativize(normalized) : normalized; + return value.toString().replace(java.io.File.separatorChar, '/'); + } + + /** A deterministic task-property representation with no object identity strings. */ + private static String stableProperty(Object value) { + if (value == null) return "null"; + if (value instanceof Provider provider) return stableProperty(provider.getOrNull()); + if (value instanceof CharSequence + || value instanceof Number + || value instanceof Boolean + || value instanceof Character) { + return value.getClass().getName() + ":" + value; + } + if (value instanceof Enum item) { + return item.getDeclaringClass().getName() + ":" + item.name(); + } + if (value instanceof Path path) { + return "path:" + + path.toAbsolutePath().normalize().toString().replace(java.io.File.separatorChar, '/'); + } + if (value instanceof java.io.File file) return stableProperty(file.toPath()); + if (value instanceof Map map) { + List entries = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + entries.add(stableProperty(entry.getKey()) + "=" + stableProperty(entry.getValue())); + } + entries.sort(KotlinGraphGenerationStore::compareUtf8); + return "{" + String.join(",", entries) + "}"; + } + if (value instanceof Iterable iterable) { + List entries = new ArrayList<>(); + for (Object entry : iterable) entries.add(stableProperty(entry)); + return "[" + String.join(",", entries) + "]"; + } + if (value.getClass().isArray()) { + List entries = new ArrayList<>(); + for (int index = 0; index < Array.getLength(value); index++) { + entries.add(stableProperty(Array.get(value, index))); + } + return "[" + String.join(",", entries) + "]"; + } + // Gradle expands nested input beans into separately named properties. The + // bean's type is meaningful; its default identity-bearing toString is not. + return "type:" + value.getClass().getName(); + } + + private static String fileDigest(Path input) throws IOException { + MessageDigest digest = sha256(); + if (Files.isRegularFile(input)) { + update(digest, Files.readAllBytes(input)); + } else if (Files.isDirectory(input)) { + try (var paths = Files.walk(input)) { + for (Path file : paths.filter(Files::isRegularFile).sorted().toList()) { + update( + digest, + input + .relativize(file) + .toString() + .replace(java.io.File.separatorChar, '/') + .getBytes(StandardCharsets.UTF_8)); + update(digest, Files.readAllBytes(file)); + } + } + } else { + update(digest, "".getBytes(StandardCharsets.UTF_8)); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static String shardSource(Path relative) { + String value = relative.toString().replace(java.io.File.separatorChar, '/'); + return value.substring(0, value.length() - SHARD_SUFFIX.length()); + } + + private static ShardMetadata shardMetadata(Path shard) throws IOException { + try { + JsonElement parsed = + Json.Default.parseToJsonElement(Files.readString(shard, StandardCharsets.UTF_8)); + if (!(parsed instanceof JsonObject object)) { + throw new IOException("Kotlin graph shard is not an object: " + shard); + } + JsonPrimitive schema = JsonElementKt.getJsonPrimitive(object.get("schemaVersion")); + String source = JsonElementKt.getJsonPrimitive(object.get("source")).getContent(); + String target = JsonElementKt.getJsonPrimitive(object.get("target")).getContent(); + String checkerDigest = + JsonElementKt.getJsonPrimitive(object.get("checkerDigest")).getContent(); + String diskDigest = JsonElementKt.getJsonPrimitive(object.get("diskDigest")).getContent(); + if (!Integer.valueOf(1).equals(JsonElementKt.getIntOrNull(schema)) + || source.isEmpty() + || target.isEmpty() + || !SHA256.matcher(checkerDigest).matches() + || (!diskDigest.isEmpty() && !SHA256.matcher(diskDigest).matches())) { + throw new IOException("Kotlin graph shard has invalid metadata: " + shard); + } + return new ShardMetadata(source, target, diskDigest); + } catch (IOException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new IOException("malformed Kotlin graph shard: " + shard, exception); + } + } + + private void validateSource(ShardMetadata metadata, Path shard) throws IOException { + if (metadata.diskDigest().isEmpty()) return; + Path source = sourceRoot.resolve(metadata.source()).normalize(); + if (!source.startsWith(sourceRoot) + || !Files.isRegularFile(source) + || !digest(Files.readAllBytes(source)).equals(metadata.diskDigest())) { + throw new IOException("Kotlin graph source moved after compilation: " + shard); + } + } + + private static List graphShards(Path root) throws IOException { + if (!Files.isDirectory(root)) return List.of(); + try (var paths = Files.walk(root)) { + return paths + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(SHARD_SUFFIX)) + .sorted() + .toList(); + } + } + + private static Set readLines(Path input) throws IOException { + return Files.isRegularFile(input) + ? new LinkedHashSet<>(Files.readAllLines(input, StandardCharsets.UTF_8)) + : Set.of(); + } + + private static String generationDigest(Path root) throws IOException { + MessageDigest digest = sha256(); + try (var paths = Files.walk(root)) { + for (Path file : paths.filter(Files::isRegularFile).sorted().toList()) { + String relative = root.relativize(file).toString().replace(java.io.File.separatorChar, '/'); + update(digest, relative.getBytes(StandardCharsets.UTF_8)); + update(digest, Files.readAllBytes(file)); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static void update(MessageDigest digest, byte[] value) { + digest.update(Integer.toString(value.length).getBytes(StandardCharsets.UTF_8)); + digest.update((byte) ':'); + digest.update(value); + } + + private static String digest(String value) { + return digest(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String digest(byte[] value) { + MessageDigest digest = sha256(); + return HexFormat.of().formatHex(digest.digest(value)); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException impossible) { + throw new AssertionError("SHA-256 is required by every Java runtime", impossible); + } + } + + private static void copyTree(Path source, Path destination) throws IOException { + Files.walkFileTree( + source, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) + throws IOException { + Files.createDirectories(destination.resolve(source.relativize(directory))); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) + throws IOException { + Path output = destination.resolve(source.relativize(file)); + try { + Files.createLink(output, file); + } catch (UnsupportedOperationException | IOException ignored) { + Files.copy(file, output, StandardCopyOption.REPLACE_EXISTING); + } + return FileVisitResult.CONTINUE; + } + }); + } + + private static void deleteEmptyDirectories(Path root) throws IOException { + if (!Files.isDirectory(root)) return; + try (var paths = Files.walk(root)) { + for (Path directory : + paths.filter(Files::isDirectory).sorted(Comparator.reverseOrder()).toList()) { + if (!directory.equals(root)) { + try (var children = Files.list(directory)) { + if (children.findAny().isEmpty()) Files.deleteIfExists(directory); + } + } + } + } + } + + private static void deleteTree(Path root) throws IOException { + if (!Files.exists(root)) return; + Files.walkFileTree( + root, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path directory, IOException exception) + throws IOException { + if (exception != null) throw exception; + Files.delete(directory); + return FileVisitResult.CONTINUE; + } + }); + } + + private static void move(Path source, Path destination, boolean replace) throws IOException { + List options = new ArrayList<>(); + options.add(StandardCopyOption.ATOMIC_MOVE); + if (replace) options.add(StandardCopyOption.REPLACE_EXISTING); + try { + Files.move(source, destination, options.toArray(StandardCopyOption[]::new)); + } catch (AtomicMoveNotSupportedException ignored) { + if (replace) Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + else Files.move(source, destination); + } + } + + private void pruneGenerations(String retained) throws IOException { + if (!Files.isDirectory(generations)) return; + try (var paths = Files.list(generations)) { + for (Path generation : paths.filter(Files::isDirectory).toList()) { + if (!generation.getFileName().toString().equals(retained)) deleteTree(generation); + } + } + } + + private static void writeAtomic(Path output, List lines) throws IOException { + Path temporary = + output.resolveSibling(output.getFileName() + ".tmp-" + ProcessHandle.current().pid()); + String text = lines.isEmpty() ? "" : String.join("\n", lines) + "\n"; + Files.writeString(temporary, text, StandardCharsets.UTF_8); + move(temporary, output, true); + } + + static int compareUtf8(String left, String right) { + return java.util.Arrays.compareUnsigned( + left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); + } + + private record ShardMetadata(String source, String target, String diskDigest) {} +} diff --git a/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGradlePlugin.java b/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGradlePlugin.java new file mode 100644 index 000000000..3a052d0ff --- /dev/null +++ b/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGradlePlugin.java @@ -0,0 +1,163 @@ +package org.scip_code.scip_java.gradle; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.TaskProvider; +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation; +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin; +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType; +import org.jetbrains.kotlin.gradle.plugin.SubpluginArtifact; +import org.jetbrains.kotlin.gradle.plugin.SubpluginOption; +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask; + +/** + * Attaches the pinned K2 graph exporter to ordinary Kotlin/JVM compilations. + * + *

This support plugin does not create a compiler task. Its build-wide commit task depends on the + * Kotlin Gradle plugin's existing compilation tasks and publishes only generations from successful + * task executions. + */ +public final class KotlinGraphGradlePlugin implements KotlinCompilerPluginSupportPlugin { + static final String KOTLIN_VERSION = "2.3.20"; + static final String ARTIFACT_GROUP = "org.scip-code"; + static final String ARTIFACT_NAME = "scip-kotlinc-k2-graph"; + static final String ARTIFACT_VERSION = "2.3.20-e940c188"; + + private Project project; + private Path sourceRoot; + private Path targetRoot; + private Path compilerPlugin; + private KotlinGraphGenerationCoordinator coordinator; + + @Override + public void apply(Project project) { + this.project = project; + Map extra = project.getExtensions().getExtraProperties().getProperties(); + this.sourceRoot = project.getRootDir().toPath().toAbsolutePath().normalize(); + this.targetRoot = + Paths.get(requiredExtra(extra, "scipTarget").toString()).toAbsolutePath().normalize(); + this.compilerPlugin = + Paths.get(requiredExtra(extra, "scipKotlincGraphJar").toString()) + .toAbsolutePath() + .normalize(); + Path compilerRepository = + Paths.get(requiredExtra(extra, "scipKotlincGraphRepository").toString()) + .toAbsolutePath() + .normalize(); + try { + project.getRepositories().maven(repository -> repository.setUrl(compilerRepository.toUri())); + } catch (GradleException rejectedProjectRepository) { + project + .getLogger() + .info( + "scip-java: using the settings-level Kotlin graph repository for project '{}'", + project.getPath()); + } + + Plugin kotlinPlugin = project.getPlugins().findPlugin("org.jetbrains.kotlin.jvm"); + String version = + kotlinPlugin == null + ? null + : kotlinPlugin.getClass().getPackage().getImplementationVersion(); + if (version == null + || !(version.equals(KOTLIN_VERSION) || version.startsWith(KOTLIN_VERSION + "-release-"))) { + throw new GradleException( + "scip-java: Kotlin graph exporter supports Kotlin Gradle Plugin " + + KOTLIN_VERSION + + " exactly; project '" + + project.getPath() + + "' uses " + + String.valueOf(version)); + } + this.coordinator = KotlinGraphGenerationCoordinator.acquire(project, targetRoot, sourceRoot); + } + + @Override + public boolean isApplicable(KotlinCompilation compilation) { + return compilation.getPlatformType() == KotlinPlatformType.jvm; + } + + @Override + public Provider> applyToCompilation(KotlinCompilation compilation) { + String targetName = targetName(compilation); + String target = project.getPath() + "|" + targetName + "|" + compilation.getCompilationName(); + KotlinGraphGenerationStore store = + new KotlinGraphGenerationStore(targetRoot, sourceRoot, target, compilerPlugin); + TaskProvider> compileTask = + compilation.getCompileTaskProvider(); + coordinator.register(compileTask, store); + List compilationUniverse = + new java.util.ArrayList<>( + List.of( + "gradle.version=" + project.getGradle().getGradleVersion(), + "project=" + project.getPath(), + "task=" + + (project.getPath().equals(":") ? ":" : project.getPath() + ":") + + compileTask.getName(), + "target=" + targetName, + "compilation=" + compilation.getCompilationName(), + "platform=" + compilation.getPlatformType().getName())); + compilation.getAllKotlinSourceSets().stream() + .map(sourceSet -> "sourceSet=" + sourceSet.getName()) + .sorted(KotlinGraphGenerationStore::compareUtf8) + .forEach(compilationUniverse::add); + compileTask.configure( + task -> { + Task gradleTask = (Task) task; + gradleTask.getOutputs().dir(store.outputRoot().toFile()); + gradleTask.doFirst(ignored -> store.prepare()); + gradleTask.doLast( + ignored -> + store.commit( + store.kotlinSources(gradleTask), + store.universe(gradleTask, compilationUniverse))); + }); + + Path scipTargetRoot = targetRoot; + Path staging = store.staging(); + return project.provider( + () -> + List.of( + new SubpluginOption("sourceroot", sourceRoot.toString()), + new SubpluginOption("targetroot", scipTargetRoot.toString()), + new SubpluginOption("graphroot", staging.toString()), + new SubpluginOption("graphtarget", target))); + } + + @Override + public String getCompilerPluginId() { + return "scip-kotlinc"; + } + + @Override + public SubpluginArtifact getPluginArtifact() { + return new SubpluginArtifact(ARTIFACT_GROUP, ARTIFACT_NAME, ARTIFACT_VERSION); + } + + @Override + @SuppressWarnings("deprecation") + public SubpluginArtifact getPluginArtifactForNative() { + return null; + } + + private static String targetName(KotlinCompilation compilation) { + String name = compilation.getTarget().getTargetName(); + return name.isBlank() ? compilation.getPlatformType().getName() : name; + } + + private static Object requiredExtra(Map extra, String name) { + Object value = extra.get(name); + if (value == null) { + throw new IllegalStateException( + name + " extra property must be set by the scip-java Gradle init script"); + } + return value; + } +} diff --git a/scip-kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.scip-code.kotlin-graph.properties b/scip-kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.scip-code.kotlin-graph.properties new file mode 100644 index 000000000..09806a4d9 --- /dev/null +++ b/scip-kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.scip-code.kotlin-graph.properties @@ -0,0 +1 @@ +implementation-class=org.scip_code.scip_java.gradle.KotlinGraphGradlePlugin diff --git a/scip-kotlinc/src/main/java/org/scip_code/scip_java/kotlinc/KotlinGraphShard.java b/scip-kotlinc/src/main/java/org/scip_code/scip_java/kotlinc/KotlinGraphShard.java new file mode 100644 index 000000000..8e6130dc3 --- /dev/null +++ b/scip-kotlinc/src/main/java/org/scip_code/scip_java/kotlinc/KotlinGraphShard.java @@ -0,0 +1,319 @@ +package org.scip_code.scip_java.kotlinc; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; + +/** + * Deterministic per-source graph facts produced from the same attributed javac tree as SCIP. + * + *

The file is deliberately a small producer-owned schema. The workspace aggregator binds these + * shards to a successful build universe and publishes the versioned graph transaction; a compiler + * invocation never needs to know about a consumer's transport protocol. + */ +public final class KotlinGraphShard { + public static final int SCHEMA_VERSION = 1; + public static final String GRAPH_ROOT = "META-INF/scip-graph"; + + /** One-based source evidence. */ + public record Evidence(String file, int startLine, int startColumn, int endLine, int endColumn) {} + + /** A declaration keyed by its canonical Java semantic symbol. */ + public record Node( + String symbol, + String kind, + String name, + String qualifiedName, + String file, + boolean exported, + List modifiers, + String signature, + String origin, + Evidence evidence) {} + + /** A resolved relationship. Endpoints are canonical symbols or the source-file coordinate. */ + public record Edge( + String from, + String to, + String kind, + String access, + String provenance, + String targetKind, + String targetName, + String targetQualifiedName, + Evidence evidence) {} + + /** A relationship site javac could not settle exactly. */ + public record Unresolved( + String family, String reason, Evidence evidence, List candidates) {} + + /** A diagnostic reported by the same compiler invocation as this shard. */ + public record Diagnostic(String severity, String message, Evidence evidence) {} + + public final String source; + public final String checkerDigest; + public final String diskDigest; + public final String target; + public final String compilerVersion; + public final List nodes; + public final List edges; + public final List unresolved; + public final List diagnostics; + + public KotlinGraphShard( + String source, + String checkerDigest, + String diskDigest, + String target, + String compilerVersion, + List nodes, + List edges, + List unresolved, + List diagnostics) { + this.source = source; + this.checkerDigest = checkerDigest; + this.diskDigest = diskDigest; + this.target = target; + this.compilerVersion = compilerVersion; + this.nodes = List.copyOf(nodes); + this.edges = List.copyOf(edges); + this.unresolved = List.copyOf(unresolved); + this.diagnostics = List.copyOf(diagnostics); + } + + /** Canonical shard path parallel to the existing {@code META-INF/scip} layout. */ + public static Path outputPath(Path targetRoot, Path relativeSource) { + return outputPathAtRoot(targetRoot.resolve("META-INF").resolve("scip-graph"), relativeSource); + } + + /** Shard path inside a task-owned generation root. */ + public static Path outputPathAtRoot(Path graphRoot, Path relativeSource) { + String filename = relativeSource.getFileName().toString() + ".graph.json"; + return graphRoot.resolve(relativeSource).resolveSibling(filename); + } + + /** Write through a sibling and atomically replace the complete prior source shard. */ + public void write(Path output) throws IOException { + Files.createDirectories(output.getParent()); + Path temporary = + output.resolveSibling( + output.getFileName().toString() + ".tmp-" + ProcessHandle.current().pid()); + Files.writeString(temporary, toJson(), StandardCharsets.UTF_8); + try { + Files.move( + temporary, + output, + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(temporary, output, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + + /** SHA-256 of compiler-owned UTF-8 text. */ + public static String digest(String text) { + return digest(text.getBytes(StandardCharsets.UTF_8)); + } + + /** SHA-256 of exact disk bytes. */ + public static String digest(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException impossible) { + throw new AssertionError("SHA-256 is required by every Java runtime", impossible); + } + } + + /** Canonical JSON: fixed keys and UTF-8 byte ordering for every set-like collection. */ + public String toJson() { + List orderedNodes = new ArrayList<>(nodes); + orderedNodes.sort( + Comparator.comparing(Node::symbol, KotlinGraphShard::compareUtf8) + .thenComparing(Node::kind, KotlinGraphShard::compareUtf8)); + List orderedEdges = new ArrayList<>(edges); + orderedEdges.sort( + Comparator.comparing(Edge::from, KotlinGraphShard::compareUtf8) + .thenComparing(Edge::to, KotlinGraphShard::compareUtf8) + .thenComparing(Edge::kind, KotlinGraphShard::compareUtf8) + .thenComparing(edge -> String.valueOf(edge.access()), KotlinGraphShard::compareUtf8) + .thenComparing(edge -> String.valueOf(edge.provenance()), KotlinGraphShard::compareUtf8) + .thenComparing(edge -> edge.evidence().file(), KotlinGraphShard::compareUtf8) + .thenComparingInt(edge -> edge.evidence().startLine()) + .thenComparingInt(edge -> edge.evidence().startColumn())); + List orderedUnresolved = new ArrayList<>(unresolved); + orderedUnresolved.sort( + Comparator.comparing(Unresolved::family, KotlinGraphShard::compareUtf8) + .thenComparing(site -> site.evidence().file(), KotlinGraphShard::compareUtf8) + .thenComparingInt(site -> site.evidence().startLine()) + .thenComparingInt(site -> site.evidence().startColumn())); + List orderedDiagnostics = new ArrayList<>(diagnostics); + orderedDiagnostics.sort( + Comparator.comparing(Diagnostic::severity, KotlinGraphShard::compareUtf8) + .thenComparing(Diagnostic::message, KotlinGraphShard::compareUtf8) + .thenComparing(item -> item.evidence().file(), KotlinGraphShard::compareUtf8) + .thenComparingInt(item -> item.evidence().startLine()) + .thenComparingInt(item -> item.evidence().startColumn())); + + StringBuilder out = new StringBuilder(); + out.append('{'); + field(out, "schemaVersion", SCHEMA_VERSION).append(','); + field(out, "language", "kotlin").append(','); + field(out, "source", source).append(','); + field(out, "checkerDigest", checkerDigest).append(','); + field(out, "diskDigest", diskDigest).append(','); + field(out, "target", target).append(','); + field(out, "compilerVersion", compilerVersion).append(','); + out.append("\"nodes\":["); + for (int i = 0; i < orderedNodes.size(); i++) { + if (i != 0) out.append(','); + node(out, orderedNodes.get(i)); + } + out.append("],\"edges\":["); + for (int i = 0; i < orderedEdges.size(); i++) { + if (i != 0) out.append(','); + edge(out, orderedEdges.get(i)); + } + out.append("],\"unresolved\":["); + for (int i = 0; i < orderedUnresolved.size(); i++) { + if (i != 0) out.append(','); + unresolved(out, orderedUnresolved.get(i)); + } + out.append("],\"diagnostics\":["); + for (int i = 0; i < orderedDiagnostics.size(); i++) { + if (i != 0) out.append(','); + diagnostic(out, orderedDiagnostics.get(i)); + } + return out.append("]}\n").toString(); + } + + private static void node(StringBuilder out, Node node) { + out.append('{'); + field(out, "symbol", node.symbol()).append(','); + field(out, "kind", node.kind()).append(','); + field(out, "name", node.name()).append(','); + field(out, "qualifiedName", node.qualifiedName()).append(','); + field(out, "file", node.file()).append(','); + field(out, "exported", node.exported()).append(','); + out.append("\"modifiers\":["); + List modifiers = new ArrayList<>(node.modifiers()); + modifiers.sort(KotlinGraphShard::compareUtf8); + for (int i = 0; i < modifiers.size(); i++) { + if (i != 0) out.append(','); + string(out, modifiers.get(i)); + } + out.append("],"); + field(out, "signature", node.signature()).append(','); + field(out, "origin", node.origin()).append(','); + out.append("\"evidence\":"); + evidence(out, node.evidence()); + out.append('}'); + } + + private static void edge(StringBuilder out, Edge edge) { + out.append('{'); + field(out, "from", edge.from()).append(','); + field(out, "to", edge.to()).append(','); + field(out, "kind", edge.kind()).append(','); + if (edge.access() == null) out.append("\"access\":null,"); + else field(out, "access", edge.access()).append(','); + if (edge.provenance() == null) out.append("\"provenance\":null,"); + else field(out, "provenance", edge.provenance()).append(','); + if (edge.targetKind() == null) out.append("\"targetKind\":null,"); + else field(out, "targetKind", edge.targetKind()).append(','); + if (edge.targetName() == null) out.append("\"targetName\":null,"); + else field(out, "targetName", edge.targetName()).append(','); + if (edge.targetQualifiedName() == null) out.append("\"targetQualifiedName\":null,"); + else field(out, "targetQualifiedName", edge.targetQualifiedName()).append(','); + out.append("\"evidence\":"); + evidence(out, edge.evidence()); + out.append('}'); + } + + private static void unresolved(StringBuilder out, Unresolved unresolved) { + out.append('{'); + field(out, "family", unresolved.family()).append(','); + field(out, "reason", unresolved.reason()).append(','); + out.append("\"evidence\":"); + evidence(out, unresolved.evidence()); + out.append(",\"candidates\":["); + List candidates = new ArrayList<>(unresolved.candidates()); + candidates.sort(KotlinGraphShard::compareUtf8); + for (int i = 0; i < candidates.size(); i++) { + if (i != 0) out.append(','); + string(out, candidates.get(i)); + } + out.append("]}"); + } + + private static void diagnostic(StringBuilder out, Diagnostic diagnostic) { + out.append('{'); + field(out, "severity", diagnostic.severity()).append(','); + field(out, "message", diagnostic.message()).append(','); + out.append("\"evidence\":"); + evidence(out, diagnostic.evidence()); + out.append('}'); + } + + private static void evidence(StringBuilder out, Evidence evidence) { + out.append('{'); + field(out, "file", evidence.file()).append(','); + field(out, "startLine", evidence.startLine()).append(','); + field(out, "startColumn", evidence.startColumn()).append(','); + field(out, "endLine", evidence.endLine()).append(','); + field(out, "endColumn", evidence.endColumn()); + out.append('}'); + } + + private static StringBuilder field(StringBuilder out, String name, String value) { + string(out, name).append(':'); + return string(out, value); + } + + private static StringBuilder field(StringBuilder out, String name, int value) { + string(out, name).append(':').append(value); + return out; + } + + private static StringBuilder field(StringBuilder out, String name, boolean value) { + string(out, name).append(':').append(value); + return out; + } + + private static StringBuilder string(StringBuilder out, String value) { + out.append('"'); + for (int index = 0; index < value.length(); index++) { + char ch = value.charAt(index); + switch (ch) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (ch < 0x20) out.append(String.format("\\u%04x", (int) ch)); + else out.append(ch); + } + } + } + return out.append('"'); + } + + private static int compareUtf8(String left, String right) { + return Arrays.compareUnsigned( + left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); + } + + private KotlinGraphShard() { + throw new AssertionError("not instantiable"); + } +} diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCheckers.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCheckers.kt index 37ece4b57..1fa561cf8 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCheckers.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCheckers.kt @@ -10,89 +10,144 @@ import org.jetbrains.kotlin.fir.analysis.checkers.MppCheckerKind import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.analysis.checkers.declaration.* import org.jetbrains.kotlin.fir.analysis.checkers.expression.ExpressionCheckers +import org.jetbrains.kotlin.fir.analysis.checkers.expression.FirExpressionChecker import org.jetbrains.kotlin.fir.analysis.checkers.expression.FirQualifiedAccessExpressionChecker +import org.jetbrains.kotlin.fir.analysis.checkers.expression.FirResolvedQualifierChecker import org.jetbrains.kotlin.fir.analysis.checkers.expression.FirTypeOperatorCallChecker -import org.jetbrains.kotlin.fir.analysis.checkers.getContainingClassSymbol import org.jetbrains.kotlin.fir.analysis.checkers.toClassLikeSymbol import org.jetbrains.kotlin.fir.analysis.extensions.FirAdditionalCheckersExtension import org.jetbrains.kotlin.fir.declarations.* +import org.jetbrains.kotlin.fir.declarations.utils.isCompanion +import org.jetbrains.kotlin.fir.expressions.FirFunctionCall +import org.jetbrains.kotlin.fir.expressions.FirPropertyAccessExpression import org.jetbrains.kotlin.fir.expressions.FirQualifiedAccessExpression +import org.jetbrains.kotlin.fir.expressions.FirResolvedQualifier import org.jetbrains.kotlin.fir.expressions.FirTypeOperatorCall +import org.jetbrains.kotlin.fir.expressions.FirVariableAssignment import org.jetbrains.kotlin.fir.references.FirResolvedNamedReference import org.jetbrains.kotlin.fir.resolve.calls.FirSyntheticFunctionSymbol +import org.jetbrains.kotlin.fir.resolve.getContainingClassSymbol import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.resolve.toClassLikeSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirAnonymousObjectSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirPropertySymbol +import org.jetbrains.kotlin.fir.types.FirTypeRef import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName -open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtension(session) { +open class AnalyzerCheckers(session: FirSession, private val state: AnalyzerCompilationState) : + FirAdditionalCheckersExtension(session) { companion object { - val visitors: MutableMap = mutableMapOf() - private fun getIdentifier(element: KtSourceElement): KtSourceElement = element.treeStructure .findChildByType(element.lighterASTNode, KtTokens.IDENTIFIER) ?.toKtLightSourceElement(element.treeStructure) ?: element + + context(context: CheckerContext) + private fun ScipVisitor.emitTypeRef(typeRef: FirTypeRef) { + val klass = typeRef.toClassLikeSymbol(context.session) + val source = typeRef.source + if (klass != null && source != null && source.kind !is KtFakeSourceElementKind) { + visitClassReference(klass, getIdentifier(source)) + } + } } override val declarationCheckers: DeclarationCheckers - get() = AnalyzerDeclarationCheckers(session.analyzerParamsProvider.sourceroot) + get() = + AnalyzerDeclarationCheckers( + session.analyzerParamsProvider.sourceroot, + session.analyzerParamsProvider.graphRoot, + session.analyzerParamsProvider.graphTarget, + session.analyzerParamsProvider.graphMessages, + state, + ) override val expressionCheckers: ExpressionCheckers get() = object : ExpressionCheckers() { override val qualifiedAccessExpressionCheckers: Set = - setOf(SemanticQualifiedAccessExpressionChecker()) + setOf(SemanticQualifiedAccessExpressionChecker(state)) + + override val resolvedQualifierCheckers: Set = + setOf(SemanticResolvedQualifierChecker(state)) - override val typeOperatorCallCheckers: - Set = - setOf(SemanticClassReferenceExpressionChecker()) + override val typeOperatorCallCheckers: Set = + setOf(SemanticClassReferenceExpressionChecker(state)) + + override val variableAssignmentCheckers: + Set> = + setOf(SemanticVariableAssignmentChecker(state)) } - open class AnalyzerDeclarationCheckers(sourceroot: Path) : DeclarationCheckers() { + open class AnalyzerDeclarationCheckers( + sourceroot: Path, + graphRoot: Path? = null, + graphTarget: String? = null, + private val graphMessages: KotlinGraphMessages? = null, + private val state: AnalyzerCompilationState, + ) : DeclarationCheckers() { override val fileCheckers: Set = - setOf(SemanticFileChecker(sourceroot), SemanticImportsChecker()) - override val classLikeCheckers: Set = setOf(SemanticClassLikeChecker()) + setOf( + SemanticFileChecker(sourceroot, graphRoot, graphTarget, graphMessages, state), + SemanticImportsChecker(state), + ) + override val classLikeCheckers: Set = + setOf(SemanticClassLikeChecker(state)) override val constructorCheckers: Set = - setOf(SemanticConstructorChecker()) + setOf(SemanticConstructorChecker(state)) override val simpleFunctionCheckers: Set = - setOf(SemanticSimpleFunctionChecker()) + setOf(SemanticSimpleFunctionChecker(state)) override val anonymousFunctionCheckers: Set = - setOf(SemanticAnonymousFunctionChecker()) - override val propertyCheckers: Set = setOf(SemanticPropertyChecker()) + setOf(SemanticAnonymousFunctionChecker(state)) + override val propertyCheckers: Set = + setOf(SemanticPropertyChecker(state)) override val valueParameterCheckers: Set = - setOf(SemanticValueParameterChecker()) + setOf(SemanticValueParameterChecker(state)) override val typeParameterCheckers: Set = - setOf(SemanticTypeParameterChecker()) - override val typeAliasCheckers: Set = setOf(SemanticTypeAliasChecker()) + setOf(SemanticTypeParameterChecker(state)) + override val typeAliasCheckers: Set = + setOf(SemanticTypeAliasChecker(state)) override val propertyAccessorCheckers: Set = - setOf(SemanticPropertyAccessorChecker()) + setOf(SemanticPropertyAccessorChecker(state)) + override val enumEntryCheckers: Set = + setOf(SemanticEnumEntryChecker(state)) } - private class SemanticFileChecker(private val sourceroot: Path) : - FirFileChecker(MppCheckerKind.Common) { - companion object { - val globals = GlobalSymbolsCache() - } - + private class SemanticFileChecker( + private val sourceroot: Path, + private val graphRoot: Path?, + private val graphTarget: String?, + private val graphMessages: KotlinGraphMessages?, + private val state: AnalyzerCompilationState, + ) : FirFileChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirFile) { val ktFile = declaration.sourceFile ?: return val lineMap = LineMap(declaration) - val visitor = ScipVisitor(sourceroot, ktFile, lineMap, globals) - visitors[ktFile] = visitor + val visitor = + ScipVisitor( + sourceroot, + ktFile, + lineMap, + state.globals, + graphRoot = graphRoot, + graphTarget = graphTarget, + ) + state.visitors[ktFile] = visitor + state.diagnosticReporters[ktFile] = reporter + graphMessages?.register(visitor) } } - class SemanticImportsChecker : FirFileChecker(MppCheckerKind.Common) { + class SemanticImportsChecker(private val state: AnalyzerCompilationState) : + FirFileChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirFile) { val ktFile = declaration.sourceFile ?: return - val visitor = visitors[ktFile] + val visitor = state.visitors[ktFile] val eachFqNameElement = { @@ -134,7 +189,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio if (names != null) { eachFqNameElement(fqName, source.treeStructure, names) { fqName, name -> - visitor?.visitPackage(fqName, name, context) + visitor?.visitPackage(fqName, name) } } } @@ -161,13 +216,15 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio ) if (klass != null) { - visitor?.visitClassReference(klass, name, context) + visitor?.visitClassReference(klass, name) + visitor?.visitImport(klass, name, import.aliasName?.asString()) } else if (callables.isNotEmpty()) { for (callable in callables) { - visitor?.visitCallableReference(callable, name, context) + visitor?.visitCallableReference(callable, name) + visitor?.visitImport(callable, name, import.aliasName?.asString()) } } else { - visitor?.visitPackage(fqName, name, context) + visitor?.visitPackage(fqName, name) } } } @@ -175,12 +232,13 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } } - private class SemanticClassLikeChecker : FirClassLikeChecker(MppCheckerKind.Common) { + private class SemanticClassLikeChecker(private val state: AnalyzerCompilationState) : + FirClassLikeChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirClassLikeDeclaration) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] val objectKeyword = if (declaration is FirAnonymousObject) { source.treeStructure @@ -189,31 +247,44 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } else { null } + val identifierSource = getIdentifier(source) + // For unnamed companion objects, getIdentifier() falls back to source (no IDENTIFIER + // token). Use the 'companion' keyword as the range instead. The COMPANION_KEYWORD is + // inside a MODIFIER_LIST child, so we use findDescendantByType instead of + // findChildByType. + val companionKeyword = + if ( + identifierSource === source && + declaration is FirRegularClass && + declaration.isCompanion + ) { + source.treeStructure + .findDescendantByType(source.lighterASTNode, KtTokens.COMPANION_KEYWORD) + ?.toKtLightSourceElement(source.treeStructure) + } else { + null + } visitor?.visitClassOrObject( declaration, - objectKeyword ?: getIdentifier(source), - context, + objectKeyword ?: companionKeyword ?: identifierSource, enclosingSource = source, ) if (declaration is FirClass) { for (superType in declaration.superTypeRefs) { - val superSymbol = superType.toClassLikeSymbol(context.session) - val superSource = superType.source - if (superSymbol != null && superSource != null) { - visitor?.visitClassReference(superSymbol, superSource, context) - } + visitor?.emitTypeRef(superType) } } } } - private class SemanticConstructorChecker : FirConstructorChecker(MppCheckerKind.Common) { + private class SemanticConstructorChecker(private val state: AnalyzerCompilationState) : + FirConstructorChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirConstructor) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] if (declaration.isPrimary) { // if the constructor is not denoted by the 'constructor' keyword, we want to link @@ -238,137 +309,104 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio visitor?.visitPrimaryConstructor( declaration, constructorKeyboard ?: objectKeyword ?: getIdentifier(klassSource), - context, enclosingSource = source, ) } else { visitor?.visitSecondaryConstructor( declaration, getIdentifier(source), - context, enclosingSource = source, ) } } } - private class SemanticSimpleFunctionChecker : FirSimpleFunctionChecker(MppCheckerKind.Common) { + private class SemanticSimpleFunctionChecker(private val state: AnalyzerCompilationState) : + FirSimpleFunctionChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) - override fun check(declaration: FirSimpleFunction) { + override fun check(declaration: FirNamedFunction) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] visitor?.visitNamedFunction( declaration, getIdentifier(source), - context, enclosingSource = source, ) - - val klass = declaration.returnTypeRef.toClassLikeSymbol(context.session) - val klassSource = declaration.returnTypeRef.source - if ( - klass != null && klassSource != null && klassSource.kind !is KtFakeSourceElementKind - ) { - visitor?.visitClassReference(klass, getIdentifier(klassSource), context) - } + visitor?.emitTypeRef(declaration.returnTypeRef) + declaration.receiverParameter?.typeRef?.let { visitor?.emitTypeRef(it) } } } - private class SemanticAnonymousFunctionChecker : + private class SemanticAnonymousFunctionChecker(private val state: AnalyzerCompilationState) : FirAnonymousFunctionChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirAnonymousFunction) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] - visitor?.visitNamedFunction(declaration, source, context, enclosingSource = source) + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] + visitor?.visitNamedFunction(declaration, source, enclosingSource = source) } } - private class SemanticPropertyChecker : FirPropertyChecker(MppCheckerKind.Common) { + private class SemanticPropertyChecker(private val state: AnalyzerCompilationState) : + FirPropertyChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirProperty) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] - visitor?.visitProperty( - declaration, - getIdentifier(source), - context, - enclosingSource = source, - ) - - val klass = declaration.returnTypeRef.toClassLikeSymbol(context.session) - val klassSource = declaration.returnTypeRef.source - if ( - klass != null && klassSource != null && klassSource.kind !is KtFakeSourceElementKind - ) { - visitor?.visitClassReference(klass, getIdentifier(klassSource), context) - } + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] + visitor?.visitProperty(declaration, getIdentifier(source), enclosingSource = source) + visitor?.emitTypeRef(declaration.returnTypeRef) + declaration.receiverParameter?.typeRef?.let { visitor?.emitTypeRef(it) } } } - private class SemanticValueParameterChecker : FirValueParameterChecker(MppCheckerKind.Common) { + private class SemanticValueParameterChecker(private val state: AnalyzerCompilationState) : + FirValueParameterChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirValueParameter) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] - visitor?.visitParameter( - declaration, - getIdentifier(source), - context, - enclosingSource = source, - ) - - val klass = declaration.returnTypeRef.toClassLikeSymbol(context.session) - val klassSource = declaration.returnTypeRef.source - if ( - klass != null && klassSource != null && klassSource.kind !is KtFakeSourceElementKind - ) { - visitor?.visitClassReference(klass, getIdentifier(klassSource), context) - } + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] + visitor?.visitParameter(declaration, getIdentifier(source), enclosingSource = source) + visitor?.emitTypeRef(declaration.returnTypeRef) } } - private class SemanticTypeParameterChecker : FirTypeParameterChecker(MppCheckerKind.Common) { + private class SemanticTypeParameterChecker(private val state: AnalyzerCompilationState) : + FirTypeParameterChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirTypeParameter) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] visitor?.visitTypeParameter( declaration, getIdentifier(source), - context, enclosingSource = source, ) } } - private class SemanticTypeAliasChecker : FirTypeAliasChecker(MppCheckerKind.Common) { + private class SemanticTypeAliasChecker(private val state: AnalyzerCompilationState) : + FirTypeAliasChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirTypeAlias) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] - visitor?.visitTypeAlias( - declaration, - getIdentifier(source), - context, - enclosingSource = source, - ) + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] + visitor?.visitTypeAlias(declaration, getIdentifier(source), enclosingSource = source) } } - private class SemanticPropertyAccessorChecker : + private class SemanticPropertyAccessorChecker(private val state: AnalyzerCompilationState) : FirPropertyAccessorChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirPropertyAccessor) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] val identifierSource = if (declaration.isGetter) { source.treeStructure @@ -382,17 +420,37 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio getIdentifier(source) } - visitor?.visitPropertyAccessor( - declaration, - identifierSource, - context, - enclosingSource = source, - ) + visitor?.visitPropertyAccessor(declaration, identifierSource, enclosingSource = source) + } + } + + private class SemanticEnumEntryChecker(private val state: AnalyzerCompilationState) : + FirEnumEntryChecker(MppCheckerKind.Common) { + context(context: CheckerContext, reporter: DiagnosticReporter) + override fun check(declaration: FirEnumEntry) { + val source = declaration.source ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] + visitor?.visitEnumEntry(declaration, getIdentifier(source), enclosingSource = source) } } - private class SemanticQualifiedAccessExpressionChecker : - FirQualifiedAccessExpressionChecker(MppCheckerKind.Common) { + private class SemanticResolvedQualifierChecker(private val state: AnalyzerCompilationState) : + FirResolvedQualifierChecker(MppCheckerKind.Common) { + context(context: CheckerContext, reporter: DiagnosticReporter) + override fun check(expression: FirResolvedQualifier) { + val symbol = expression.symbol ?: return + val source = expression.source ?: return + if (source.kind is KtFakeSourceElementKind) return + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] + visitor?.visitClassReference(symbol, getIdentifier(source)) + } + } + + private class SemanticQualifiedAccessExpressionChecker( + private val state: AnalyzerCompilationState + ) : FirQualifiedAccessExpressionChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(expression: FirQualifiedAccessExpression) { val source = expression.source ?: return @@ -401,27 +459,28 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio return } - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] - visitor?.visitSimpleNameExpression( - calleeReference, - getIdentifier(calleeReference.source ?: source), - context, - ) + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] + val identifierSource = getIdentifier(calleeReference.source ?: source) + visitor?.visitSimpleNameExpression(calleeReference, identifierSource) + + when (expression) { + is FirFunctionCall -> + (calleeReference.resolvedSymbol + as? org.jetbrains.kotlin.fir.symbols.impl.FirCallableSymbol<*>) + ?.let { visitor?.visitCall(it, identifierSource) } + is FirPropertyAccessExpression -> + visitor?.visitAccess(calleeReference.resolvedSymbol, identifierSource, "read") + } val resolvedSymbol = calleeReference.resolvedSymbol if ( resolvedSymbol.origin == FirDeclarationOrigin.SamConstructor && resolvedSymbol is FirSyntheticFunctionSymbol ) { - val referencedKlass = - resolvedSymbol.resolvedReturnType.toClassLikeSymbol(context.session) + val referencedKlass = resolvedSymbol.resolvedReturnType.toClassLikeSymbol() if (referencedKlass != null) { - visitor?.visitClassReference( - referencedKlass, - getIdentifier(calleeReference.source ?: source), - context, - ) + visitor?.visitClassReference(referencedKlass, identifierSource) } } @@ -429,38 +488,43 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio // symbols if (resolvedSymbol is FirPropertySymbol) { resolvedSymbol.getterSymbol?.let { - visitor?.visitCallableReference( - it, - getIdentifier(calleeReference.source ?: source), - context, - ) + visitor?.visitCallableReference(it, identifierSource) } resolvedSymbol.setterSymbol?.let { - visitor?.visitCallableReference( - it, - getIdentifier(calleeReference.source ?: source), - context, - ) + visitor?.visitCallableReference(it, identifierSource) } } } } - private class SemanticClassReferenceExpressionChecker : - FirTypeOperatorCallChecker(MppCheckerKind.Common) { + private class SemanticClassReferenceExpressionChecker( + private val state: AnalyzerCompilationState + ) : FirTypeOperatorCallChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(expression: FirTypeOperatorCall) { val typeRef = expression.conversionTypeRef val source = typeRef.source ?: return - val classSymbol = - expression.conversionTypeRef.toClassLikeSymbol(context.session) ?: return - val ktFile = context.containingFile?.sourceFile ?: return - val visitor = visitors[ktFile] - - visitor?.visitClassReference( - classSymbol, - getIdentifier(expression.conversionTypeRef.source ?: source), - context, + val classSymbol = typeRef.toClassLikeSymbol(context.session) ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return + val visitor = state.visitors[ktFile] + + visitor?.visitClassReference(classSymbol, getIdentifier(source)) + } + } + + private class SemanticVariableAssignmentChecker(private val state: AnalyzerCompilationState) : + FirExpressionChecker(MppCheckerKind.Common) { + context(context: CheckerContext, reporter: DiagnosticReporter) + override fun check(expression: FirVariableAssignment) { + val access = expression.lValue as? FirQualifiedAccessExpression ?: return + val reference = access.calleeReference as? FirResolvedNamedReference ?: return + val source = reference.source ?: access.source ?: return + if (source.kind is KtFakeSourceElementKind) return + val ktFile = context.containingFileSymbol?.sourceFile ?: return + state.visitors[ktFile]?.visitAccess( + reference.resolvedSymbol, + getIdentifier(source), + "write", ) } } diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCommandLineProcessor.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCommandLineProcessor.kt index 45bb987b2..7ced8bdb9 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCommandLineProcessor.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCommandLineProcessor.kt @@ -15,9 +15,17 @@ val KEY_SOURCES = CompilerConfigurationKey(VAL_SOURCES) const val VAL_TARGET = "targetroot" val KEY_TARGET = CompilerConfigurationKey(VAL_TARGET) +const val VAL_GRAPH_ROOT = "graphroot" +val KEY_GRAPH_ROOT = CompilerConfigurationKey(VAL_GRAPH_ROOT) + +const val VAL_GRAPH_TARGET = "graphtarget" +val KEY_GRAPH_TARGET = CompilerConfigurationKey(VAL_GRAPH_TARGET) + +const val PLUGIN_ID = "scip-kotlinc" + @OptIn(ExperimentalCompilerApi::class) class AnalyzerCommandLineProcessor : CommandLineProcessor { - override val pluginId: String = "scip-kotlinc" + override val pluginId: String = PLUGIN_ID override val pluginOptions: Collection = listOf( CliOption( @@ -32,6 +40,18 @@ class AnalyzerCommandLineProcessor : CommandLineProcessor { "the absolute path to the directory where to generate SCIP files.", required = true, ), + CliOption( + VAL_GRAPH_ROOT, + "", + "the task-owned staging directory for graph shards", + required = false, + ), + CliOption( + VAL_GRAPH_TARGET, + "", + "the Gradle compilation coordinate that owns graph shards", + required = false, + ), ) override fun processOption( @@ -42,6 +62,8 @@ class AnalyzerCommandLineProcessor : CommandLineProcessor { when (option.optionName) { VAL_SOURCES -> configuration.put(KEY_SOURCES, Paths.get(value)) VAL_TARGET -> configuration.put(KEY_TARGET, Paths.get(value)) + VAL_GRAPH_ROOT -> configuration.put(KEY_GRAPH_ROOT, Paths.get(value)) + VAL_GRAPH_TARGET -> configuration.put(KEY_GRAPH_TARGET, value) } } } diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCompilationState.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCompilationState.kt new file mode 100644 index 000000000..432365199 --- /dev/null +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCompilationState.kt @@ -0,0 +1,11 @@ +package org.scip_code.scip_java.kotlinc + +import java.util.concurrent.ConcurrentHashMap +import org.jetbrains.kotlin.KtSourceFile +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter + +/** Compiler-invocation state that must never leak across parallel Kotlin compilations. */ +class AnalyzerCompilationState(val globals: GlobalSymbolsCache = GlobalSymbolsCache()) { + val visitors: MutableMap = ConcurrentHashMap() + val diagnosticReporters: MutableMap = ConcurrentHashMap() +} diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerFirExtensionRegistrar.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerFirExtensionRegistrar.kt index e0d6271de..3b3f7cf16 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerFirExtensionRegistrar.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerFirExtensionRegistrar.kt @@ -1,11 +1,18 @@ package org.scip_code.scip_java.kotlinc +import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrar import org.scip_code.scip_java.shared.ScipOptions -class AnalyzerFirExtensionRegistrar(private val options: ScipOptions) : FirExtensionRegistrar() { +class AnalyzerFirExtensionRegistrar( + private val options: ScipOptions, + private val graphRoot: java.nio.file.Path?, + private val graphTarget: String?, + private val graphMessages: KotlinGraphMessages?, + private val state: AnalyzerCompilationState, +) : FirExtensionRegistrar() { override fun ExtensionRegistrarContext.configurePlugin() { - +AnalyzerParamsProvider.getFactory(options) - +::AnalyzerCheckers + +AnalyzerParamsProvider.getFactory(options, graphRoot, graphTarget, graphMessages) + +{ session: FirSession -> AnalyzerCheckers(session, state) } } } diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerParamsProvider.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerParamsProvider.kt index 38f64ce2a..20073ede7 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerParamsProvider.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerParamsProvider.kt @@ -6,14 +6,26 @@ import org.jetbrains.kotlin.fir.extensions.FirExtensionSessionComponent import org.jetbrains.kotlin.fir.extensions.FirExtensionSessionComponent.Factory import org.scip_code.scip_java.shared.ScipOptions -open class AnalyzerParamsProvider(session: FirSession, val options: ScipOptions) : - FirExtensionSessionComponent(session) { +open class AnalyzerParamsProvider( + session: FirSession, + val options: ScipOptions, + val graphRoot: Path? = null, + val graphTarget: String? = null, + val graphMessages: KotlinGraphMessages? = null, +) : FirExtensionSessionComponent(session) { val sourceroot: Path get() = options.sourceroot companion object { - fun getFactory(options: ScipOptions): Factory { - return Factory { AnalyzerParamsProvider(it, options) } + fun getFactory( + options: ScipOptions, + graphRoot: Path?, + graphTarget: String?, + graphMessages: KotlinGraphMessages?, + ): Factory { + return Factory { + AnalyzerParamsProvider(it, options, graphRoot, graphTarget, graphMessages) + } } } } diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerRegistrar.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerRegistrar.kt index 289a0cf4e..1968660af 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerRegistrar.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerRegistrar.kt @@ -3,6 +3,7 @@ package org.scip_code.scip_java.kotlinc import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.jetbrains.kotlin.config.CommonConfigurationKeys import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter import org.scip_code.scip.Document @@ -16,16 +17,39 @@ class AnalyzerRegistrar(private val callback: (Document) -> Unit = {}) : Compile sourceroot = configuration[KEY_SOURCES]!! targetroot = configuration[KEY_TARGET]!! } - FirExtensionRegistrarAdapter.registerExtension(AnalyzerFirExtensionRegistrar(options)) + val graphRoot = configuration[KEY_GRAPH_ROOT] + val graphTarget = configuration[KEY_GRAPH_TARGET] + require((graphRoot == null) == (graphTarget == null)) { + "scip-kotlinc graphroot and graphtarget must be provided together" + } + val messages = + if (graphRoot == null) null + else { + val delegate = + configuration[CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY] + ?: org.jetbrains.kotlin.cli.common.messages.MessageCollector.NONE + KotlinGraphMessages(delegate).also { + configuration.put(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, it) + } + } + val state = AnalyzerCompilationState() + FirExtensionRegistrarAdapter.registerExtension( + AnalyzerFirExtensionRegistrar(options, graphRoot, graphTarget, messages, state) + ) IrGenerationExtension.registerExtension( PostAnalysisExtension( + configuration = configuration, sourceRoot = options.sourceroot, targetRoot = options.targetroot, callback = callback, + graphMessages = messages, + state = state, ) ) } + override val pluginId = PLUGIN_ID + override val supportsK2: Boolean get() = true } diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/KotlinGraphDocumentBuilder.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/KotlinGraphDocumentBuilder.kt new file mode 100644 index 000000000..7bfdad583 --- /dev/null +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/KotlinGraphDocumentBuilder.kt @@ -0,0 +1,503 @@ +package org.scip_code.scip_java.kotlinc + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.LinkedHashMap +import org.jetbrains.kotlin.KtSourceElement +import org.jetbrains.kotlin.KtSourceFile +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation +import org.jetbrains.kotlin.diagnostics.KtDiagnostic +import org.jetbrains.kotlin.fir.FirElement +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.analysis.checkers.declaration.isLocalDeclaredInBlock +import org.jetbrains.kotlin.fir.analysis.checkers.directOverriddenSymbolsSafe +import org.jetbrains.kotlin.fir.analysis.checkers.toClassLikeSymbol +import org.jetbrains.kotlin.fir.declarations.FirCallableDeclaration +import org.jetbrains.kotlin.fir.declarations.FirClass +import org.jetbrains.kotlin.fir.declarations.FirClassLikeDeclaration +import org.jetbrains.kotlin.fir.declarations.FirConstructor +import org.jetbrains.kotlin.fir.declarations.FirDeclaration +import org.jetbrains.kotlin.fir.declarations.FirEnumEntry +import org.jetbrains.kotlin.fir.declarations.FirField +import org.jetbrains.kotlin.fir.declarations.FirMemberDeclaration +import org.jetbrains.kotlin.fir.declarations.FirProperty +import org.jetbrains.kotlin.fir.declarations.FirPropertyAccessor +import org.jetbrains.kotlin.fir.declarations.FirTypeAlias +import org.jetbrains.kotlin.fir.declarations.FirTypeParameter +import org.jetbrains.kotlin.fir.declarations.FirValueParameter +import org.jetbrains.kotlin.fir.declarations.FirVariable +import org.jetbrains.kotlin.fir.declarations.utils.isInterface +import org.jetbrains.kotlin.fir.declarations.utils.isLocal +import org.jetbrains.kotlin.fir.renderer.ConeIdFullRenderer +import org.jetbrains.kotlin.fir.renderer.ConeTypeRenderer +import org.jetbrains.kotlin.fir.renderer.FirAllModifierRenderer +import org.jetbrains.kotlin.fir.renderer.FirCallNoArgumentsRenderer +import org.jetbrains.kotlin.fir.renderer.FirCallableSignatureRendererForReadability +import org.jetbrains.kotlin.fir.renderer.FirDeclarationRenderer +import org.jetbrains.kotlin.fir.renderer.FirNoClassMemberRenderer +import org.jetbrains.kotlin.fir.renderer.FirRenderer +import org.jetbrains.kotlin.fir.resolve.getContainingSymbol +import org.jetbrains.kotlin.fir.symbols.FirBasedSymbol +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.symbols.impl.FirAnonymousFunctionSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirCallableSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirConstructorSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirFileSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirFunctionSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirPropertyAccessorSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirPropertySymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirTypeParameterSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirValueParameterSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirVariableSymbol +import org.jetbrains.kotlin.fir.types.ConeKotlinType +import org.jetbrains.kotlin.fir.types.coneType +import org.scip_code.scip_java.shared.ScipShardPaths + +/** Compiler-owned graph facts for one Kotlin source file. */ +@OptIn(SymbolInternals::class) +class KotlinGraphDocumentBuilder( + private val sourceRoot: Path, + private val targetRoot: Path, + private val target: String, + private val file: KtSourceFile, + private val lineMap: LineMap, +) { + private val source = ScipShardPaths.relativePath(sourceRoot, Paths.get(file.path)) + private val bytes = file.getContentsAsStream().use { it.readBytes() } + private val text = String(bytes, StandardCharsets.UTF_8) + private val nodes = LinkedHashMap() + private val edges = LinkedHashMap() + private val unresolved = LinkedHashMap() + private val diagnostics = LinkedHashMap() + + context(context: CheckerContext) + fun declare(symbol: FirBasedSymbol<*>, element: KtSourceElement, enclosing: KtSourceElement?) { + val key = symbol(symbol) + if (key.isEmpty()) return + val kind = kind(symbol.fir) + val name = name(symbol) + val qualifiedName = qualifiedName(symbol) + val evidence = evidence(element, enclosing) + val declaration = symbol.fir + val node = + KotlinGraphShard.Node( + key, + kind, + name, + qualifiedName, + source, + exported(declaration), + modifiers(declaration), + signature(symbol.fir), + origin(symbol), + evidence, + ) + val prior = nodes.putIfAbsent(key, node) + check(prior == null || prior == node) { + "Kotlin graph symbol changed within one source: $key" + } + edge(owner(), key, "contains", null, null, symbol, evidence) + if (node.exported()) edge(source, key, "exports", null, null, symbol, evidence) + decorate(symbol, key, declaration, evidence) + when (symbol) { + is FirClassLikeSymbol<*> -> inheritance(symbol, key, element) + is FirFunctionSymbol<*> -> overrides(symbol, key, element) + is FirPropertySymbol -> overrides(symbol, key, element) + } + } + + context(context: CheckerContext) + fun reference( + targetSymbol: FirBasedSymbol<*>, + element: KtSourceElement, + family: String, + access: String? = null, + provenance: String? = null, + ) { + val to = symbol(targetSymbol) + if (to.isEmpty()) { + unresolved(family, element, "analysis-error") + return + } + edge(owner(), to, family, access, provenance, targetSymbol, evidence(element, null)) + } + + context(context: CheckerContext) + fun dispatch(targetSymbol: FirBasedSymbol<*>, element: KtSourceElement) { + val declaration = targetSymbol.fir as? FirCallableDeclaration ?: return + if ( + declaration.status.modality?.name?.lowercase() !in setOf("open", "abstract", "sealed") + ) { + return + } + unresolved("dispatches", element, "dynamic", listOf(symbol(targetSymbol))) + } + + fun diagnostic( + severity: CompilerMessageSeverity, + message: String, + location: CompilerMessageSourceLocation?, + ) { + val path = location?.path ?: return + val normalized = Paths.get(path).toAbsolutePath().normalize() + if (normalized != Paths.get(file.path).toAbsolutePath().normalize()) return + val startLine = maxOf(1, location.line) + val startColumn = maxOf(1, location.column) + val item = + KotlinGraphShard.Diagnostic( + if (severity.isError) "error" else "warning", + message, + KotlinGraphShard.Evidence(source, startLine, startColumn, startLine, startColumn), + ) + diagnostics.putIfAbsent( + "${item.severity()}\u0000${item.message()}\u0000$startLine\u0000$startColumn", + item, + ) + } + + fun diagnostic(diagnostic: KtDiagnostic) { + val range = diagnostic.firstRange + val startLine = lineMap.lineNumberForOffset(range.startOffset) + val startColumn = lineMap.columnForOffset(range.startOffset) + 1 + val endLine = lineMap.lineNumberForOffset(range.endOffset) + val endColumn = lineMap.columnForOffset(range.endOffset) + 1 + val severity = + if (diagnostic.severity == org.jetbrains.kotlin.diagnostics.Severity.ERROR) { + "error" + } else { + "warning" + } + val item = + KotlinGraphShard.Diagnostic( + severity, + diagnostic.renderMessage(), + KotlinGraphShard.Evidence(source, startLine, startColumn, endLine, endColumn), + ) + diagnostics.putIfAbsent( + "${item.severity()}\u0000${item.message()}\u0000$startLine\u0000$startColumn", + item, + ) + } + + fun build(): KotlinGraphShard { + val disk = Paths.get(file.path).toAbsolutePath().normalize() + val diskDigest = + if (Files.isRegularFile(disk)) KotlinGraphShard.digest(Files.readAllBytes(disk)) else "" + return KotlinGraphShard( + source, + KotlinGraphShard.digest(bytes), + diskDigest, + target, + org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION, + nodes.values.toList(), + edges.values.toList(), + unresolved.values.toList(), + diagnostics.values.toList(), + ) + } + + context(context: CheckerContext) + private fun inheritance(symbol: FirClassLikeSymbol<*>, from: String, element: KtSourceElement) { + val declaration = symbol.fir as? FirClass ?: return + for (typeRef in declaration.superTypeRefs) { + val typeSource = typeRef.source ?: continue + if (typeSource.kind is org.jetbrains.kotlin.KtFakeSourceElementKind) continue + val parent = typeRef.toClassLikeSymbol(context.session) ?: continue + val family = + if ((parent.fir as? FirClass)?.isInterface == true) "implements" else "extends" + edge(from, symbol(parent), family, null, null, parent, evidence(typeSource, null)) + } + } + + context(context: CheckerContext) + private fun overrides(symbol: FirCallableSymbol<*>, from: String, element: KtSourceElement) { + for (parent in symbol.directOverriddenSymbolsSafe()) { + edge(from, symbol(parent), "overrides", null, null, parent, evidence(element, null)) + } + } + + context(context: CheckerContext) + private fun decorate( + symbol: FirBasedSymbol<*>, + from: String, + declaration: FirDeclaration?, + evidence: KotlinGraphShard.Evidence, + ) { + val annotations = declaration?.annotations.orEmpty() + for (annotation in annotations) { + val annotationSymbol = + annotation.annotationTypeRef.toClassLikeSymbol(context.session) ?: continue + val annotationEvidence = annotation.source?.let { evidence(it, null) } ?: evidence + edge( + from, + symbol(annotationSymbol), + "decorates", + null, + useSite(annotation.source), + annotationSymbol, + annotationEvidence, + ) + if (isTestAnnotation(annotationSymbol)) { + edge( + from, + symbol(annotationSymbol), + "tests", + null, + "annotation", + annotationSymbol, + annotationEvidence, + ) + } + } + } + + private fun useSite(source: KtSourceElement?): String? { + val annotation = + source?.let { text.substring(it.startOffset, it.endOffset.coerceAtMost(text.length)) } + ?: return null + val marker = annotation.substringAfter('@', "").substringBefore(':', "") + return marker.takeIf { + it in + setOf( + "file", + "field", + "property", + "get", + "set", + "receiver", + "param", + "setparam", + "delegate", + ) + } + } + + private fun isTestAnnotation(symbol: FirClassLikeSymbol<*>): Boolean { + val name = symbol.classId.asSingleFqName().asString() + return name == "org.junit.Test" || + name == "org.junit.jupiter.api.Test" || + name == "kotlin.test.Test" || + name.startsWith("io.kotest.") + } + + context(context: CheckerContext) + private fun owner(): String = + context.containingDeclarations + .lastOrNull { it !is FirFileSymbol } + ?.let(::symbol) + ?.takeIf(String::isNotEmpty) ?: source + + private fun edge( + from: String, + to: String, + family: String, + access: String?, + provenance: String?, + targetSymbol: FirBasedSymbol<*>, + evidence: KotlinGraphShard.Evidence, + ) { + if (from.isEmpty() || to.isEmpty()) return + val edge = + KotlinGraphShard.Edge( + from, + to, + family, + access, + provenance, + kind(targetSymbol.fir), + name(targetSymbol), + qualifiedName(targetSymbol), + evidence, + ) + edges.putIfAbsent( + "$family\u0000$from\u0000$to\u0000${access.orEmpty()}\u0000${provenance.orEmpty()}\u0000${evidence.startLine()}\u0000${evidence.startColumn()}", + edge, + ) + } + + private fun unresolved( + family: String, + element: KtSourceElement, + reason: String, + candidates: List = emptyList(), + ) { + val evidence = evidence(element, null) + val item = KotlinGraphShard.Unresolved(family, reason, evidence, candidates) + unresolved.putIfAbsent( + "$family\u0000${evidence.startLine()}\u0000${evidence.startColumn()}\u0000$reason", + item, + ) + } + + private fun evidence( + element: KtSourceElement, + enclosing: KtSourceElement?, + ): KotlinGraphShard.Evidence { + val startLine = lineMap.lineNumber(element) + val startColumn = lineMap.startCharacter(element) + 1 + val endOffset = enclosing?.endOffset ?: element.endOffset + val endLine = lineMap.lineNumberForOffset(endOffset) + val endColumn = lineMap.columnForOffset(endOffset) + 1 + return KotlinGraphShard.Evidence(source, startLine, startColumn, endLine, endColumn) + } + + @OptIn(SymbolInternals::class) + private fun symbol(symbol: FirBasedSymbol<*>): String = + when (symbol) { + is FirAnonymousFunctionSymbol -> localSymbol(symbol, "lambda") + is FirClassLikeSymbol<*> -> "class:${symbol.classId.asString()}" + is FirPropertyAccessorSymbol -> + callableSymbol(symbol.propertySymbol) + + "|accessor=" + + if (symbol.isGetter) "get" else "set" + is FirConstructorSymbol -> callableSymbol(symbol) + "|constructor" + is FirTypeParameterSymbol -> + symbol.containingDeclarationSymbol.let(::symbol) + + "|type-parameter:${symbol.name.asString()}" + is FirValueParameterSymbol -> + symbol.containingDeclarationSymbol.let(::symbol) + + "|parameter:${symbol.name.asString()}" + is FirPropertySymbol -> callableSymbol(symbol) + is FirVariableSymbol<*> -> localSymbol(symbol, "variable:${symbol.name.asString()}") + is FirCallableSymbol<*> -> callableSymbol(symbol) + else -> localSymbol(symbol, symbol.javaClass.simpleName) + } + + @OptIn(SymbolInternals::class) + private fun callableSymbol(symbol: FirCallableSymbol<*>): String { + val callableId = + symbol.callableId ?: return localSymbol(symbol, "callable:${symbol.name.asString()}") + if (isGraphLocal(symbol)) return localSymbol(symbol, "callable:${symbol.name.asString()}") + val declaration = symbol.fir + val receiver = declaration.receiverParameter?.typeRef?.coneType?.let(::renderType).orEmpty() + val contexts = + declaration.contextParameters.joinToString(",") { + renderType(it.returnTypeRef.coneType) + } + val parameters = + (declaration as? org.jetbrains.kotlin.fir.declarations.FirFunction) + ?.valueParameters + ?.joinToString(",") { renderType(it.returnTypeRef.coneType) } + .orEmpty() + return "callable:${callableId.asSingleFqName().asString()}|receiver=$receiver|context=$contexts|parameters=$parameters|arity=${declaration.typeParameters.size}" + } + + private fun localSymbol(symbol: FirBasedSymbol<*>, role: String): String { + val source = symbol.source + return "local:${this.source}:${source?.startOffset ?: -1}:$role" + } + + private fun renderType(type: ConeKotlinType): String { + val renderer = ConeTypeRenderer() + val out = StringBuilder() + val idRenderer = ConeIdFullRenderer() + renderer.builder = out + idRenderer.builder = out + renderer.idRenderer = idRenderer + renderer.render(type, "") + return out.toString() + } + + private fun name(symbol: FirBasedSymbol<*>): String = + when (symbol) { + is FirClassLikeSymbol<*> -> symbol.classId.shortClassName.asString() + is FirPropertyAccessorSymbol -> if (symbol.isGetter) "get" else "set" + is FirConstructorSymbol -> "" + is FirCallableSymbol<*> -> symbol.name.asString() + is FirTypeParameterSymbol -> symbol.name.asString() + is FirValueParameterSymbol -> symbol.name.asString() + is FirVariableSymbol<*> -> symbol.name.asString() + else -> symbol.javaClass.simpleName + } + + private fun qualifiedName(symbol: FirBasedSymbol<*>): String = + when (symbol) { + is FirClassLikeSymbol<*> -> symbol.classId.asSingleFqName().asString() + is FirTypeParameterSymbol, + is FirValueParameterSymbol -> "" + is FirPropertySymbol -> + symbol.callableId + ?.takeUnless { isGraphLocal(symbol) } + ?.asSingleFqName() + ?.asString() + .orEmpty() + is FirVariableSymbol<*> -> "" + is FirCallableSymbol<*> -> + symbol.callableId + ?.takeUnless { isGraphLocal(symbol) } + ?.asSingleFqName() + ?.asString() + .orEmpty() + else -> "" + } + + @OptIn(SymbolInternals::class) + private fun isGraphLocal(symbol: FirCallableSymbol<*>): Boolean { + if (symbol.fir.isLocalDeclaredInBlock) return true + val owner = symbol.getContainingSymbol(symbol.fir.moduleData.session) + return owner is FirClassLikeSymbol<*> && owner.isLocal + } + + private fun kind(element: FirElement): String = + when (element) { + is FirClass -> + when { + element.isInterface -> "interface" + element.classKind.name == "ENUM_CLASS" -> "enum" + else -> "class" + } + is FirTypeAlias -> "type" + is FirConstructor -> "constructor" + is FirTypeParameter -> "type" + is FirValueParameter -> "parameter" + is FirField -> "field" + is FirPropertyAccessor -> "method" + is FirProperty -> "property" + is FirEnumEntry -> "field" + is FirVariable -> "variable" + is FirCallableDeclaration -> "function" + is FirClassLikeDeclaration -> "class" + else -> "variable" + } + + private fun exported(declaration: FirDeclaration?): Boolean = + (declaration as? FirMemberDeclaration)?.status?.visibility?.name == "public" + + private fun modifiers(declaration: FirDeclaration?): List { + val status = (declaration as? FirMemberDeclaration)?.status ?: return emptyList() + val out = linkedSetOf() + status.visibility.name + .takeIf { it in setOf("public", "private", "protected", "internal") } + ?.let(out::add) + if (status.modality?.name == "abstract") out += "abstract" + if (status.isStatic) out += "static" + if (status.isConst) out += "const" + if (status.isSuspend) out += "async" + if (exported(declaration)) out += "export" + return out.toList() + } + + private fun signature(element: FirElement): String { + val renderer = + FirRenderer( + typeRenderer = ConeTypeRenderer(), + idRenderer = ConeIdFullRenderer(), + classMemberRenderer = FirNoClassMemberRenderer(), + bodyRenderer = null, + propertyAccessorRenderer = null, + callArgumentsRenderer = FirCallNoArgumentsRenderer(), + modifierRenderer = FirAllModifierRenderer(), + callableSignatureRenderer = FirCallableSignatureRendererForReadability(), + declarationRenderer = FirDeclarationRenderer("local "), + ) + return renderer.renderElementAsString(element) + } + + private fun origin(symbol: FirBasedSymbol<*>): String = symbol.fir.origin.toString() + + fun outputPath(): Path = KotlinGraphShard.outputPathAtRoot(targetRoot, Paths.get(source)) +} diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/KotlinGraphMessages.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/KotlinGraphMessages.kt new file mode 100644 index 000000000..46ec703d0 --- /dev/null +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/KotlinGraphMessages.kt @@ -0,0 +1,51 @@ +package org.scip_code.scip_java.kotlinc + +import java.util.concurrent.CopyOnWriteArrayList +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation +import org.jetbrains.kotlin.cli.common.messages.MessageCollector + +/** Keeps serializable compiler messages while preserving the build's collector. */ +class KotlinGraphMessages(private val delegate: MessageCollector) : MessageCollector { + data class Item( + val severity: CompilerMessageSeverity, + val message: String, + val location: CompilerMessageSourceLocation?, + ) + + private val items = CopyOnWriteArrayList() + private val visitors = CopyOnWriteArrayList() + + override fun clear() { + items.clear() + delegate.clear() + } + + override fun hasErrors(): Boolean = delegate.hasErrors() + + override fun report( + severity: CompilerMessageSeverity, + message: String, + location: CompilerMessageSourceLocation?, + ) { + if ( + severity.isError || + severity == CompilerMessageSeverity.WARNING || + severity == CompilerMessageSeverity.STRONG_WARNING + ) { + val item = Item(severity, message, location) + items += item + visitors.forEach { visitor -> + visitor.graphDiagnostic(item.severity, item.message, item.location) + visitor.writeGraph() + } + } + delegate.report(severity, message, location) + } + + fun snapshot(): List = items.toList() + + fun register(visitor: ScipVisitor) { + visitors += visitor + } +} diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/LineMap.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/LineMap.kt index c2da05fdf..afd51bd0a 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/LineMap.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/LineMap.kt @@ -10,22 +10,22 @@ class LineMap(private val file: FirFile) { private fun offsetToLineAndCol(offset: Int): Pair? = file.sourceFileLinesMapping?.getLineAndColumnByOffset(offset) - /** Returns the non-0-based line number for a given offset */ + /** Returns the 1-based line number for a given offset (subtract 1 for protobuf). */ fun lineNumberForOffset(offset: Int): Int = file.sourceFileLinesMapping?.getLineByOffset(offset)?.let { it + 1 } ?: 0 - /** Returns the non-0-based column number for a given offset */ + /** Returns the 0-based column number for a given offset. */ fun columnForOffset(offset: Int): Int = offsetToLineAndCol(offset)?.second ?: 0 - /** Returns the non-0-based start character */ + /** Returns the 0-based start character. */ fun startCharacter(element: KtSourceElement): Int = offsetToLineAndCol(element.startOffset)?.second ?: 0 - /** Returns the non-0-based end character */ + /** Returns the 0-based end character. */ fun endCharacter(element: KtSourceElement): Int = startCharacter(element) + nameForOffset(element).length - /** Returns the non-0-based line number */ + /** Returns the 1-based line number (subtract 1 for protobuf). */ fun lineNumber(element: KtSourceElement): Int = file.sourceFileLinesMapping?.getLineByOffset(element.startOffset)?.let { it + 1 } ?: 0 diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/PostAnalysisExtension.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/PostAnalysisExtension.kt index 54ea38fa6..7075ce24b 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/PostAnalysisExtension.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/PostAnalysisExtension.kt @@ -12,6 +12,7 @@ import org.jetbrains.kotlin.cli.common.messages.MessageRenderer import org.jetbrains.kotlin.cli.common.messages.PrintingMessageCollector import org.jetbrains.kotlin.config.CommonConfigurationKeys import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.diagnostics.impl.BaseDiagnosticsCollector import org.jetbrains.kotlin.ir.declarations.IrModuleFragment import org.scip_code.scip.Document import org.scip_code.scip_java.shared.ScipShardPaths @@ -25,26 +26,51 @@ import org.scip_code.scip_java.shared.ScipShardWriter * outside the source root are skipped with a stderr warning. */ class PostAnalysisExtension( + private val configuration: CompilerConfiguration, private val sourceRoot: Path, private val targetRoot: Path, private val callback: (Document) -> Unit, + private val graphMessages: KotlinGraphMessages? = null, + private val state: AnalyzerCompilationState, ) : IrGenerationExtension { override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { try { - for ((ktSourceFile, visitor) in AnalyzerCheckers.visitors) { + for ((ktSourceFile, visitor) in state.visitors) { try { + val reporter = + state.diagnosticReporters[ktSourceFile] as? BaseDiagnosticsCollector + reporter + ?.diagnosticsByFilePath + ?.entries + ?.firstOrNull { (file, _) -> + runCatching { + Paths.get(file).toAbsolutePath().normalize() == + Paths.get(ktSourceFile.path).toAbsolutePath().normalize() + } + .getOrDefault(file == ktSourceFile.path) + } + ?.value + ?.forEach(visitor::graphDiagnostic) + graphMessages?.snapshot()?.forEach { message -> + visitor.graphDiagnostic(message.severity, message.message, message.location) + } val document = visitor.build() scipShardPathForFile(ktSourceFile)?.let { outPath -> ScipShardWriter.writeShard(outPath, document) } callback(document) + visitor.writeGraph() } catch (e: Exception) { handleException(e) + if (graphMessages != null) throw e } } } catch (e: Exception) { handleException(e) + if (graphMessages != null) throw e } + state.visitors.clear() + state.diagnosticReporters.clear() } private fun scipShardPathForFile(file: KtSourceFile): Path? { @@ -60,11 +86,10 @@ class PostAnalysisExtension( } private val messageCollector = - CompilerConfiguration() - .get( - CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, - PrintingMessageCollector(System.err, MessageRenderer.PLAIN_FULL_PATHS, false), - ) + configuration.get( + CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, + PrintingMessageCollector(System.err, MessageRenderer.PLAIN_FULL_PATHS, false), + ) private fun handleException(e: Exception) { val writer = @@ -73,7 +98,7 @@ class PostAnalysisExtension( val buf = StringBuffer() override fun close() = - messageCollector.report(CompilerMessageSeverity.EXCEPTION, buf.toString()) + messageCollector.report(CompilerMessageSeverity.WARNING, buf.toString()) override fun flush() = Unit diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/ScipTextDocumentBuilder.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/ScipTextDocumentBuilder.kt index f767dda2b..b9fff9d45 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/ScipTextDocumentBuilder.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/ScipTextDocumentBuilder.kt @@ -5,10 +5,13 @@ import java.nio.file.Paths import org.jetbrains.kotlin.KtSourceElement import org.jetbrains.kotlin.KtSourceFile import org.jetbrains.kotlin.fir.FirElement +import org.jetbrains.kotlin.fir.FirPackageDirective import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.analysis.checkers.directOverriddenSymbolsSafe import org.jetbrains.kotlin.fir.analysis.checkers.toClassLikeSymbol import org.jetbrains.kotlin.fir.analysis.getChild +import org.jetbrains.kotlin.fir.declarations.* +import org.jetbrains.kotlin.fir.declarations.utils.isInterface import org.jetbrains.kotlin.fir.renderer.* import org.jetbrains.kotlin.fir.symbols.FirBasedSymbol import org.jetbrains.kotlin.fir.symbols.SymbolInternals @@ -19,6 +22,7 @@ import org.jetbrains.kotlin.text import org.scip_code.scip.Document import org.scip_code.scip.Occurrence import org.scip_code.scip.SymbolInformation +import org.scip_code.scip.SymbolInformation.Kind import org.scip_code.scip.SymbolRole import org.scip_code.scip.relationship import org.scip_code.scip.signature @@ -35,30 +39,30 @@ class ScipTextDocumentBuilder( private val cache: SymbolsCache, ) { private val documentBuilder = ScipDocumentBuilder() - private val fileText = file.getContentsAsStream().reader().readText() + private val fileText = file.getContentsAsStream().reader().use { it.readText() } fun build(): Document = documentBuilder.build("kotlin", relativePath(), fileText) + context(context: CheckerContext) fun emitScipData( firBasedSymbol: FirBasedSymbol<*>?, symbol: Symbol, element: KtSourceElement, isDefinition: Boolean, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { documentBuilder.addOccurrence(occurrence(symbol, element, isDefinition, enclosingSource)) if (isDefinition) { - documentBuilder.addSymbol(symbolInformation(firBasedSymbol, symbol, element, context)) + documentBuilder.addSymbol(symbolInformation(firBasedSymbol, symbol, element)) } } @OptIn(SymbolInternals::class) + context(context: CheckerContext) private fun symbolInformation( firBasedSymbol: FirBasedSymbol<*>?, symbol: Symbol, element: KtSourceElement, - context: CheckerContext, ): SymbolInformation { val supers = when (firBasedSymbol) { @@ -68,7 +72,7 @@ class ScipTextDocumentBuilder( .mapNotNull { it.toClassLikeSymbol(firBasedSymbol.moduleData.session) } .flatMap { cache[it] } is FirFunctionSymbol<*> -> - firBasedSymbol.directOverriddenSymbolsSafe(context).flatMap { cache[it] } + firBasedSymbol.directOverriddenSymbolsSafe().flatMap { cache[it] } else -> emptyList() } return symbolInformation { @@ -84,6 +88,10 @@ class ScipTextDocumentBuilder( } docComment(firBasedSymbol.fir)?.let { documentation += it } } + this.kind = scipKind(firBasedSymbol?.fir) + this.enclosingSymbol = + context.containingDeclarations.lastOrNull()?.let { cache[it].last().toString() } + ?: "" for (parent in supers) { relationships += relationship { this.symbol = parent.toString() @@ -159,6 +167,23 @@ class ScipTextDocumentBuilder( return stripKdoc(kdoc).ifEmpty { null } } + private fun scipKind(element: FirElement?): Kind = + when (element) { + is FirClass if element.isInterface -> Kind.Interface + is FirTypeAlias -> Kind.TypeAlias + is FirClassLikeDeclaration -> Kind.Class + is FirConstructor -> Kind.Constructor + is FirTypeParameter -> Kind.TypeParameter + is FirValueParameter -> Kind.Parameter + is FirField -> Kind.Field + is FirProperty -> Kind.Property + is FirEnumEntry -> Kind.EnumMember + is FirVariable -> Kind.Variable + is FirCallableDeclaration -> Kind.Method + is FirPackageDirective -> Kind.Package + else -> Kind.UNRECOGNIZED + } + /** Strips the `/**`, leading `*`s, and `*/` from a kdoc block, returning just the body text. */ private fun stripKdoc(kdoc: String): String { if (kdoc.isEmpty()) return kdoc @@ -184,14 +209,16 @@ class ScipTextDocumentBuilder( } companion object { - @OptIn(SymbolInternals::class) + @OptIn(SymbolInternals::class, RenderingInternals::class) private fun displayName(firBasedSymbol: FirBasedSymbol<*>): String = when (firBasedSymbol) { - is FirClassSymbol -> firBasedSymbol.classId.shortClassName.asString() + is FirClassLikeSymbol -> firBasedSymbol.classId.shortClassName.asString() is FirPropertyAccessorSymbol -> firBasedSymbol.fir.propertySymbol.name.asString() is FirFunctionSymbol -> firBasedSymbol.callableId.callableName.asString() - is FirPropertySymbol -> firBasedSymbol.callableId.callableName.asString() + is FirPropertySymbol -> + firBasedSymbol.callableIdForRendering.callableName.asString() is FirVariableSymbol -> firBasedSymbol.name.asString() + is FirTypeParameterSymbol -> firBasedSymbol.name.asString() else -> firBasedSymbol.toString() } } diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/ScipVisitor.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/ScipVisitor.kt index 80b9866e5..9e93dd525 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/ScipVisitor.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/ScipVisitor.kt @@ -3,6 +3,7 @@ package org.scip_code.scip_java.kotlinc import java.nio.file.Path import org.jetbrains.kotlin.KtSourceElement import org.jetbrains.kotlin.KtSourceFile +import org.jetbrains.kotlin.diagnostics.KtDiagnostic import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.declarations.* import org.jetbrains.kotlin.fir.references.FirResolvedNamedReference @@ -22,9 +23,14 @@ class ScipVisitor( lineMap: LineMap, globals: GlobalSymbolsCache, locals: LocalSymbolsCache = LocalSymbolsCache(), + graphRoot: Path? = null, + graphTarget: String? = null, ) { private val cache = SymbolsCache(globals, locals) private val documentBuilder = ScipTextDocumentBuilder(sourceroot, file, lineMap, cache) + private val graphBuilder = + if (graphRoot == null || graphTarget == null) null + else KotlinGraphDocumentBuilder(sourceroot, graphRoot, graphTarget, file, lineMap) private data class SymbolDescriptorPair( val firBasedSymbol: FirBasedSymbol<*>?, @@ -33,10 +39,27 @@ class ScipVisitor( fun build(): Document = documentBuilder.build() + fun buildGraph(): KotlinGraphShard? = graphBuilder?.build() + + fun graphOutputPath(): Path? = graphBuilder?.outputPath() + + fun writeGraph() { + val builder = graphBuilder ?: return + builder.build().write(builder.outputPath()) + } + + fun graphDiagnostic( + severity: org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity, + message: String, + location: org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation?, + ) = graphBuilder?.diagnostic(severity, message, location) + + fun graphDiagnostic(diagnostic: KtDiagnostic) = graphBuilder?.diagnostic(diagnostic) + + context(context: CheckerContext) private fun Sequence?.emitAll( element: KtSourceElement, isDefinition: Boolean, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ): List? = this?.onEach { (firBasedSymbol, symbol) -> @@ -45,7 +68,6 @@ class ScipVisitor( symbol, element, isDefinition, - context, enclosingSource, ) } @@ -55,132 +77,171 @@ class ScipVisitor( private fun Sequence.with(firBasedSymbol: FirBasedSymbol<*>?) = this.map { SymbolDescriptorPair(firBasedSymbol, it) } - fun visitPackage(pkg: FqName, element: KtSourceElement, context: CheckerContext) { - cache[pkg].with(null).emitAll(element, isDefinition = false, context) + context(context: CheckerContext) + fun visitPackage(pkg: FqName, element: KtSourceElement) { + cache[pkg].with(null).emitAll(element, isDefinition = false) } - fun visitClassReference( - firClassSymbol: FirClassLikeSymbol<*>, - element: KtSourceElement, - context: CheckerContext, - ) { - cache[firClassSymbol].with(firClassSymbol).emitAll(element, isDefinition = false, context) + context(context: CheckerContext) + fun visitClassReference(firClassSymbol: FirClassLikeSymbol<*>, element: KtSourceElement) { + cache[firClassSymbol].with(firClassSymbol).emitAll(element, isDefinition = false) + graphBuilder?.reference(firClassSymbol, element, "type_ref") + graphBuilder?.reference(firClassSymbol, element, "references") } - fun visitCallableReference( - firClassSymbol: FirCallableSymbol<*>, - element: KtSourceElement, - context: CheckerContext, - ) { - cache[firClassSymbol].with(firClassSymbol).emitAll(element, isDefinition = false, context) + context(context: CheckerContext) + fun visitCallableReference(firClassSymbol: FirCallableSymbol<*>, element: KtSourceElement) { + cache[firClassSymbol].with(firClassSymbol).emitAll(element, isDefinition = false) + graphBuilder?.reference(firClassSymbol, element, "references") } + context(context: CheckerContext) + fun visitImport(firSymbol: FirBasedSymbol<*>, element: KtSourceElement, alias: String? = null) { + graphBuilder?.reference(firSymbol, element, "imports", provenance = alias) + } + + context(context: CheckerContext) + fun visitCall(firSymbol: FirCallableSymbol<*>, element: KtSourceElement) { + graphBuilder?.reference(firSymbol, element, "calls") + if (firSymbol is org.jetbrains.kotlin.fir.symbols.impl.FirConstructorSymbol) { + graphBuilder?.reference(firSymbol, element, "instantiates") + } + graphBuilder?.dispatch(firSymbol, element) + } + + context(context: CheckerContext) + fun visitAccess(firSymbol: FirBasedSymbol<*>, element: KtSourceElement, access: String) { + graphBuilder?.reference(firSymbol, element, "accesses", access) + } + + context(context: CheckerContext) fun visitClassOrObject( firClass: FirClassLikeDeclaration, element: KtSourceElement, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { cache[firClass.symbol] .with(firClass.symbol) - .emitAll(element, isDefinition = true, context, enclosingSource) + .emitAll(element, isDefinition = true, enclosingSource) + graphBuilder?.declare(firClass.symbol, element, enclosingSource) } + context(context: CheckerContext) fun visitPrimaryConstructor( firConstructor: FirConstructor, source: KtSourceElement, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { cache[firConstructor.symbol] .with(firConstructor.symbol) - .emitAll(source, isDefinition = true, context, enclosingSource) + .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firConstructor.symbol, source, enclosingSource) } + context(context: CheckerContext) fun visitSecondaryConstructor( firConstructor: FirConstructor, source: KtSourceElement, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { cache[firConstructor.symbol] .with(firConstructor.symbol) - .emitAll(source, isDefinition = true, context, enclosingSource) + .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firConstructor.symbol, source, enclosingSource) } + context(context: CheckerContext) fun visitNamedFunction( firFunction: FirFunction, source: KtSourceElement, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { cache[firFunction.symbol] .with(firFunction.symbol) - .emitAll(source, isDefinition = true, context, enclosingSource) + .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firFunction.symbol, source, enclosingSource) } + context(context: CheckerContext) fun visitProperty( firProperty: FirProperty, source: KtSourceElement, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { cache[firProperty.symbol] .with(firProperty.symbol) - .emitAll(source, isDefinition = true, context, enclosingSource) + .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firProperty.symbol, source, enclosingSource) } + context(context: CheckerContext) fun visitParameter( firParameter: FirValueParameter, source: KtSourceElement, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { cache[firParameter.symbol] .with(firParameter.symbol) - .emitAll(source, isDefinition = true, context, enclosingSource) + .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firParameter.symbol, source, enclosingSource) } + context(context: CheckerContext) fun visitTypeParameter( firTypeParameter: FirTypeParameter, source: KtSourceElement, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { cache[firTypeParameter.symbol] .with(firTypeParameter.symbol) - .emitAll(source, isDefinition = true, context, enclosingSource) + .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firTypeParameter.symbol, source, enclosingSource) } + context(context: CheckerContext) fun visitTypeAlias( firTypeAlias: FirTypeAlias, source: KtSourceElement, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { cache[firTypeAlias.symbol] .with(firTypeAlias.symbol) - .emitAll(source, isDefinition = true, context, enclosingSource) + .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firTypeAlias.symbol, source, enclosingSource) } + context(context: CheckerContext) fun visitPropertyAccessor( firPropertyAccessor: FirPropertyAccessor, source: KtSourceElement, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ) { cache[firPropertyAccessor.symbol] .with(firPropertyAccessor.symbol) - .emitAll(source, isDefinition = true, context, enclosingSource) + .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firPropertyAccessor.symbol, source, enclosingSource) + } + + context(context: CheckerContext) + fun visitEnumEntry( + firEnumEntry: FirEnumEntry, + source: KtSourceElement, + enclosingSource: KtSourceElement? = null, + ) { + cache[firEnumEntry.symbol] + .with(firEnumEntry.symbol) + .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firEnumEntry.symbol, source, enclosingSource) } + context(context: CheckerContext) fun visitSimpleNameExpression( firResolvedNamedReference: FirResolvedNamedReference, source: KtSourceElement, - context: CheckerContext, ) { cache[firResolvedNamedReference.resolvedSymbol] .with(firResolvedNamedReference.resolvedSymbol) - .emitAll(source, isDefinition = false, context) + .emitAll(source, isDefinition = false) + graphBuilder?.reference(firResolvedNamedReference.resolvedSymbol, source, "references") } } diff --git a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/SymbolsCache.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/SymbolsCache.kt index 57f5b1b69..7ba63f961 100644 --- a/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/SymbolsCache.kt +++ b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/SymbolsCache.kt @@ -1,20 +1,20 @@ package org.scip_code.scip_java.kotlinc import java.lang.System.err -import org.jetbrains.kotlin.fir.analysis.checkers.declaration.isLocalMember -import org.jetbrains.kotlin.fir.analysis.checkers.getContainingSymbol +import org.jetbrains.kotlin.fir.analysis.checkers.declaration.isLocalDeclaredInBlock import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess -import org.jetbrains.kotlin.fir.declarations.FirClass import org.jetbrains.kotlin.fir.declarations.FirDeclarationOrigin import org.jetbrains.kotlin.fir.declarations.utils.memberDeclarationNameOrNull import org.jetbrains.kotlin.fir.packageFqName import org.jetbrains.kotlin.fir.resolve.getContainingDeclaration +import org.jetbrains.kotlin.fir.resolve.getContainingSymbol import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.symbols.FirBasedSymbol import org.jetbrains.kotlin.fir.symbols.SymbolInternals import org.jetbrains.kotlin.fir.symbols.impl.* +import org.jetbrains.kotlin.fir.types.classId +import org.jetbrains.kotlin.fir.types.coneType import org.jetbrains.kotlin.name.FqName -import org.jetbrains.kotlin.util.capitalizeDecapitalize.capitalizeAsciiOnly import org.scip_code.scip_java.kotlinc.ScipSymbolDescriptor.Kind import org.scip_code.scip_java.shared.LocalSymbolsCache as SharedLocalSymbolsCache @@ -80,7 +80,7 @@ class GlobalSymbolsCache(testing: Boolean = false) : Iterable { private fun uncachedSymbol(symbol: FirBasedSymbol<*>?, locals: LocalSymbolsCache): Symbol { if (symbol == null || symbol is FirAnonymousFunctionSymbol) return Symbol.NONE - if (symbol.fir.isLocalMember) return locals + symbol + if (symbol.fir.isLocalDeclaredInBlock) return locals + symbol val owner = getParentSymbol(symbol, locals) @@ -115,10 +115,30 @@ class GlobalSymbolsCache(testing: Boolean = false) : Iterable { return getSymbol(symbol.containingDeclarationSymbol, locals) is FirValueParameterSymbol -> return getSymbol(symbol.containingDeclarationSymbol, locals) + is FirPropertyAccessorSymbol -> return getSymbol(symbol.propertySymbol, locals) is FirCallableSymbol -> { val session = symbol.fir.moduleData.session - return symbol.getContainingSymbol(session)?.let { getSymbol(it, locals) } - ?: getSymbol(symbol.packageFqName()) + val containingSymbol = symbol.getContainingSymbol(session) + // For top-level extension functions/properties (containingSymbol is file or null), + // use the receiver type as a synthetic parent within the package + // (e.g. sample/String#foo().). + if (containingSymbol == null || containingSymbol is FirFileSymbol) { + val receiverClassId = symbol.fir.receiverParameter?.typeRef?.coneType?.classId + if (receiverClassId != null) { + val packageSymbol = getSymbol(symbol.packageFqName()) + return Symbol.createGlobal( + packageSymbol, + ScipSymbolDescriptor( + Kind.TYPE, + receiverClassId.shortClassName.asString(), + ), + ) + } + } + containingSymbol?.let { + return getSymbol(it, locals) + } + return getSymbol(symbol.packageFqName()) } is FirClassLikeSymbol -> { val session = symbol.fir.moduleData.session @@ -142,15 +162,9 @@ class GlobalSymbolsCache(testing: Boolean = false) : Iterable { symbol is FirClassLikeSymbol -> ScipSymbolDescriptor(Kind.TYPE, symbol.classId.shortClassName.asString()) symbol is FirPropertyAccessorSymbol && symbol.isSetter -> - ScipSymbolDescriptor( - Kind.METHOD, - "set" + symbol.propertySymbol.fir.name.toString().capitalizeAsciiOnly(), - ) + ScipSymbolDescriptor(Kind.METHOD, "set") symbol is FirPropertyAccessorSymbol && symbol.isGetter -> - ScipSymbolDescriptor( - Kind.METHOD, - "get" + symbol.propertySymbol.fir.name.toString().capitalizeAsciiOnly(), - ) + ScipSymbolDescriptor(Kind.METHOD, "get") symbol is FirConstructorSymbol -> ScipSymbolDescriptor(Kind.METHOD, "", methodDisambiguator(symbol)) symbol is FirFunctionSymbol -> @@ -164,6 +178,7 @@ class GlobalSymbolsCache(testing: Boolean = false) : Iterable { symbol is FirValueParameterSymbol -> ScipSymbolDescriptor(Kind.PARAMETER, symbol.name.toString()) symbol is FirVariableSymbol -> ScipSymbolDescriptor(Kind.TERM, symbol.name.toString()) + symbol is FirFileSymbol -> ScipSymbolDescriptor.NONE else -> { err.println("unknown symbol kind ${symbol.javaClass.simpleName}") ScipSymbolDescriptor.NONE @@ -177,14 +192,43 @@ class GlobalSymbolsCache(testing: Boolean = false) : Iterable { val siblings = when (val containingSymbol = symbol.getContainingSymbol(session)) { - is FirClassSymbol -> - (containingSymbol.fir as FirClass).declarations.map { it.symbol } - is FirFileSymbol -> containingSymbol.fir.declarations.map { it.symbol } - null -> - symbol.moduleData.session.symbolProvider.getTopLevelCallableSymbols( - symbol.packageFqName(), - symbol.name, - ) + is FirClassSymbol -> containingSymbol.fir.declarations.map { it.symbol } + is FirFileSymbol, + null -> { + // For top-level extension functions, siblings are the receiver class members + // (if in the same package) followed by other extension functions on the same + // receiver type in this package. This ensures consistent disambiguation + // when both a class member and an extension share the same parent namespace + // (e.g. sample/MyClass#foo(). vs sample/MyClass#foo(+1).). + val receiverClassId = symbol.fir.receiverParameter?.typeRef?.coneType?.classId + if (receiverClassId != null) { + val receiverClass = + session.symbolProvider.getClassLikeSymbolByClassId(receiverClassId) + as? FirClassSymbol<*> + val classMembers = + if (receiverClass?.packageFqName() == symbol.packageFqName()) { + receiverClass.fir.declarations.map { it.symbol } + } else { + emptyList() + } + val extensionFns = + session.symbolProvider + .getTopLevelCallableSymbols(symbol.packageFqName(), symbol.name) + .filter { + it is FirFunctionSymbol<*> && + it.fir.receiverParameter?.typeRef?.coneType?.classId == + receiverClassId + } + classMembers + extensionFns + } else if (containingSymbol is FirFileSymbol) { + containingSymbol.fir.declarations.map { it.symbol } + } else { + session.symbolProvider.getTopLevelCallableSymbols( + symbol.packageFqName(), + symbol.name, + ) + } + } else -> return "()" } @@ -201,7 +245,11 @@ class GlobalSymbolsCache(testing: Boolean = false) : Iterable { } } - if (count == 0 || !found) return "()" + if (!found) { + err.println("methodDisambiguator: ${symbol.callableId} not found in sibling list") + return "()" + } + if (count == 0) return "()" return "(+${count})" } diff --git a/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/AnalyzerTest.kt b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/AnalyzerTest.kt index 36e94ad56..4f77e9a82 100644 --- a/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/AnalyzerTest.kt +++ b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/AnalyzerTest.kt @@ -7,6 +7,7 @@ import io.kotest.assertions.fail import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain import java.io.File import java.nio.file.Path import kotlin.test.Test @@ -15,6 +16,7 @@ import org.intellij.lang.annotations.Language import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.junit.jupiter.api.io.TempDir import org.scip_code.scip.Document +import org.scip_code.scip.SymbolInformation.Kind import org.scip_code.scip_java.kotlinc.* @OptIn(ExperimentalCompilerApi::class) @@ -107,11 +109,15 @@ class AnalyzerTest { arrayOf( scipSymbol { symbol = "sample/Banana#" + kind = Kind.Class + enclosingSymbol = "sample/" displayName = "Banana" signatureText = "public final class Banana : Any" }, scipSymbol { symbol = "sample/Banana#foo()." + kind = Kind.Method + enclosingSymbol = "sample/Banana#" displayName = "foo" signatureText = "public final fun foo(): Unit" }, @@ -279,21 +285,29 @@ class AnalyzerTest { arrayOf( scipSymbol { symbol = "sample/foo()." + kind = Kind.Method + enclosingSymbol = "sample/" displayName = "foo" signatureText = "public final fun foo(): Unit" }, scipSymbol { symbol = "local 0" + kind = Kind.Class + enclosingSymbol = "sample/foo()." displayName = "LocalClass" signatureText = "local final class LocalClass : Any" }, scipSymbol { symbol = "local 1" + kind = Kind.Constructor + enclosingSymbol = "local 0" displayName = "LocalClass" signatureText = "public constructor(): LocalClass" }, scipSymbol { symbol = "local 2" + kind = Kind.Method + enclosingSymbol = "local 0" displayName = "localClassMethod" signatureText = "public final fun localClassMethod(): Unit" }, @@ -302,104 +316,75 @@ class AnalyzerTest { } @Test - fun overrides(@TempDir path: Path) { + fun `lambda parameters`(@TempDir path: Path) { val document = compileScip( path, """ - package sample - - interface Interface { - fun foo() - } + package sample - class Class : Interface { - override fun foo() {} - } - """, + fun use() { + val f = { n: Int -> n * 2 } + } + """, ) val occurrences = arrayOf( - scipOccurrence { - role = REFERENCE - symbol = "sample/" - range { - startLine = 0 - startCharacter = 8 - endLine = 0 - endCharacter = 14 - } - }, - scipOccurrence { - role = DEFINITION - symbol = "sample/Interface#" - range { - startLine = 2 - startCharacter = 10 - endLine = 2 - endCharacter = 19 - } - enclosingRange { - startLine = 2 - endLine = 4 - endCharacter = 1 - } - }, + // val f is a local variable — gets local0 via isLocalMember scipOccurrence { role = DEFINITION - symbol = "sample/Interface#foo()." + symbol = "local 0" range { startLine = 3 startCharacter = 8 endLine = 3 - endCharacter = 11 + endCharacter = 9 } enclosingRange { startLine = 3 startCharacter = 4 endLine = 3 - endCharacter = 13 + endCharacter = 31 } }, + // n is a lambda parameter — gets local1 via the owner == Symbol.NONE path + // (the containing FirAnonymousFunction is skipped, yielding Symbol.NONE as owner) scipOccurrence { role = DEFINITION - symbol = "sample/Class#" + symbol = "local 1" range { - startLine = 6 - startCharacter = 6 - endLine = 6 - endCharacter = 11 + startLine = 3 + startCharacter = 14 + endLine = 3 + endCharacter = 15 } enclosingRange { - startLine = 6 - endLine = 8 - endCharacter = 1 + startLine = 3 + startCharacter = 14 + endLine = 3 + endCharacter = 20 } }, + // explicit type annotation on n emits a class reference scipOccurrence { role = REFERENCE - symbol = "sample/Interface#" + symbol = "kotlin/Int#" range { - startLine = 6 - startCharacter = 14 - endLine = 6 - endCharacter = 23 + startLine = 3 + startCharacter = 17 + endLine = 3 + endCharacter = 20 } }, + // reference to n in the lambda body uses the same local symbol scipOccurrence { - role = DEFINITION - symbol = "sample/Class#foo()." + role = REFERENCE + symbol = "local 1" range { - startLine = 7 - startCharacter = 17 - endLine = 7 - endCharacter = 20 - } - enclosingRange { - startLine = 7 - startCharacter = 4 - endLine = 7 + startLine = 3 + startCharacter = 24 + endLine = 3 endCharacter = 25 } }, @@ -409,211 +394,248 @@ class AnalyzerTest { val symbols = arrayOf( scipSymbol { - symbol = "sample/Interface#" - displayName = "Interface" - signatureText = "public abstract interface Interface : Any" - }, - scipSymbol { - symbol = "sample/Interface#foo()." - displayName = "foo" - signatureText = "public abstract fun foo(): Unit\n" + symbol = "sample/use()." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "use" + signatureText = "public final fun use(): Unit" }, scipSymbol { - symbol = "sample/Class#" - displayName = "Class" - signatureText = "public final class Class : Interface" - addOverriddenSymbols("sample/Interface#") + symbol = "local 0" + kind = Kind.Property + enclosingSymbol = "sample/use()." + displayName = "f" + signatureText = "local val f: (Int) -> Int" }, scipSymbol { - symbol = "sample/Class#foo()." - displayName = "foo" - signatureText = "public open override fun foo(): Unit" - addOverriddenSymbols("sample/Interface#foo().") + symbol = "local 1" + kind = Kind.Parameter + displayName = "n" + signatureText = "n: Int" }, ) document.symbolsList.shouldContainAll(*symbols) } @Test - fun `anonymous object`(@TempDir path: Path) { + fun `local functions`(@TempDir path: Path) { val document = compileScip( path, """ - package sample - - interface Interface { - fun foo() - } + package sample - fun main() { - val a = object : Interface { - override fun foo() {} - } - val b = object : Interface { - override fun foo() {} - } - } - """, + fun outer() { + fun inner() {} + fun innerWithReturnType(): Int = 42 + inner() + } + """, ) val occurrences = arrayOf( - scipOccurrence { - role = REFERENCE - symbol = "sample/" - range { - startLine = 0 - startCharacter = 8 - endLine = 0 - endCharacter = 14 - } - }, scipOccurrence { role = DEFINITION - symbol = "sample/Interface#" + symbol = "sample/outer()." range { startLine = 2 - startCharacter = 10 + startCharacter = 4 endLine = 2 - endCharacter = 19 + endCharacter = 9 } enclosingRange { startLine = 2 - endLine = 4 + startCharacter = 0 + endLine = 6 endCharacter = 1 } }, + // inner() — local named function gets a local symbol scipOccurrence { role = DEFINITION - symbol = "sample/Interface#foo()." + symbol = "local 0" range { startLine = 3 startCharacter = 8 endLine = 3 - endCharacter = 11 + endCharacter = 13 } enclosingRange { startLine = 3 startCharacter = 4 endLine = 3 - endCharacter = 13 - } - }, - scipOccurrence { - role = DEFINITION - symbol = "sample/``#" - range { - startLine = 7 - startCharacter = 12 - endLine = 7 endCharacter = 18 } - enclosingRange { - startLine = 7 - startCharacter = 12 - endLine = 9 - endCharacter = 5 - } }, + // innerWithReturnType() — local named function with explicit return type scipOccurrence { role = DEFINITION - symbol = "sample/``#``()." + symbol = "local 1" range { - startLine = 7 - startCharacter = 12 - endLine = 7 - endCharacter = 18 + startLine = 4 + startCharacter = 8 + endLine = 4 + endCharacter = 27 } enclosingRange { - startLine = 7 - startCharacter = 12 - endLine = 9 - endCharacter = 5 + startLine = 4 + startCharacter = 4 + endLine = 4 + endCharacter = 39 } }, + // Int return-type reference scipOccurrence { role = REFERENCE - symbol = "sample/Interface#" + symbol = "kotlin/Int#" range { - startLine = 7 - startCharacter = 21 - endLine = 7 - endCharacter = 30 + startLine = 4 + startCharacter = 31 + endLine = 4 + endCharacter = 34 } }, + // call site inner() references the same local symbol scipOccurrence { - role = DEFINITION - symbol = "sample/``#foo()." + role = REFERENCE + symbol = "local 0" range { - startLine = 8 - startCharacter = 21 - endLine = 8 - endCharacter = 24 - } - enclosingRange { - startLine = 8 - startCharacter = 8 - endLine = 8 - endCharacter = 29 + startLine = 5 + startCharacter = 4 + endLine = 5 + endCharacter = 9 } }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "local 0" + kind = Kind.Method + enclosingSymbol = "sample/outer()." + displayName = "inner" + signatureText = "local final fun inner(): Unit" + }, + scipSymbol { + symbol = "local 1" + kind = Kind.Method + enclosingSymbol = "sample/outer()." + displayName = "innerWithReturnType" + signatureText = "local final fun innerWithReturnType(): Int" + }, + ) + document.symbolsList.shouldContainAll(*symbols) + } + + @Test + fun `user-defined class as return type`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + class MyClass + + fun bar(): MyClass = MyClass() + """, + ) + + val occurrences = + arrayOf( scipOccurrence { role = DEFINITION - symbol = "sample/``#" + symbol = "sample/bar()." range { - startLine = 10 - startCharacter = 12 - endLine = 10 - endCharacter = 18 + startLine = 4 + startCharacter = 4 + endLine = 4 + endCharacter = 7 } enclosingRange { - startLine = 10 - startCharacter = 12 - endLine = 12 - endCharacter = 5 + startLine = 4 + startCharacter = 0 + endLine = 4 + endCharacter = 30 } }, + // MyClass in the return type position generates a class reference scipOccurrence { - role = DEFINITION - symbol = "sample/``#``()." + role = REFERENCE + symbol = "sample/MyClass#" range { - startLine = 10 - startCharacter = 12 - endLine = 10 + startLine = 4 + startCharacter = 11 + endLine = 4 endCharacter = 18 } - enclosingRange { - startLine = 10 - startCharacter = 12 - endLine = 12 - endCharacter = 5 - } }, - scipOccurrence { - role = REFERENCE - symbol = "sample/Interface#" - range { - startLine = 10 - startCharacter = 21 - endLine = 10 - endCharacter = 30 + ) + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/bar()." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "bar" + signatureText = "public final fun bar(): MyClass" + } + ) + document.symbolsList.shouldContainAll(*symbols) + } + + @Test + fun `typealias`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + typealias MyAlias = Int + val x: MyAlias = 42 + """, + ) + + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/MyAlias#" + range { + startLine = 2 + startCharacter = 10 + endLine = 2 + endCharacter = 17 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 2 + endCharacter = 23 } }, + // Note: val x: MyAlias does not emit a REFERENCE for sample/MyAlias# because the + // property checker resolves the type alias to its expansion (Int). scipOccurrence { role = DEFINITION - symbol = "sample/``#foo()." + symbol = "sample/x." range { - startLine = 11 - startCharacter = 21 - endLine = 11 - endCharacter = 24 + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 5 } enclosingRange { - startLine = 11 - startCharacter = 8 - endLine = 11 - endCharacter = 29 + startLine = 3 + startCharacter = 0 + endLine = 3 + endCharacter = 19 } }, ) @@ -622,101 +644,143 @@ class AnalyzerTest { val symbols = arrayOf( scipSymbol { - symbol = "sample/Interface#" - displayName = "Interface" - signatureText = "public abstract interface Interface : Any" - }, - scipSymbol { - symbol = "sample/``#" - displayName = "" - signatureText = "object : Interface" - addOverriddenSymbols("sample/Interface#") - }, - scipSymbol { - symbol = "sample/``#foo()." - displayName = "foo" - signatureText = "public open override fun foo(): Unit" - addOverriddenSymbols("sample/Interface#foo().") - }, - scipSymbol { - symbol = "sample/``#" - displayName = "" - signatureText = "object : Interface" - addOverriddenSymbols("sample/Interface#") - }, - scipSymbol { - symbol = "sample/``#foo()." - displayName = "foo" - signatureText = "public open override fun foo(): Unit" - addOverriddenSymbols("sample/Interface#foo().") - }, + symbol = "sample/MyAlias#" + kind = Kind.TypeAlias + enclosingSymbol = "sample/" + displayName = "MyAlias" + signatureText = "public final typealias MyAlias = Int\n" + } ) document.symbolsList.shouldContainAll(*symbols) } @Test - fun `function return type`(@TempDir path: Path) { + fun `type parameters`(@TempDir path: Path) { val document = compileScip( path, """ - package sample + package sample - fun foo(arg: Int): Boolean = true - """, + fun identity(x: T): T = x + """, ) val occurrences = arrayOf( scipOccurrence { role = DEFINITION - symbol = "sample/foo()." + symbol = "sample/identity()." range { startLine = 2 - startCharacter = 4 + startCharacter = 8 endLine = 2 - endCharacter = 7 + endCharacter = 16 } enclosingRange { startLine = 2 + startCharacter = 0 endLine = 2 - endCharacter = 33 + endCharacter = 29 } }, scipOccurrence { role = DEFINITION - symbol = "sample/foo().(arg)" + symbol = "sample/identity().[T]" range { startLine = 2 - startCharacter = 8 + startCharacter = 5 endLine = 2 - endCharacter = 11 + endCharacter = 6 } enclosingRange { startLine = 2 - startCharacter = 8 + startCharacter = 5 endLine = 2 - endCharacter = 16 + endCharacter = 6 } }, + // Note: T in type-annotation positions (x: T and return type : T) does not produce + // REFERENCE occurrences. The checkers use toClassLikeSymbol() to detect type + // references, which returns null for type parameters, so those usages are not + // currently tracked. + ) + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/identity().[T]" + kind = Kind.TypeParameter + enclosingSymbol = "sample/identity()." + displayName = "T" + signatureText = "T" + } + ) + document.symbolsList.shouldContainAll(*symbols) + } + + @Test + fun `generic class declaration`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + class Box(val value: T) { + fun unwrap(): T = value + } + """, + ) + + val occurrences = + arrayOf( scipOccurrence { - role = REFERENCE - symbol = "kotlin/Int#" + role = DEFINITION + symbol = "sample/Box#" range { startLine = 2 - startCharacter = 13 + startCharacter = 6 endLine = 2 - endCharacter = 16 + endCharacter = 9 + } + enclosingRange { + startLine = 2 + endLine = 4 + endCharacter = 1 } }, scipOccurrence { - role = REFERENCE - symbol = "kotlin/Boolean#" + role = DEFINITION + symbol = "sample/Box#[T]" range { startLine = 2 - startCharacter = 19 + startCharacter = 10 endLine = 2 - endCharacter = 26 + endCharacter = 11 + } + enclosingRange { + startLine = 2 + startCharacter = 10 + endLine = 2 + endCharacter = 11 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Box#unwrap()." + range { + startLine = 3 + startCharacter = 8 + endLine = 3 + endCharacter = 14 + } + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 27 } }, ) @@ -724,18 +788,19 @@ class AnalyzerTest { } @Test - fun `type operators`(@TempDir path: Path) { + fun overrides(@TempDir path: Path) { val document = compileScip( path, """ package sample - fun foo(x: Any) { - when (x) { - is Int -> true - else -> x as Float - } + interface Interface { + fun foo() + } + + class Class : Interface { + override fun foo() {} } """, ) @@ -744,638 +809,2195 @@ class AnalyzerTest { arrayOf( scipOccurrence { role = REFERENCE - symbol = "kotlin/Int#" + symbol = "sample/" range { - startLine = 4 - startCharacter = 11 - endLine = 4 + startLine = 0 + startCharacter = 8 + endLine = 0 endCharacter = 14 } }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Interface#" + range { + startLine = 2 + startCharacter = 10 + endLine = 2 + endCharacter = 19 + } + enclosingRange { + startLine = 2 + endLine = 4 + endCharacter = 1 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Interface#foo()." + range { + startLine = 3 + startCharacter = 8 + endLine = 3 + endCharacter = 11 + } + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 13 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Class#" + range { + startLine = 6 + startCharacter = 6 + endLine = 6 + endCharacter = 11 + } + enclosingRange { + startLine = 6 + endLine = 8 + endCharacter = 1 + } + }, scipOccurrence { role = REFERENCE - symbol = "kotlin/Float#" + symbol = "sample/Interface#" range { - startLine = 5 - startCharacter = 21 - endLine = 5 - endCharacter = 26 + startLine = 6 + startCharacter = 14 + endLine = 6 + endCharacter = 23 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Class#foo()." + range { + startLine = 7 + startCharacter = 17 + endLine = 7 + endCharacter = 20 + } + enclosingRange { + startLine = 7 + startCharacter = 4 + endLine = 7 + endCharacter = 25 } }, ) document.occurrencesList.shouldContainAll(*occurrences) - } - @Test - fun `exception test`(@TempDir path: Path) { - val buildPath = File(path.resolve("build").toString()).apply { mkdir() } - val result = - KotlinCompilation() - .apply { - sources = listOf(SourceFile.testKt("")) - compilerPluginRegistrars = - listOf(AnalyzerRegistrar { throw Exception("sample text") }) - verbose = false - pluginOptions = - listOf( - PluginOption("scip-kotlinc", "sourceroot", path.toString()), - PluginOption("scip-kotlinc", "targetroot", buildPath.toString()), - ) - commandLineProcessors = listOf(AnalyzerCommandLineProcessor()) - workingDir = path.toFile() - } - .compile() - - result.exitCode shouldBe KotlinCompilation.ExitCode.OK + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/Interface#" + kind = Kind.Interface + enclosingSymbol = "sample/" + displayName = "Interface" + signatureText = "public abstract interface Interface : Any" + }, + scipSymbol { + symbol = "sample/Interface#foo()." + kind = Kind.Method + enclosingSymbol = "sample/Interface#" + displayName = "foo" + signatureText = "public abstract fun foo(): Unit\n" + }, + scipSymbol { + symbol = "sample/Class#" + kind = Kind.Class + enclosingSymbol = "sample/" + displayName = "Class" + signatureText = "public final class Class : Interface" + addOverriddenSymbols("sample/Interface#") + }, + scipSymbol { + symbol = "sample/Class#foo()." + kind = Kind.Method + enclosingSymbol = "sample/Class#" + displayName = "foo" + signatureText = "public open override fun foo(): Unit" + addOverriddenSymbols("sample/Interface#foo().") + }, + ) + document.symbolsList.shouldContainAll(*symbols) } @Test - // shamelessly stolen code snippet from https://learnxinyminutes.com/docs/kotlin/ - fun `learn x in y test`(@TempDir path: Path) { - val buildPath = File(path.resolve("build").toString()).apply { mkdir() } - - val source = - SourceFile.testKt( + fun `anonymous object`(@TempDir path: Path) { + val document = + compileScip( + path, """ - @file:Suppress("UNUSED_VARIABLE", "UNUSED_PARAMETER", "NAME_SHADOWING", "ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE", "UNUSED_VALUE") package sample - fun main(args: Array) { - val fooVal = 10 // we cannot later reassign fooVal to something else - var fooVar = 10 - fooVar = 20 // fooVar can be reassigned - - /* - In most cases, Kotlin can determine what the type of a variable is, - so we don't have to explicitly specify it every time. - We can explicitly declare the type of a variable like so: - */ - val foo: Int = 7 - - /* - Strings can be represented in a similar way as in Java. - Escaping is done with a backslash. - */ - val fooString = "My String Is Here!" - val barString = "Printing on a new line?\nNo Problem!" - val bazString = "Do you want to add a tab?\tNo Problem!" - println(fooString) - println(barString) - println(bazString) - - /* - Strings can contain template expressions. - A template expression starts with a dollar sign (${'$'}). - */ - val fooTemplateString = "$'fooString' has ${"fooString.length"} characters" - println(fooTemplateString) // => My String Is Here! has 18 characters - - /* - For a variable to hold null it must be explicitly specified as nullable. - A variable can be specified as nullable by appending a ? to its type. - We can access a nullable variable by using the ?. operator. - We can use the ?: operator to specify an alternative value to use - if a variable is null. - */ - var fooNullable: String? = "abc" - println(fooNullable?.length) // => 3 - println(fooNullable?.length ?: -1) // => 3 - fooNullable = null - println(fooNullable?.length) // => null - println(fooNullable?.length ?: -1) // => -1 - - /* - Functions can be declared using the "fun" keyword. - Function arguments are specified in brackets after the function name. - Function arguments can optionally have a default value. - The function return type, if required, is specified after the arguments. - */ - fun hello(name: String = "world"): String { - return "Hello, $'name'!" - } - println(hello("foo")) // => Hello, foo! - println(hello(name = "bar")) // => Hello, bar! - println(hello()) // => Hello, world! - - /* - A function parameter may be marked with the "vararg" keyword - to allow a variable number of arguments to be passed to the function. - */ - fun varargExample(vararg names: Int) { - println("Argument has ${"names.size"} elements") - } - varargExample() // => Argument has 0 elements - varargExample(1) // => Argument has 1 elements - varargExample(1, 2, 3) // => Argument has 3 elements - - /* - When a function consists of a single expression then the curly brackets can - be omitted. The body is specified after the = symbol. - */ - fun odd(x: Int): Boolean = x % 2 == 1 - println(odd(6)) // => false - println(odd(7)) // => true - - // If the return type can be inferred then we don't need to specify it. - fun even(x: Int) = x % 2 == 0 - println(even(6)) // => true - println(even(7)) // => false + interface Interface { + fun foo() + } - // Functions can take functions as arguments and return functions. - fun not(f: (Int) -> Boolean): (Int) -> Boolean { - return {n -> !f.invoke(n)} + fun main() { + val a = object : Interface { + override fun foo() {} } - // Named functions can be specified as arguments using the :: operator. - val notOdd = not(::odd) - val notEven = not(::even) - // Lambda expressions can be specified as arguments. - val notZero = not {n -> n == 0} - /* - If a lambda has only one parameter - then its declaration can be omitted (along with the ->). - The name of the single parameter will be "it". - */ - val notPositive = not {it > 0} - for (i in 0..4) { - println("${"notOdd(i)"} ${"notEven(i)"} ${"notZero(i)"} ${"notPositive(i)"}") + val b = object : Interface { + override fun foo() {} } + } + """, + ) - // The "class" keyword is used to declare classes. - class ExampleClass(val x: Int) { - fun memberFunction(y: Int): Int { - return x + y + val occurrences = + arrayOf( + scipOccurrence { + role = REFERENCE + symbol = "sample/" + range { + startLine = 0 + startCharacter = 8 + endLine = 0 + endCharacter = 14 } - - infix fun infixMemberFunction(y: Int): Int { - return x * y + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Interface#" + range { + startLine = 2 + startCharacter = 10 + endLine = 2 + endCharacter = 19 } - } - /* - To create a new instance we call the constructor. - Note that Kotlin does not have a "new" keyword. - */ - val fooExampleClass = ExampleClass(7) - // Member functions can be called using dot notation. - println(fooExampleClass.memberFunction(4)) // => 11 - /* - If a function has been marked with the "infix" keyword then it can be - called using infix notation. - */ - println(fooExampleClass infixMemberFunction 4) // => 28 - - /* - Data classes are a concise way to create classes that just hold data. - The "hashCode"/"equals" and "toString" methods are automatically generated. - */ - data class DataClassExample (val x: Int, val y: Int, val z: Int) - val fooData = DataClassExample(1, 2, 4) - println(fooData) // => DataClassExample(x=1, y=2, z=4) - - // Data classes have a "copy" function. - val fooCopy = fooData.copy(y = 100) - println(fooCopy) // => DataClassExample(x=1, y=100, z=4) - - // Objects can be destructured into multiple variables. - val (a, b, c) = fooCopy - println("$'a' $'b' $'c'") // => 1 100 4 - - // destructuring in "for" loop - for ((a, b, c) in listOf(fooData)) { - println("$'a' $'b' $'c'") // => 1 2 4 - } - - val mapData = mapOf("a" to 1, "b" to 2) - // Map.Entry is destructurable as well - for ((key, value) in mapData) { - println("$'key' -> $'value'") - } - - // The "with" function is similar to the JavaScript "with" statement. - data class MutableDataClassExample (var x: Int, var y: Int, var z: Int) - val fooMutableData = MutableDataClassExample(7, 4, 9) - with (fooMutableData) { - x -= 2 - y += 2 - z-- - } - println(fooMutableData) // => MutableDataClassExample(x=5, y=6, z=8) - - /* - We can create a list using the "listOf" function. - The list will be immutable - elements cannot be added or removed. - */ - val fooList = listOf("a", "b", "c") - println(fooList.size) // => 3 - println(fooList.first()) // => a - println(fooList.last()) // => c - // Elements of a list can be accessed by their index. - println(fooList[1]) // => b - - // A mutable list can be created using the "mutableListOf" function. - val fooMutableList = mutableListOf("a", "b", "c") - fooMutableList.add("d") - println(fooMutableList.last()) // => d - println(fooMutableList.size) // => 4 - - // We can create a set using the "setOf" function. - val fooSet = setOf("a", "b", "c") + enclosingRange { + startLine = 2 + endLine = 4 + endCharacter = 1 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Interface#foo()." + range { + startLine = 3 + startCharacter = 8 + endLine = 3 + endCharacter = 11 + } + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 13 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "local 1" + range { + startLine = 7 + startCharacter = 12 + endLine = 7 + endCharacter = 18 + } + enclosingRange { + startLine = 7 + startCharacter = 12 + endLine = 9 + endCharacter = 5 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "local 2" + range { + startLine = 7 + startCharacter = 12 + endLine = 7 + endCharacter = 18 + } + enclosingRange { + startLine = 7 + startCharacter = 12 + endLine = 9 + endCharacter = 5 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Interface#" + range { + startLine = 7 + startCharacter = 21 + endLine = 7 + endCharacter = 30 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "local 3" + range { + startLine = 8 + startCharacter = 21 + endLine = 8 + endCharacter = 24 + } + enclosingRange { + startLine = 8 + startCharacter = 8 + endLine = 8 + endCharacter = 29 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "local 5" + range { + startLine = 10 + startCharacter = 12 + endLine = 10 + endCharacter = 18 + } + enclosingRange { + startLine = 10 + startCharacter = 12 + endLine = 12 + endCharacter = 5 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "local 6" + range { + startLine = 10 + startCharacter = 12 + endLine = 10 + endCharacter = 18 + } + enclosingRange { + startLine = 10 + startCharacter = 12 + endLine = 12 + endCharacter = 5 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Interface#" + range { + startLine = 10 + startCharacter = 21 + endLine = 10 + endCharacter = 30 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "local 7" + range { + startLine = 11 + startCharacter = 21 + endLine = 11 + endCharacter = 24 + } + enclosingRange { + startLine = 11 + startCharacter = 8 + endLine = 11 + endCharacter = 29 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/Interface#" + kind = Kind.Interface + enclosingSymbol = "sample/" + displayName = "Interface" + signatureText = "public abstract interface Interface : Any" + }, + scipSymbol { + symbol = "local 1" + kind = Kind.Class + enclosingSymbol = "local 0" + displayName = "" + signatureText = "object : Interface" + addOverriddenSymbols("sample/Interface#") + }, + scipSymbol { + symbol = "local 3" + kind = Kind.Method + enclosingSymbol = "local 1" + displayName = "foo" + signatureText = "public open override fun foo(): Unit" + addOverriddenSymbols("sample/Interface#foo().") + }, + scipSymbol { + symbol = "local 5" + kind = Kind.Class + enclosingSymbol = "local 4" + displayName = "" + signatureText = "object : Interface" + addOverriddenSymbols("sample/Interface#") + }, + scipSymbol { + symbol = "local 7" + kind = Kind.Method + enclosingSymbol = "local 5" + displayName = "foo" + signatureText = "public open override fun foo(): Unit" + addOverriddenSymbols("sample/Interface#foo().") + }, + ) + document.symbolsList.shouldContainAll(*symbols) + } + + @Test + fun `function return type`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + fun foo(arg: Int): Boolean = true + """, + ) + + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/foo()." + range { + startLine = 2 + startCharacter = 4 + endLine = 2 + endCharacter = 7 + } + enclosingRange { + startLine = 2 + endLine = 2 + endCharacter = 33 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/foo().(arg)" + range { + startLine = 2 + startCharacter = 8 + endLine = 2 + endCharacter = 11 + } + enclosingRange { + startLine = 2 + startCharacter = 8 + endLine = 2 + endCharacter = 16 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "kotlin/Int#" + range { + startLine = 2 + startCharacter = 13 + endLine = 2 + endCharacter = 16 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "kotlin/Boolean#" + range { + startLine = 2 + startCharacter = 19 + endLine = 2 + endCharacter = 26 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + } + + @Test + fun `type operators`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + fun foo(x: Any) { + when (x) { + is Int -> true + else -> x as Float + } + } + + class Wrapper + fun classify(x: Any) { + if (x is Wrapper) {} + val s = x as? String + val w = x as Wrapper + } + """, + ) + + val occurrences = + arrayOf( + scipOccurrence { + role = REFERENCE + symbol = "kotlin/Int#" + range { + startLine = 4 + startCharacter = 11 + endLine = 4 + endCharacter = 14 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "kotlin/Float#" + range { + startLine = 5 + startCharacter = 21 + endLine = 5 + endCharacter = 26 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Wrapper#" + range { + startLine = 11 + startCharacter = 13 + endLine = 11 + endCharacter = 20 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "kotlin/String#" + range { + startLine = 12 + startCharacter = 18 + endLine = 12 + endCharacter = 24 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Wrapper#" + range { + startLine = 13 + startCharacter = 17 + endLine = 13 + endCharacter = 24 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + } + + @Test + fun `exception test`(@TempDir path: Path) { + val buildPath = File(path.resolve("build").toString()).apply { mkdir() } + val result = + KotlinCompilation() + .apply { + sources = listOf(SourceFile.testKt("")) + compilerPluginRegistrars = + listOf(AnalyzerRegistrar { throw Exception("sample text") }) + verbose = false + messageOutputStream = java.io.OutputStream.nullOutputStream() + pluginOptions = + listOf( + PluginOption("scip-kotlinc", "sourceroot", path.toString()), + PluginOption("scip-kotlinc", "targetroot", buildPath.toString()), + ) + commandLineProcessors = listOf(AnalyzerCommandLineProcessor()) + workingDir = path.toFile() + } + .compile() + + result.exitCode shouldBe KotlinCompilation.ExitCode.OK + result.messages shouldContain "Exception in scip-kotlin compiler plugin:" + result.messages shouldContain "sample text" + } + + @Test + // shamelessly stolen code snippet from https://learnxinyminutes.com/docs/kotlin/ + fun `learn x in y test`(@TempDir path: Path) { + val buildPath = File(path.resolve("build").toString()).apply { mkdir() } + + val source = + SourceFile.testKt( + """ + @file:Suppress("UNUSED_VARIABLE", "UNUSED_PARAMETER", "NAME_SHADOWING", "ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE", "UNUSED_VALUE") + package sample + + fun main(args: Array) { + val fooVal = 10 // we cannot later reassign fooVal to something else + var fooVar = 10 + fooVar = 20 // fooVar can be reassigned + + /* + In most cases, Kotlin can determine what the type of a variable is, + so we don't have to explicitly specify it every time. + We can explicitly declare the type of a variable like so: + */ + val foo: Int = 7 + + /* + Strings can be represented in a similar way as in Java. + Escaping is done with a backslash. + */ + val fooString = "My String Is Here!" + val barString = "Printing on a new line?\nNo Problem!" + val bazString = "Do you want to add a tab?\tNo Problem!" + println(fooString) + println(barString) + println(bazString) + + /* + Strings can contain template expressions. + A template expression starts with a dollar sign (${'$'}). + */ + val fooTemplateString = "$'fooString' has ${"fooString.length"} characters" + println(fooTemplateString) // => My String Is Here! has 18 characters + + /* + For a variable to hold null it must be explicitly specified as nullable. + A variable can be specified as nullable by appending a ? to its type. + We can access a nullable variable by using the ?. operator. + We can use the ?: operator to specify an alternative value to use + if a variable is null. + */ + var fooNullable: String? = "abc" + println(fooNullable?.length) // => 3 + println(fooNullable?.length ?: -1) // => 3 + fooNullable = null + println(fooNullable?.length) // => null + println(fooNullable?.length ?: -1) // => -1 + + /* + Functions can be declared using the "fun" keyword. + Function arguments are specified in brackets after the function name. + Function arguments can optionally have a default value. + The function return type, if required, is specified after the arguments. + */ + fun hello(name: String = "world"): String { + return "Hello, $'name'!" + } + println(hello("foo")) // => Hello, foo! + println(hello(name = "bar")) // => Hello, bar! + println(hello()) // => Hello, world! + + /* + A function parameter may be marked with the "vararg" keyword + to allow a variable number of arguments to be passed to the function. + */ + fun varargExample(vararg names: Int) { + println("Argument has ${"names.size"} elements") + } + varargExample() // => Argument has 0 elements + varargExample(1) // => Argument has 1 elements + varargExample(1, 2, 3) // => Argument has 3 elements + + /* + When a function consists of a single expression then the curly brackets can + be omitted. The body is specified after the = symbol. + */ + fun odd(x: Int): Boolean = x % 2 == 1 + println(odd(6)) // => false + println(odd(7)) // => true + + // If the return type can be inferred then we don't need to specify it. + fun even(x: Int) = x % 2 == 0 + println(even(6)) // => true + println(even(7)) // => false + + // Functions can take functions as arguments and return functions. + fun not(f: (Int) -> Boolean): (Int) -> Boolean { + return {n -> !f.invoke(n)} + } + // Named functions can be specified as arguments using the :: operator. + val notOdd = not(::odd) + val notEven = not(::even) + // Lambda expressions can be specified as arguments. + val notZero = not {n -> n == 0} + /* + If a lambda has only one parameter + then its declaration can be omitted (along with the ->). + The name of the single parameter will be "it". + */ + val notPositive = not {it > 0} + for (i in 0..4) { + println("${"notOdd(i)"} ${"notEven(i)"} ${"notZero(i)"} ${"notPositive(i)"}") + } + + // The "class" keyword is used to declare classes. + class ExampleClass(val x: Int) { + fun memberFunction(y: Int): Int { + return x + y + } + + infix fun infixMemberFunction(y: Int): Int { + return x * y + } + } + /* + To create a new instance we call the constructor. + Note that Kotlin does not have a "new" keyword. + */ + val fooExampleClass = ExampleClass(7) + // Member functions can be called using dot notation. + println(fooExampleClass.memberFunction(4)) // => 11 + /* + If a function has been marked with the "infix" keyword then it can be + called using infix notation. + */ + println(fooExampleClass infixMemberFunction 4) // => 28 + + /* + Data classes are a concise way to create classes that just hold data. + The "hashCode"/"equals" and "toString" methods are automatically generated. + */ + data class DataClassExample (val x: Int, val y: Int, val z: Int) + val fooData = DataClassExample(1, 2, 4) + println(fooData) // => DataClassExample(x=1, y=2, z=4) + + // Data classes have a "copy" function. + val fooCopy = fooData.copy(y = 100) + println(fooCopy) // => DataClassExample(x=1, y=100, z=4) + + // Objects can be destructured into multiple variables. + val (a, b, c) = fooCopy + println("$'a' $'b' $'c'") // => 1 100 4 + + // destructuring in "for" loop + for ((a, b, c) in listOf(fooData)) { + println("$'a' $'b' $'c'") // => 1 2 4 + } + + val mapData = mapOf("a" to 1, "b" to 2) + // Map.Entry is destructurable as well + for ((key, value) in mapData) { + println("$'key' -> $'value'") + } + + // The "with" function is similar to the JavaScript "with" statement. + data class MutableDataClassExample (var x: Int, var y: Int, var z: Int) + val fooMutableData = MutableDataClassExample(7, 4, 9) + with (fooMutableData) { + x -= 2 + y += 2 + z-- + } + println(fooMutableData) // => MutableDataClassExample(x=5, y=6, z=8) + + /* + We can create a list using the "listOf" function. + The list will be immutable - elements cannot be added or removed. + */ + val fooList = listOf("a", "b", "c") + println(fooList.size) // => 3 + println(fooList.first()) // => a + println(fooList.last()) // => c + // Elements of a list can be accessed by their index. + println(fooList[1]) // => b + + // A mutable list can be created using the "mutableListOf" function. + val fooMutableList = mutableListOf("a", "b", "c") + fooMutableList.add("d") + println(fooMutableList.last()) // => d + println(fooMutableList.size) // => 4 + + // We can create a set using the "setOf" function. + val fooSet = setOf("a", "b", "c") println(fooSet.contains("a")) // => true println(fooSet.contains("z")) // => false - // We can create a map using the "mapOf" function. - val fooMap = mapOf("a" to 8, "b" to 7, "c" to 9) - // Map values can be accessed by their key. - println(fooMap["a"]) // => 8 + // We can create a map using the "mapOf" function. + val fooMap = mapOf("a" to 8, "b" to 7, "c" to 9) + // Map values can be accessed by their key. + println(fooMap["a"]) // => 8 + + /* + Sequences represent lazily-evaluated collections. + We can create a sequence using the "generateSequence" function. + */ + val fooSequence = generateSequence(1, { it + 1 }) + val x = fooSequence.take(10).toList() + println(x) // => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + + // An example of using a sequence to generate Fibonacci numbers: + fun fibonacciSequence(): Sequence { + var a = 0L + var b = 1L + + fun next(): Long { + val result = a + b + a = b + b = result + return a + } + + return generateSequence(::next) + } + val y = fibonacciSequence().take(10).toList() + println(y) // => [1, 1, 2, 3, 5, 8, 13, 21, 34, 55] + + // Kotlin provides higher-order functions for working with collections. + val z = (1..9).map {it * 3} + .filter {it < 20} + .groupBy {it % 2 == 0} + .mapKeys {if (it.key) "even" else "odd"} + println(z) // => {odd=[3, 9, 15], even=[6, 12, 18]} + + // A "for" loop can be used with anything that provides an iterator. + for (c in "hello") { + println(c) + } + + // "while" loops work in the same way as other languages. + var ctr = 0 + while (ctr < 5) { + println(ctr) + ctr++ + } + do { + println(ctr) + ctr++ + } while (ctr < 10) + + /* + "if" can be used as an expression that returns a value. + For this reason the ternary ?: operator is not needed in Kotlin. + */ + val num = 5 + val message = if (num % 2 == 0) "even" else "odd" + println("$'num' is $'message'") // => 5 is odd + + // "when" can be used as an alternative to "if-else if" chains. + val i = 10 + when { + i < 7 -> println("first block") + fooString.startsWith("hello") -> println("second block") + else -> println("else block") + } + + // "when" can be used with an argument. + when (i) { + 0, 21 -> println("0 or 21") + in 1..20 -> println("in the range 1 to 20") + else -> println("none of the above") + } + + // "when" can be used as a function that returns a value. + var result = when (i) { + 0, 21 -> "0 or 21" + in 1..20 -> "in the range 1 to 20" + else -> "none of the above" + } + println(result) + + /* + We can check if an object is of a particular type by using the "is" operator. + If an object passes a type check then it can be used as that type without + explicitly casting it. + */ + fun smartCastExample(x: Any) : Boolean { + if (x is Boolean) { + // x is automatically cast to Boolean + return x + } else if (x is Int) { + // x is automatically cast to Int + return x > 0 + } else if (x is String) { + // x is automatically cast to String + return x.isNotEmpty() + } else { + return false + } + } + println(smartCastExample("Hello, world!")) // => true + println(smartCastExample("")) // => false + println(smartCastExample(5)) // => true + println(smartCastExample(0)) // => false + println(smartCastExample(true)) // => true + + // Smartcast also works with when block + fun smartCastWhenExample(x: Any) = when (x) { + is Boolean -> x + is Int -> x > 0 + is String -> x.isNotEmpty() + else -> false + } + + /* + Extensions are a way to add new functionality to a class. + This is similar to C# extension methods. + */ + fun String.remove(c: Char): String { + return this.filter {it != c} + } + println("Hello, world!".remove('l')) // => Heo, word! + } + + // Enum classes are similar to Java enum types. + enum class EnumExample { + A, B, C // Enum constants are separated with commas. + } + fun printEnum() = println(EnumExample.A) // => A + + // Since each enum is an instance of the enum class, they can be initialized as: + enum class EnumExample1(val value: Int) { + A(value = 1), + B(value = 2), + C(value = 3) + } + fun printProperty() = println(EnumExample1.A.value) // => 1 + + // Every enum has properties to obtain its name and ordinal(position) in the enum class declaration: + fun printName() = println(EnumExample1.A.name) // => A + fun printPosition() = println(EnumExample1.A.ordinal) // => 0 + + /* + The "object" keyword can be used to create singleton objects. + We cannot instantiate it but we can refer to its unique instance by its name. + This is similar to Scala singleton objects. + */ + object ObjectExample { + fun hello(): String { + return "hello" + } + + override fun toString(): String { + return "Hello, it's me, ${"ObjectExample::class.simpleName"}" + } + } + + + fun useSingletonObject() { + println(ObjectExample.hello()) // => hello + // In Kotlin, "Any" is the root of the class hierarchy, just like "Object" is in Java + val someRef: Any = ObjectExample + println(someRef) // => Hello, it's me, ObjectExample + } + + + /* The not-null assertion operator (!!) converts any value to a non-null type and + throws an exception if the value is null. + */ + var b: String? = "abc" + val l = b!!.length + + data class Counter(var value: Int) { + // overload Counter += Int + operator fun plusAssign(increment: Int) { + this.value += increment + } + + // overload Counter++ and ++Counter + operator fun inc() = Counter(value + 1) + + // overload Counter + Counter + operator fun plus(other: Counter) = Counter(this.value + other.value) + + // overload Counter * Counter + operator fun times(other: Counter) = Counter(this.value * other.value) + + // overload Counter * Int + operator fun times(value: Int) = Counter(this.value * value) + + // overload Counter in Counter + operator fun contains(other: Counter) = other.value == this.value + + // overload Counter[Int] = Int + operator fun set(index: Int, value: Int) { + this.value = index + value + } + + // overload Counter instance invocation + operator fun invoke() = println("The value of the counter is $'value'") + + } + /* You can also overload operators through extension methods */ + // overload -Counter + operator fun Counter.unaryMinus() = Counter(-this.value) + + fun operatorOverloadingDemo() { + var counter1 = Counter(0) + var counter2 = Counter(5) + counter1 += 7 + println(counter1) // => Counter(value=7) + println(counter1 + counter2) // => Counter(value=12) + println(counter1 * counter2) // => Counter(value=35) + println(counter2 * 2) // => Counter(value=10) + println(counter1 in Counter(5)) // => false + println(counter1 in Counter(7)) // => true + counter1[26] = 10 + println(counter1) // => Counter(value=36) + counter1() // => The value of the counter is 36 + println(-counter2) // => Counter(value=-5) + } + """ + ) + + val result = + KotlinCompilation() + .apply { + sources = listOf(source) + compilerPluginRegistrars = listOf(AnalyzerRegistrar()) + verbose = false + pluginOptions = + listOf( + PluginOption("scip-kotlinc", "sourceroot", path.toString()), + PluginOption("scip-kotlinc", "targetroot", buildPath.toString()), + ) + commandLineProcessors = listOf(AnalyzerCommandLineProcessor()) + workingDir = path.toFile() + } + .compile() + + result.exitCode shouldBe KotlinCompilation.ExitCode.OK + } + + @Test + fun `compound package name semicolon test`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package hello.sample; + class Apple + """ + .trimIndent(), + ) + + val occurrences = + arrayOf( + scipOccurrence { + role = REFERENCE + symbol = "hello/" + range { + startLine = 0 + startCharacter = 8 + endLine = 0 + endCharacter = 13 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "hello/sample/" + range { + startLine = 0 + startCharacter = 14 + endLine = 0 + endCharacter = 20 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "hello/sample/Apple#" + range { + startLine = 1 + startCharacter = 6 + endLine = 1 + endCharacter = 11 + } + enclosingRange { + startLine = 1 + endLine = 1 + endCharacter = 11 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "hello/sample/Apple#``()." + range { + startLine = 1 + startCharacter = 6 + endLine = 1 + endCharacter = 11 + } + enclosingRange { + startLine = 1 + endLine = 1 + endCharacter = 11 + } + }, + ) + + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "hello/sample/Apple#" + kind = Kind.Class + enclosingSymbol = "hello/sample/" + displayName = "Apple" + signatureText = "public final class Apple : Any" + } + ) + + document.symbolsList.shouldContainAll(*symbols) + } + + @Test + fun `simple package name semicolon test`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample; + class Banana { + fun foo() { } + }""", + ) + + val occurrences = + arrayOf( + scipOccurrence { + role = REFERENCE + symbol = "sample/" + range { + startLine = 0 + startCharacter = 8 + endLine = 0 + endCharacter = 14 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Banana#" + range { + startLine = 1 + startCharacter = 6 + endLine = 1 + endCharacter = 12 + } + enclosingRange { + startLine = 1 + endLine = 3 + endCharacter = 1 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Banana#foo()." + range { + startLine = 2 + startCharacter = 8 + endLine = 2 + endCharacter = 11 + } + enclosingRange { + startLine = 2 + startCharacter = 4 + endLine = 2 + endCharacter = 17 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Banana#" + range { + startLine = 1 + startCharacter = 6 + endLine = 1 + endCharacter = 12 + } + enclosingRange { + startLine = 1 + endLine = 3 + endCharacter = 1 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/Banana#" + kind = Kind.Class + enclosingSymbol = "sample/" + displayName = "Banana" + signatureText = "public final class Banana : Any" + }, + scipSymbol { + symbol = "sample/Banana#foo()." + kind = Kind.Method + enclosingSymbol = "sample/Banana#" + displayName = "foo" + signatureText = "public final fun foo(): Unit" + }, + ) + document.symbolsList.shouldContainAll(*symbols) + } - /* - Sequences represent lazily-evaluated collections. - We can create a sequence using the "generateSequence" function. - */ - val fooSequence = generateSequence(1, { it + 1 }) - val x = fooSequence.take(10).toList() - println(x) // => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + @Test + fun documentation(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + import java.io.Serializable + abstract class DocstringSuperclass - // An example of using a sequence to generate Fibonacci numbers: - fun fibonacciSequence(): Sequence { - var a = 0L - var b = 1L + /** Example class docstring */ + class Docstrings: DocstringSuperclass(), Serializable - fun next(): Long { - val result = a + b - a = b - b = result - return a - } + /** + * Example method docstring + * + **/ + fun docstrings(msg: String): Int { return msg.length } + """ + .trimIndent(), + ) + document.assertDocumentation("sample/Docstrings#", "Example class docstring") + document.assertDocumentation("sample/docstrings().", "Example method docstring") + } - return generateSequence(::next) - } - val y = fibonacciSequence().take(10).toList() - println(y) // => [1, 1, 2, 3, 5, 8, 13, 21, 34, 55] + @Test + fun `extension receiver type reference`(@TempDir path: Path) { + // String is from the kotlin package; our extension in sample gets + // symbol sample/String#foo(). — distinct from kotlin/String#foo(). + // This means extensions on cross-package types never collide with + // the receiver class's own methods in the symbol table. + val document = + compileScip( + path, + """ + package sample - // Kotlin provides higher-order functions for working with collections. - val z = (1..9).map {it * 3} - .filter {it < 20} - .groupBy {it % 2 == 0} - .mapKeys {if (it.key) "even" else "odd"} - println(z) // => {odd=[3, 9, 15], even=[6, 12, 18]} + fun String.foo(): Int = 42 + fun use(s: String) = s.foo() + """, + ) - // A "for" loop can be used with anything that provides an iterator. - for (c in "hello") { - println(c) - } + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/String#foo()." + range { + startLine = 2 + startCharacter = 11 + endLine = 2 + endCharacter = 14 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 2 + endCharacter = 26 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "kotlin/String#" + range { + startLine = 2 + startCharacter = 4 + endLine = 2 + endCharacter = 10 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "kotlin/Int#" + range { + startLine = 2 + startCharacter = 18 + endLine = 2 + endCharacter = 21 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/String#foo()." + range { + startLine = 3 + startCharacter = 23 + endLine = 3 + endCharacter = 26 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) - // "while" loops work in the same way as other languages. - var ctr = 0 - while (ctr < 5) { - println(ctr) - ctr++ + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/String#foo()." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "foo" + signatureText = "public final fun String.foo(): Int" } - do { - println(ctr) - ctr++ - } while (ctr < 10) + ) + document.symbolsList.shouldContainAll(*symbols) + } - /* - "if" can be used as an expression that returns a value. - For this reason the ternary ?: operator is not needed in Kotlin. - */ - val num = 5 - val message = if (num % 2 == 0) "even" else "odd" - println("$'num' is $'message'") // => 5 is odd + @Test + fun `extension property receiver type reference`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample - // "when" can be used as an alternative to "if-else if" chains. - val i = 10 - when { - i < 7 -> println("first block") - fooString.startsWith("hello") -> println("second block") - else -> println("else block") - } + val Int.asString: String get() = this.toString() + fun use() = 42.asString + """, + ) - // "when" can be used with an argument. - when (i) { - 0, 21 -> println("0 or 21") - in 1..20 -> println("in the range 1 to 20") - else -> println("none of the above") - } + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/Int#asString." + range { + startLine = 2 + startCharacter = 8 + endLine = 2 + endCharacter = 16 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 2 + endCharacter = 48 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "kotlin/Int#" + range { + startLine = 2 + startCharacter = 4 + endLine = 2 + endCharacter = 7 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "kotlin/String#" + range { + startLine = 2 + startCharacter = 18 + endLine = 2 + endCharacter = 24 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Int#asString." + range { + startLine = 3 + startCharacter = 15 + endLine = 3 + endCharacter = 23 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) - // "when" can be used as a function that returns a value. - var result = when (i) { - 0, 21 -> "0 or 21" - in 1..20 -> "in the range 1 to 20" - else -> "none of the above" + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/Int#asString." + kind = Kind.Property + enclosingSymbol = "sample/" + displayName = "asString" + signatureText = "public final val Int.asString: String" } - println(result) + ) + document.symbolsList.shouldContainAll(*symbols) + } - /* - We can check if an object is of a particular type by using the "is" operator. - If an object passes a type check then it can be used as that type without - explicitly casting it. - */ - fun smartCastExample(x: Any) : Boolean { - if (x is Boolean) { - // x is automatically cast to Boolean - return x - } else if (x is Int) { - // x is automatically cast to Int - return x > 0 - } else if (x is String) { - // x is automatically cast to String - return x.isNotEmpty() - } else { - return false + @Test + fun `extension overload disambiguator`(@TempDir path: Path) { + // When a class already has a member named foo() and an extension also + // adds foo(), the extension is counted after the member in the combined + // sibling list (class members + same-package extensions on the same + // receiver type), so the extension gets the (+1) disambiguator and the + // two produce distinct symbols. + val document = + compileScip( + path, + """ + package sample + + class MyClass { + fun foo() {} } - } - println(smartCastExample("Hello, world!")) // => true - println(smartCastExample("")) // => false - println(smartCastExample(5)) // => true - println(smartCastExample(0)) // => false - println(smartCastExample(true)) // => true + fun MyClass.foo(x: Int) {} + """, + ) - // Smartcast also works with when block - fun smartCastWhenExample(x: Any) = when (x) { - is Boolean -> x - is Int -> x > 0 - is String -> x.isNotEmpty() - else -> false - } + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/MyClass#foo()." + range { + startLine = 3 + startCharacter = 8 + endLine = 3 + endCharacter = 11 + } + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 16 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/MyClass#foo(+1)." + range { + startLine = 5 + startCharacter = 12 + endLine = 5 + endCharacter = 15 + } + enclosingRange { + startLine = 5 + startCharacter = 0 + endLine = 5 + endCharacter = 26 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + } - /* - Extensions are a way to add new functionality to a class. - This is similar to C# extension methods. - */ - fun String.remove(c: Char): String { - return this.filter {it != c} - } - println("Hello, world!".remove('l')) // => Heo, word! - } + @Test + fun `enum entry definitions`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample - // Enum classes are similar to Java enum types. - enum class EnumExample { - A, B, C // Enum constants are separated with commas. - } - fun printEnum() = println(EnumExample.A) // => A + enum class Color { RED, GREEN, BLUE } - // Since each enum is an instance of the enum class, they can be initialized as: - enum class EnumExample1(val value: Int) { - A(value = 1), - B(value = 2), - C(value = 3) - } - fun printProperty() = println(EnumExample1.A.value) // => 1 + fun useEnum(): Color = Color.RED + """, + ) - // Every enum has properties to obtain its name and ordinal(position) in the enum class declaration: - fun printName() = println(EnumExample1.A.name) // => A - fun printPosition() = println(EnumExample1.A.ordinal) // => 0 + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/Color#" + range { + startLine = 2 + startCharacter = 11 + endLine = 2 + endCharacter = 16 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 2 + endCharacter = 37 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Color#RED." + range { + startLine = 2 + startCharacter = 19 + endLine = 2 + endCharacter = 22 + } + enclosingRange { + startLine = 2 + startCharacter = 19 + endLine = 2 + endCharacter = 23 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Color#GREEN." + range { + startLine = 2 + startCharacter = 24 + endLine = 2 + endCharacter = 29 + } + enclosingRange { + startLine = 2 + startCharacter = 24 + endLine = 2 + endCharacter = 30 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Color#BLUE." + range { + startLine = 2 + startCharacter = 31 + endLine = 2 + endCharacter = 35 + } + enclosingRange { + startLine = 2 + startCharacter = 31 + endLine = 2 + endCharacter = 35 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Color#" + range { + startLine = 4 + startCharacter = 23 + endLine = 4 + endCharacter = 28 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Color#RED." + range { + startLine = 4 + startCharacter = 29 + endLine = 4 + endCharacter = 32 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) - /* - The "object" keyword can be used to create singleton objects. - We cannot instantiate it but we can refer to its unique instance by its name. - This is similar to Scala singleton objects. - */ - object ObjectExample { - fun hello(): String { - return "hello" - } + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/Color#" + kind = Kind.Class + enclosingSymbol = "sample/" + displayName = "Color" + addOverriddenSymbols("kotlin/Enum#") + signatureText = "public final enum class Color : Enum" + }, + scipSymbol { + symbol = "sample/Color#RED." + kind = Kind.EnumMember + enclosingSymbol = "sample/Color#" + displayName = "RED" + signatureText = "public final static enum entry RED: Color" + }, + scipSymbol { + symbol = "sample/Color#GREEN." + kind = Kind.EnumMember + enclosingSymbol = "sample/Color#" + displayName = "GREEN" + signatureText = "public final static enum entry GREEN: Color" + }, + scipSymbol { + symbol = "sample/Color#BLUE." + kind = Kind.EnumMember + enclosingSymbol = "sample/Color#" + displayName = "BLUE" + signatureText = "public final static enum entry BLUE: Color" + }, + ) + document.symbolsList.shouldContainAll(*symbols) + } - override fun toString(): String { - return "Hello, it's me, ${"ObjectExample::class.simpleName"}" - } - } + @Test + fun `enum entry with body`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + enum class Op { + PLUS { + override fun apply(a: Int, b: Int) = a + b + }; - fun useSingletonObject() { - println(ObjectExample.hello()) // => hello - // In Kotlin, "Any" is the root of the class hierarchy, just like "Object" is in Java - val someRef: Any = ObjectExample - println(someRef) // => Hello, it's me, ObjectExample - } + abstract fun apply(a: Int, b: Int): Int + } + """, + ) + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/Op#PLUS." + range { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 8 + } + // Body-having enum entry: enclosing_range covers PLUS through the + // trailing `};` that terminates the entry list. + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 5 + endCharacter = 6 + } + }, + // An enum entry with a body is modeled as a synthetic anonymous subclass, + // so its overridden member is a local symbol (local2), not a global + // sample/Op#PLUS.apply(). It still gets an enclosing_range spanning the + // function declaration. + scipOccurrence { + role = DEFINITION + symbol = "local 2" + range { + startLine = 4 + startCharacter = 21 + endLine = 4 + endCharacter = 26 + } + enclosingRange { + startLine = 4 + startCharacter = 8 + endLine = 4 + endCharacter = 50 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) - /* The not-null assertion operator (!!) converts any value to a non-null type and - throws an exception if the value is null. - */ - var b: String? = "abc" - val l = b!!.length + val symbols = + arrayOf( + // The entry body becomes an anonymous class enclosed by the entry... + scipSymbol { + symbol = "local 0" + kind = Kind.Class + enclosingSymbol = "sample/Op#PLUS." + displayName = "" + addOverriddenSymbols("sample/Op#") + signatureText = "object : Op" + }, + // ...and the override is a method of that anonymous class. + scipSymbol { + symbol = "local 2" + kind = Kind.Method + enclosingSymbol = "local 0" + displayName = "apply" + addOverriddenSymbols("sample/Op#apply().") + signatureText = "public open override fun apply(a: Int, b: Int): Int" + }, + ) + document.symbolsList.shouldContainAll(*symbols) + } - data class Counter(var value: Int) { - // overload Counter += Int - operator fun plusAssign(increment: Int) { - this.value += increment - } + @Test + fun `named object declarations`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample - // overload Counter++ and ++Counter - operator fun inc() = Counter(value + 1) + object MySingleton { + fun hello(): String = "hi" + } + fun use() = MySingleton.hello() + """, + ) - // overload Counter + Counter - operator fun plus(other: Counter) = Counter(this.value + other.value) + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/MySingleton#" + range { + startLine = 2 + startCharacter = 7 + endLine = 2 + endCharacter = 18 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 4 + endCharacter = 1 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/MySingleton#hello()." + range { + startLine = 3 + startCharacter = 8 + endLine = 3 + endCharacter = 13 + } + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 30 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/MySingleton#" + range { + startLine = 5 + startCharacter = 12 + endLine = 5 + endCharacter = 23 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/MySingleton#hello()." + range { + startLine = 5 + startCharacter = 24 + endLine = 5 + endCharacter = 29 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) - // overload Counter * Counter - operator fun times(other: Counter) = Counter(this.value * other.value) + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/MySingleton#" + kind = Kind.Class + enclosingSymbol = "sample/" + displayName = "MySingleton" + signatureText = "public final object MySingleton : Any" + }, + scipSymbol { + symbol = "sample/MySingleton#hello()." + kind = Kind.Method + enclosingSymbol = "sample/MySingleton#" + displayName = "hello" + signatureText = "public final fun hello(): String" + }, + ) + document.symbolsList.shouldContainAll(*symbols) + } - // overload Counter * Int - operator fun times(value: Int) = Counter(this.value * value) + @Test + fun `companion object`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample - // overload Counter in Counter - operator fun contains(other: Counter) = other.value == this.value + class Foo { + companion object Factory { + fun create(): Foo = Foo() + } + } + fun use() = Foo.Factory.create() + """, + ) - // overload Counter[Int] = Int - operator fun set(index: Int, value: Int) { - this.value = index + value - } + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/Foo#" + range { + startLine = 2 + startCharacter = 6 + endLine = 2 + endCharacter = 9 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 6 + endCharacter = 1 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Foo#Factory#" + range { + startLine = 3 + startCharacter = 21 + endLine = 3 + endCharacter = 28 + } + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 5 + endCharacter = 5 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Foo#Factory#create()." + range { + startLine = 4 + startCharacter = 12 + endLine = 4 + endCharacter = 18 + } + enclosingRange { + startLine = 4 + startCharacter = 8 + endLine = 4 + endCharacter = 33 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Foo#" + range { + startLine = 7 + startCharacter = 12 + endLine = 7 + endCharacter = 15 + } + }, + // Foo.Factory is a FirResolvedQualifier spanning the full qualifier expression + scipOccurrence { + role = REFERENCE + symbol = "sample/Foo#Factory#" + range { + startLine = 7 + startCharacter = 12 + endLine = 7 + endCharacter = 23 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Foo#Factory#create()." + range { + startLine = 7 + startCharacter = 24 + endLine = 7 + endCharacter = 30 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) - // overload Counter instance invocation - operator fun invoke() = println("The value of the counter is $'value'") + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/Foo#" + kind = Kind.Class + enclosingSymbol = "sample/" + displayName = "Foo" + signatureText = "public final class Foo : Any" + }, + scipSymbol { + symbol = "sample/Foo#Factory#" + kind = Kind.Class + enclosingSymbol = "sample/Foo#" + displayName = "Factory" + signatureText = "public final companion object Factory : Any" + }, + scipSymbol { + symbol = "sample/Foo#Factory#create()." + kind = Kind.Method + enclosingSymbol = "sample/Foo#Factory#" + displayName = "create" + signatureText = "public final fun create(): Foo" + }, + ) + document.symbolsList.shouldContainAll(*symbols) + } - } - /* You can also overload operators through extension methods */ - // overload -Counter - operator fun Counter.unaryMinus() = Counter(-this.value) + @Test + fun `unnamed companion object`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample - fun operatorOverloadingDemo() { - var counter1 = Counter(0) - var counter2 = Counter(5) - counter1 += 7 - println(counter1) // => Counter(value=7) - println(counter1 + counter2) // => Counter(value=12) - println(counter1 * counter2) // => Counter(value=35) - println(counter2 * 2) // => Counter(value=10) - println(counter1 in Counter(5)) // => false - println(counter1 in Counter(7)) // => true - counter1[26] = 10 - println(counter1) // => Counter(value=36) - counter1() // => The value of the counter is 36 - println(-counter2) // => Counter(value=-5) - } - """ + class Bar { + companion object { + fun instance(): Bar = Bar() + } + } + fun use() = Bar.instance() + """, + ) + + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/Bar#" + range { + startLine = 2 + startCharacter = 6 + endLine = 2 + endCharacter = 9 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 6 + endCharacter = 1 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Bar#Companion#instance()." + range { + startLine = 4 + startCharacter = 12 + endLine = 4 + endCharacter = 20 + } + enclosingRange { + startLine = 4 + startCharacter = 8 + endLine = 4 + endCharacter = 35 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Bar#" + range { + startLine = 7 + startCharacter = 12 + endLine = 7 + endCharacter = 15 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Bar#Companion#instance()." + range { + startLine = 7 + startCharacter = 16 + endLine = 7 + endCharacter = 24 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Bar#Companion#" + range { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 13 + } + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 5 + endCharacter = 5 + } + }, ) + document.occurrencesList.shouldContainAll(*occurrences) - val result = - KotlinCompilation() - .apply { - sources = listOf(source) - compilerPluginRegistrars = listOf(AnalyzerRegistrar()) - verbose = false - pluginOptions = - listOf( - PluginOption("scip-kotlinc", "sourceroot", path.toString()), - PluginOption("scip-kotlinc", "targetroot", buildPath.toString()), - ) - commandLineProcessors = listOf(AnalyzerCommandLineProcessor()) - workingDir = path.toFile() - } - .compile() - - result.exitCode shouldBe KotlinCompilation.ExitCode.OK + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/Bar#" + kind = Kind.Class + enclosingSymbol = "sample/" + displayName = "Bar" + signatureText = "public final class Bar : Any" + }, + scipSymbol { + symbol = "sample/Bar#Companion#" + kind = Kind.Class + enclosingSymbol = "sample/Bar#" + displayName = "Companion" + signatureText = "public final companion object Companion : Any" + }, + scipSymbol { + symbol = "sample/Bar#Companion#instance()." + kind = Kind.Method + enclosingSymbol = "sample/Bar#Companion#" + displayName = "instance" + signatureText = "public final fun instance(): Bar" + }, + ) + document.symbolsList.shouldContainAll(*symbols) } @Test - fun `compound package name semicolon test`(@TempDir path: Path) { + fun `string template references`(@TempDir path: Path) { val document = compileScip( path, """ - package hello.sample; - class Apple - """ - .trimIndent(), + package sample + + fun greet(name: String) = "Hello, ${'$'}name!" + """, ) val occurrences = arrayOf( scipOccurrence { - role = REFERENCE - symbol = "hello/" + role = DEFINITION + symbol = "sample/greet()." range { - startLine = 0 - startCharacter = 8 - endLine = 0 - endCharacter = 13 + startLine = 2 + startCharacter = 4 + endLine = 2 + endCharacter = 9 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 2 + endCharacter = 41 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/greet().(name)" + range { + startLine = 2 + startCharacter = 10 + endLine = 2 + endCharacter = 14 + } + enclosingRange { + startLine = 2 + startCharacter = 10 + endLine = 2 + endCharacter = 22 } }, scipOccurrence { role = REFERENCE - symbol = "hello/sample/" + symbol = "sample/greet().(name)" range { - startLine = 0 - startCharacter = 14 - endLine = 0 - endCharacter = 20 + startLine = 2 + startCharacter = 35 + endLine = 2 + endCharacter = 39 } }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/greet()." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "greet" + signatureText = "public final fun greet(name: String): String" + } + ) + document.symbolsList.shouldContainAll(*symbols) + } + + @Test + fun `multiple supertype references`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + interface Named + interface Speakable + class Person : Named, Speakable + fun use(p: Person): Named = p + """, + ) + + val occurrences = + arrayOf( scipOccurrence { role = DEFINITION - symbol = "hello/sample/Apple#" + symbol = "sample/Person#" range { - startLine = 1 + startLine = 4 startCharacter = 6 - endLine = 1 - endCharacter = 11 + endLine = 4 + endCharacter = 12 } enclosingRange { - startLine = 1 - endLine = 1 - endCharacter = 11 + startLine = 4 + startCharacter = 0 + endLine = 4 + endCharacter = 31 } }, scipOccurrence { - role = DEFINITION - symbol = "hello/sample/Apple#``()." + role = REFERENCE + symbol = "sample/Named#" range { - startLine = 1 - startCharacter = 6 - endLine = 1 - endCharacter = 11 + startLine = 4 + startCharacter = 15 + endLine = 4 + endCharacter = 20 } - enclosingRange { - startLine = 1 - endLine = 1 - endCharacter = 11 + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Speakable#" + range { + startLine = 4 + startCharacter = 22 + endLine = 4 + endCharacter = 31 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Named#" + range { + startLine = 5 + startCharacter = 20 + endLine = 5 + endCharacter = 25 } }, ) - document.occurrencesList.shouldContainAll(*occurrences) val symbols = arrayOf( scipSymbol { - symbol = "hello/sample/Apple#" - displayName = "Apple" - signatureText = "public final class Apple : Any" + symbol = "sample/Person#" + kind = Kind.Class + enclosingSymbol = "sample/" + displayName = "Person" + addOverriddenSymbols("sample/Named#") + addOverriddenSymbols("sample/Speakable#") + signatureText = "public final class Person : Named, Speakable" } ) - document.symbolsList.shouldContainAll(*symbols) } @Test - fun `simple package name semicolon test`(@TempDir path: Path) { + fun `three-way overload disambiguator`(@TempDir path: Path) { val document = compileScip( path, """ - package sample; - class Banana { - fun foo() { } - }""", + package sample + + fun add(x: Int, y: Int): Int = x + y + fun add(x: Double, y: Double): Double = x + y + fun add(x: String, y: String): String = x + y + fun use() { + add(1, 2) + add(1.0, 2.0) + add("a", "b") + } + """, ) val occurrences = arrayOf( scipOccurrence { - role = REFERENCE - symbol = "sample/" + role = DEFINITION + symbol = "sample/add()." range { - startLine = 0 - startCharacter = 8 - endLine = 0 - endCharacter = 14 + startLine = 2 + startCharacter = 4 + endLine = 2 + endCharacter = 7 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 2 + endCharacter = 36 } }, scipOccurrence { role = DEFINITION - symbol = "sample/Banana#" + symbol = "sample/add(+1)." range { - startLine = 1 - startCharacter = 6 - endLine = 1 - endCharacter = 12 + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 7 } enclosingRange { - startLine = 1 + startLine = 3 + startCharacter = 0 endLine = 3 - endCharacter = 1 + endCharacter = 45 } }, scipOccurrence { role = DEFINITION - symbol = "sample/Banana#foo()." + symbol = "sample/add(+2)." range { - startLine = 2 - startCharacter = 8 - endLine = 2 - endCharacter = 11 + startLine = 4 + startCharacter = 4 + endLine = 4 + endCharacter = 7 } enclosingRange { - startLine = 2 + startLine = 4 + startCharacter = 0 + endLine = 4 + endCharacter = 45 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/add()." + range { + startLine = 6 startCharacter = 4 - endLine = 2 - endCharacter = 17 + endLine = 6 + endCharacter = 7 } }, scipOccurrence { - role = DEFINITION - symbol = "sample/Banana#" + role = REFERENCE + symbol = "sample/add(+1)." range { - startLine = 1 - startCharacter = 6 - endLine = 1 - endCharacter = 12 + startLine = 7 + startCharacter = 4 + endLine = 7 + endCharacter = 7 } - enclosingRange { - startLine = 1 - endLine = 3 - endCharacter = 1 + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/add(+2)." + range { + startLine = 8 + startCharacter = 4 + endLine = 8 + endCharacter = 7 } }, ) @@ -1384,48 +3006,34 @@ class AnalyzerTest { val symbols = arrayOf( scipSymbol { - symbol = "sample/Banana#" - displayName = "Banana" - signatureText = "public final class Banana : Any" + symbol = "sample/add()." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "add" + signatureText = "public final fun add(x: Int, y: Int): Int" }, scipSymbol { - symbol = "sample/Banana#foo()." - displayName = "foo" - signatureText = "public final fun foo(): Unit" + symbol = "sample/add(+1)." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "add" + signatureText = "public final fun add(x: Double, y: Double): Double" + }, + scipSymbol { + symbol = "sample/add(+2)." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "add" + signatureText = "public final fun add(x: String, y: String): String" }, ) document.symbolsList.shouldContainAll(*symbols) } - @Test - fun documentation(@TempDir path: Path) { - val document = - compileScip( - path, - """ - package sample - import java.io.Serializable - abstract class DocstringSuperclass - - /** Example class docstring */ - class Docstrings: DocstringSuperclass(), Serializable - - /** - * Example method docstring - * - **/ - inline fun docstrings(msg: String): Int { return msg.length } - """ - .trimIndent(), - ) - document.assertDocumentation("sample/Docstrings#", "Example class docstring") - document.assertDocumentation("sample/docstrings().", "Example method docstring") - } - private fun Document.assertDocumentation(symbol: String, expectedDocumentation: String) { val info = this.symbolsList.find { it.symbol == symbol } - ?: fail("no SymbolInformation for symbol $symbol") + ?: fail("no scipSymbol for symbol $symbol") val obtainedDocumentation = info.documentationList.joinToString("\n").trim() assertEquals(expectedDocumentation, obtainedDocumentation) } diff --git a/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/KotlinGraphTest.kt b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/KotlinGraphTest.kt new file mode 100644 index 000000000..e4805a85a --- /dev/null +++ b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/KotlinGraphTest.kt @@ -0,0 +1,151 @@ +package org.scip_code.scip_java.kotlinc.test + +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.PluginOption +import com.tschuchort.compiletesting.SourceFile +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.io.TempDir +import org.scip_code.scip_java.kotlinc.AnalyzerCommandLineProcessor +import org.scip_code.scip_java.kotlinc.AnalyzerRegistrar + +@OptIn(ExperimentalCompilerApi::class) +class KotlinGraphTest { + @Test + fun graphIdentitiesAreStructuralAndTopLevelDeclarationsBelongToTheSource(@TempDir root: Path) { + val graphRoot = root.resolve("graph") + val result = + KotlinCompilation() + .apply { + sources = + listOf( + SourceFile.kotlin( + "Graph.kt", + """ + package example + + import kotlin.collections.List as KList + + @Deprecated("fixture") + fun old(): Unit = Unit + + fun String.describe(): String = this + fun Int.describe(): String = toString() + + fun first(value: String): String = value + fun second(value: Int): Int = value + + fun overloaded(value: String): String = value + fun overloaded(value: Int): Int = value + + @get:JvmName("getXProperty") + val x: String + get() = "x" + + fun getX(): String = x + + class Many { + constructor() + constructor(value: Int) + } + + fun identity(value: T): T = value + + fun sameName(): Unit = Unit + + fun use(values: KList): String { + old() + return values.first().describe() + 1.describe() + } + """ + .trimIndent(), + ), + SourceFile.kotlin( + "Other.kt", + "package other\nfun sameName(): Unit = Unit", + ), + ) + compilerPluginRegistrars = listOf(AnalyzerRegistrar()) + commandLineProcessors = listOf(AnalyzerCommandLineProcessor()) + pluginOptions = + listOf( + PluginOption("scip-kotlinc", "sourceroot", root.toString()), + PluginOption( + "scip-kotlinc", + "targetroot", + root.resolve("scip").toString(), + ), + PluginOption("scip-kotlinc", "graphroot", graphRoot.toString()), + PluginOption("scip-kotlinc", "graphtarget", ":|jvm|main"), + ) + workingDir = root.toFile() + verbose = false + } + .compile() + + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + val shard = + Files.walk(graphRoot).use { paths -> + val output = + paths + .filter(Files::isRegularFile) + .filter { it.fileName.toString() == "Graph.kt.graph.json" } + .findFirst() + .orElseThrow() + Files.readString(output, StandardCharsets.UTF_8) + } + assertTrue(shard.contains("receiver=kotlin/String")) + assertTrue(shard.contains("receiver=kotlin/Int")) + assertTrue( + shard.contains( + "callable:example.first|receiver=|context=|parameters=kotlin/String|arity=0|parameter:value" + ) + ) + assertTrue( + shard.contains( + "callable:example.second|receiver=|context=|parameters=kotlin/Int|arity=0|parameter:value" + ) + ) + assertTrue( + shard.contains( + "callable:example.overloaded|receiver=|context=|parameters=kotlin/String|arity=0" + ) + ) + assertTrue( + shard.contains( + "callable:example.overloaded|receiver=|context=|parameters=kotlin/Int|arity=0" + ) + ) + assertTrue( + shard.contains("callable:example.x|receiver=|context=|parameters=|arity=0|accessor=get") + ) + assertTrue(shard.contains("callable:example.getX|receiver=|context=|parameters=|arity=0")) + assertTrue( + shard.contains( + "callable:example.Many.Many|receiver=|context=|parameters=|arity=0|constructor" + ) + ) + assertTrue( + shard.contains( + "callable:example.Many.Many|receiver=|context=|parameters=kotlin/Int|arity=0|constructor" + ) + ) + assertTrue( + shard.contains("callable:example.identity|receiver=|context=|parameters=T|arity=1") + ) + assertTrue( + shard.contains("callable:example.sameName|receiver=|context=|parameters=|arity=0") + ) + assertTrue(shard.contains("\"from\":\"sources/Graph.kt\"")) + assertFalse(shard.contains("FirFileSymbol")) + assertTrue(shard.contains("\"kind\":\"imports\"")) + assertTrue(shard.contains("\"provenance\":\"KList\"")) + assertTrue(shard.contains("\"severity\":\"warning\"")) + } +} diff --git a/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/ScipBuilders.kt b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/ScipBuilders.kt index 1e8ee95a7..fe8bdebc7 100644 --- a/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/ScipBuilders.kt +++ b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/ScipBuilders.kt @@ -76,6 +76,8 @@ class ScipOccurrenceBuilder { @ScipBuilderDsl class ScipSymbolInformationBuilder { var symbol: String = "" + var kind: SymbolInformation.Kind = SymbolInformation.Kind.UnspecifiedKind + var enclosingSymbol: String = "" var displayName: String = "" var signatureText: String? = null private val docs = mutableListOf() @@ -95,6 +97,8 @@ class ScipSymbolInformationBuilder { internal fun build(): SymbolInformation = symbolInformation { symbol = this@ScipSymbolInformationBuilder.symbol + kind = this@ScipSymbolInformationBuilder.kind + enclosingSymbol = this@ScipSymbolInformationBuilder.enclosingSymbol if (this@ScipSymbolInformationBuilder.displayName.isNotEmpty()) { displayName = this@ScipSymbolInformationBuilder.displayName } diff --git a/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/ScipSymbolsTest.kt b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/ScipSymbolsTest.kt index 048d33cee..0bc8349bf 100644 --- a/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/ScipSymbolsTest.kt +++ b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/ScipSymbolsTest.kt @@ -3,6 +3,7 @@ package org.scip_code.scip_java.kotlinc.test import com.tschuchort.compiletesting.SourceFile import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.junit.jupiter.api.TestFactory +import org.scip_code.scip.SymbolInformation.Kind import org.scip_code.scip_java.kotlinc.* import org.scip_code.scip_java.kotlinc.test.ExpectedSymbols.ScipData import org.scip_code.scip_java.kotlinc.test.ExpectedSymbols.SymbolCacheData @@ -275,7 +276,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "getX()." + symbol = "x.get()." range { startLine = 0 startCharacter = 4 @@ -286,7 +287,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "setX()." + symbol = "x.set()." range { startLine = 0 startCharacter = 4 @@ -327,7 +328,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "setX()." + symbol = "x.set()." range { startLine = 0 startCharacter = 4 @@ -341,7 +342,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "getX()." + symbol = "x.get()." range { startLine = 1 startCharacter = 4 @@ -387,7 +388,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "getX()." + symbol = "x.get()." range { startLine = 0 startCharacter = 4 @@ -401,7 +402,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "setX()." + symbol = "x.set()." range { startLine = 1 startCharacter = 4 @@ -448,7 +449,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "getX()." + symbol = "x.get()." range { startLine = 1 startCharacter = 4 @@ -464,7 +465,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "setX()." + symbol = "x.set()." range { startLine = 2 startCharacter = 4 @@ -527,7 +528,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "Test#getSample()." + symbol = "Test#sample.get()." range { startLine = 0 startCharacter = 15 @@ -541,7 +542,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "Test#setSample()." + symbol = "Test#sample.set()." range { startLine = 0 startCharacter = 15 @@ -575,7 +576,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = REFERENCE - symbol = "Test#getSample()." + symbol = "Test#sample.get()." range { startLine = 2 startCharacter = 16 @@ -775,12 +776,16 @@ class ScipSymbolsTest { listOf( scipSymbol { symbol = "x." + kind = Kind.Property + enclosingSymbol = "_root_/" displayName = "x" signatureText = "public final val x: String" documentation("hello world\n test content") }, scipSymbol { - symbol = "getX()." + symbol = "x.get()." + kind = Kind.Method + enclosingSymbol = "x." displayName = "x" signatureText = "public get(): String" documentation("hello world\n test content") diff --git a/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/Utils.kt b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/Utils.kt index 4576e9893..17f5f5b1c 100644 --- a/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/Utils.kt +++ b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/Utils.kt @@ -29,7 +29,6 @@ import org.scip_code.scip.Document import org.scip_code.scip.Occurrence import org.scip_code.scip.SymbolInformation import org.scip_code.scip_java.kotlinc.* -import org.scip_code.scip_java.kotlinc.AnalyzerCheckers.Companion.visitors import org.scip_code.scip_java.shared.ScipOptions data class ExpectedSymbols( @@ -128,7 +127,8 @@ private class TestAnalyzerDeclarationCheckers( globals: GlobalSymbolsCache, locals: LocalSymbolsCache, sourceRoot: Path, -) : AnalyzerCheckers.AnalyzerDeclarationCheckers(sourceRoot) { + private val state: AnalyzerCompilationState, +) : AnalyzerCheckers.AnalyzerDeclarationCheckers(sourceRoot, state = state) { override val fileCheckers: Set = setOf( object : FirFileChecker(MppCheckerKind.Common) { @@ -137,20 +137,24 @@ private class TestAnalyzerDeclarationCheckers( val ktFile = declaration.sourceFile ?: return val lineMap = LineMap(declaration) val visitor = ScipVisitor(sourceRoot, ktFile, lineMap, globals, locals) - visitors[ktFile] = visitor + state.visitors[ktFile] = visitor } }, - AnalyzerCheckers.SemanticImportsChecker(), + AnalyzerCheckers.SemanticImportsChecker(state), ) } -private class TestAnalyzerCheckers(session: FirSession) : AnalyzerCheckers(session) { +private class TestAnalyzerCheckers( + session: FirSession, + private val state: AnalyzerCompilationState, +) : AnalyzerCheckers(session, state) { override val declarationCheckers: DeclarationCheckers get() = TestAnalyzerDeclarationCheckers( session.testAnalyzerParamsProvider.globals, session.testAnalyzerParamsProvider.locals, session.testAnalyzerParamsProvider.sourceroot, + state, ) } @@ -183,23 +187,28 @@ fun scipVisitorAnalyzer( ): CompilerPluginRegistrar { return object : CompilerPluginRegistrar() { override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + val state = AnalyzerCompilationState(globals) FirExtensionRegistrarAdapter.registerExtension( object : FirExtensionRegistrar() { override fun ExtensionRegistrarContext.configurePlugin() { +TestAnalyzerParamsProvider.getFactory(globals, locals, sourceroot) - +::TestAnalyzerCheckers + +{ session: FirSession -> TestAnalyzerCheckers(session, state) } } } ) IrGenerationExtension.registerExtension( PostAnalysisExtension( + configuration = configuration, sourceRoot = sourceroot, targetRoot = Paths.get(""), callback = hook, + state = state, ) ) } + override val pluginId = PLUGIN_ID + override val supportsK2: Boolean get() = true } diff --git a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Class.kt b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Class.kt index 7ee742752..288589564 100644 --- a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Class.kt +++ b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Class.kt @@ -5,52 +5,60 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#``(). // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#``().(banana) // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#banana. -// ⌄ enclosing_range_start scip-java maven . . snapshots/Class#getBanana(). -// ⌄ enclosing_range_start scip-java maven . . snapshots/Class#setBanana(). -// ⌄ enclosing_range_start scip-java maven . . snapshots/Class#setBanana().(value) +// ⌄ enclosing_range_start scip-java maven . . snapshots/Class#banana.get(). +// ⌄ enclosing_range_start scip-java maven . . snapshots/Class#banana.set(). +// ⌄ enclosing_range_start scip-java maven . . snapshots/Class#banana.set().(value) // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#``().(apple) class Class constructor(private var banana: Int, apple: String) : // ^^^^^ definition scip-java maven . . snapshots/Class# +// kind Class // display_name Class // signature_documentation // > public final class Class : Throwable // relationship scip-java maven . . kotlin/Throwable# implementation // ^^^^^^^^^^^ definition scip-java maven . . snapshots/Class#``(). +// kind Constructor // display_name Class // signature_documentation // > public constructor(banana: Int, apple: String): Class // ^^^^^^ definition scip-java maven . . snapshots/Class#``().(banana) +// kind Parameter // display_name banana // signature_documentation // > banana: Int // ^^^^^^ definition scip-java maven . . snapshots/Class#banana. +// kind Property // display_name banana // signature_documentation // > private final var banana: Int // ^^^^^^ reference scip-java maven . . snapshots/Class#``().(banana) -// ^^^^^^ definition scip-java maven . . snapshots/Class#getBanana(). +// ^^^^^^ definition scip-java maven . . snapshots/Class#banana.get(). +// kind Method // display_name banana // signature_documentation // > private get(): Int -// ^^^^^^ definition scip-java maven . . snapshots/Class#setBanana(). +// ^^^^^^ definition scip-java maven . . snapshots/Class#banana.set(). +// kind Method // display_name banana // signature_documentation // > private set(value: Int): Unit -// ^^^^^^ definition scip-java maven . . snapshots/Class#setBanana().(value) +// ^^^^^^ definition scip-java maven . . snapshots/Class#banana.set().(value) +// kind Parameter // display_name value // signature_documentation // > value: Int // ^^^ reference scip-java maven . . kotlin/Int# // ^^^^^ definition scip-java maven . . snapshots/Class#``().(apple) +// kind Parameter // display_name apple // signature_documentation // > apple: String // ^^^^^^ reference scip-java maven . . kotlin/String# // ⌃ enclosing_range_end scip-java maven . . snapshots/Class#``().(banana) // ⌃ enclosing_range_end scip-java maven . . snapshots/Class#banana. -// ⌃ enclosing_range_end scip-java maven . . snapshots/Class#getBanana(). -// ⌃ enclosing_range_end scip-java maven . . snapshots/Class#setBanana(). -// ⌃ enclosing_range_end scip-java maven . . snapshots/Class#setBanana().(value) +// ⌃ enclosing_range_end scip-java maven . . snapshots/Class#banana.get(). +// ⌃ enclosing_range_end scip-java maven . . snapshots/Class#banana.set(). +// ⌃ enclosing_range_end scip-java maven . . snapshots/Class#banana.set().(value) // ⌃ enclosing_range_end scip-java maven . . snapshots/Class#``().(apple) // ⌃ enclosing_range_end scip-java maven . . snapshots/Class#``(). Throwable(banana.toString() + apple) { @@ -65,43 +73,50 @@ } // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#asdf. -// ⌄ enclosing_range_start scip-java maven . . snapshots/Class#getAsdf(). +// ⌄ enclosing_range_start scip-java maven . . snapshots/Class#asdf.get(). val asdf = // ^^^^ definition scip-java maven . . snapshots/Class#asdf. +// kind Property // display_name asdf // signature_documentation // > public final val asdf: Any -// ^^^^ definition scip-java maven . . snapshots/Class#getAsdf(). +// ^^^^ definition scip-java maven . . snapshots/Class#asdf.get(). +// kind Method // display_name asdf // signature_documentation // > public get(): Any -// ⌄ enclosing_range_start scip-java maven . . snapshots/``# -// ⌄ enclosing_range_start scip-java maven . . snapshots/``#``(). +// ⌄ enclosing_range_start local 0 +// ⌄ enclosing_range_start local 1 object { -// ^^^^^^ definition scip-java maven . . snapshots/``# +// ^^^^^^ definition local 0 +// kind Class // display_name // signature_documentation // > object : Any -// ^^^^^^ definition scip-java maven . . snapshots/``#``(). +// ^^^^^^ definition local 1 +// kind Constructor // display_name // signature_documentation // > private constructor(): -// ⌄ enclosing_range_start scip-java maven . . snapshots/``#doStuff(). +// ⌄ enclosing_range_start local 2 fun doStuff() = Unit -// ^^^^^^^ definition scip-java maven . . snapshots/``#doStuff(). +// ^^^^^^^ definition local 2 +// kind Method // display_name doStuff // signature_documentation // > public final fun doStuff(): Unit -// ⌃ enclosing_range_end scip-java maven . . snapshots/``#doStuff(). +// ^^^^ reference scip-java maven . . kotlin/Unit# +// ⌃ enclosing_range_end local 2 } // ⌃ enclosing_range_end scip-java maven . . snapshots/Class#asdf. -// ⌃ enclosing_range_end scip-java maven . . snapshots/Class#getAsdf(). -// ⌃ enclosing_range_end scip-java maven . . snapshots/``# -// ⌃ enclosing_range_end scip-java maven . . snapshots/``#``(). +// ⌃ enclosing_range_end scip-java maven . . snapshots/Class#asdf.get(). +// ⌃ enclosing_range_end local 0 +// ⌃ enclosing_range_end local 1 // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#``(+1). constructor() : this(1, "") // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/Class#``(+1). +// kind Constructor // display_name Class // signature_documentation // > public constructor(): Class @@ -111,10 +126,12 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#``(+2).(banana) constructor(banana: Int) : this(banana, "") // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/Class#``(+2). +// kind Constructor // display_name Class // signature_documentation // > public constructor(banana: Int): Class // ^^^^^^ definition scip-java maven . . snapshots/Class#``(+2).(banana) +// kind Parameter // display_name banana // signature_documentation // > banana: Int @@ -126,20 +143,22 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#run(). fun run() { // ^^^ definition scip-java maven . . snapshots/Class#run(). +// kind Method // display_name run // signature_documentation // > public final fun run(): Unit println(Class::class) // ^^^^^^^ reference scip-java maven . . kotlin/io/println(). +// ^^^^^ reference scip-java maven . . snapshots/Class# println("I eat $banana for lunch") // ^^^^^^^ reference scip-java maven . . kotlin/io/println(). // ^^^^^^ reference scip-java maven . . snapshots/Class#banana. -// ^^^^^^ reference scip-java maven . . snapshots/Class#getBanana(). -// ^^^^^^ reference scip-java maven . . snapshots/Class#setBanana(). +// ^^^^^^ reference scip-java maven . . snapshots/Class#banana.get(). +// ^^^^^^ reference scip-java maven . . snapshots/Class#banana.set(). banana = 42 // ^^^^^^ reference scip-java maven . . snapshots/Class#banana. -// ^^^^^^ reference scip-java maven . . snapshots/Class#getBanana(). -// ^^^^^^ reference scip-java maven . . snapshots/Class#setBanana(). +// ^^^^^^ reference scip-java maven . . snapshots/Class#banana.get(). +// ^^^^^^ reference scip-java maven . . snapshots/Class#banana.set(). } // ⌃ enclosing_range_end scip-java maven . . snapshots/Class#run(). } diff --git a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/CompanionOwner.kt b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/CompanionOwner.kt index d81175cdb..db2d168b9 100644 --- a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/CompanionOwner.kt +++ b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/CompanionOwner.kt @@ -5,27 +5,32 @@ //⌄ enclosing_range_start scip-java maven . . snapshots/CompanionOwner#``(). class CompanionOwner { // ^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/CompanionOwner# +// kind Class // display_name CompanionOwner // signature_documentation // > public final class CompanionOwner : Any // ^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/CompanionOwner#``(). +// kind Constructor // display_name CompanionOwner // signature_documentation // > public constructor(): CompanionOwner // ⌄ enclosing_range_start scip-java maven . . snapshots/CompanionOwner#Companion# // ⌄ enclosing_range_start scip-java maven . . snapshots/CompanionOwner#Companion#``(). companion object { -// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/CompanionOwner#Companion# -// display_name Companion -// signature_documentation -// > public final companion object Companion : Any +// ^^^^^^^^^ definition scip-java maven . . snapshots/CompanionOwner#Companion# +// kind Class +// display_name Companion +// signature_documentation +// > public final companion object Companion : Any // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/CompanionOwner#Companion#``(). +// kind Constructor // display_name Companion // signature_documentation // > private constructor(): CompanionOwner.Companion // ⌄ enclosing_range_start scip-java maven . . snapshots/CompanionOwner#Companion#create(). fun create(): CompanionOwner = CompanionOwner() // ^^^^^^ definition scip-java maven . . snapshots/CompanionOwner#Companion#create(). +// kind Method // display_name create // signature_documentation // > public final fun create(): CompanionOwner @@ -38,10 +43,12 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/CompanionOwner#create(). fun create(): Int = CompanionOwner.create().hashCode() // ^^^^^^ definition scip-java maven . . snapshots/CompanionOwner#create(). +// kind Method // display_name create // signature_documentation // > public final fun create(): Int // ^^^ reference scip-java maven . . kotlin/Int# +// ^^^^^^^^^^^^^^ reference scip-java maven . . snapshots/CompanionOwner# // ^^^^^^ reference scip-java maven . . snapshots/CompanionOwner#Companion#create(). // ^^^^^^^^ reference scip-java maven . . kotlin/Any#hashCode(). // ⌃ enclosing_range_end scip-java maven . . snapshots/CompanionOwner#create(). diff --git a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Docstrings.kt b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Docstrings.kt index b7a60c8a5..69c6333f2 100644 --- a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Docstrings.kt +++ b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Docstrings.kt @@ -10,10 +10,12 @@ //⌄ enclosing_range_start scip-java maven . . snapshots/DocstringSuperclass#``(). abstract class DocstringSuperclass // ^^^^^^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/DocstringSuperclass# +// kind Class // display_name DocstringSuperclass // signature_documentation // > public abstract class DocstringSuperclass : Any // ^^^^^^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/DocstringSuperclass#``(). +// kind Constructor // display_name DocstringSuperclass // signature_documentation // > public constructor(): DocstringSuperclass @@ -24,6 +26,7 @@ /** Example class docstring. */ class Docstrings : DocstringSuperclass(), Serializable { // ^^^^^^^^^^ definition scip-java maven . . snapshots/Docstrings# +// kind Class // display_name Docstrings // signature_documentation // > public final class Docstrings : DocstringSuperclass, Serializable @@ -32,6 +35,7 @@ // relationship scip-java maven . . snapshots/DocstringSuperclass# implementation // relationship scip-java maven jdk 17 java/io/Serializable# implementation // ^^^^^^^^^^ definition scip-java maven . . snapshots/Docstrings#``(). +// kind Constructor // display_name Docstrings // signature_documentation // > public constructor(): Docstrings @@ -47,6 +51,7 @@ /** Example method docstring. */ fun docstrings() { } // ^^^^^^^^^^ definition scip-java maven . . snapshots/docstrings(). +// kind Method // display_name docstrings // signature_documentation // > public final fun docstrings(): Unit diff --git a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Functions.kt b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Functions.kt index 2b9224e80..0ee9ed764 100644 --- a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Functions.kt +++ b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Functions.kt @@ -5,10 +5,12 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/sampleText().(x) fun sampleText(x: String = "") { // ^^^^^^^^^^ definition scip-java maven . . snapshots/sampleText(). +// kind Method // display_name sampleText // signature_documentation // > public final fun sampleText(x: String = ...): Unit // ^ definition scip-java maven . . snapshots/sampleText().(x) +// kind Parameter // display_name x // signature_documentation // > x: String = ... diff --git a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Implementations.kt b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Implementations.kt index c9c88d892..0041a9f5a 100644 --- a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Implementations.kt +++ b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Implementations.kt @@ -5,11 +5,13 @@ //⌄ enclosing_range_start scip-java maven . . snapshots/Overrides#``(). class Overrides : AutoCloseable { // ^^^^^^^^^ definition scip-java maven . . snapshots/Overrides# +// kind Class // display_name Overrides // signature_documentation // > public final class Overrides : {kotlin/AutoCloseable=} AutoCloseable // relationship scip-java maven jdk 17 java/lang/AutoCloseable# implementation // ^^^^^^^^^ definition scip-java maven . . snapshots/Overrides#``(). +// kind Constructor // display_name Overrides // signature_documentation // > public constructor(): Overrides @@ -17,6 +19,7 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Overrides#close(). override fun close() { // ^^^^^ definition scip-java maven . . snapshots/Overrides#close(). +// kind Method // display_name close // signature_documentation // > public open override fun close(): Unit @@ -32,26 +35,30 @@ //⌄ enclosing_range_start scip-java maven . . snapshots/Animal# interface Animal { // ^^^^^^ definition scip-java maven . . snapshots/Animal# +// kind Interface // display_name Animal // signature_documentation // > public abstract interface Animal : Any // ⌄ enclosing_range_start scip-java maven . . snapshots/Animal#favoriteNumber. -// ⌄ enclosing_range_start scip-java maven . . snapshots/Animal#getFavoriteNumber(). +// ⌄ enclosing_range_start scip-java maven . . snapshots/Animal#favoriteNumber.get(). val favoriteNumber: Int // ^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/Animal#favoriteNumber. +// kind Property // display_name favoriteNumber // signature_documentation // > public abstract val favoriteNumber: Int -// ^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/Animal#getFavoriteNumber(). +// ^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/Animal#favoriteNumber.get(). +// kind Method // display_name favoriteNumber // signature_documentation // > public get(): Int // ^^^ reference scip-java maven . . kotlin/Int# // ⌃ enclosing_range_end scip-java maven . . snapshots/Animal#favoriteNumber. -// ⌃ enclosing_range_end scip-java maven . . snapshots/Animal#getFavoriteNumber(). +// ⌃ enclosing_range_end scip-java maven . . snapshots/Animal#favoriteNumber.get(). // ⌄ enclosing_range_start scip-java maven . . snapshots/Animal#sound(). fun sound(): String // ^^^^^ definition scip-java maven . . snapshots/Animal#sound(). +// kind Method // display_name sound // signature_documentation // > public abstract fun sound(): String @@ -64,11 +71,13 @@ //⌄ enclosing_range_start scip-java maven . . snapshots/Bird#``(). open class Bird : Animal { // ^^^^ definition scip-java maven . . snapshots/Bird# +// kind Class // display_name Bird // signature_documentation // > public open class Bird : Animal // relationship scip-java maven . . snapshots/Animal# implementation // ^^^^ definition scip-java maven . . snapshots/Bird#``(). +// kind Constructor // display_name Bird // signature_documentation // > public constructor(): Bird @@ -76,22 +85,25 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Bird#favoriteNumber. override val favoriteNumber: Int // ^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/Bird#favoriteNumber. +// kind Property // display_name favoriteNumber // signature_documentation // > public open override val favoriteNumber: Int // ^^^ reference scip-java maven . . kotlin/Int# -// ⌄ enclosing_range_start scip-java maven . . snapshots/Bird#getFavoriteNumber(). +// ⌄ enclosing_range_start scip-java maven . . snapshots/Bird#favoriteNumber.get(). get() = 42 -// ^^^ definition scip-java maven . . snapshots/Bird#getFavoriteNumber(). +// ^^^ definition scip-java maven . . snapshots/Bird#favoriteNumber.get(). +// kind Method // display_name favoriteNumber // signature_documentation // > public get(): Int // ⌃ enclosing_range_end scip-java maven . . snapshots/Bird#favoriteNumber. -// ⌃ enclosing_range_end scip-java maven . . snapshots/Bird#getFavoriteNumber(). +// ⌃ enclosing_range_end scip-java maven . . snapshots/Bird#favoriteNumber.get(). // ⌄ enclosing_range_start scip-java maven . . snapshots/Bird#sound(). override fun sound(): String { // ^^^^^ definition scip-java maven . . snapshots/Bird#sound(). +// kind Method // display_name sound // signature_documentation // > public open override fun sound(): String @@ -107,11 +119,13 @@ //⌄ enclosing_range_start scip-java maven . . snapshots/Seagull#``(). class Seagull : Bird() { // ^^^^^^^ definition scip-java maven . . snapshots/Seagull# +// kind Class // display_name Seagull // signature_documentation // > public final class Seagull : Bird // relationship scip-java maven . . snapshots/Bird# implementation // ^^^^^^^ definition scip-java maven . . snapshots/Seagull#``(). +// kind Constructor // display_name Seagull // signature_documentation // > public constructor(): Seagull @@ -119,21 +133,24 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Seagull#favoriteNumber. override val favoriteNumber: Int // ^^^^^^^^^^^^^^ definition scip-java maven . . snapshots/Seagull#favoriteNumber. +// kind Property // display_name favoriteNumber // signature_documentation // > public open override val favoriteNumber: Int // ^^^ reference scip-java maven . . kotlin/Int# -// ⌄ enclosing_range_start scip-java maven . . snapshots/Seagull#getFavoriteNumber(). +// ⌄ enclosing_range_start scip-java maven . . snapshots/Seagull#favoriteNumber.get(). get() = 1337 -// ^^^ definition scip-java maven . . snapshots/Seagull#getFavoriteNumber(). +// ^^^ definition scip-java maven . . snapshots/Seagull#favoriteNumber.get(). +// kind Method // display_name favoriteNumber // signature_documentation // > public get(): Int // ⌃ enclosing_range_end scip-java maven . . snapshots/Seagull#favoriteNumber. -// ⌃ enclosing_range_end scip-java maven . . snapshots/Seagull#getFavoriteNumber(). +// ⌃ enclosing_range_end scip-java maven . . snapshots/Seagull#favoriteNumber.get(). // ⌄ enclosing_range_start scip-java maven . . snapshots/Seagull#sound(). override fun sound(): String { // ^^^^^ definition scip-java maven . . snapshots/Seagull#sound(). +// kind Method // display_name sound // signature_documentation // > public open override fun sound(): String diff --git a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Lambdas.kt b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Lambdas.kt index 0d80eb5bc..1748c0b3b 100644 --- a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Lambdas.kt +++ b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Lambdas.kt @@ -2,25 +2,29 @@ // ^^^^^^^^^ reference scip-java maven . . snapshots/ //⌄ enclosing_range_start scip-java maven . . snapshots/x. -//⌄ enclosing_range_start scip-java maven . . snapshots/getX(). +//⌄ enclosing_range_start scip-java maven . . snapshots/x.get(). // ⌄ enclosing_range_start local 0 // ⌄ enclosing_range_start local 1 val x = arrayListOf().forEachIndexed { i, s -> println("$i $s") } // ^ definition scip-java maven . . snapshots/x. +// kind Property // display_name x // signature_documentation // > public final val x: Unit -// ^ definition scip-java maven . . snapshots/getX(). +// ^ definition scip-java maven . . snapshots/x.get(). +// kind Method // display_name x // signature_documentation // > public get(): Unit // ^^^^^^^^^^^ reference scip-java maven . . kotlin/collections/arrayListOf(). -// ^^^^^^^^^^^^^^ reference scip-java maven . . kotlin/collections/forEachIndexed(+9). +// ^^^^^^^^^^^^^^ reference scip-java maven . . kotlin/collections/Iterable#forEachIndexed(). // ^ definition local 0 +// kind Parameter // display_name i // signature_documentation // > i: Int // ^ definition local 1 +// kind Parameter // display_name s // signature_documentation // > s: String @@ -30,47 +34,52 @@ // ⌃ enclosing_range_end local 0 // ⌃ enclosing_range_end local 1 // ⌃ enclosing_range_end scip-java maven . . snapshots/x. -// ⌃ enclosing_range_end scip-java maven . . snapshots/getX(). +// ⌃ enclosing_range_end scip-java maven . . snapshots/x.get(). //⌄ enclosing_range_start scip-java maven . . snapshots/y. -//⌄ enclosing_range_start scip-java maven . . snapshots/getY(). +//⌄ enclosing_range_start scip-java maven . . snapshots/y.get(). val y = "fdsa".run { this.toByteArray() } // ^ definition scip-java maven . . snapshots/y. +// kind Property // display_name y // signature_documentation // > public final val y: ByteArray -// ^ definition scip-java maven . . snapshots/getY(). +// ^ definition scip-java maven . . snapshots/y.get(). +// kind Method // display_name y // signature_documentation // > public get(): ByteArray // ^^^ reference scip-java maven . . kotlin/run(+1). -// ^^^^^^^^^^^ reference scip-java maven . . kotlin/text/toByteArray(). +// ^^^^^^^^^^^ reference scip-java maven . . kotlin/text/String#toByteArray(). // ⌃ enclosing_range_end scip-java maven . . snapshots/y. -// ⌃ enclosing_range_end scip-java maven . . snapshots/getY(). +// ⌃ enclosing_range_end scip-java maven . . snapshots/y.get(). //⌄ enclosing_range_start scip-java maven . . snapshots/z. -//⌄ enclosing_range_start scip-java maven . . snapshots/getZ(). +//⌄ enclosing_range_start scip-java maven . . snapshots/z.get(). // ⌄ enclosing_range_start local 2 val z = y.let { it.size } // ^ definition scip-java maven . . snapshots/z. +// kind Property // display_name z // signature_documentation // > public final val z: Int -// ^ definition scip-java maven . . snapshots/getZ(). +// ^ definition scip-java maven . . snapshots/z.get(). +// kind Method // display_name z // signature_documentation // > public get(): Int // ^ reference scip-java maven . . snapshots/y. -// ^ reference scip-java maven . . snapshots/getY(). +// ^ reference scip-java maven . . snapshots/y.get(). // ^^^ reference scip-java maven . . kotlin/let(). // ^^^^^^^^^^^ definition local 2 +// kind Parameter // display_name it // signature_documentation // > it: ByteArray // ^^ reference local 2 // ^^^^ reference scip-java maven . . kotlin/ByteArray#size. -// ^^^^ reference scip-java maven . . kotlin/ByteArray#getSize(). +// ^^^^ reference scip-java maven . . kotlin/ByteArray#size.get(). // ⌃ enclosing_range_end scip-java maven . . snapshots/z. -// ⌃ enclosing_range_end scip-java maven . . snapshots/getZ(). +// ⌃ enclosing_range_end scip-java maven . . snapshots/z.get(). // ⌃ enclosing_range_end local 2 diff --git a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/ObjectKt.kt b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/ObjectKt.kt index 3b803c7c3..6211a42e2 100644 --- a/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/ObjectKt.kt +++ b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/ObjectKt.kt @@ -10,10 +10,12 @@ //⌄ enclosing_range_start scip-java maven . . snapshots/ObjectKt#``(). object ObjectKt { // ^^^^^^^^ definition scip-java maven . . snapshots/ObjectKt# +// kind Class // display_name ObjectKt // signature_documentation // > public final object ObjectKt : Any // ^^^^^^^^ definition scip-java maven . . snapshots/ObjectKt#``(). +// kind Constructor // display_name ObjectKt // signature_documentation // > private constructor(): ObjectKt @@ -21,10 +23,12 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/ObjectKt#fail().(message) fun fail(message: String?): Nothing { // ^^^^ definition scip-java maven . . snapshots/ObjectKt#fail(). +// kind Method // display_name fail // signature_documentation // > public final fun fail(message: String?): Nothing // ^^^^^^^ definition scip-java maven . . snapshots/ObjectKt#fail().(message) +// kind Parameter // display_name message // signature_documentation // > message: String? diff --git a/settings.gradle.kts b/settings.gradle.kts index 7a3395b07..75ec29cf4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,7 @@ include( "scip-aggregator", "scip-maven-plugin", "scip-gradle-plugin", + "scip-kotlin-gradle-plugin", "scip-java", "scip-snapshots", "scip-snapshots-java-common",