From 23f635516542a7a353c1c01ab7ed2aeaae6f7676 Mon Sep 17 00:00:00 2001 From: Nicolas Guichard Date: Tue, 7 Jul 2026 13:18:22 +0200 Subject: [PATCH 01/25] Don't add project repositories when settings repositories are set This goes one bit further than a3eb2f23 and doesn't even try to set project repositories when settings repositories are set. This prevents the build from failing on projects that set repositories in Settings but don't set FAIL_ON_PROJECT_REPOS, such as the KotlinConf app. --- .../scip_java/gradle/ScipGradlePlugin.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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..8ad6ddfa7 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,11 +3,12 @@ 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 { @@ -18,18 +19,18 @@ public void apply(Project project) { } 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 = From 6a8cc6c1b0aba6838c5e2e7c04b510e3f302b674 Mon Sep 17 00:00:00 2001 From: Nicolas Guichard Date: Fri, 3 Jul 2026 14:52:12 +0200 Subject: [PATCH 02/25] Update to Kotlin 2.2.20 For scip-kotlinc: * CheckerContext.containingFile was renamed to containingFileSymbol. * FirCallableSymbol<*>.directOverriddenSymbolsSafe now takes context by context parameter. * FirCallableSymbol.callableId was made nullable and is replaced by FirCallableSymbol.callableIdForRendering for rendering purpose. * Anonymous objects are now considered as locals. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/3f4756486010cce6163b8b409441cd1e51278008 --- gradle/libs.versions.toml | 2 +- .../gradle/kotlin-jvm-toolchains/build.gradle | 2 +- .../fixtures/gradle/kotlin2/build.gradle | 2 +- .../scip_java/kotlinc/AnalyzerCheckers.kt | 78 ++++++------------- .../kotlinc/ScipTextDocumentBuilder.kt | 13 ++-- .../scip_java/kotlinc/ScipVisitor.kt | 66 +++++++--------- .../scip_java/kotlinc/test/AnalyzerTest.kt | 20 ++--- .../common/src/main/kotlin/snapshots/Class.kt | 18 ++--- 8 files changed, 83 insertions(+), 118 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d1286f3e9..1150452ba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ gradle-api = "8.11.1" junit-jupiter = "5.11.4" kctfork = "0.7.1" kotest = "6.2.1" -kotlin = "2.2.0" +kotlin = "2.2.20" kotlinx-serialization = "1.11.0" lombok = "1.18.46" maven-plugin-annotations = "3.15.2" 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..712fe1a60 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.2.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..43952f0bc 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.2.20' } kotlin { jvmToolchain(17) 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..3258bc0c6 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 @@ -134,7 +134,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 +161,13 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio ) if (klass != null) { - visitor?.visitClassReference(klass, name, context) + visitor?.visitClassReference(klass, name) } else if (callables.isNotEmpty()) { for (callable in callables) { - visitor?.visitCallableReference(callable, name, context) + visitor?.visitCallableReference(callable, name) } } else { - visitor?.visitPackage(fqName, name, context) + visitor?.visitPackage(fqName, name) } } } @@ -179,7 +179,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirClassLikeDeclaration) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] val objectKeyword = if (declaration is FirAnonymousObject) { @@ -192,7 +192,6 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio visitor?.visitClassOrObject( declaration, objectKeyword ?: getIdentifier(source), - context, enclosingSource = source, ) @@ -201,7 +200,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio val superSymbol = superType.toClassLikeSymbol(context.session) val superSource = superType.source if (superSymbol != null && superSource != null) { - visitor?.visitClassReference(superSymbol, superSource, context) + visitor?.visitClassReference(superSymbol, superSource) } } } @@ -212,7 +211,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirConstructor) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] if (declaration.isPrimary) { @@ -238,14 +237,12 @@ 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, ) } @@ -256,12 +253,11 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirSimpleFunction) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] visitor?.visitNamedFunction( declaration, getIdentifier(source), - context, enclosingSource = source, ) @@ -270,7 +266,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio if ( klass != null && klassSource != null && klassSource.kind !is KtFakeSourceElementKind ) { - visitor?.visitClassReference(klass, getIdentifier(klassSource), context) + visitor?.visitClassReference(klass, getIdentifier(klassSource)) } } } @@ -280,9 +276,9 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirAnonymousFunction) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] - visitor?.visitNamedFunction(declaration, source, context, enclosingSource = source) + visitor?.visitNamedFunction(declaration, source, enclosingSource = source) } } @@ -290,21 +286,16 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirProperty) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] - visitor?.visitProperty( - declaration, - getIdentifier(source), - context, - enclosingSource = source, - ) + visitor?.visitProperty(declaration, getIdentifier(source), 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?.visitClassReference(klass, getIdentifier(klassSource)) } } } @@ -313,21 +304,16 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirValueParameter) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] - visitor?.visitParameter( - declaration, - getIdentifier(source), - context, - enclosingSource = source, - ) + visitor?.visitParameter(declaration, getIdentifier(source), 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?.visitClassReference(klass, getIdentifier(klassSource)) } } } @@ -336,12 +322,11 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirTypeParameter) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] visitor?.visitTypeParameter( declaration, getIdentifier(source), - context, enclosingSource = source, ) } @@ -351,14 +336,9 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirTypeAlias) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] - visitor?.visitTypeAlias( - declaration, - getIdentifier(source), - context, - enclosingSource = source, - ) + visitor?.visitTypeAlias(declaration, getIdentifier(source), enclosingSource = source) } } @@ -367,7 +347,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirPropertyAccessor) { val source = declaration.source ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] val identifierSource = if (declaration.isGetter) { @@ -382,12 +362,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio getIdentifier(source) } - visitor?.visitPropertyAccessor( - declaration, - identifierSource, - context, - enclosingSource = source, - ) + visitor?.visitPropertyAccessor(declaration, identifierSource, enclosingSource = source) } } @@ -401,12 +376,11 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio return } - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] visitor?.visitSimpleNameExpression( calleeReference, getIdentifier(calleeReference.source ?: source), - context, ) val resolvedSymbol = calleeReference.resolvedSymbol @@ -420,7 +394,6 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio visitor?.visitClassReference( referencedKlass, getIdentifier(calleeReference.source ?: source), - context, ) } } @@ -432,14 +405,12 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio visitor?.visitCallableReference( it, getIdentifier(calleeReference.source ?: source), - context, ) } resolvedSymbol.setterSymbol?.let { visitor?.visitCallableReference( it, getIdentifier(calleeReference.source ?: source), - context, ) } } @@ -454,13 +425,12 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio val source = typeRef.source ?: return val classSymbol = expression.conversionTypeRef.toClassLikeSymbol(context.session) ?: return - val ktFile = context.containingFile?.sourceFile ?: return + val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] visitor?.visitClassReference( classSymbol, getIdentifier(expression.conversionTypeRef.source ?: source), - context, ) } } 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..55f353fab 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 @@ -39,26 +39,26 @@ class ScipTextDocumentBuilder( 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 +68,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 { @@ -184,13 +184,14 @@ 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 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() 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..d5d668864 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 @@ -33,10 +33,10 @@ class ScipVisitor( fun build(): Document = documentBuilder.build() + context(context: CheckerContext) private fun Sequence?.emitAll( element: KtSourceElement, isDefinition: Boolean, - context: CheckerContext, enclosingSource: KtSourceElement? = null, ): List? = this?.onEach { (firBasedSymbol, symbol) -> @@ -45,7 +45,6 @@ class ScipVisitor( symbol, element, isDefinition, - context, enclosingSource, ) } @@ -55,132 +54,127 @@ 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) } - 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) } + 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) } + 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) } + 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) } + 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) } + 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) } + 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) } + 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) } + 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) } + 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) } + 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) } } 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..55b422c31 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 @@ -502,7 +502,7 @@ class AnalyzerTest { }, scipOccurrence { role = DEFINITION - symbol = "sample/``#" + symbol = "local 1" range { startLine = 7 startCharacter = 12 @@ -518,7 +518,7 @@ class AnalyzerTest { }, scipOccurrence { role = DEFINITION - symbol = "sample/``#``()." + symbol = "local 2" range { startLine = 7 startCharacter = 12 @@ -544,7 +544,7 @@ class AnalyzerTest { }, scipOccurrence { role = DEFINITION - symbol = "sample/``#foo()." + symbol = "local 3" range { startLine = 8 startCharacter = 21 @@ -560,7 +560,7 @@ class AnalyzerTest { }, scipOccurrence { role = DEFINITION - symbol = "sample/``#" + symbol = "local 5" range { startLine = 10 startCharacter = 12 @@ -576,7 +576,7 @@ class AnalyzerTest { }, scipOccurrence { role = DEFINITION - symbol = "sample/``#``()." + symbol = "local 6" range { startLine = 10 startCharacter = 12 @@ -602,7 +602,7 @@ class AnalyzerTest { }, scipOccurrence { role = DEFINITION - symbol = "sample/``#foo()." + symbol = "local 7" range { startLine = 11 startCharacter = 21 @@ -627,25 +627,25 @@ class AnalyzerTest { signatureText = "public abstract interface Interface : Any" }, scipSymbol { - symbol = "sample/``#" + symbol = "local 1" displayName = "" signatureText = "object : Interface" addOverriddenSymbols("sample/Interface#") }, scipSymbol { - symbol = "sample/``#foo()." + symbol = "local 3" displayName = "foo" signatureText = "public open override fun foo(): Unit" addOverriddenSymbols("sample/Interface#foo().") }, scipSymbol { - symbol = "sample/``#" + symbol = "local 5" displayName = "" signatureText = "object : Interface" addOverriddenSymbols("sample/Interface#") }, scipSymbol { - symbol = "sample/``#foo()." + symbol = "local 7" displayName = "foo" signatureText = "public open override fun foo(): Unit" addOverriddenSymbols("sample/Interface#foo().") 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..3ec43a153 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 @@ -75,29 +75,29 @@ // 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 // display_name // signature_documentation // > object : Any -// ^^^^^^ definition scip-java maven . . snapshots/``#``(). +// ^^^^^^ definition local 1 // 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 // display_name doStuff // signature_documentation // > public final fun doStuff(): Unit -// ⌃ enclosing_range_end scip-java maven . . snapshots/``#doStuff(). +// ⌃ 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 local 0 +// ⌃ enclosing_range_end local 1 // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#``(+1). constructor() : this(1, "") From e8deeffadc1053c123db28c89975732e7b8b463c Mon Sep 17 00:00:00 2001 From: Nicolas Guichard Date: Fri, 3 Jul 2026 15:14:32 +0200 Subject: [PATCH 03/25] scip-kotlinc: Populate SymbolInformation.Kind This will allow us to distinguish between method and constructors, and get better kind information for locals. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/6affaf7a525cea82a018cccf8d58bbedcef8a8e1 --- .../kotlinc/ScipTextDocumentBuilder.kt | 20 +++++++++++++++++++ .../scip_java/kotlinc/test/AnalyzerTest.kt | 19 ++++++++++++++++++ .../scip_java/kotlinc/test/ScipBuilders.kt | 2 ++ .../scip_java/kotlinc/test/ScipSymbolsTest.kt | 3 +++ .../common/src/main/kotlin/snapshots/Class.kt | 17 ++++++++++++++++ .../main/kotlin/snapshots/CompanionOwner.kt | 6 ++++++ .../src/main/kotlin/snapshots/Docstrings.kt | 5 +++++ .../src/main/kotlin/snapshots/Functions.kt | 2 ++ .../main/kotlin/snapshots/Implementations.kt | 17 ++++++++++++++++ .../src/main/kotlin/snapshots/Lambdas.kt | 9 +++++++++ .../src/main/kotlin/snapshots/ObjectKt.kt | 4 ++++ 11 files changed, 104 insertions(+) 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 55f353fab..dab8f500d 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 @@ -84,6 +88,7 @@ class ScipTextDocumentBuilder( } docComment(firBasedSymbol.fir)?.let { documentation += it } } + this.kind = scipKind(firBasedSymbol?.fir) for (parent in supers) { relationships += relationship { this.symbol = parent.toString() @@ -159,6 +164,21 @@ class ScipTextDocumentBuilder( return stripKdoc(kdoc).ifEmpty { null } } + private fun scipKind(element: FirElement?): Kind = + when (element) { + is FirClass if element.isInterface -> Kind.Interface + 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 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 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 55b422c31..338b41b2d 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 @@ -15,6 +15,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 +108,13 @@ class AnalyzerTest { arrayOf( scipSymbol { symbol = "sample/Banana#" + kind = Kind.Class displayName = "Banana" signatureText = "public final class Banana : Any" }, scipSymbol { symbol = "sample/Banana#foo()." + kind = Kind.Method displayName = "foo" signatureText = "public final fun foo(): Unit" }, @@ -279,21 +282,25 @@ class AnalyzerTest { arrayOf( scipSymbol { symbol = "sample/foo()." + kind = Kind.Method displayName = "foo" signatureText = "public final fun foo(): Unit" }, scipSymbol { symbol = "local 0" + kind = Kind.Class displayName = "LocalClass" signatureText = "local final class LocalClass : Any" }, scipSymbol { symbol = "local 1" + kind = Kind.Constructor displayName = "LocalClass" signatureText = "public constructor(): LocalClass" }, scipSymbol { symbol = "local 2" + kind = Kind.Method displayName = "localClassMethod" signatureText = "public final fun localClassMethod(): Unit" }, @@ -410,22 +417,26 @@ class AnalyzerTest { arrayOf( scipSymbol { symbol = "sample/Interface#" + kind = Kind.Interface displayName = "Interface" signatureText = "public abstract interface Interface : Any" }, scipSymbol { symbol = "sample/Interface#foo()." + kind = Kind.Method displayName = "foo" signatureText = "public abstract fun foo(): Unit\n" }, scipSymbol { symbol = "sample/Class#" + kind = Kind.Class displayName = "Class" signatureText = "public final class Class : Interface" addOverriddenSymbols("sample/Interface#") }, scipSymbol { symbol = "sample/Class#foo()." + kind = Kind.Method displayName = "foo" signatureText = "public open override fun foo(): Unit" addOverriddenSymbols("sample/Interface#foo().") @@ -623,29 +634,34 @@ class AnalyzerTest { arrayOf( scipSymbol { symbol = "sample/Interface#" + kind = Kind.Interface displayName = "Interface" signatureText = "public abstract interface Interface : Any" }, scipSymbol { symbol = "local 1" + kind = Kind.Class displayName = "" signatureText = "object : Interface" addOverriddenSymbols("sample/Interface#") }, scipSymbol { symbol = "local 3" + kind = Kind.Method displayName = "foo" signatureText = "public open override fun foo(): Unit" addOverriddenSymbols("sample/Interface#foo().") }, scipSymbol { symbol = "local 5" + kind = Kind.Class displayName = "" signatureText = "object : Interface" addOverriddenSymbols("sample/Interface#") }, scipSymbol { symbol = "local 7" + kind = Kind.Method displayName = "foo" signatureText = "public open override fun foo(): Unit" addOverriddenSymbols("sample/Interface#foo().") @@ -1300,6 +1316,7 @@ class AnalyzerTest { arrayOf( scipSymbol { symbol = "hello/sample/Apple#" + kind = Kind.Class displayName = "Apple" signatureText = "public final class Apple : Any" } @@ -1385,11 +1402,13 @@ class AnalyzerTest { arrayOf( scipSymbol { symbol = "sample/Banana#" + kind = Kind.Class displayName = "Banana" signatureText = "public final class Banana : Any" }, scipSymbol { symbol = "sample/Banana#foo()." + kind = Kind.Method displayName = "foo" signatureText = "public final fun foo(): Unit" }, 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..a7d549dd7 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,7 @@ class ScipOccurrenceBuilder { @ScipBuilderDsl class ScipSymbolInformationBuilder { var symbol: String = "" + var kind: SymbolInformation.Kind = SymbolInformation.Kind.UnspecifiedKind var displayName: String = "" var signatureText: String? = null private val docs = mutableListOf() @@ -95,6 +96,7 @@ class ScipSymbolInformationBuilder { internal fun build(): SymbolInformation = symbolInformation { symbol = this@ScipSymbolInformationBuilder.symbol + kind = this@ScipSymbolInformationBuilder.kind 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..e0865cd2f 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 @@ -775,12 +776,14 @@ class ScipSymbolsTest { listOf( scipSymbol { symbol = "x." + kind = Kind.Property displayName = "x" signatureText = "public final val x: String" documentation("hello world\n test content") }, scipSymbol { symbol = "getX()." + kind = Kind.Method displayName = "x" signatureText = "public get(): String" documentation("hello world\n test content") 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 3ec43a153..e24088f4b 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 @@ -11,37 +11,45 @@ // ⌄ 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(). +// kind Method // display_name banana // signature_documentation // > private get(): Int // ^^^^^^ definition scip-java maven . . snapshots/Class#setBanana(). +// kind Method // display_name banana // signature_documentation // > private set(value: Int): Unit // ^^^^^^ definition scip-java maven . . snapshots/Class#setBanana().(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 @@ -68,10 +76,12 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Class#getAsdf(). 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(). +// kind Method // display_name asdf // signature_documentation // > public get(): Any @@ -79,16 +89,19 @@ // ⌄ enclosing_range_start local 1 object { // ^^^^^^ definition local 0 +// kind Class // display_name // signature_documentation // > object : Any // ^^^^^^ definition local 1 +// kind Constructor // display_name // signature_documentation // > private constructor(): // ⌄ enclosing_range_start local 2 fun doStuff() = Unit // ^^^^^^^ definition local 2 +// kind Method // display_name doStuff // signature_documentation // > public final fun doStuff(): Unit @@ -102,6 +115,7 @@ // ⌄ 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 +125,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,6 +142,7 @@ // ⌄ 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 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..f16b420f1 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,10 +5,12 @@ //⌄ 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 @@ -16,16 +18,19 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/CompanionOwner#Companion#``(). companion object { // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 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,6 +43,7 @@ // ⌄ 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 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..5a6005427 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,6 +35,7 @@ //⌄ 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 @@ -39,10 +43,12 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Animal#getFavoriteNumber(). 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(). +// kind Method // display_name favoriteNumber // signature_documentation // > public get(): Int @@ -52,6 +58,7 @@ // ⌄ 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,6 +85,7 @@ // ⌄ 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 @@ -83,6 +93,7 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Bird#getFavoriteNumber(). get() = 42 // ^^^ definition scip-java maven . . snapshots/Bird#getFavoriteNumber(). +// kind Method // display_name favoriteNumber // signature_documentation // > public get(): Int @@ -92,6 +103,7 @@ // ⌄ 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,6 +133,7 @@ // ⌄ 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 @@ -126,6 +141,7 @@ // ⌄ enclosing_range_start scip-java maven . . snapshots/Seagull#getFavoriteNumber(). get() = 1337 // ^^^ definition scip-java maven . . snapshots/Seagull#getFavoriteNumber(). +// kind Method // display_name favoriteNumber // signature_documentation // > public get(): Int @@ -134,6 +150,7 @@ // ⌄ 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..af772e260 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 @@ -7,20 +7,24 @@ // ⌄ 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(). +// 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). // ^ definition local 0 +// kind Parameter // display_name i // signature_documentation // > i: Int // ^ definition local 1 +// kind Parameter // display_name s // signature_documentation // > s: String @@ -36,10 +40,12 @@ //⌄ enclosing_range_start scip-java maven . . snapshots/getY(). 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(). +// kind Method // display_name y // signature_documentation // > public get(): ByteArray @@ -53,10 +59,12 @@ // ⌄ 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(). +// kind Method // display_name z // signature_documentation // > public get(): Int @@ -64,6 +72,7 @@ // ^ reference scip-java maven . . snapshots/getY(). // ^^^ reference scip-java maven . . kotlin/let(). // ^^^^^^^^^^^ definition local 2 +// kind Parameter // display_name it // signature_documentation // > it: ByteArray 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? From df1a7fcfd2bd9e258bbadfb6c5872d6a80758404 Mon Sep 17 00:00:00 2001 From: Nicolas Guichard Date: Fri, 3 Jul 2026 15:24:09 +0200 Subject: [PATCH 04/25] scip-kotlinc: Rework getters and setters to be property children This changes the symbols of getters and setters to be `x.get().` and `x.set().` instead of `getX().` and `setX().`. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/7b4bd701b8cfc83855f0e336fb81588d04a19741 --- .../scip_java/kotlinc/SymbolsCache.kt | 12 ++----- .../scip_java/kotlinc/test/ScipSymbolsTest.kt | 24 +++++++------- .../common/src/main/kotlin/snapshots/Class.kt | 32 +++++++++---------- .../main/kotlin/snapshots/Implementations.kt | 18 +++++------ .../src/main/kotlin/snapshots/Lambdas.kt | 22 ++++++------- 5 files changed, 51 insertions(+), 57 deletions(-) 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..c7eb0f9a6 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 @@ -14,7 +14,6 @@ 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.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 @@ -115,6 +114,7 @@ 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) } @@ -142,15 +142,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 -> 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 e0865cd2f..b4c7035b8 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 @@ -276,7 +276,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "getX()." + symbol = "x.get()." range { startLine = 0 startCharacter = 4 @@ -287,7 +287,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "setX()." + symbol = "x.set()." range { startLine = 0 startCharacter = 4 @@ -328,7 +328,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "setX()." + symbol = "x.set()." range { startLine = 0 startCharacter = 4 @@ -342,7 +342,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "getX()." + symbol = "x.get()." range { startLine = 1 startCharacter = 4 @@ -388,7 +388,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "getX()." + symbol = "x.get()." range { startLine = 0 startCharacter = 4 @@ -402,7 +402,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "setX()." + symbol = "x.set()." range { startLine = 1 startCharacter = 4 @@ -449,7 +449,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "getX()." + symbol = "x.get()." range { startLine = 1 startCharacter = 4 @@ -465,7 +465,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "setX()." + symbol = "x.set()." range { startLine = 2 startCharacter = 4 @@ -528,7 +528,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "Test#getSample()." + symbol = "Test#sample.get()." range { startLine = 0 startCharacter = 15 @@ -542,7 +542,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = DEFINITION - symbol = "Test#setSample()." + symbol = "Test#sample.set()." range { startLine = 0 startCharacter = 15 @@ -576,7 +576,7 @@ class ScipSymbolsTest { }, scipOccurrence { role = REFERENCE - symbol = "Test#getSample()." + symbol = "Test#sample.get()." range { startLine = 2 startCharacter = 16 @@ -782,7 +782,7 @@ class ScipSymbolsTest { documentation("hello world\n test content") }, scipSymbol { - symbol = "getX()." + symbol = "x.get()." kind = Kind.Method displayName = "x" signatureText = "public get(): String" 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 e24088f4b..b5da6c141 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,9 +5,9 @@ // ⌄ 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# @@ -32,17 +32,17 @@ // 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 @@ -56,9 +56,9 @@ // ^^^^^^ 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) { @@ -73,14 +73,14 @@ } // ⌄ 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 @@ -108,7 +108,7 @@ // ⌃ 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/Class#asdf.get(). // ⌃ enclosing_range_end local 0 // ⌃ enclosing_range_end local 1 @@ -151,12 +151,12 @@ 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/Implementations.kt b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Implementations.kt index 5a6005427..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 @@ -40,21 +40,21 @@ // 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(). @@ -90,15 +90,15 @@ // 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 { @@ -138,15 +138,15 @@ // 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(). 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 af772e260..ffcfb7682 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,7 +2,7 @@ // ^^^^^^^^^ 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") } @@ -11,7 +11,7 @@ // 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 @@ -34,17 +34,17 @@ // ⌃ 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 @@ -52,10 +52,10 @@ // ^^^ reference scip-java maven . . kotlin/run(+1). // ^^^^^^^^^^^ reference scip-java maven . . kotlin/text/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. @@ -63,13 +63,13 @@ // 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 @@ -78,8 +78,8 @@ // > 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 From d199e2156e9360549e2036e517f9d42effff9f9c Mon Sep 17 00:00:00 2001 From: Nicolas Guichard Date: Fri, 3 Jul 2026 15:30:47 +0200 Subject: [PATCH 05/25] scip-kotlinc: Add enclosing_symbol field This will allow us to get the parent symbol for locals as well. For non-locals, it should be equal to the current symbol minus the last segment. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/63c9b6582e081f62b2cc736c6c640b75b334185a --- .../kotlinc/ScipTextDocumentBuilder.kt | 3 +++ .../scip_java/kotlinc/test/AnalyzerTest.kt | 18 ++++++++++++++++++ .../scip_java/kotlinc/test/ScipBuilders.kt | 2 ++ .../scip_java/kotlinc/test/ScipSymbolsTest.kt | 2 ++ 4 files changed, 25 insertions(+) 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 dab8f500d..3ed4578a5 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 @@ -89,6 +89,9 @@ 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() 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 338b41b2d..ade938cfe 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 @@ -109,12 +109,14 @@ class AnalyzerTest { 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" }, @@ -283,24 +285,28 @@ class AnalyzerTest { 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" }, @@ -418,18 +424,21 @@ class AnalyzerTest { 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#") @@ -437,6 +446,7 @@ class AnalyzerTest { scipSymbol { symbol = "sample/Class#foo()." kind = Kind.Method + enclosingSymbol = "sample/Class#" displayName = "foo" signatureText = "public open override fun foo(): Unit" addOverriddenSymbols("sample/Interface#foo().") @@ -635,12 +645,14 @@ class AnalyzerTest { 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#") @@ -648,6 +660,7 @@ class AnalyzerTest { scipSymbol { symbol = "local 3" kind = Kind.Method + enclosingSymbol = "local 1" displayName = "foo" signatureText = "public open override fun foo(): Unit" addOverriddenSymbols("sample/Interface#foo().") @@ -655,6 +668,7 @@ class AnalyzerTest { scipSymbol { symbol = "local 5" kind = Kind.Class + enclosingSymbol = "local 4" displayName = "" signatureText = "object : Interface" addOverriddenSymbols("sample/Interface#") @@ -662,6 +676,7 @@ class AnalyzerTest { scipSymbol { symbol = "local 7" kind = Kind.Method + enclosingSymbol = "local 5" displayName = "foo" signatureText = "public open override fun foo(): Unit" addOverriddenSymbols("sample/Interface#foo().") @@ -1317,6 +1332,7 @@ class AnalyzerTest { scipSymbol { symbol = "hello/sample/Apple#" kind = Kind.Class + enclosingSymbol = "hello/sample/" displayName = "Apple" signatureText = "public final class Apple : Any" } @@ -1403,12 +1419,14 @@ class AnalyzerTest { 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" }, 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 a7d549dd7..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 @@ -77,6 +77,7 @@ class ScipOccurrenceBuilder { 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() @@ -97,6 +98,7 @@ 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 b4c7035b8..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 @@ -777,6 +777,7 @@ class ScipSymbolsTest { scipSymbol { symbol = "x." kind = Kind.Property + enclosingSymbol = "_root_/" displayName = "x" signatureText = "public final val x: String" documentation("hello world\n test content") @@ -784,6 +785,7 @@ class ScipSymbolsTest { scipSymbol { symbol = "x.get()." kind = Kind.Method + enclosingSymbol = "x." displayName = "x" signatureText = "public get(): String" documentation("hello world\n test content") From 44d0d50fe8f7fc8c50cc9a12d40add8ab8065be8 Mon Sep 17 00:00:00 2001 From: Nicolas Guichard Date: Fri, 3 Jul 2026 15:31:59 +0200 Subject: [PATCH 06/25] scip-kotlinc: Ignore FirFileSymbols without warning Reduces the debug log clutter. Ported from https://github.com//mozsearch/semanticdb-kotlinc/commit/4b7afc21337cba75c2c7b724043bcc9204eb9eb0 --- .../main/kotlin/org/scip_code/scip_java/kotlinc/SymbolsCache.kt | 1 + 1 file changed, 1 insertion(+) 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 c7eb0f9a6..04469bd1b 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 @@ -158,6 +158,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 From 9dc22e64738e08c93c633772516bcdea15e9a402 Mon Sep 17 00:00:00 2001 From: Nicolas Guichard Date: Fri, 3 Jul 2026 15:33:14 +0200 Subject: [PATCH 07/25] scip-kotlinc: Clear AnalyzerCheckers.visitors after consuming them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This should reduce memory usage a bit and avoids lots of “given file is not under the sourceroot” clutter when running the tests. Ported from https://github.com//mozsearch/semanticdb-kotlinc/commit/65b1898de26c02929f1fbd445a3c9c92e8fd3bab --- .../org/scip_code/scip_java/kotlinc/PostAnalysisExtension.kt | 1 + 1 file changed, 1 insertion(+) 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..af74b5786 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 @@ -45,6 +45,7 @@ class PostAnalysisExtension( } catch (e: Exception) { handleException(e) } + AnalyzerCheckers.visitors.clear() } private fun scipShardPathForFile(file: KtSourceFile): Path? { From 9a3bd3910a73fb194f705b67bbbfd4ae1401a086 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 15:43:13 +0200 Subject: [PATCH 08/25] Update to Kotlin 2.3.10 scip-kotlinc changes: getContainingSymbol was moved from o.j.k.fir.analysis.checkers to org.jetbrains.kotlin.fir.resolve. CompilerPluginRegistrar now has a virtual pluginId which must match the CommandLineProcessor. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/cde86db9a62d29afa680de072ace1c60d117c37f --- gradle/libs.versions.toml | 4 ++-- .../fixtures/gradle/kotlin-jvm-toolchains/build.gradle | 2 +- .../src/test/resources/fixtures/gradle/kotlin2/build.gradle | 2 +- .../org/scip_code/scip_java/kotlinc/AnalyzerCheckers.kt | 2 +- .../scip_java/kotlinc/AnalyzerCommandLineProcessor.kt | 4 +++- .../org/scip_code/scip_java/kotlinc/AnalyzerRegistrar.kt | 2 ++ .../kotlin/org/scip_code/scip_java/kotlinc/SymbolsCache.kt | 2 +- .../test/kotlin/org/scip_code/scip_java/kotlinc/test/Utils.kt | 2 ++ 8 files changed, 13 insertions(+), 7 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1150452ba..ce921f61d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,9 +2,9 @@ clikt = "5.1.0" gradle-api = "8.11.1" junit-jupiter = "5.11.4" -kctfork = "0.7.1" +kctfork = "0.12.1" kotest = "6.2.1" -kotlin = "2.2.20" +kotlin = "2.3.10" kotlinx-serialization = "1.11.0" lombok = "1.18.46" maven-plugin-annotations = "3.15.2" 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 712fe1a60..13a1ab323 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.20' + id 'org.jetbrains.kotlin.jvm' version '2.3.10' } 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 43952f0bc..42f3cbd18 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.20' + id 'org.jetbrains.kotlin.jvm' version '2.3.10' } kotlin { jvmToolchain(17) 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 3258bc0c6..40675112c 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 @@ -12,7 +12,6 @@ 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.FirQualifiedAccessExpressionChecker 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.* @@ -20,6 +19,7 @@ import org.jetbrains.kotlin.fir.expressions.FirQualifiedAccessExpression import org.jetbrains.kotlin.fir.expressions.FirTypeOperatorCall 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 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..601511099 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,11 @@ val KEY_SOURCES = CompilerConfigurationKey(VAL_SOURCES) const val VAL_TARGET = "targetroot" val KEY_TARGET = CompilerConfigurationKey(VAL_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( 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..0b05005e4 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 @@ -26,6 +26,8 @@ class AnalyzerRegistrar(private val callback: (Document) -> Unit = {}) : Compile ) } + 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/SymbolsCache.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/SymbolsCache.kt index 04469bd1b..73e4be90c 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 @@ -2,13 +2,13 @@ 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.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 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..0d3da7c4a 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 @@ -200,6 +200,8 @@ fun scipVisitorAnalyzer( ) } + override val pluginId = PLUGIN_ID + override val supportsK2: Boolean get() = true } From c7c6385fe87e02cdf0af655c6764aa185e2fad55 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 15:52:32 +0200 Subject: [PATCH 09/25] scip-kotlinc: Fix compiler warnings - Remove unnecessary cast to FirClass in SymbolsCache (FirClassSymbol.fir already returns FirClass) - Use parameterless toClassLikeSymbol() overload in AnalyzerCheckers - Remove unnecessary inline modifier from test snippet Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/eb683777e27a40bee4771b5b1200aaf83ef71bae --- .../org/scip_code/scip_java/kotlinc/AnalyzerCheckers.kt | 3 +-- .../kotlin/org/scip_code/scip_java/kotlinc/SymbolsCache.kt | 4 +--- .../org/scip_code/scip_java/kotlinc/test/AnalyzerTest.kt | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) 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 40675112c..5e120bda5 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 @@ -388,8 +388,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio 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, 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 73e4be90c..bfff11f0b 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 @@ -3,7 +3,6 @@ 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.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 @@ -172,8 +171,7 @@ 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 FirClassSymbol -> containingSymbol.fir.declarations.map { it.symbol } is FirFileSymbol -> containingSymbol.fir.declarations.map { it.symbol } null -> symbol.moduleData.session.symbolProvider.getTopLevelCallableSymbols( 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 ade938cfe..7e36e926f 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 @@ -1451,7 +1451,7 @@ class AnalyzerTest { * Example method docstring * **/ - inline fun docstrings(msg: String): Int { return msg.length } + fun docstrings(msg: String): Int { return msg.length } """ .trimIndent(), ) From 81a6715e20f9ae7a690378268575386e1733b52b Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 15:57:15 +0200 Subject: [PATCH 10/25] scip-kotlinc: Fix PostAnalysisExtension to use the compilation's message collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously handleException constructed a fresh CompilerConfiguration() to retrieve the message collector key, which always fell back to PrintingMessageCollector(System.err) — ignoring whatever collector the actual compilation was configured with. Pass the real CompilerConfiguration into PostAnalysisExtension so that exception messages are routed through the same collector as all other diagnostics. Also change the severity from EXCEPTION to WARNING, since EXCEPTION is treated as isError=true by the Kotlin compiler and would cause the build to fail — contrary to the plugin's intentional "log-and-continue" behaviour. The exception test now captures compiler output via result.messages and asserts that the warning is actually emitted with the expected content, rather than only checking the exit code. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/e6b72f5267de9f28813594a5cd290437b4d67d56 --- .../scip_code/scip_java/kotlinc/AnalyzerRegistrar.kt | 1 + .../scip_java/kotlinc/PostAnalysisExtension.kt | 12 ++++++------ .../scip_code/scip_java/kotlinc/test/AnalyzerTest.kt | 4 ++++ .../org/scip_code/scip_java/kotlinc/test/Utils.kt | 1 + 4 files changed, 12 insertions(+), 6 deletions(-) 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 0b05005e4..5833d8883 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 @@ -19,6 +19,7 @@ class AnalyzerRegistrar(private val callback: (Document) -> Unit = {}) : Compile FirExtensionRegistrarAdapter.registerExtension(AnalyzerFirExtensionRegistrar(options)) IrGenerationExtension.registerExtension( PostAnalysisExtension( + configuration = configuration, sourceRoot = options.sourceroot, targetRoot = options.targetroot, callback = callback, 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 af74b5786..e814439c5 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 @@ -25,6 +25,7 @@ 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, @@ -61,11 +62,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 = @@ -74,7 +74,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/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 7e36e926f..c8f2bd055 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 @@ -807,6 +808,7 @@ class AnalyzerTest { compilerPluginRegistrars = listOf(AnalyzerRegistrar { throw Exception("sample text") }) verbose = false + messageOutputStream = java.io.OutputStream.nullOutputStream() pluginOptions = listOf( PluginOption("scip-kotlinc", "sourceroot", path.toString()), @@ -818,6 +820,8 @@ class AnalyzerTest { .compile() result.exitCode shouldBe KotlinCompilation.ExitCode.OK + result.messages shouldContain "Exception in scip-kotlin compiler plugin:" + result.messages shouldContain "sample text" } @Test 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 0d3da7c4a..5622fe594 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 @@ -193,6 +193,7 @@ fun scipVisitorAnalyzer( ) IrGenerationExtension.registerExtension( PostAnalysisExtension( + configuration = configuration, sourceRoot = sourceroot, targetRoot = Paths.get(""), callback = hook, From 05ad50607c2d63cc439efc9b2c8a15d01185e063 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 16:06:37 +0200 Subject: [PATCH 11/25] scip-kotlinc: Add test for lambda parameters This is a test for 88f6272784c5e56eb135019a4b0dda5dd0808017. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/2c1f17047f1cb4ed15e0f53332737f82cbbcdc8b --- .../scip_java/kotlinc/test/AnalyzerTest.kt | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) 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 c8f2bd055..1f37b561a 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 @@ -315,6 +315,108 @@ class AnalyzerTest { document.symbolsList.shouldContainAll(*symbols) } + @Test + fun `lambda parameters`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + fun use() { + val f = { n: Int -> n * 2 } + } + """, + ) + + val occurrences = + arrayOf( + // val f is a local variable — gets local0 via isLocalMember + scipOccurrence { + role = DEFINITION + symbol = "local 0" + range { + startLine = 3 + startCharacter = 8 + endLine = 3 + endCharacter = 9 + } + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 3 + 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 = "local 1" + range { + startLine = 3 + startCharacter = 14 + endLine = 3 + endCharacter = 15 + } + enclosingRange { + startLine = 3 + startCharacter = 14 + endLine = 3 + endCharacter = 20 + } + }, + // explicit type annotation on n emits a class reference + scipOccurrence { + role = REFERENCE + symbol = "kotlin/Int#" + range { + startLine = 3 + startCharacter = 17 + endLine = 3 + endCharacter = 20 + } + }, + // reference to n in the lambda body uses the same local symbol + scipOccurrence { + role = REFERENCE + symbol = "local 1" + range { + startLine = 3 + startCharacter = 24 + endLine = 3 + endCharacter = 25 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/use()." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "use" + signatureText = "public final fun use(): Unit" + }, + scipSymbol { + symbol = "local 0" + kind = Kind.Property + enclosingSymbol = "sample/use()." + displayName = "f" + signatureText = "local val f: (Int) -> Int" + }, + scipSymbol { + symbol = "local 1" + kind = Kind.Parameter + displayName = "n" + signatureText = "n: Int" + }, + ) + document.symbolsList.shouldContainAll(*symbols) + } + @Test fun overrides(@TempDir path: Path) { val document = From 5b0d4ae097fbde89db840b412e9f17233b93b60d Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 16:18:26 +0200 Subject: [PATCH 12/25] scip-kotlinc: Add tests for local functions and user-defined class return types - local functions: verifies that named functions declared inside a function body receive local symbols (local0, local1), that explicit return type references are emitted (kotlin/Int#), and that call-site references resolve to the same local symbol. Also adds a SemanticdbSymbolsTest entry confirming the locals counter increments correctly. - user-defined class as return type: verifies that SemanticSimple- FunctionChecker emits a REFERENCE occurrence for a user-defined class appearing in the return-type position (sample/MyClass#), exercising the returnTypeRef path that built-in types do not reach. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/ff23a7ec482dcd7ab2d79ac3004ffb91e2d92d60 --- .../scip_java/kotlinc/test/AnalyzerTest.kt | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) 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 1f37b561a..514dda5c2 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 @@ -417,6 +417,178 @@ class AnalyzerTest { document.symbolsList.shouldContainAll(*symbols) } + @Test + fun `local functions`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + fun outer() { + fun inner() {} + fun innerWithReturnType(): Int = 42 + inner() + } + """, + ) + + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/outer()." + range { + startLine = 2 + startCharacter = 4 + endLine = 2 + endCharacter = 9 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 6 + endCharacter = 1 + } + }, + // inner() — local named function gets a local symbol + scipOccurrence { + role = DEFINITION + symbol = "local 0" + range { + startLine = 3 + startCharacter = 8 + endLine = 3 + endCharacter = 13 + } + enclosingRange { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 18 + } + }, + // innerWithReturnType() — local named function with explicit return type + scipOccurrence { + role = DEFINITION + symbol = "local 1" + range { + startLine = 4 + startCharacter = 8 + endLine = 4 + endCharacter = 27 + } + enclosingRange { + startLine = 4 + startCharacter = 4 + endLine = 4 + endCharacter = 39 + } + }, + // Int return-type reference + scipOccurrence { + role = REFERENCE + symbol = "kotlin/Int#" + range { + startLine = 4 + startCharacter = 31 + endLine = 4 + endCharacter = 34 + } + }, + // call site inner() references the same local symbol + scipOccurrence { + role = REFERENCE + symbol = "local 0" + range { + 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/bar()." + range { + startLine = 4 + startCharacter = 4 + endLine = 4 + endCharacter = 7 + } + enclosingRange { + startLine = 4 + startCharacter = 0 + endLine = 4 + endCharacter = 30 + } + }, + // MyClass in the return type position generates a class reference + scipOccurrence { + role = REFERENCE + symbol = "sample/MyClass#" + range { + startLine = 4 + startCharacter = 11 + endLine = 4 + endCharacter = 18 + } + }, + ) + 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 overrides(@TempDir path: Path) { val document = From abe07513a687e968327ef1e9d9b7511b75b198f0 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 16:20:29 +0200 Subject: [PATCH 13/25] scip-kotlinc: Fix displayName() for type aliases and type parameters displayName() in SemanticdbTextDocumentBuilder fell through to `firBasedSymbol.toString()` for two symbol types, producing strings like "FirTypeAliasSymbol sample/MyAlias" and "FirTypeParameterSymbol T" instead of the short name. - Broaden the FirClassSymbol branch to FirClassLikeSymbol so that FirTypeAliasSymbol (a subtype of FirClassLikeSymbol but not of FirClassSymbol) also uses classId.shortClassName. - Add an explicit FirTypeParameterSymbol branch that returns symbol.name.asString(). Add tests that exercise the corrected paths: - typealias: first test for SemanticTypeAliasChecker; verifies the DEFINITION occurrence and SymbolInformation (including the now-correct displayName "MyAlias"). Notes that type-alias references in value declarations are not currently tracked (the property checker resolves aliases to their expansion). - type parameters: first test for SemanticTypeParameterChecker; verifies the DEFINITION occurrence and SymbolInformation (displayName "T"). Notes that T in type-annotation positions does not produce REFERENCE occurrences because toClassLikeSymbol() returns null for type parameters. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/20b1d20d53232a00388a6036cf39caf484a71b99 --- .../kotlinc/ScipTextDocumentBuilder.kt | 4 +- .../scip_java/kotlinc/test/AnalyzerTest.kt | 131 ++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) 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 3ed4578a5..ad09c9dc0 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 @@ -170,6 +170,7 @@ class ScipTextDocumentBuilder( 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 @@ -210,12 +211,13 @@ class ScipTextDocumentBuilder( @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.callableIdForRendering.callableName.asString() is FirVariableSymbol -> firBasedSymbol.name.asString() + is FirTypeParameterSymbol -> firBasedSymbol.name.asString() else -> firBasedSymbol.toString() } } 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 514dda5c2..20fcaf0f3 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 @@ -589,6 +589,137 @@ class AnalyzerTest { 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/x." + range { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 5 + } + enclosingRange { + startLine = 3 + startCharacter = 0 + endLine = 3 + endCharacter = 19 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/MyAlias#" + kind = Kind.TypeAlias + enclosingSymbol = "sample/" + displayName = "MyAlias" + signatureText = "public final typealias MyAlias = Int\n" + } + ) + document.symbolsList.shouldContainAll(*symbols) + } + + @Test + fun `type parameters`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + fun identity(x: T): T = x + """, + ) + + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/identity()." + range { + startLine = 2 + startCharacter = 8 + endLine = 2 + endCharacter = 16 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 2 + endCharacter = 29 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/identity().[T]" + range { + startLine = 2 + startCharacter = 5 + endLine = 2 + endCharacter = 6 + } + enclosingRange { + startLine = 2 + startCharacter = 5 + endLine = 2 + 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 overrides(@TempDir path: Path) { val document = From 50d70ca8b302ce06242412e54ebf3388658d9d9a Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 17:12:41 +0200 Subject: [PATCH 14/25] scip-kotlinc: Fix extension receiver, enum entry, and is/as type occurrences - Fix SemanticSimpleFunctionChecker and SemanticPropertyChecker to emit REFERENCE occurrences for extension receiver types - Add SemanticEnumEntryChecker to emit DEFINITION occurrences for enum entries (previously missing from AnalyzerDeclarationCheckers) - Fix semanticdbKind() to return Kind.ENUM_MEMBER for FirEnumEntry (FirEnumEntry extends FirVariable, which would otherwise map to Kind.LOCAL) - Fix SemanticClassReferenceExpressionChecker (is/as operators) to use the already-extracted typeRef and source locals consistently rather than re-accessing expression.conversionTypeRef on each line - Extract emitTypeRef() helper to reduce duplication across checkers; use it for supertype references in SemanticClassLikeChecker so they get the same fake-source guard as other type reference emissions - Add tests for extension receivers, enum entries, named/unnamed companion objects, string template references, and is/as type references Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/d823d350cee7d182706781e6e8c5c32582960d9b --- .../scip_java/kotlinc/AnalyzerCheckers.kt | 126 +-- .../kotlinc/ScipTextDocumentBuilder.kt | 1 + .../scip_java/kotlinc/ScipVisitor.kt | 11 + .../scip_java/kotlinc/SymbolsCache.kt | 73 +- .../scip_java/kotlinc/test/AnalyzerTest.kt | 765 +++++++++++++++++- .../common/src/main/kotlin/snapshots/Class.kt | 2 + .../main/kotlin/snapshots/CompanionOwner.kt | 11 +- .../src/main/kotlin/snapshots/Lambdas.kt | 4 +- 8 files changed, 922 insertions(+), 71 deletions(-) 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 5e120bda5..e5675daaa 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 @@ -11,11 +11,14 @@ 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.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.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.FirQualifiedAccessExpression +import org.jetbrains.kotlin.fir.expressions.FirResolvedQualifier import org.jetbrains.kotlin.fir.expressions.FirTypeOperatorCall import org.jetbrains.kotlin.fir.references.FirResolvedNamedReference import org.jetbrains.kotlin.fir.resolve.calls.FirSyntheticFunctionSymbol @@ -24,6 +27,7 @@ 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 @@ -36,6 +40,15 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio 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 @@ -48,8 +61,10 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio Set = setOf(SemanticQualifiedAccessExpressionChecker()) - override val typeOperatorCallCheckers: - Set = + override val resolvedQualifierCheckers: Set = + setOf(SemanticResolvedQualifierChecker()) + + override val typeOperatorCallCheckers: Set = setOf(SemanticClassReferenceExpressionChecker()) } @@ -71,6 +86,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio override val typeAliasCheckers: Set = setOf(SemanticTypeAliasChecker()) override val propertyAccessorCheckers: Set = setOf(SemanticPropertyAccessorChecker()) + override val enumEntryCheckers: Set = setOf(SemanticEnumEntryChecker()) } private class SemanticFileChecker(private val sourceroot: Path) : @@ -189,19 +205,32 @@ 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), + 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) - } + visitor?.emitTypeRef(superType) } } } @@ -260,14 +289,8 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio getIdentifier(source), 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)) - } + visitor?.emitTypeRef(declaration.returnTypeRef) + declaration.receiverParameter?.typeRef?.let { visitor?.emitTypeRef(it) } } } @@ -289,14 +312,8 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] visitor?.visitProperty(declaration, getIdentifier(source), 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)) - } + visitor?.emitTypeRef(declaration.returnTypeRef) + declaration.receiverParameter?.typeRef?.let { visitor?.emitTypeRef(it) } } } @@ -307,14 +324,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] visitor?.visitParameter(declaration, getIdentifier(source), 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)) - } + visitor?.emitTypeRef(declaration.returnTypeRef) } } @@ -366,6 +376,29 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } } + private class SemanticEnumEntryChecker : 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 = visitors[ktFile] + visitor?.visitEnumEntry(declaration, getIdentifier(source), enclosingSource = source) + } + } + + private class SemanticResolvedQualifierChecker : + 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 = visitors[ktFile] + visitor?.visitClassReference(symbol, getIdentifier(source)) + } + } + private class SemanticQualifiedAccessExpressionChecker : FirQualifiedAccessExpressionChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) @@ -378,10 +411,8 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] - visitor?.visitSimpleNameExpression( - calleeReference, - getIdentifier(calleeReference.source ?: source), - ) + val identifierSource = getIdentifier(calleeReference.source ?: source) + visitor?.visitSimpleNameExpression(calleeReference, identifierSource) val resolvedSymbol = calleeReference.resolvedSymbol if ( @@ -390,10 +421,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio ) { val referencedKlass = resolvedSymbol.resolvedReturnType.toClassLikeSymbol() if (referencedKlass != null) { - visitor?.visitClassReference( - referencedKlass, - getIdentifier(calleeReference.source ?: source), - ) + visitor?.visitClassReference(referencedKlass, identifierSource) } } @@ -401,16 +429,10 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio // symbols if (resolvedSymbol is FirPropertySymbol) { resolvedSymbol.getterSymbol?.let { - visitor?.visitCallableReference( - it, - getIdentifier(calleeReference.source ?: source), - ) + visitor?.visitCallableReference(it, identifierSource) } resolvedSymbol.setterSymbol?.let { - visitor?.visitCallableReference( - it, - getIdentifier(calleeReference.source ?: source), - ) + visitor?.visitCallableReference(it, identifierSource) } } } @@ -422,15 +444,11 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio override fun check(expression: FirTypeOperatorCall) { val typeRef = expression.conversionTypeRef val source = typeRef.source ?: return - val classSymbol = - expression.conversionTypeRef.toClassLikeSymbol(context.session) ?: return + val classSymbol = typeRef.toClassLikeSymbol(context.session) ?: return val ktFile = context.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] - visitor?.visitClassReference( - classSymbol, - getIdentifier(expression.conversionTypeRef.source ?: source), - ) + visitor?.visitClassReference(classSymbol, getIdentifier(source)) } } } 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 ad09c9dc0..948497611 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 @@ -177,6 +177,7 @@ class ScipTextDocumentBuilder( 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 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 d5d668864..5cfbe6c5a 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 @@ -168,6 +168,17 @@ class ScipVisitor( .emitAll(source, isDefinition = true, enclosingSource) } + context(context: CheckerContext) + fun visitEnumEntry( + firEnumEntry: FirEnumEntry, + source: KtSourceElement, + enclosingSource: KtSourceElement? = null, + ) { + cache[firEnumEntry.symbol] + .with(firEnumEntry.symbol) + .emitAll(source, isDefinition = true, enclosingSource) + } + context(context: CheckerContext) fun visitSimpleNameExpression( firResolvedNamedReference: FirResolvedNamedReference, 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 bfff11f0b..b628e4527 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 @@ -12,6 +12,8 @@ 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.scip_code.scip_java.kotlinc.ScipSymbolDescriptor.Kind import org.scip_code.scip_java.shared.LocalSymbolsCache as SharedLocalSymbolsCache @@ -116,8 +118,27 @@ class GlobalSymbolsCache(testing: Boolean = false) : Iterable { 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 @@ -172,12 +193,42 @@ class GlobalSymbolsCache(testing: Boolean = false) : Iterable { val siblings = when (val containingSymbol = symbol.getContainingSymbol(session)) { is FirClassSymbol -> containingSymbol.fir.declarations.map { it.symbol } - is FirFileSymbol -> containingSymbol.fir.declarations.map { it.symbol } - null -> - symbol.moduleData.session.symbolProvider.getTopLevelCallableSymbols( - symbol.packageFqName(), - symbol.name, - ) + 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 "()" } @@ -194,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 20fcaf0f3..76a039d51 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 @@ -1868,10 +1868,773 @@ class AnalyzerTest { document.assertDocumentation("sample/docstrings().", "Example method docstring") } + @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 + + fun String.foo(): Int = 42 + fun use(s: String) = s.foo() + """, + ) + + 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) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/String#foo()." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "foo" + signatureText = "public final fun String.foo(): Int" + } + ) + document.symbolsList.shouldContainAll(*symbols) + } + + @Test + fun `extension property receiver type reference`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + val Int.asString: String get() = this.toString() + fun use() = 42.asString + """, + ) + + 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) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/Int#asString." + kind = Kind.Property + enclosingSymbol = "sample/" + displayName = "asString" + signatureText = "public final val Int.asString: String" + } + ) + document.symbolsList.shouldContainAll(*symbols) + } + + @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() {} + } + fun MyClass.foo(x: Int) {} + """, + ) + + 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) + } + + @Test + fun `enum entry definitions`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + enum class Color { RED, GREEN, BLUE } + + fun useEnum(): Color = Color.RED + """, + ) + + 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) + + 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) + } + + @Test + fun `named object declarations`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + object MySingleton { + fun hello(): String = "hi" + } + fun use() = MySingleton.hello() + """, + ) + + 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) + + 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) + } + + @Test + fun `companion object`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + class Foo { + companion object Factory { + fun create(): Foo = Foo() + } + } + fun use() = Foo.Factory.create() + """, + ) + + 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) + + 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) + } + + @Test + fun `unnamed companion object`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + 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 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 `string template references`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + package sample + + fun greet(name: String) = "Hello, ${'$'}name!" + """, + ) + + val occurrences = + arrayOf( + scipOccurrence { + role = DEFINITION + symbol = "sample/greet()." + range { + 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 = "sample/greet().(name)" + range { + 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) + } + 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-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 b5da6c141..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 @@ -105,6 +105,7 @@ // display_name doStuff // signature_documentation // > public final fun doStuff(): Unit +// ^^^^ reference scip-java maven . . kotlin/Unit# // ⌃ enclosing_range_end local 2 } // ⌃ enclosing_range_end scip-java maven . . snapshots/Class#asdf. @@ -148,6 +149,7 @@ // > 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. 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 f16b420f1..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 @@ -17,11 +17,11 @@ // ⌄ 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# -// kind Class -// 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 @@ -48,6 +48,7 @@ // 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/Lambdas.kt b/scip-snapshots/expected/kotlin/common/scip-snapshots/cases/kotlin/common/src/main/kotlin/snapshots/Lambdas.kt index ffcfb7682..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 @@ -17,7 +17,7 @@ // 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 @@ -50,7 +50,7 @@ // 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/y.get(). From 2681cbeb585160bbb3410c522e60c4cf4955b456 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 17:22:24 +0200 Subject: [PATCH 15/25] scip-kotlinc: Add tests for multiple supertypes and overload disambiguators - multiple supertype references: verifies that SemanticClassLikeChecker emits REFERENCE occurrences for every entry in superTypeRefs, and that SymbolInformation.overriddenSymbols is populated for all supertypes - three-way overload disambiguator: extends the existing two-overload coverage to three, verifying the (+1)/(+2) suffix counting logic in methodDisambiguator() Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/24b64333c0ac45cd0a6bb1021423881133d2e755 --- .../scip_java/kotlinc/test/AnalyzerTest.kt | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) 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 76a039d51..1b91275b9 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 @@ -1174,6 +1174,13 @@ class AnalyzerTest { else -> x as Float } } + + class Wrapper + fun classify(x: Any) { + if (x is Wrapper) {} + val s = x as? String + val w = x as Wrapper + } """, ) @@ -1199,6 +1206,36 @@ class AnalyzerTest { 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) } @@ -2631,6 +2668,216 @@ class AnalyzerTest { 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 = "sample/Person#" + range { + startLine = 4 + startCharacter = 6 + endLine = 4 + endCharacter = 12 + } + enclosingRange { + startLine = 4 + startCharacter = 0 + endLine = 4 + endCharacter = 31 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/Named#" + range { + startLine = 4 + startCharacter = 15 + endLine = 4 + endCharacter = 20 + } + }, + 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 = "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 `three-way overload disambiguator`(@TempDir path: Path) { + val document = + compileScip( + path, + """ + 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 = DEFINITION + symbol = "sample/add()." + range { + startLine = 2 + startCharacter = 4 + endLine = 2 + endCharacter = 7 + } + enclosingRange { + startLine = 2 + startCharacter = 0 + endLine = 2 + endCharacter = 36 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/add(+1)." + range { + startLine = 3 + startCharacter = 4 + endLine = 3 + endCharacter = 7 + } + enclosingRange { + startLine = 3 + startCharacter = 0 + endLine = 3 + endCharacter = 45 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/add(+2)." + range { + startLine = 4 + startCharacter = 4 + endLine = 4 + endCharacter = 7 + } + enclosingRange { + startLine = 4 + startCharacter = 0 + endLine = 4 + endCharacter = 45 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/add()." + range { + startLine = 6 + startCharacter = 4 + endLine = 6 + endCharacter = 7 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/add(+1)." + range { + startLine = 7 + startCharacter = 4 + endLine = 7 + endCharacter = 7 + } + }, + scipOccurrence { + role = REFERENCE + symbol = "sample/add(+2)." + range { + startLine = 8 + startCharacter = 4 + endLine = 8 + endCharacter = 7 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + + val symbols = + arrayOf( + scipSymbol { + symbol = "sample/add()." + kind = Kind.Method + enclosingSymbol = "sample/" + displayName = "add" + signatureText = "public final fun add(x: Int, y: Int): Int" + }, + scipSymbol { + 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) + } + private fun Document.assertDocumentation(symbol: String, expectedDocumentation: String) { val info = this.symbolsList.find { it.symbol == symbol } From f489b7ff24d11ee463f003734a5d083ce0eccb07 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 17:24:25 +0200 Subject: [PATCH 16/25] scip-kotlinc: Fix misleading LineMap docstrings The "non-0-based" phrasing predates this branch but is misleading: line helpers return 1-based values (callers subtract 1 for protobuf), while column helpers already return 0-based values. Make the docstrings say what they actually return. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/305951777e98bfe80dfa5a2cf159fd940dab3bec --- .../kotlin/org/scip_code/scip_java/kotlinc/LineMap.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 From 6ebc606ace2f155d9b4124ac556596ac36c53087 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 17:28:51 +0200 Subject: [PATCH 17/25] scip-kotlinc: Extend enclosing_range test coverage Add two test cases: - enum entry with body (Op.PLUS { override ... }): the body is modeled as a synthetic anonymous subclass, so the entry gets a multi-line enclosing_range and the overridden member surfaces as a local symbol rather than a sample/Op#PLUS.apply() global. Asserts both. - multi-line generic class declaration: general coverage we lacked for generic classes. Asserts enclosing_range for the class, its type parameter, and a member. Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/979f1f392a82c0d3cf8c17673ee3af20bb6b73c3 --- .../scip_java/kotlinc/test/AnalyzerTest.kt | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) 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 1b91275b9..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 @@ -720,6 +720,73 @@ class AnalyzerTest { 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 = DEFINITION + symbol = "sample/Box#" + range { + startLine = 2 + startCharacter = 6 + endLine = 2 + endCharacter = 9 + } + enclosingRange { + startLine = 2 + endLine = 4 + endCharacter = 1 + } + }, + scipOccurrence { + role = DEFINITION + symbol = "sample/Box#[T]" + range { + startLine = 2 + startCharacter = 10 + endLine = 2 + 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 + } + }, + ) + document.occurrencesList.shouldContainAll(*occurrences) + } + @Test fun overrides(@TempDir path: Path) { val document = @@ -2259,6 +2326,91 @@ class AnalyzerTest { document.symbolsList.shouldContainAll(*symbols) } + @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 + }; + + 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) + + 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) + } + @Test fun `named object declarations`(@TempDir path: Path) { val document = From e940c1889767a81347387067a375320dc6f5d83e Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Fri, 3 Jul 2026 17:36:07 +0200 Subject: [PATCH 18/25] Update to Kotlin 2.3.20 Changes in scip-kotlinc: - Rename FirSimpleFunction -> FirNamedFunction in SemanticSimpleFunctionChecker - Replace removed isLocalMember with isLocalDeclaredInBlock (moved within org.jetbrains.kotlin.fir.analysis.checkers.declaration) Ported from https://github.com/mozsearch/semanticdb-kotlinc/commit/832bf44ec114c14d2c65826a920d057d120dae0c --- gradle/libs.versions.toml | 2 +- .../fixtures/gradle/kotlin-jvm-toolchains/build.gradle | 2 +- .../src/test/resources/fixtures/gradle/kotlin2/build.gradle | 2 +- .../org/scip_code/scip_java/kotlinc/AnalyzerCheckers.kt | 2 +- .../kotlin/org/scip_code/scip_java/kotlinc/SymbolsCache.kt | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce921f61d..993c81dde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ gradle-api = "8.11.1" junit-jupiter = "5.11.4" kctfork = "0.12.1" kotest = "6.2.1" -kotlin = "2.3.10" +kotlin = "2.3.20" kotlinx-serialization = "1.11.0" lombok = "1.18.46" maven-plugin-annotations = "3.15.2" 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 13a1ab323..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.3.10' + 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 42f3cbd18..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.3.10' + id 'org.jetbrains.kotlin.jvm' version '2.3.20' } kotlin { jvmToolchain(17) 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 e5675daaa..1251f3a8f 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 @@ -280,7 +280,7 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio private class SemanticSimpleFunctionChecker : 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.containingFileSymbol?.sourceFile ?: return val visitor = visitors[ktFile] 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 b628e4527..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,7 +1,7 @@ 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.declaration.isLocalDeclaredInBlock import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.FirDeclarationOrigin import org.jetbrains.kotlin.fir.declarations.utils.memberDeclarationNameOrNull @@ -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) From a2683f05a7e389d8a4700ee2bc405c446922dfb5 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 4 Sep 2026 13:47:05 +0900 Subject: [PATCH 19/25] feat: add Kotlin K2 graph snapshots --- gradle/libs.versions.toml | 1 + .../scip_java/gradle/ScipGradlePlugin.java | 45 +- scip-java/build.gradle.kts | 5 + .../org/scip_code/scip_java/Embedded.kt | 2 + .../scip_java/buildtools/GradleBuildTool.kt | 163 +++++- .../scip_java/commands/IndexCommand.kt | 8 + .../commands/KotlinGraphAggregateRunner.kt | 344 +++++++++++ .../src/test/kotlin/tests/BuildToolHarness.kt | 4 +- .../tests/KotlinGraphGradleBuildToolTest.kt | 254 ++++++++ .../fixtures/gradle/kotlin-graph/build.gradle | 33 ++ .../src/main/kotlin/example/GraphFixture.kt | 66 +++ .../test/kotlin/example/GraphFixtureTest.kt | 8 + scip-kotlin-gradle-plugin/build.gradle.kts | 13 + .../KotlinGraphGenerationCoordinator.java | 156 +++++ .../gradle/KotlinGraphGenerationStore.java | 544 ++++++++++++++++++ .../gradle/KotlinGraphGradlePlugin.java | 163 ++++++ .../org.scip-code.kotlin-graph.properties | 1 + .../scip_java/kotlinc/KotlinGraphShard.java | 319 ++++++++++ .../scip_java/kotlinc/AnalyzerCheckers.kt | 191 ++++-- .../kotlinc/AnalyzerCommandLineProcessor.kt | 20 + .../kotlinc/AnalyzerCompilationState.kt | 11 + .../kotlinc/AnalyzerFirExtensionRegistrar.kt | 13 +- .../kotlinc/AnalyzerParamsProvider.kt | 20 +- .../scip_java/kotlinc/AnalyzerRegistrar.kt | 23 +- .../kotlinc/KotlinGraphDocumentBuilder.kt | 503 ++++++++++++++++ .../scip_java/kotlinc/KotlinGraphMessages.kt | 51 ++ .../kotlinc/PostAnalysisExtension.kt | 28 +- .../kotlinc/ScipTextDocumentBuilder.kt | 2 +- .../scip_java/kotlinc/ScipVisitor.kt | 56 ++ .../scip_java/kotlinc/test/KotlinGraphTest.kt | 151 +++++ .../scip_code/scip_java/kotlinc/test/Utils.kt | 18 +- settings.gradle.kts | 1 + 32 files changed, 3118 insertions(+), 99 deletions(-) create mode 100644 scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphAggregateRunner.kt create mode 100644 scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-graph/build.gradle create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-graph/src/main/kotlin/example/GraphFixture.kt create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-graph/src/test/kotlin/example/GraphFixtureTest.kt create mode 100644 scip-kotlin-gradle-plugin/build.gradle.kts create mode 100644 scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGenerationCoordinator.java create mode 100644 scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGenerationStore.java create mode 100644 scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGradlePlugin.java create mode 100644 scip-kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.scip-code.kotlin-graph.properties create mode 100644 scip-kotlinc/src/main/java/org/scip_code/scip_java/kotlinc/KotlinGraphShard.java create mode 100644 scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/AnalyzerCompilationState.kt create mode 100644 scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/KotlinGraphDocumentBuilder.kt create mode 100644 scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/KotlinGraphMessages.kt create mode 100644 scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/KotlinGraphTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 993c81dde..bcb48ad3d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ gradle-test-kit = { module = "dev.gradleplugins:gradle-test-kit", version.ref = 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 8ad6ddfa7..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 @@ -15,6 +15,36 @@ 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); } @@ -38,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. @@ -112,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..99ea7c7ec 100644 --- a/scip-java/build.gradle.kts +++ b/scip-java/build.gradle.kts @@ -13,6 +13,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 { @@ -46,6 +48,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/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/buildtools/GradleBuildTool.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/GradleBuildTool.kt index 900287442..a4ac0809d 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,16 @@ 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 java.security.MessageDigest +import java.util.HexFormat 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 +21,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 +84,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 +100,160 @@ 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 { - val pluginpath = Embedded.scipJar(tmp) - val gradlePluginPath = Embedded.gradlePluginJar(tmp) - val scipKotlincPath = Embedded.scipKotlincJar(tmp) + val graphArtifact = + if (index.kotlinGraphOutput == null) null else prepareKotlinGraphArtifact(tmp) + val pluginpath = graphArtifact?.javacPlugin ?: Embedded.scipJar(tmp) + val gradlePluginPath = graphArtifact?.gradlePlugin ?: Embedded.gradlePluginJar(tmp) + val kotlinGradlePluginPath = graphArtifact?.kotlinGradlePlugin + val scipKotlincPath = graphArtifact?.jar ?: 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 + ${if (graphArtifact == null) "" else """ + settingsEvaluated { settings -> + settings.dependencyResolutionManagement.repositories.maven { + url = new File("${scriptPath(graphArtifact.repository)}") + } + } + """} + allprojects { - project.ext["scipTarget"] = "${targetroot()}" - project.ext["javacPluginJar"] = "$pluginpath" - project.ext["dependenciesOut"] = "$dependenciesPath" - project.ext["scipKotlincJar"] = "$scipKotlincPath" + ${if (kotlinGradlePluginPath == null) "" else """buildscript { + dependencies { + classpath(files("${scriptPath(kotlinGradlePluginPath)}")) + } + } + """} + project.ext["scipTarget"] = "${scriptPath(targetroot())}" + project.ext["javacPluginJar"] = "${scriptPath(pluginpath)}" + project.ext["dependenciesOut"] = "${scriptPath(dependenciesPath)}" + project.ext["scipKotlincJar"] = "${scriptPath(scipKotlincPath)}" + project.ext["scipKotlinGraphEnabled"] = ${graphArtifact != null} + ${if (graphArtifact == null) "" else """project.ext["scipKotlincGraphJar"] = "${scriptPath(graphArtifact.jar)}" + project.ext["scipKotlincGraphRepository"] = "${scriptPath(graphArtifact.repository)}" + """} apply plugin: ScipGradlePlugin } """ .trimIndent() - val out = tmp.resolve("init-script.gradle") - Files.write(out, script.toByteArray(StandardCharsets.UTF_8)) + val out = graphArtifact?.initScript ?: tmp.resolve("init-script.gradle") + writeIfChanged(out, script.toByteArray(StandardCharsets.UTF_8)) return out } + + private data class KotlinGraphArtifact( + val repository: Path, + val jar: Path, + val javacPlugin: Path, + val gradlePlugin: Path, + val kotlinGradlePlugin: Path, + val initScript: Path, + ) + + private fun prepareKotlinGraphArtifact(tmp: Path): KotlinGraphArtifact { + val kotlinc = Files.readAllBytes(Embedded.scipKotlincJar(tmp)) + val javac = Files.readAllBytes(Embedded.scipJar(tmp)) + val gradle = Files.readAllBytes(Embedded.gradlePluginJar(tmp)) + val kotlinGradle = Files.readAllBytes(Embedded.kotlinGradlePluginJar(tmp)) + 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) + return KotlinGraphArtifact( + repository, + artifact, + persistentJavac, + persistentGradle, + persistentKotlinGradle, + tools.resolve("init-script.gradle"), + ) + } + + 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..3cc6f65f5 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,14 @@ class IndexCommand : CliktCommand(name = "index") { ) .path() + val kotlinGraphOutput: Path? by + option( + "--kotlin-graph-output", + hidden = true, + 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/test/kotlin/tests/BuildToolHarness.kt b/scip-java/src/test/kotlin/tests/BuildToolHarness.kt index 2ea39aa09..b4c59d64a 100644 --- a/scip-java/src/test/kotlin/tests/BuildToolHarness.kt +++ b/scip-java/src/test/kotlin/tests/BuildToolHarness.kt @@ -25,7 +25,7 @@ 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): Pair { val buffer = ByteArrayOutputStream() val stream = PrintStream(buffer, true, StandardCharsets.UTF_8.name()) val app = ScipJavaApp() @@ -52,7 +52,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..c74e29f09 --- /dev/null +++ b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt @@ -0,0 +1,254 @@ +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.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 (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) + assertFalse( + first.contentEquals(Files.readAllBytes(workingDirectory.resolve("edited.json"))) + ) + + 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 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 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-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..35afc4983 --- /dev/null +++ b/scip-kotlin-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/KotlinGraphGenerationStore.java @@ -0,0 +1,544 @@ +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()))); + List inputs = + task.getInputs().getFiles().getFiles().stream() + .map(java.io.File::toPath) + .map(Path::toAbsolutePath) + .map(Path::normalize) + .map(this::universeInputUnchecked) + .sorted(KotlinGraphGenerationStore::compareUtf8) + .toList(); + for (String input : inputs) rows.add("input=" + input); + return rows; + } + + String universeInput(Path input) throws IOException { + Path normalized = input.toAbsolutePath().normalize(); + String digest = fileDigest(normalized); + String identity; + if (normalized.startsWith(sourceRoot)) { + identity = normalizedPath(normalized); + } else 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 { + // 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 1251f3a8f..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,6 +10,7 @@ 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 @@ -17,9 +18,12 @@ 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 @@ -32,10 +36,9 @@ 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) @@ -52,63 +55,99 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } 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()) + setOf(SemanticResolvedQualifierChecker(state)) override val typeOperatorCallCheckers: Set = - setOf(SemanticClassReferenceExpressionChecker()) + 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()) - override val enumEntryCheckers: Set = setOf(SemanticEnumEntryChecker()) + 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 = { @@ -178,9 +217,11 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio if (klass != null) { visitor?.visitClassReference(klass, name) + visitor?.visitImport(klass, name, import.aliasName?.asString()) } else if (callables.isNotEmpty()) { for (callable in callables) { visitor?.visitCallableReference(callable, name) + visitor?.visitImport(callable, name, import.aliasName?.asString()) } } else { visitor?.visitPackage(fqName, name) @@ -191,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.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + val visitor = state.visitors[ktFile] val objectKeyword = if (declaration is FirAnonymousObject) { source.treeStructure @@ -236,12 +278,13 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } } - 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.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + val visitor = state.visitors[ktFile] if (declaration.isPrimary) { // if the constructor is not denoted by the 'constructor' keyword, we want to link @@ -278,12 +321,13 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } } - 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: FirNamedFunction) { val source = declaration.source ?: return val ktFile = context.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + val visitor = state.visitors[ktFile] visitor?.visitNamedFunction( declaration, getIdentifier(source), @@ -294,46 +338,49 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } } - 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.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + 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.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + 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.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + 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.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + val visitor = state.visitors[ktFile] visitor?.visitTypeParameter( declaration, getIdentifier(source), @@ -342,23 +389,24 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } } - 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.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + 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.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + val visitor = state.visitors[ktFile] val identifierSource = if (declaration.isGetter) { source.treeStructure @@ -376,17 +424,18 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } } - private class SemanticEnumEntryChecker : FirEnumEntryChecker(MppCheckerKind.Common) { + 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 = visitors[ktFile] + val visitor = state.visitors[ktFile] visitor?.visitEnumEntry(declaration, getIdentifier(source), enclosingSource = source) } } - private class SemanticResolvedQualifierChecker : + private class SemanticResolvedQualifierChecker(private val state: AnalyzerCompilationState) : FirResolvedQualifierChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(expression: FirResolvedQualifier) { @@ -394,13 +443,14 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio val source = expression.source ?: return if (source.kind is KtFakeSourceElementKind) return val ktFile = context.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + val visitor = state.visitors[ktFile] visitor?.visitClassReference(symbol, getIdentifier(source)) } } - private class SemanticQualifiedAccessExpressionChecker : - FirQualifiedAccessExpressionChecker(MppCheckerKind.Common) { + 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 @@ -410,10 +460,19 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } val ktFile = context.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + 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 && @@ -438,17 +497,35 @@ open class AnalyzerCheckers(session: FirSession) : FirAdditionalCheckersExtensio } } - 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 = typeRef.toClassLikeSymbol(context.session) ?: return val ktFile = context.containingFileSymbol?.sourceFile ?: return - val visitor = visitors[ktFile] + 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 601511099..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,6 +15,12 @@ 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) @@ -34,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( @@ -44,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 5833d8883..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,13 +17,33 @@ 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, ) ) } 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/PostAnalysisExtension.kt b/scip-kotlinc/src/main/kotlin/org/scip_code/scip_java/kotlinc/PostAnalysisExtension.kt index e814439c5..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 @@ -29,24 +30,47 @@ class PostAnalysisExtension( 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 } - AnalyzerCheckers.visitors.clear() + state.visitors.clear() + state.diagnosticReporters.clear() } private fun scipShardPathForFile(file: KtSourceFile): Path? { 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 948497611..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 @@ -39,7 +39,7 @@ 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) 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 5cfbe6c5a..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,6 +39,23 @@ 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, @@ -62,11 +85,33 @@ class ScipVisitor( 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") } 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) @@ -78,6 +123,7 @@ class ScipVisitor( cache[firClass.symbol] .with(firClass.symbol) .emitAll(element, isDefinition = true, enclosingSource) + graphBuilder?.declare(firClass.symbol, element, enclosingSource) } context(context: CheckerContext) @@ -89,6 +135,7 @@ class ScipVisitor( cache[firConstructor.symbol] .with(firConstructor.symbol) .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firConstructor.symbol, source, enclosingSource) } context(context: CheckerContext) @@ -100,6 +147,7 @@ class ScipVisitor( cache[firConstructor.symbol] .with(firConstructor.symbol) .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firConstructor.symbol, source, enclosingSource) } context(context: CheckerContext) @@ -111,6 +159,7 @@ class ScipVisitor( cache[firFunction.symbol] .with(firFunction.symbol) .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firFunction.symbol, source, enclosingSource) } context(context: CheckerContext) @@ -122,6 +171,7 @@ class ScipVisitor( cache[firProperty.symbol] .with(firProperty.symbol) .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firProperty.symbol, source, enclosingSource) } context(context: CheckerContext) @@ -133,6 +183,7 @@ class ScipVisitor( cache[firParameter.symbol] .with(firParameter.symbol) .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firParameter.symbol, source, enclosingSource) } context(context: CheckerContext) @@ -144,6 +195,7 @@ class ScipVisitor( cache[firTypeParameter.symbol] .with(firTypeParameter.symbol) .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firTypeParameter.symbol, source, enclosingSource) } context(context: CheckerContext) @@ -155,6 +207,7 @@ class ScipVisitor( cache[firTypeAlias.symbol] .with(firTypeAlias.symbol) .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firTypeAlias.symbol, source, enclosingSource) } context(context: CheckerContext) @@ -166,6 +219,7 @@ class ScipVisitor( cache[firPropertyAccessor.symbol] .with(firPropertyAccessor.symbol) .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firPropertyAccessor.symbol, source, enclosingSource) } context(context: CheckerContext) @@ -177,6 +231,7 @@ class ScipVisitor( cache[firEnumEntry.symbol] .with(firEnumEntry.symbol) .emitAll(source, isDefinition = true, enclosingSource) + graphBuilder?.declare(firEnumEntry.symbol, source, enclosingSource) } context(context: CheckerContext) @@ -187,5 +242,6 @@ class ScipVisitor( cache[firResolvedNamedReference.resolvedSymbol] .with(firResolvedNamedReference.resolvedSymbol) .emitAll(source, isDefinition = false) + graphBuilder?.reference(firResolvedNamedReference.resolvedSymbol, source, "references") } } 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/Utils.kt b/scip-kotlinc/src/test/kotlin/org/scip_code/scip_java/kotlinc/test/Utils.kt index 5622fe594..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,11 +187,12 @@ 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) } } } ) @@ -197,6 +202,7 @@ fun scipVisitorAnalyzer( sourceRoot = sourceroot, targetRoot = Paths.get(""), callback = hook, + state = state, ) ) } 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", From bef34e5666bfcea9b4cbb2dce569cd20c3f0e0ab Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 4 Sep 2026 14:28:46 +0900 Subject: [PATCH 20/25] Add resident Kotlin graph build service --- gradle/libs.versions.toml | 2 + scip-java/build.gradle.kts | 1 + .../org/scip_code/scip_java/CliEnvironment.kt | 4 + .../org/scip_code/scip_java/ScipJavaApp.kt | 8 +- .../scip_java/buildtools/GradleBuildTool.kt | 99 ++----------- .../KotlinGraphGradleIntegration.kt | 121 +++++++++++++++ .../commands/KotlinGraphServerCommand.kt | 138 ++++++++++++++++++ .../src/test/kotlin/tests/BuildToolHarness.kt | 9 +- .../tests/KotlinGraphGradleBuildToolTest.kt | 31 ++++ 9 files changed, 321 insertions(+), 92 deletions(-) create mode 100644 scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/KotlinGraphGradleIntegration.kt create mode 100644 scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphServerCommand.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bcb48ad3d..d062c2932 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] clikt = "5.1.0" gradle-api = "8.11.1" +gradle-tooling-api = "7.3-20210825160000+0000" junit-jupiter = "5.11.4" kctfork = "0.12.1" kotest = "6.2.1" @@ -21,6 +22,7 @@ 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" } diff --git a/scip-java/build.gradle.kts b/scip-java/build.gradle.kts index 99ea7c7ec..50cbc7671 100644 --- a/scip-java/build.gradle.kts +++ b/scip-java/build.gradle.kts @@ -27,6 +27,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) 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/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 a4ac0809d..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 @@ -6,8 +6,6 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.nio.file.StandardCopyOption -import java.security.MessageDigest -import java.util.HexFormat import org.scip_code.scip_java.Embedded import org.scip_code.scip_java.commands.IndexCommand import org.scip_code.scip_java.commands.KotlinGraphAggregateRunner @@ -120,12 +118,13 @@ This means our SCIP compiler plugin was not attached to one or more JavaCompile } private fun initScript(tmp: Path): Path { - val graphArtifact = - if (index.kotlinGraphOutput == null) null else prepareKotlinGraphArtifact(tmp) - val pluginpath = graphArtifact?.javacPlugin ?: Embedded.scipJar(tmp) - val gradlePluginPath = graphArtifact?.gradlePlugin ?: Embedded.gradlePluginJar(tmp) - val kotlinGradlePluginPath = graphArtifact?.kotlinGradlePlugin - val scipKotlincPath = graphArtifact?.jar ?: Embedded.scipKotlincJar(tmp) + 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('\\', '/') @@ -143,102 +142,22 @@ This means our SCIP compiler plugin was not attached to one or more JavaCompile import org.scip_code.scip_java.gradle.ScipGradlePlugin - ${if (graphArtifact == null) "" else """ - settingsEvaluated { settings -> - settings.dependencyResolutionManagement.repositories.maven { - url = new File("${scriptPath(graphArtifact.repository)}") - } - } - """} - allprojects { - ${if (kotlinGradlePluginPath == null) "" else """buildscript { - dependencies { - classpath(files("${scriptPath(kotlinGradlePluginPath)}")) - } - } - """} project.ext["scipTarget"] = "${scriptPath(targetroot())}" project.ext["javacPluginJar"] = "${scriptPath(pluginpath)}" project.ext["dependenciesOut"] = "${scriptPath(dependenciesPath)}" project.ext["scipKotlincJar"] = "${scriptPath(scipKotlincPath)}" - project.ext["scipKotlinGraphEnabled"] = ${graphArtifact != null} - ${if (graphArtifact == null) "" else """project.ext["scipKotlincGraphJar"] = "${scriptPath(graphArtifact.jar)}" - project.ext["scipKotlincGraphRepository"] = "${scriptPath(graphArtifact.repository)}" - """} + project.ext["scipKotlinGraphEnabled"] = false apply plugin: ScipGradlePlugin } """ .trimIndent() - val out = graphArtifact?.initScript ?: tmp.resolve("init-script.gradle") + val out = tmp.resolve("init-script.gradle") writeIfChanged(out, script.toByteArray(StandardCharsets.UTF_8)) return out } - private data class KotlinGraphArtifact( - val repository: Path, - val jar: Path, - val javacPlugin: Path, - val gradlePlugin: Path, - val kotlinGradlePlugin: Path, - val initScript: Path, - ) - - private fun prepareKotlinGraphArtifact(tmp: Path): KotlinGraphArtifact { - val kotlinc = Files.readAllBytes(Embedded.scipKotlincJar(tmp)) - val javac = Files.readAllBytes(Embedded.scipJar(tmp)) - val gradle = Files.readAllBytes(Embedded.gradlePluginJar(tmp)) - val kotlinGradle = Files.readAllBytes(Embedded.kotlinGradlePluginJar(tmp)) - 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) - return KotlinGraphArtifact( - repository, - artifact, - persistentJavac, - persistentGradle, - persistentKotlinGradle, - tools.resolve("init-script.gradle"), - ) - } - - 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) 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/KotlinGraphServerCommand.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphServerCommand.kt new file mode 100644 index 000000000..5a9ccf550 --- /dev/null +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/commands/KotlinGraphServerCommand.kt @@ -0,0 +1,138 @@ +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() { + 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 b4c59d64a..07b676bfc 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. */ - protected 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, ) diff --git a/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt index c74e29f09..0b8445322 100644 --- a/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt +++ b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt @@ -10,6 +10,7 @@ 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 @@ -93,6 +94,36 @@ class KotlinGraphGradleBuildToolTest : BuildToolHarness() { 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 (residentExit, residentLog) = + runScipJava( + workingDirectory, + listOf("kotlin-graph-server"), + requests.joinToString("\n", postfix = "\n"), + ) + assertEquals(0, residentExit, residentLog) + val responses = + residentLog + .lineSequence() + .filter { it.startsWith("{\"id\":") } + .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 }, residentLog) + 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") From 84ad06a91692e730832c8c8c63af5fa245c99e3a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 4 Sep 2026 14:47:20 +0900 Subject: [PATCH 21/25] fix: advertise Kotlin graph output capability --- .../scip_java/commands/IndexCommand.kt | 577 +++++++++--------- 1 file changed, 288 insertions(+), 289 deletions(-) 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 3cc6f65f5..bb435feab 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 @@ -1,289 +1,288 @@ -package org.scip_code.scip_java.commands - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.ProgramResult -import com.github.ajalt.clikt.core.requireObject -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.arguments.multiple -import com.github.ajalt.clikt.parameters.options.default -import com.github.ajalt.clikt.parameters.options.flag -import com.github.ajalt.clikt.parameters.options.multiple -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.types.path -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import org.scip_code.scip_java.ScipJavaApp -import org.scip_code.scip_java.buildtools.BuildTool -import org.scip_code.scip_java.buildtools.ScipBuildTool - -/** - * `scip-java index`: detects a build tool in the current working directory and shells out to it - * (Maven/Gradle/Bazel/scip-java.json) to produce a SCIP index in `index.scip`. - */ -class IndexCommand : CliktCommand(name = "index") { - - override fun help(context: com.github.ajalt.clikt.core.Context): String = - "Automatically generate an SCIP index in the current working directory." - - /** - * Resolved from the clikt context (set by the root command) at run-time. When an `IndexCommand` - * is constructed outside of a clikt parse flow (e.g. to enumerate build-tool names), [app] - * falls back to a fresh app. - */ - private val sharedApp by requireObject() - private var explicitApp: ScipJavaApp? = null - - val app: ScipJavaApp - get() = - explicitApp - ?: runCatching { sharedApp } - .getOrElse { - // No clikt context (e.g. someone constructed `IndexCommand()` to - // enumerate build tool names). Fall back to a fresh app so calls - // that only touch `.name` / `.isHidden` don't crash. - ScipJavaApp().also { explicitApp = it } - } - - val output: Path by - option("--output", help = "The path where to generate the SCIP index.") - .path() - .default(Paths.get("index.scip")) - - val targetroot: Path? by - option( - "--targetroot", - help = - "The directory where to generate SCIP files. Defaults to a build-specific path. " + - "For example, the default value for Gradle is 'build/scip-targetroot' " + - "and for Maven it's 'target/scip-targetroot'.", - ) - .path() - - val kotlinGraphOutput: Path? by - option( - "--kotlin-graph-output", - hidden = true, - help = "Write a compiler-owned Kotlin graph snapshot instead of a SCIP index.", - ) - .path() - - val buildTool: String? by - option( - "--build-tool", - help = - "Explicitly specify which build tool to use. By default, the build tool is automatically detected. " + - "Use this flag if the automatic build tool detection is not working correctly.", - metavar = "Gradle", - ) - - val cleanup: Boolean by - option( - "--cleanup", - "--no-cleanup", - help = "Whether to remove generated temporary files on exit.", - ) - .flag("--no-cleanup", default = true) - - val temporaryDirectory: Path? by option("--temporary-directory", hidden = true).path() - - val scipIgnoredJavacOptionPrefixes: List by - option( - "--scip-ignored-javac-option-prefixes", - help = - "List of Java compiler option prefixes that should be excluded from compilation during indexing. " + - "This flag is only used when indexing via scip-java.json files or Bazel.", - ) - .multiple() - - val scipIgnoredAnnotationProcessors: List by - option( - "--scip-ignored-annotation-processors", - help = - "List of fully qualified annotation processors that should be ignored when indexing a codebase. " + - "This flag is only used when indexing via scip-java.json files or Bazel.", - ) - .multiple() - - val scipConfig: Path? by - option( - "--scip-config", - help = - "Path to a scip-java.json file with build configuration. By default, the path scip-java.json is used.", - ) - .path() - - val bazelScipJavaBinary: String? by - option( - "--bazel-scip-java-binary", - help = "Optional path to a `scip-java` binary. Required to index a Bazel codebase.", - ) - - val bazelAspect: Path by - option( - "--bazel-aspect", - help = - "Relative path to a Bazel aspect file with an aspect named 'scip_java_aspect'.", - ) - .path() - .default(Paths.get("aspects/scip_java.bzl")) - - val bazelOverwriteAspectFile: Boolean by - option( - "--bazel-overwrite-aspect-file", - help = "If true, overwrites the existing Bazel aspect file (if any).", - ) - .flag() - - val bazelAutorunSandboxCommand: Boolean by - option( - "--bazel-autorun-sandbox-command", - "--no-bazel-autorun-sandbox-command", - help = - "If true, automatically tries to extract the printed out sandbox command " + - "and re-run the command to reveal the underlying problem.", - ) - .flag("--no-bazel-autorun-sandbox-command", default = true) - - val strictCompilation: Boolean by - option( - "--strict-compilation", - hidden = true, - help = "Fail command invocation if compiler produces any errors.", - ) - .flag() - - val buildCommand: List by - argument( - help = - "Optional. The build command to use to compile all sources. Defaults to a build-specific command." - ) - .multiple() - - // Forwarded options for the embedded `aggregate` step. The Bazel aspect - // passes these as `--aggregate.`; clikt forbids `.` in option names, - // so they're registered with `-` and the dotted form is rewritten during - // preprocessing (ScipJavaApp.run). Consumed by BuildTool.generateScipFromTargetroot. - val aggregateParallel: Boolean by - option("--aggregate-parallel", "--aggregate-no-parallel", hidden = true) - .flag("--aggregate-no-parallel", default = true) - - val aggregateEmitInverseRelationships: Boolean by - option( - "--aggregate-emit-inverse-relationships", - "--aggregate-no-emit-inverse-relationships", - hidden = true, - ) - .flag("--aggregate-no-emit-inverse-relationships", default = true) - - val aggregateAllowEmptyIndex: Boolean by - option("--aggregate-allow-empty-index", hidden = true).flag() - - val aggregateAllowExportingGlobalSymbolsFromDirectoryEntries: Boolean by - option( - "--aggregate-allow-exporting-global-symbols-from-directory-entries", - "--aggregate-no-allow-exporting-global-symbols-from-directory-entries", - hidden = true, - ) - .flag( - "--aggregate-no-allow-exporting-global-symbols-from-directory-entries", - default = true, - ) - - val workingDirectory: Path - get() = app.env.workingDirectory.toAbsolutePath() - - fun finalTargetroot(default: Path): Path = workingDirectory.resolve(targetroot ?: default) - - val finalOutput: Path - get() = workingDirectory.resolve(output) - - fun finalBuildCommand(default: List): List = - if (buildCommand.isEmpty()) default else buildCommand - - override fun run() { - val exit = doRun() - if (exit != 0) throw ProgramResult(exit) - } - - fun doRun(): Int { - val allBuildTools = BuildTool.all(this) - val usedBuildTools = allBuildTools.filter { it.usedInCurrentDirectory() } - val matchingBuildTools = - usedBuildTools.filter { tool -> - val name = buildTool - name == null || tool.name.compareTo(name, ignoreCase = true) == 0 - } - - val name = buildTool - if (name != null && name.equals("auto", ignoreCase = true)) { - return runAutoBuildTool() - } - - return when (matchingBuildTools.size) { - 0 -> unknownBuildTool(buildTool, usedBuildTools) - 1 -> matchingBuildTools[0].generateScip() - else -> { - val first = matchingBuildTools[0] - if (first is ScipBuildTool && scipConfig != null) { - first.generateScip() - } else { - val names = matchingBuildTools.joinToString(", ") { it.name } - app.error( - "Multiple build tools detected: $names. " + - "To fix this problem, use the '--build-tool=BUILD_TOOL_NAME' flag to specify which build tool to run." - ) - 1 - } - } - } - } - - private fun unknownBuildTool(explicit: String?, usedBuildTools: List): Int { - if (explicit != null && usedBuildTools.isNotEmpty()) { - val autoDetected = usedBuildTools.joinToString(", ") { it.name } - app.error( - "Automatically detected the build tool(s) $autoDetected but none of them match the explicitly provided flag '--build-tool=$explicit'. " + - "To fix this problem, run again with the --build-tool flag set to one of the detected build tools." - ) - } else { - if (Files.isDirectory(workingDirectory)) { - app.error( - "No build tool detected in workspace '$workingDirectory'. " + - "At the moment, the only supported build tools are: ${BuildTool.allNames()}." - ) - } else { - val cause = - if (Files.exists(workingDirectory)) - "Workspace '$workingDirectory' is not a directory" - else "The directory '$workingDirectory' does not exist" - app.error( - "$cause. To fix this problem, make sure the working directory is an actual directory." - ) - } - } - return 1 - } - - private fun runAutoBuildTool(): Int { - val usedInOrder = BuildTool.autoOrdered(this).filter { it.usedInCurrentDirectory() } - if (usedInOrder.isEmpty()) { - app.error("Build tool mode set to `auto`, but no supported build tools were detected") - return 1 - } - val first = usedInOrder.first() - val rest = usedInOrder.drop(1) - val restMessage = - if (rest.isEmpty()) "" - else - rest.joinToString( - ", ", - prefix = ", other tools that were detected: [", - postfix = "]", - ) { - it.name - } - app.info("Auto mode: `${first.name}` will be used in this workspace$restMessage") - return first.generateScip() - } -} +package org.scip_code.scip_java.commands + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.core.requireObject +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.multiple +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.multiple +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.path +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import org.scip_code.scip_java.ScipJavaApp +import org.scip_code.scip_java.buildtools.BuildTool +import org.scip_code.scip_java.buildtools.ScipBuildTool + +/** + * `scip-java index`: detects a build tool in the current working directory and shells out to it + * (Maven/Gradle/Bazel/scip-java.json) to produce a SCIP index in `index.scip`. + */ +class IndexCommand : CliktCommand(name = "index") { + + override fun help(context: com.github.ajalt.clikt.core.Context): String = + "Automatically generate an SCIP index in the current working directory." + + /** + * Resolved from the clikt context (set by the root command) at run-time. When an `IndexCommand` + * is constructed outside of a clikt parse flow (e.g. to enumerate build-tool names), [app] + * falls back to a fresh app. + */ + private val sharedApp by requireObject() + private var explicitApp: ScipJavaApp? = null + + val app: ScipJavaApp + get() = + explicitApp + ?: runCatching { sharedApp } + .getOrElse { + // No clikt context (e.g. someone constructed `IndexCommand()` to + // enumerate build tool names). Fall back to a fresh app so calls + // that only touch `.name` / `.isHidden` don't crash. + ScipJavaApp().also { explicitApp = it } + } + + val output: Path by + option("--output", help = "The path where to generate the SCIP index.") + .path() + .default(Paths.get("index.scip")) + + val targetroot: Path? by + option( + "--targetroot", + help = + "The directory where to generate SCIP files. Defaults to a build-specific path. " + + "For example, the default value for Gradle is 'build/scip-targetroot' " + + "and for Maven it's 'target/scip-targetroot'.", + ) + .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", + help = + "Explicitly specify which build tool to use. By default, the build tool is automatically detected. " + + "Use this flag if the automatic build tool detection is not working correctly.", + metavar = "Gradle", + ) + + val cleanup: Boolean by + option( + "--cleanup", + "--no-cleanup", + help = "Whether to remove generated temporary files on exit.", + ) + .flag("--no-cleanup", default = true) + + val temporaryDirectory: Path? by option("--temporary-directory", hidden = true).path() + + val scipIgnoredJavacOptionPrefixes: List by + option( + "--scip-ignored-javac-option-prefixes", + help = + "List of Java compiler option prefixes that should be excluded from compilation during indexing. " + + "This flag is only used when indexing via scip-java.json files or Bazel.", + ) + .multiple() + + val scipIgnoredAnnotationProcessors: List by + option( + "--scip-ignored-annotation-processors", + help = + "List of fully qualified annotation processors that should be ignored when indexing a codebase. " + + "This flag is only used when indexing via scip-java.json files or Bazel.", + ) + .multiple() + + val scipConfig: Path? by + option( + "--scip-config", + help = + "Path to a scip-java.json file with build configuration. By default, the path scip-java.json is used.", + ) + .path() + + val bazelScipJavaBinary: String? by + option( + "--bazel-scip-java-binary", + help = "Optional path to a `scip-java` binary. Required to index a Bazel codebase.", + ) + + val bazelAspect: Path by + option( + "--bazel-aspect", + help = + "Relative path to a Bazel aspect file with an aspect named 'scip_java_aspect'.", + ) + .path() + .default(Paths.get("aspects/scip_java.bzl")) + + val bazelOverwriteAspectFile: Boolean by + option( + "--bazel-overwrite-aspect-file", + help = "If true, overwrites the existing Bazel aspect file (if any).", + ) + .flag() + + val bazelAutorunSandboxCommand: Boolean by + option( + "--bazel-autorun-sandbox-command", + "--no-bazel-autorun-sandbox-command", + help = + "If true, automatically tries to extract the printed out sandbox command " + + "and re-run the command to reveal the underlying problem.", + ) + .flag("--no-bazel-autorun-sandbox-command", default = true) + + val strictCompilation: Boolean by + option( + "--strict-compilation", + hidden = true, + help = "Fail command invocation if compiler produces any errors.", + ) + .flag() + + val buildCommand: List by + argument( + help = + "Optional. The build command to use to compile all sources. Defaults to a build-specific command." + ) + .multiple() + + // Forwarded options for the embedded `aggregate` step. The Bazel aspect + // passes these as `--aggregate.`; clikt forbids `.` in option names, + // so they're registered with `-` and the dotted form is rewritten during + // preprocessing (ScipJavaApp.run). Consumed by BuildTool.generateScipFromTargetroot. + val aggregateParallel: Boolean by + option("--aggregate-parallel", "--aggregate-no-parallel", hidden = true) + .flag("--aggregate-no-parallel", default = true) + + val aggregateEmitInverseRelationships: Boolean by + option( + "--aggregate-emit-inverse-relationships", + "--aggregate-no-emit-inverse-relationships", + hidden = true, + ) + .flag("--aggregate-no-emit-inverse-relationships", default = true) + + val aggregateAllowEmptyIndex: Boolean by + option("--aggregate-allow-empty-index", hidden = true).flag() + + val aggregateAllowExportingGlobalSymbolsFromDirectoryEntries: Boolean by + option( + "--aggregate-allow-exporting-global-symbols-from-directory-entries", + "--aggregate-no-allow-exporting-global-symbols-from-directory-entries", + hidden = true, + ) + .flag( + "--aggregate-no-allow-exporting-global-symbols-from-directory-entries", + default = true, + ) + + val workingDirectory: Path + get() = app.env.workingDirectory.toAbsolutePath() + + fun finalTargetroot(default: Path): Path = workingDirectory.resolve(targetroot ?: default) + + val finalOutput: Path + get() = workingDirectory.resolve(output) + + fun finalBuildCommand(default: List): List = + if (buildCommand.isEmpty()) default else buildCommand + + override fun run() { + val exit = doRun() + if (exit != 0) throw ProgramResult(exit) + } + + fun doRun(): Int { + val allBuildTools = BuildTool.all(this) + val usedBuildTools = allBuildTools.filter { it.usedInCurrentDirectory() } + val matchingBuildTools = + usedBuildTools.filter { tool -> + val name = buildTool + name == null || tool.name.compareTo(name, ignoreCase = true) == 0 + } + + val name = buildTool + if (name != null && name.equals("auto", ignoreCase = true)) { + return runAutoBuildTool() + } + + return when (matchingBuildTools.size) { + 0 -> unknownBuildTool(buildTool, usedBuildTools) + 1 -> matchingBuildTools[0].generateScip() + else -> { + val first = matchingBuildTools[0] + if (first is ScipBuildTool && scipConfig != null) { + first.generateScip() + } else { + val names = matchingBuildTools.joinToString(", ") { it.name } + app.error( + "Multiple build tools detected: $names. " + + "To fix this problem, use the '--build-tool=BUILD_TOOL_NAME' flag to specify which build tool to run." + ) + 1 + } + } + } + } + + private fun unknownBuildTool(explicit: String?, usedBuildTools: List): Int { + if (explicit != null && usedBuildTools.isNotEmpty()) { + val autoDetected = usedBuildTools.joinToString(", ") { it.name } + app.error( + "Automatically detected the build tool(s) $autoDetected but none of them match the explicitly provided flag '--build-tool=$explicit'. " + + "To fix this problem, run again with the --build-tool flag set to one of the detected build tools." + ) + } else { + if (Files.isDirectory(workingDirectory)) { + app.error( + "No build tool detected in workspace '$workingDirectory'. " + + "At the moment, the only supported build tools are: ${BuildTool.allNames()}." + ) + } else { + val cause = + if (Files.exists(workingDirectory)) + "Workspace '$workingDirectory' is not a directory" + else "The directory '$workingDirectory' does not exist" + app.error( + "$cause. To fix this problem, make sure the working directory is an actual directory." + ) + } + } + return 1 + } + + private fun runAutoBuildTool(): Int { + val usedInOrder = BuildTool.autoOrdered(this).filter { it.usedInCurrentDirectory() } + if (usedInOrder.isEmpty()) { + app.error("Build tool mode set to `auto`, but no supported build tools were detected") + return 1 + } + val first = usedInOrder.first() + val rest = usedInOrder.drop(1) + val restMessage = + if (rest.isEmpty()) "" + else + rest.joinToString( + ", ", + prefix = ", other tools that were detected: [", + postfix = "]", + ) { + it.name + } + app.info("Auto mode: `${first.name}` will be used in this workspace$restMessage") + return first.generateScip() + } +} From 558d4703d04bb88cda91d22292f3c60d2939bbaa Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 4 Sep 2026 15:00:37 +0900 Subject: [PATCH 22/25] fix: preserve Kotlin universe across source edits --- .../tests/KotlinGraphGradleBuildToolTest.kt | 586 ++++----- .../gradle/KotlinGraphGenerationStore.java | 1102 +++++++++-------- 2 files changed, 859 insertions(+), 829 deletions(-) diff --git a/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt index 0b8445322..a05dab801 100644 --- a/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt +++ b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt @@ -1,285 +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 (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) - assertFalse( - first.contentEquals(Files.readAllBytes(workingDirectory.resolve("edited.json"))) - ) - - 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 (residentExit, residentLog) = - runScipJava( - workingDirectory, - listOf("kotlin-graph-server"), - requests.joinToString("\n", postfix = "\n"), - ) - assertEquals(0, residentExit, residentLog) - val responses = - residentLog - .lineSequence() - .filter { it.startsWith("{\"id\":") } - .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 }, residentLog) - 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 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") - } -} +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 (residentExit, residentLog) = + runScipJava( + workingDirectory, + listOf("kotlin-graph-server"), + requests.joinToString("\n", postfix = "\n"), + ) + assertEquals(0, residentExit, residentLog) + val responses = + residentLog + .lineSequence() + .filter { it.startsWith("{\"id\":") } + .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 }, residentLog) + 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-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 index 35afc4983..b3f899a46 100644 --- 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 @@ -1,544 +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()))); - List inputs = - task.getInputs().getFiles().getFiles().stream() - .map(java.io.File::toPath) - .map(Path::toAbsolutePath) - .map(Path::normalize) - .map(this::universeInputUnchecked) - .sorted(KotlinGraphGenerationStore::compareUtf8) - .toList(); - for (String input : inputs) rows.add("input=" + input); - return rows; - } - - String universeInput(Path input) throws IOException { - Path normalized = input.toAbsolutePath().normalize(); - String digest = fileDigest(normalized); - String identity; - if (normalized.startsWith(sourceRoot)) { - identity = normalizedPath(normalized); - } else 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 { - // 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) {} -} +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) {} +} From 647d09580af3079f29df0530594b69141fd558b7 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 4 Sep 2026 15:01:50 +0900 Subject: [PATCH 23/25] chore: normalize Kotlin graph sources --- .../scip_java/commands/IndexCommand.kt | 576 ++++----- .../tests/KotlinGraphGradleBuildToolTest.kt | 602 ++++----- .../gradle/KotlinGraphGenerationStore.java | 1116 ++++++++--------- 3 files changed, 1147 insertions(+), 1147 deletions(-) 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 bb435feab..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 @@ -1,288 +1,288 @@ -package org.scip_code.scip_java.commands - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.ProgramResult -import com.github.ajalt.clikt.core.requireObject -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.arguments.multiple -import com.github.ajalt.clikt.parameters.options.default -import com.github.ajalt.clikt.parameters.options.flag -import com.github.ajalt.clikt.parameters.options.multiple -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.types.path -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import org.scip_code.scip_java.ScipJavaApp -import org.scip_code.scip_java.buildtools.BuildTool -import org.scip_code.scip_java.buildtools.ScipBuildTool - -/** - * `scip-java index`: detects a build tool in the current working directory and shells out to it - * (Maven/Gradle/Bazel/scip-java.json) to produce a SCIP index in `index.scip`. - */ -class IndexCommand : CliktCommand(name = "index") { - - override fun help(context: com.github.ajalt.clikt.core.Context): String = - "Automatically generate an SCIP index in the current working directory." - - /** - * Resolved from the clikt context (set by the root command) at run-time. When an `IndexCommand` - * is constructed outside of a clikt parse flow (e.g. to enumerate build-tool names), [app] - * falls back to a fresh app. - */ - private val sharedApp by requireObject() - private var explicitApp: ScipJavaApp? = null - - val app: ScipJavaApp - get() = - explicitApp - ?: runCatching { sharedApp } - .getOrElse { - // No clikt context (e.g. someone constructed `IndexCommand()` to - // enumerate build tool names). Fall back to a fresh app so calls - // that only touch `.name` / `.isHidden` don't crash. - ScipJavaApp().also { explicitApp = it } - } - - val output: Path by - option("--output", help = "The path where to generate the SCIP index.") - .path() - .default(Paths.get("index.scip")) - - val targetroot: Path? by - option( - "--targetroot", - help = - "The directory where to generate SCIP files. Defaults to a build-specific path. " + - "For example, the default value for Gradle is 'build/scip-targetroot' " + - "and for Maven it's 'target/scip-targetroot'.", - ) - .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", - help = - "Explicitly specify which build tool to use. By default, the build tool is automatically detected. " + - "Use this flag if the automatic build tool detection is not working correctly.", - metavar = "Gradle", - ) - - val cleanup: Boolean by - option( - "--cleanup", - "--no-cleanup", - help = "Whether to remove generated temporary files on exit.", - ) - .flag("--no-cleanup", default = true) - - val temporaryDirectory: Path? by option("--temporary-directory", hidden = true).path() - - val scipIgnoredJavacOptionPrefixes: List by - option( - "--scip-ignored-javac-option-prefixes", - help = - "List of Java compiler option prefixes that should be excluded from compilation during indexing. " + - "This flag is only used when indexing via scip-java.json files or Bazel.", - ) - .multiple() - - val scipIgnoredAnnotationProcessors: List by - option( - "--scip-ignored-annotation-processors", - help = - "List of fully qualified annotation processors that should be ignored when indexing a codebase. " + - "This flag is only used when indexing via scip-java.json files or Bazel.", - ) - .multiple() - - val scipConfig: Path? by - option( - "--scip-config", - help = - "Path to a scip-java.json file with build configuration. By default, the path scip-java.json is used.", - ) - .path() - - val bazelScipJavaBinary: String? by - option( - "--bazel-scip-java-binary", - help = "Optional path to a `scip-java` binary. Required to index a Bazel codebase.", - ) - - val bazelAspect: Path by - option( - "--bazel-aspect", - help = - "Relative path to a Bazel aspect file with an aspect named 'scip_java_aspect'.", - ) - .path() - .default(Paths.get("aspects/scip_java.bzl")) - - val bazelOverwriteAspectFile: Boolean by - option( - "--bazel-overwrite-aspect-file", - help = "If true, overwrites the existing Bazel aspect file (if any).", - ) - .flag() - - val bazelAutorunSandboxCommand: Boolean by - option( - "--bazel-autorun-sandbox-command", - "--no-bazel-autorun-sandbox-command", - help = - "If true, automatically tries to extract the printed out sandbox command " + - "and re-run the command to reveal the underlying problem.", - ) - .flag("--no-bazel-autorun-sandbox-command", default = true) - - val strictCompilation: Boolean by - option( - "--strict-compilation", - hidden = true, - help = "Fail command invocation if compiler produces any errors.", - ) - .flag() - - val buildCommand: List by - argument( - help = - "Optional. The build command to use to compile all sources. Defaults to a build-specific command." - ) - .multiple() - - // Forwarded options for the embedded `aggregate` step. The Bazel aspect - // passes these as `--aggregate.`; clikt forbids `.` in option names, - // so they're registered with `-` and the dotted form is rewritten during - // preprocessing (ScipJavaApp.run). Consumed by BuildTool.generateScipFromTargetroot. - val aggregateParallel: Boolean by - option("--aggregate-parallel", "--aggregate-no-parallel", hidden = true) - .flag("--aggregate-no-parallel", default = true) - - val aggregateEmitInverseRelationships: Boolean by - option( - "--aggregate-emit-inverse-relationships", - "--aggregate-no-emit-inverse-relationships", - hidden = true, - ) - .flag("--aggregate-no-emit-inverse-relationships", default = true) - - val aggregateAllowEmptyIndex: Boolean by - option("--aggregate-allow-empty-index", hidden = true).flag() - - val aggregateAllowExportingGlobalSymbolsFromDirectoryEntries: Boolean by - option( - "--aggregate-allow-exporting-global-symbols-from-directory-entries", - "--aggregate-no-allow-exporting-global-symbols-from-directory-entries", - hidden = true, - ) - .flag( - "--aggregate-no-allow-exporting-global-symbols-from-directory-entries", - default = true, - ) - - val workingDirectory: Path - get() = app.env.workingDirectory.toAbsolutePath() - - fun finalTargetroot(default: Path): Path = workingDirectory.resolve(targetroot ?: default) - - val finalOutput: Path - get() = workingDirectory.resolve(output) - - fun finalBuildCommand(default: List): List = - if (buildCommand.isEmpty()) default else buildCommand - - override fun run() { - val exit = doRun() - if (exit != 0) throw ProgramResult(exit) - } - - fun doRun(): Int { - val allBuildTools = BuildTool.all(this) - val usedBuildTools = allBuildTools.filter { it.usedInCurrentDirectory() } - val matchingBuildTools = - usedBuildTools.filter { tool -> - val name = buildTool - name == null || tool.name.compareTo(name, ignoreCase = true) == 0 - } - - val name = buildTool - if (name != null && name.equals("auto", ignoreCase = true)) { - return runAutoBuildTool() - } - - return when (matchingBuildTools.size) { - 0 -> unknownBuildTool(buildTool, usedBuildTools) - 1 -> matchingBuildTools[0].generateScip() - else -> { - val first = matchingBuildTools[0] - if (first is ScipBuildTool && scipConfig != null) { - first.generateScip() - } else { - val names = matchingBuildTools.joinToString(", ") { it.name } - app.error( - "Multiple build tools detected: $names. " + - "To fix this problem, use the '--build-tool=BUILD_TOOL_NAME' flag to specify which build tool to run." - ) - 1 - } - } - } - } - - private fun unknownBuildTool(explicit: String?, usedBuildTools: List): Int { - if (explicit != null && usedBuildTools.isNotEmpty()) { - val autoDetected = usedBuildTools.joinToString(", ") { it.name } - app.error( - "Automatically detected the build tool(s) $autoDetected but none of them match the explicitly provided flag '--build-tool=$explicit'. " + - "To fix this problem, run again with the --build-tool flag set to one of the detected build tools." - ) - } else { - if (Files.isDirectory(workingDirectory)) { - app.error( - "No build tool detected in workspace '$workingDirectory'. " + - "At the moment, the only supported build tools are: ${BuildTool.allNames()}." - ) - } else { - val cause = - if (Files.exists(workingDirectory)) - "Workspace '$workingDirectory' is not a directory" - else "The directory '$workingDirectory' does not exist" - app.error( - "$cause. To fix this problem, make sure the working directory is an actual directory." - ) - } - } - return 1 - } - - private fun runAutoBuildTool(): Int { - val usedInOrder = BuildTool.autoOrdered(this).filter { it.usedInCurrentDirectory() } - if (usedInOrder.isEmpty()) { - app.error("Build tool mode set to `auto`, but no supported build tools were detected") - return 1 - } - val first = usedInOrder.first() - val rest = usedInOrder.drop(1) - val restMessage = - if (rest.isEmpty()) "" - else - rest.joinToString( - ", ", - prefix = ", other tools that were detected: [", - postfix = "]", - ) { - it.name - } - app.info("Auto mode: `${first.name}` will be used in this workspace$restMessage") - return first.generateScip() - } -} +package org.scip_code.scip_java.commands + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.core.requireObject +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.multiple +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.multiple +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.path +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import org.scip_code.scip_java.ScipJavaApp +import org.scip_code.scip_java.buildtools.BuildTool +import org.scip_code.scip_java.buildtools.ScipBuildTool + +/** + * `scip-java index`: detects a build tool in the current working directory and shells out to it + * (Maven/Gradle/Bazel/scip-java.json) to produce a SCIP index in `index.scip`. + */ +class IndexCommand : CliktCommand(name = "index") { + + override fun help(context: com.github.ajalt.clikt.core.Context): String = + "Automatically generate an SCIP index in the current working directory." + + /** + * Resolved from the clikt context (set by the root command) at run-time. When an `IndexCommand` + * is constructed outside of a clikt parse flow (e.g. to enumerate build-tool names), [app] + * falls back to a fresh app. + */ + private val sharedApp by requireObject() + private var explicitApp: ScipJavaApp? = null + + val app: ScipJavaApp + get() = + explicitApp + ?: runCatching { sharedApp } + .getOrElse { + // No clikt context (e.g. someone constructed `IndexCommand()` to + // enumerate build tool names). Fall back to a fresh app so calls + // that only touch `.name` / `.isHidden` don't crash. + ScipJavaApp().also { explicitApp = it } + } + + val output: Path by + option("--output", help = "The path where to generate the SCIP index.") + .path() + .default(Paths.get("index.scip")) + + val targetroot: Path? by + option( + "--targetroot", + help = + "The directory where to generate SCIP files. Defaults to a build-specific path. " + + "For example, the default value for Gradle is 'build/scip-targetroot' " + + "and for Maven it's 'target/scip-targetroot'.", + ) + .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", + help = + "Explicitly specify which build tool to use. By default, the build tool is automatically detected. " + + "Use this flag if the automatic build tool detection is not working correctly.", + metavar = "Gradle", + ) + + val cleanup: Boolean by + option( + "--cleanup", + "--no-cleanup", + help = "Whether to remove generated temporary files on exit.", + ) + .flag("--no-cleanup", default = true) + + val temporaryDirectory: Path? by option("--temporary-directory", hidden = true).path() + + val scipIgnoredJavacOptionPrefixes: List by + option( + "--scip-ignored-javac-option-prefixes", + help = + "List of Java compiler option prefixes that should be excluded from compilation during indexing. " + + "This flag is only used when indexing via scip-java.json files or Bazel.", + ) + .multiple() + + val scipIgnoredAnnotationProcessors: List by + option( + "--scip-ignored-annotation-processors", + help = + "List of fully qualified annotation processors that should be ignored when indexing a codebase. " + + "This flag is only used when indexing via scip-java.json files or Bazel.", + ) + .multiple() + + val scipConfig: Path? by + option( + "--scip-config", + help = + "Path to a scip-java.json file with build configuration. By default, the path scip-java.json is used.", + ) + .path() + + val bazelScipJavaBinary: String? by + option( + "--bazel-scip-java-binary", + help = "Optional path to a `scip-java` binary. Required to index a Bazel codebase.", + ) + + val bazelAspect: Path by + option( + "--bazel-aspect", + help = + "Relative path to a Bazel aspect file with an aspect named 'scip_java_aspect'.", + ) + .path() + .default(Paths.get("aspects/scip_java.bzl")) + + val bazelOverwriteAspectFile: Boolean by + option( + "--bazel-overwrite-aspect-file", + help = "If true, overwrites the existing Bazel aspect file (if any).", + ) + .flag() + + val bazelAutorunSandboxCommand: Boolean by + option( + "--bazel-autorun-sandbox-command", + "--no-bazel-autorun-sandbox-command", + help = + "If true, automatically tries to extract the printed out sandbox command " + + "and re-run the command to reveal the underlying problem.", + ) + .flag("--no-bazel-autorun-sandbox-command", default = true) + + val strictCompilation: Boolean by + option( + "--strict-compilation", + hidden = true, + help = "Fail command invocation if compiler produces any errors.", + ) + .flag() + + val buildCommand: List by + argument( + help = + "Optional. The build command to use to compile all sources. Defaults to a build-specific command." + ) + .multiple() + + // Forwarded options for the embedded `aggregate` step. The Bazel aspect + // passes these as `--aggregate.`; clikt forbids `.` in option names, + // so they're registered with `-` and the dotted form is rewritten during + // preprocessing (ScipJavaApp.run). Consumed by BuildTool.generateScipFromTargetroot. + val aggregateParallel: Boolean by + option("--aggregate-parallel", "--aggregate-no-parallel", hidden = true) + .flag("--aggregate-no-parallel", default = true) + + val aggregateEmitInverseRelationships: Boolean by + option( + "--aggregate-emit-inverse-relationships", + "--aggregate-no-emit-inverse-relationships", + hidden = true, + ) + .flag("--aggregate-no-emit-inverse-relationships", default = true) + + val aggregateAllowEmptyIndex: Boolean by + option("--aggregate-allow-empty-index", hidden = true).flag() + + val aggregateAllowExportingGlobalSymbolsFromDirectoryEntries: Boolean by + option( + "--aggregate-allow-exporting-global-symbols-from-directory-entries", + "--aggregate-no-allow-exporting-global-symbols-from-directory-entries", + hidden = true, + ) + .flag( + "--aggregate-no-allow-exporting-global-symbols-from-directory-entries", + default = true, + ) + + val workingDirectory: Path + get() = app.env.workingDirectory.toAbsolutePath() + + fun finalTargetroot(default: Path): Path = workingDirectory.resolve(targetroot ?: default) + + val finalOutput: Path + get() = workingDirectory.resolve(output) + + fun finalBuildCommand(default: List): List = + if (buildCommand.isEmpty()) default else buildCommand + + override fun run() { + val exit = doRun() + if (exit != 0) throw ProgramResult(exit) + } + + fun doRun(): Int { + val allBuildTools = BuildTool.all(this) + val usedBuildTools = allBuildTools.filter { it.usedInCurrentDirectory() } + val matchingBuildTools = + usedBuildTools.filter { tool -> + val name = buildTool + name == null || tool.name.compareTo(name, ignoreCase = true) == 0 + } + + val name = buildTool + if (name != null && name.equals("auto", ignoreCase = true)) { + return runAutoBuildTool() + } + + return when (matchingBuildTools.size) { + 0 -> unknownBuildTool(buildTool, usedBuildTools) + 1 -> matchingBuildTools[0].generateScip() + else -> { + val first = matchingBuildTools[0] + if (first is ScipBuildTool && scipConfig != null) { + first.generateScip() + } else { + val names = matchingBuildTools.joinToString(", ") { it.name } + app.error( + "Multiple build tools detected: $names. " + + "To fix this problem, use the '--build-tool=BUILD_TOOL_NAME' flag to specify which build tool to run." + ) + 1 + } + } + } + } + + private fun unknownBuildTool(explicit: String?, usedBuildTools: List): Int { + if (explicit != null && usedBuildTools.isNotEmpty()) { + val autoDetected = usedBuildTools.joinToString(", ") { it.name } + app.error( + "Automatically detected the build tool(s) $autoDetected but none of them match the explicitly provided flag '--build-tool=$explicit'. " + + "To fix this problem, run again with the --build-tool flag set to one of the detected build tools." + ) + } else { + if (Files.isDirectory(workingDirectory)) { + app.error( + "No build tool detected in workspace '$workingDirectory'. " + + "At the moment, the only supported build tools are: ${BuildTool.allNames()}." + ) + } else { + val cause = + if (Files.exists(workingDirectory)) + "Workspace '$workingDirectory' is not a directory" + else "The directory '$workingDirectory' does not exist" + app.error( + "$cause. To fix this problem, make sure the working directory is an actual directory." + ) + } + } + return 1 + } + + private fun runAutoBuildTool(): Int { + val usedInOrder = BuildTool.autoOrdered(this).filter { it.usedInCurrentDirectory() } + if (usedInOrder.isEmpty()) { + app.error("Build tool mode set to `auto`, but no supported build tools were detected") + return 1 + } + val first = usedInOrder.first() + val rest = usedInOrder.drop(1) + val restMessage = + if (rest.isEmpty()) "" + else + rest.joinToString( + ", ", + prefix = ", other tools that were detected: [", + postfix = "]", + ) { + it.name + } + app.info("Auto mode: `${first.name}` will be used in this workspace$restMessage") + return first.generateScip() + } +} diff --git a/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt index a05dab801..7785d4e54 100644 --- a/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt +++ b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt @@ -1,301 +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 (residentExit, residentLog) = - runScipJava( - workingDirectory, - listOf("kotlin-graph-server"), - requests.joinToString("\n", postfix = "\n"), - ) - assertEquals(0, residentExit, residentLog) - val responses = - residentLog - .lineSequence() - .filter { it.startsWith("{\"id\":") } - .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 }, residentLog) - 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") - } -} +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 (residentExit, residentLog) = + runScipJava( + workingDirectory, + listOf("kotlin-graph-server"), + requests.joinToString("\n", postfix = "\n"), + ) + assertEquals(0, residentExit, residentLog) + val responses = + residentLog + .lineSequence() + .filter { it.startsWith("{\"id\":") } + .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 }, residentLog) + 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-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 index b3f899a46..7f979c6c5 100644 --- 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 @@ -1,558 +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) {} -} +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) {} +} From 22ec4bd89062ed2f7040ef14d14c05db8776b816 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 4 Sep 2026 15:26:45 +0900 Subject: [PATCH 24/25] fix: keep the Windows launcher below cmd limits --- scip-java/build.gradle.kts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/scip-java/build.gradle.kts b/scip-java/build.gradle.kts index 50cbc7671..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") @@ -42,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" } From 3a1565d0647d89a28880fa40ecbef0966a1a328c Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 4 Sep 2026 16:31:26 +0900 Subject: [PATCH 25/25] fix: reserve Kotlin graph server stdout --- .../commands/KotlinGraphServerCommand.kt | 13 +++++++ .../src/test/kotlin/tests/BuildToolHarness.kt | 36 +++++++++++++++++++ .../tests/KotlinGraphGradleBuildToolTest.kt | 12 +++---- 3 files changed, 55 insertions(+), 6 deletions(-) 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 index 5a9ccf550..328aa3eae 100644 --- 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 @@ -25,6 +25,19 @@ class KotlinGraphServerCommand : CliktCommand(name = "kotlin-graph-server") { "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") diff --git a/scip-java/src/test/kotlin/tests/BuildToolHarness.kt b/scip-java/src/test/kotlin/tests/BuildToolHarness.kt index 07b676bfc..8e8992e3f 100644 --- a/scip-java/src/test/kotlin/tests/BuildToolHarness.kt +++ b/scip-java/src/test/kotlin/tests/BuildToolHarness.kt @@ -46,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 -> diff --git a/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt index 7785d4e54..e14cda8ef 100644 --- a/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt +++ b/scip-java/src/test/kotlin/tests/KotlinGraphGradleBuildToolTest.kt @@ -105,17 +105,17 @@ class KotlinGraphGradleBuildToolTest : BuildToolHarness() { listOf(residentFirst, residentSecond).mapIndexed { index, output -> """{"id":${index + 1},"protocolVersion":1,"output":${JsonPrimitive(output.toString())}}""" } - val (residentExit, residentLog) = - runScipJava( + val resident = + runScipJavaProtocol( workingDirectory, listOf("kotlin-graph-server"), requests.joinToString("\n", postfix = "\n"), ) - assertEquals(0, residentExit, residentLog) + assertEquals(0, resident.exit, resident.error) val responses = - residentLog + resident.output .lineSequence() - .filter { it.startsWith("{\"id\":") } + .filter { it.isNotBlank() } .map(Json::parseToJsonElement) .map { it.jsonObject } .toList() @@ -123,7 +123,7 @@ class KotlinGraphGradleBuildToolTest : BuildToolHarness() { listOf(1, 2), responses.map { it.getValue("id").jsonPrimitive.content.toInt() }, ) - assertTrue(responses.all { it.getValue("ok").jsonPrimitive.boolean }, residentLog) + assertTrue(responses.all { it.getValue("ok").jsonPrimitive.boolean }, resident.error) assertContentEquals( Files.readAllBytes(residentFirst), Files.readAllBytes(residentSecond),