diff --git a/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFile.fir.kt.txt b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFile.fir.kt.txt new file mode 100644 index 0000000..f6e2a47 --- /dev/null +++ b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFile.fir.kt.txt @@ -0,0 +1,59 @@ +data class Wrapper { + val value: ToBeStable + field = value + get + + constructor(value: ToBeStable) /* primary */ { + super/*Any*/() + /* () */ + + } + + operator fun component1(): ToBeStable { + return .#value + } + + fun copy(value: ToBeStable = .#value): Wrapper { + return Wrapper(value = value) + } + + override operator fun equals(other: Any?): Boolean { + when { + EQEQEQ(arg0 = , arg1 = other) -> return true + } + when { + other !is Wrapper -> return false + } + val tmp_0: Wrapper = other /*as Wrapper */ + when { + EQEQ(arg0 = .#value, arg1 = tmp_0.#value).not() -> return false + } + return true + } + + override fun hashCode(): Int { + return .#value.hashCode() + } + + override fun toString(): String { + return "Wrapper(" + "value=" + .#value + ")" + } + +} + +interface ToBeStable { +} + +@Composable +fun ComposableToBeSkippable(wrapper: Wrapper) { + val _tracker: RecompositionTracker = rememberRecompositionTracker(composableName = "ComposableToBeSkippable", tag = "", threshold = 2, fqName = "ComposableToBeSkippable", isAutoTraced = true) + _tracker.trackParameter(name = "wrapper", type = ".Wrapper", value = wrapper, isStable = true) + val _startTime: Long = nanoTime() + try // COMPOSITE { + println(message = wrapper.toString()) + // } + finally // COMPOSITE { + _tracker.recordDuration(startTimeNanos = _startTime) + _tracker.logIfThresholdMet() + // } +} diff --git a/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFile.kt b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFile.kt new file mode 100644 index 0000000..0b5609a --- /dev/null +++ b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFile.kt @@ -0,0 +1,17 @@ +// DUMP_KT_IR +// ENABLE_TRACE_ALL +// STABILITY_CONFIGURATION_FILES: compiler-tests/src/test/data/dump/ir/stability_config.conf + +import androidx.compose.runtime.Composable + +interface ToBeStable + +data class Wrapper(val value: ToBeStable) + +// The following line in StabilityConfigurationFile.fir.kt.txt confirms the stability_config.conf was taken into account: +// _tracker.trackParameter(name = "wrapper", type = ".Wrapper", value = wrapper, isStable = true) +// The test will fail if isStable = false (meaning ToBeStable was not marked as stable, hence Wrapper is also not stable). +@Composable +fun ComposableToBeSkippable(wrapper: Wrapper) { + println(wrapper.toString()) +} diff --git a/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileMismatch.fir.kt.txt b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileMismatch.fir.kt.txt new file mode 100644 index 0000000..9c60582 --- /dev/null +++ b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileMismatch.fir.kt.txt @@ -0,0 +1,59 @@ +data class Wrapper { + val value: ToBeStable + field = value + get + + constructor(value: ToBeStable) /* primary */ { + super/*Any*/() + /* () */ + + } + + operator fun component1(): ToBeStable { + return .#value + } + + fun copy(value: ToBeStable = .#value): Wrapper { + return Wrapper(value = value) + } + + override operator fun equals(other: Any?): Boolean { + when { + EQEQEQ(arg0 = , arg1 = other) -> return true + } + when { + other !is Wrapper -> return false + } + val tmp_0: Wrapper = other /*as Wrapper */ + when { + EQEQ(arg0 = .#value, arg1 = tmp_0.#value).not() -> return false + } + return true + } + + override fun hashCode(): Int { + return .#value.hashCode() + } + + override fun toString(): String { + return "Wrapper(" + "value=" + .#value + ")" + } + +} + +interface ToBeStable { +} + +@Composable +fun ComposableToBeSkippable(wrapper: Wrapper) { + val _tracker: RecompositionTracker = rememberRecompositionTracker(composableName = "ComposableToBeSkippable", tag = "", threshold = 2, fqName = "ComposableToBeSkippable", isAutoTraced = true) + _tracker.trackParameter(name = "wrapper", type = ".Wrapper", value = wrapper, isStable = false) + val _startTime: Long = nanoTime() + try // COMPOSITE { + println(message = wrapper.toString()) + // } + finally // COMPOSITE { + _tracker.recordDuration(startTimeNanos = _startTime) + _tracker.logIfThresholdMet() + // } +} diff --git a/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileMismatch.kt b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileMismatch.kt new file mode 100644 index 0000000..5f0a835 --- /dev/null +++ b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileMismatch.kt @@ -0,0 +1,18 @@ +// DUMP_KT_IR +// ENABLE_TRACE_ALL +// STABILITY_CONFIGURATION_FILES: compiler-tests/src/test/data/dump/ir/stability_config_mismatch.conf + +import androidx.compose.runtime.Composable + +interface ToBeStable + +data class Wrapper(val value: ToBeStable) + +// Negative counterpart to StabilityConfigurationFile.kt: a config file IS supplied, but it lists a +// different type (not ToBeStable). The parser runs and produces a matcher, yet nothing matches +// Wrapper/ToBeStable, so Wrapper stays unstable and the injected call reads: +// _tracker.trackParameter(name = "wrapper", type = ".Wrapper", value = wrapper, isStable = false) +@Composable +fun ComposableToBeSkippable(wrapper: Wrapper) { + println(wrapper.toString()) +} diff --git a/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileNoConfig.fir.kt.txt b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileNoConfig.fir.kt.txt new file mode 100644 index 0000000..9c60582 --- /dev/null +++ b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileNoConfig.fir.kt.txt @@ -0,0 +1,59 @@ +data class Wrapper { + val value: ToBeStable + field = value + get + + constructor(value: ToBeStable) /* primary */ { + super/*Any*/() + /* () */ + + } + + operator fun component1(): ToBeStable { + return .#value + } + + fun copy(value: ToBeStable = .#value): Wrapper { + return Wrapper(value = value) + } + + override operator fun equals(other: Any?): Boolean { + when { + EQEQEQ(arg0 = , arg1 = other) -> return true + } + when { + other !is Wrapper -> return false + } + val tmp_0: Wrapper = other /*as Wrapper */ + when { + EQEQ(arg0 = .#value, arg1 = tmp_0.#value).not() -> return false + } + return true + } + + override fun hashCode(): Int { + return .#value.hashCode() + } + + override fun toString(): String { + return "Wrapper(" + "value=" + .#value + ")" + } + +} + +interface ToBeStable { +} + +@Composable +fun ComposableToBeSkippable(wrapper: Wrapper) { + val _tracker: RecompositionTracker = rememberRecompositionTracker(composableName = "ComposableToBeSkippable", tag = "", threshold = 2, fqName = "ComposableToBeSkippable", isAutoTraced = true) + _tracker.trackParameter(name = "wrapper", type = ".Wrapper", value = wrapper, isStable = false) + val _startTime: Long = nanoTime() + try // COMPOSITE { + println(message = wrapper.toString()) + // } + finally // COMPOSITE { + _tracker.recordDuration(startTimeNanos = _startTime) + _tracker.logIfThresholdMet() + // } +} diff --git a/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileNoConfig.kt b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileNoConfig.kt new file mode 100644 index 0000000..4f9a226 --- /dev/null +++ b/compiler-tests/src/test/data/dump/ir/StabilityConfigurationFileNoConfig.kt @@ -0,0 +1,17 @@ +// DUMP_KT_IR +// ENABLE_TRACE_ALL + +import androidx.compose.runtime.Composable + +interface ToBeStable + +data class Wrapper(val value: ToBeStable) + +// Baseline for StabilityConfigurationFile.kt: same Wrapper/ToBeStable setup, but WITHOUT +// STABILITY_CONFIGURATION_FILES. Confirms default behavior is unchanged — because ToBeStable is +// an unannotated interface, Wrapper stays unstable and the injected call reads: +// _tracker.trackParameter(name = "wrapper", type = ".Wrapper", value = wrapper, isStable = false) +@Composable +fun ComposableToBeSkippable(wrapper: Wrapper) { + println(wrapper.toString()) +} diff --git a/compiler-tests/src/test/data/dump/ir/stability_config.conf b/compiler-tests/src/test/data/dump/ir/stability_config.conf new file mode 100644 index 0000000..8656d5e --- /dev/null +++ b/compiler-tests/src/test/data/dump/ir/stability_config.conf @@ -0,0 +1 @@ +ToBeStable diff --git a/compiler-tests/src/test/data/dump/ir/stability_config_mismatch.conf b/compiler-tests/src/test/data/dump/ir/stability_config_mismatch.conf new file mode 100644 index 0000000..b22d46c --- /dev/null +++ b/compiler-tests/src/test/data/dump/ir/stability_config_mismatch.conf @@ -0,0 +1 @@ +SomeOtherUnrelatedType diff --git a/compiler-tests/src/test/java/com/skydoves/compose/stability/compiler/tests/IrDumpTestGenerated.java b/compiler-tests/src/test/java/com/skydoves/compose/stability/compiler/tests/IrDumpTestGenerated.java index 5b884ac..acfd840 100644 --- a/compiler-tests/src/test/java/com/skydoves/compose/stability/compiler/tests/IrDumpTestGenerated.java +++ b/compiler-tests/src/test/java/com/skydoves/compose/stability/compiler/tests/IrDumpTestGenerated.java @@ -42,6 +42,24 @@ public void testSimpleDataClass() { run("SimpleDataClass.kt"); } + @Test + @TestMetadata("StabilityConfigurationFile.kt") + public void testStabilityConfigurationFile() { + run("StabilityConfigurationFile.kt"); + } + + @Test + @TestMetadata("StabilityConfigurationFileMismatch.kt") + public void testStabilityConfigurationFileMismatch() { + run("StabilityConfigurationFileMismatch.kt"); + } + + @Test + @TestMetadata("StabilityConfigurationFileNoConfig.kt") + public void testStabilityConfigurationFileNoConfig() { + run("StabilityConfigurationFileNoConfig.kt"); + } + @Test @TestMetadata("TraceAllInjection.kt") public void testTraceAllInjection() { diff --git a/compiler-tests/src/test/kotlin/com/skydoves/compose/stability/compiler/tests/StabilityTestConfigurator.kt b/compiler-tests/src/test/kotlin/com/skydoves/compose/stability/compiler/tests/StabilityTestConfigurator.kt index 1588a83..2270a4d 100644 --- a/compiler-tests/src/test/kotlin/com/skydoves/compose/stability/compiler/tests/StabilityTestConfigurator.kt +++ b/compiler-tests/src/test/kotlin/com/skydoves/compose/stability/compiler/tests/StabilityTestConfigurator.kt @@ -27,6 +27,7 @@ import org.jetbrains.kotlin.test.directives.model.DirectivesContainer import org.jetbrains.kotlin.test.model.TestModule import org.jetbrains.kotlin.test.services.EnvironmentConfigurator import org.jetbrains.kotlin.test.services.TestServices +import java.io.File /** * Test configurator that registers the Stability Analyzer compiler plugin @@ -53,12 +54,17 @@ class StabilityTestConfigurator(testServices: TestServices) : val traceAll = StabilityTestDirectives.ENABLE_TRACE_ALL in module.directives + val stabilityConfigurationFiles = module + .directives[StabilityTestDirectives.STABILITY_CONFIGURATION_FILES] + .map { File(it) } + // Register IR generation extension for backend transformations IrGenerationExtension.registerExtension( StabilityAnalyzerIrGenerationExtension( stabilityOutputDir = "", projectDependencies = "", traceAll = traceAll, + stabilityConfigurationFiles = stabilityConfigurationFiles, ), ) } diff --git a/compiler-tests/src/test/kotlin/com/skydoves/compose/stability/compiler/tests/StabilityTestDirectives.kt b/compiler-tests/src/test/kotlin/com/skydoves/compose/stability/compiler/tests/StabilityTestDirectives.kt index 09702fd..8879757 100644 --- a/compiler-tests/src/test/kotlin/com/skydoves/compose/stability/compiler/tests/StabilityTestDirectives.kt +++ b/compiler-tests/src/test/kotlin/com/skydoves/compose/stability/compiler/tests/StabilityTestDirectives.kt @@ -29,4 +29,7 @@ object StabilityTestDirectives : SimpleDirectivesContainer() { val ENABLE_TRACE_ALL by directive( "Enables trace-all auto-instrumentation (composeStabilityAnalyzer.traceAll) for this test", ) + val STABILITY_CONFIGURATION_FILES by stringDirective( + "Specifies stability configuration file(s) for this test", + ) } diff --git a/docs/gradle-plugin/stability-configuration-files.md b/docs/gradle-plugin/stability-configuration-files.md new file mode 100644 index 0000000..6d4ca13 --- /dev/null +++ b/docs/gradle-plugin/stability-configuration-files.md @@ -0,0 +1,24 @@ +# Stability configuration files + +You can provide stability configuration files to tell compiler plugin which types should be treated as stable, even if the compiler marks them as unstable. This uses the same format as the [Compose compiler's stability configuration file](https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file), so you can reuse the same file for both the compiler and stability validation. + +```kotlin +composeStabilityAnalyzer { + stabilityConfigurationFiles.add( + rootProject.layout.projectDirectory.file("stability_config.conf") + ) +} +``` + +The configuration file contains fully-qualified type names, one per line. Lines starting with `//` are treated as comments. Wildcard patterns are supported: `*` matches a single package segment and `**` matches across package boundaries. + +``` +// stability_config.conf +com.google.firebase.auth.FirebaseUser +com.example.generated.* +com.example.models.** +``` + +When these files are configured, the compiler plugin treats any parameter type matching these patterns as stable. + +This is particularly useful when your project already uses a stability configuration file for the Compose compiler. By pointing `stabilityConfigurationFiles` to the same file, the stability validation respects the same overrides, keeping the two in sync. diff --git a/docs/gradle-plugin/stability-validation.md b/docs/gradle-plugin/stability-validation.md index deb915c..79452f8 100644 --- a/docs/gradle-plugin/stability-validation.md +++ b/docs/gradle-plugin/stability-validation.md @@ -133,6 +133,7 @@ composeStabilityAnalyzer { // Add stability configuration file // Matches compose's identical property // (see https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file) + // DEPRECATED: use stabilityConfigurationFiles in top-level composeStabilityAnalyzer block stabilityConfigurationFiles.add( rootProject.layout.projectDirectory.file("stability_config.conf") ) @@ -184,6 +185,10 @@ composeStabilityAnalyzer { ### `stabilityConfigurationFiles` +!!! warning "DEPRECATED" + + This option has been deprecated in favor of setting `stabilityConfigurationFiles` in top-level `composeStabilityAnalyzer` block. New option wires stability configuration files directly into compiler plugin to support marking nested class members as stable. See [Stability configuration files](stability-configuration-files.md) for how to use new option. + You can provide stability configuration files to tell `stabilityCheck` which types should be treated as stable, even if the compiler marks them as unstable. This uses the same format as the [Compose compiler's stability configuration file](https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file), so you can reuse the same file for both the compiler and stability validation. ```kotlin diff --git a/mkdocs.yml b/mkdocs.yml index f2e5446..e1972db 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,6 +29,7 @@ nav: - 'Custom Logger': gradle-plugin/custom-logger.md - 'Stability Validation': gradle-plugin/stability-validation.md - 'CI/CD Integration': gradle-plugin/ci-cd.md + - 'Stability configuration files': gradle-plugin/stability-configuration-files.md - 'Stability Concepts': stability-concepts.md - 'Kotlin Version Map': version-map.md - 'Sponsor': sponsor.md diff --git a/stability-compiler/api/stability-compiler.api b/stability-compiler/api/stability-compiler.api index 1883ea9..1054425 100644 --- a/stability-compiler/api/stability-compiler.api +++ b/stability-compiler/api/stability-compiler.api @@ -43,6 +43,13 @@ public final class com/skydoves/compose/stability/compiler/ComposableStabilityIn public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class com/skydoves/compose/stability/compiler/FqNameMatcher { + public fun (Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public final fun matches (Ljava/lang/String;)Z +} + public final class com/skydoves/compose/stability/compiler/ParameterStabilityInfo { public static final field Companion Lcom/skydoves/compose/stability/compiler/ParameterStabilityInfo$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V @@ -92,6 +99,7 @@ public final class com/skydoves/compose/stability/compiler/StabilityAnalyzerComm public final class com/skydoves/compose/stability/compiler/StabilityAnalyzerCommandLineProcessor$Companion { public final fun getOPTION_ENABLED ()Lorg/jetbrains/kotlin/compiler/plugin/CliOption; public final fun getOPTION_PROJECT_DEPENDENCIES ()Lorg/jetbrains/kotlin/compiler/plugin/CliOption; + public final fun getOPTION_STABILITY_CONFIGURATION_FILE ()Lorg/jetbrains/kotlin/compiler/plugin/CliOption; public final fun getOPTION_STABILITY_OUTPUT_DIR ()Lorg/jetbrains/kotlin/compiler/plugin/CliOption; public final fun getOPTION_TRACE_ALL ()Lorg/jetbrains/kotlin/compiler/plugin/CliOption; public final fun getOPTION_TRACE_ALL_THRESHOLD ()Lorg/jetbrains/kotlin/compiler/plugin/CliOption; @@ -101,6 +109,7 @@ public final class com/skydoves/compose/stability/compiler/StabilityAnalyzerConf public static final field INSTANCE Lcom/skydoves/compose/stability/compiler/StabilityAnalyzerConfigurationKeys; public final fun getKEY_ENABLED ()Lorg/jetbrains/kotlin/config/CompilerConfigurationKey; public final fun getKEY_PROJECT_DEPENDENCIES ()Lorg/jetbrains/kotlin/config/CompilerConfigurationKey; + public final fun getKEY_STABILITY_CONFIGURATION_FILES ()Lorg/jetbrains/kotlin/config/CompilerConfigurationKey; public final fun getKEY_STABILITY_OUTPUT_DIR ()Lorg/jetbrains/kotlin/config/CompilerConfigurationKey; public final fun getKEY_TRACE_ALL ()Lorg/jetbrains/kotlin/config/CompilerConfigurationKey; public final fun getKEY_TRACE_ALL_THRESHOLD ()Lorg/jetbrains/kotlin/config/CompilerConfigurationKey; @@ -111,8 +120,8 @@ public final class com/skydoves/compose/stability/compiler/StabilityAnalyzerFirE } public final class com/skydoves/compose/stability/compiler/StabilityAnalyzerIrGenerationExtension : org/jetbrains/kotlin/backend/common/extensions/IrGenerationExtension { - public fun (Ljava/lang/String;Ljava/lang/String;ZI)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;ZILjava/util/List;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZILjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun generate (Lorg/jetbrains/kotlin/ir/declarations/IrModuleFragment;Lorg/jetbrains/kotlin/backend/common/extensions/IrPluginContext;)V public fun getPlatformIntrinsicExtension (Lorg/jetbrains/kotlin/backend/common/LoweringContext;)Lorg/jetbrains/kotlin/backend/common/extensions/IrIntrinsicExtension; public fun getShouldAlsoBeAppliedInKaptStubGenerationMode ()Z @@ -204,8 +213,8 @@ public final class com/skydoves/compose/stability/compiler/lower/RecompositionIr public final class com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer : org/jetbrains/kotlin/backend/common/IrElementTransformerVoidWithContext { public static final field Companion Lcom/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer$Companion; - public fun (Lorg/jetbrains/kotlin/backend/common/extensions/IrPluginContext;Lcom/skydoves/compose/stability/compiler/StabilityInfoCollector;Ljava/util/List;ZI)V - public synthetic fun (Lorg/jetbrains/kotlin/backend/common/extensions/IrPluginContext;Lcom/skydoves/compose/stability/compiler/StabilityInfoCollector;Ljava/util/List;ZIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/jetbrains/kotlin/backend/common/extensions/IrPluginContext;Lcom/skydoves/compose/stability/compiler/StabilityInfoCollector;Ljava/util/List;ZILjava/util/List;)V + public synthetic fun (Lorg/jetbrains/kotlin/backend/common/extensions/IrPluginContext;Lcom/skydoves/compose/stability/compiler/StabilityInfoCollector;Ljava/util/List;ZILjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun visitFunctionNew (Lorg/jetbrains/kotlin/ir/declarations/IrFunction;)Lorg/jetbrains/kotlin/ir/IrStatement; } diff --git a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerCommandLineProcessor.kt b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerCommandLineProcessor.kt index fa43de4..14ea180 100644 --- a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerCommandLineProcessor.kt +++ b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerCommandLineProcessor.kt @@ -21,6 +21,7 @@ import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.config.CompilerConfigurationKey +import java.io.File public object StabilityAnalyzerConfigurationKeys { public val KEY_ENABLED: CompilerConfigurationKey = @@ -37,6 +38,9 @@ public object StabilityAnalyzerConfigurationKeys { public val KEY_TRACE_ALL_THRESHOLD: CompilerConfigurationKey = CompilerConfigurationKey("traceAllThreshold") + + public val KEY_STABILITY_CONFIGURATION_FILES: CompilerConfigurationKey> = + CompilerConfigurationKey>("stabilityConfigurationFiles") } @OptIn(ExperimentalCompilerApi::class) @@ -79,6 +83,14 @@ public class StabilityAnalyzerCommandLineProcessor : CommandLineProcessor { description = "Recomposition count threshold for auto-traced composables", required = false, ) + + public val OPTION_STABILITY_CONFIGURATION_FILE: CliOption = CliOption( + optionName = "stabilityConfigurationFile", + valueDescription = "", + description = "Stability configuration file to take into account when analyzing stability", + required = false, + allowMultipleOccurrences = true, + ) } override val pluginId: String = PLUGIN_ID @@ -89,6 +101,7 @@ public class StabilityAnalyzerCommandLineProcessor : CommandLineProcessor { OPTION_PROJECT_DEPENDENCIES, OPTION_TRACE_ALL, OPTION_TRACE_ALL_THRESHOLD, + OPTION_STABILITY_CONFIGURATION_FILE, ) override fun processOption( @@ -121,6 +134,11 @@ public class StabilityAnalyzerCommandLineProcessor : CommandLineProcessor { StabilityAnalyzerConfigurationKeys.KEY_TRACE_ALL_THRESHOLD, value.toIntOrNull() ?: 2, ) + + OPTION_STABILITY_CONFIGURATION_FILE -> configuration.appendList( + StabilityAnalyzerConfigurationKeys.KEY_STABILITY_CONFIGURATION_FILES, + File(value), + ) } } } diff --git a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerIrGenerationExtension.kt b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerIrGenerationExtension.kt index 5600b35..490cdc6 100644 --- a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerIrGenerationExtension.kt +++ b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerIrGenerationExtension.kt @@ -27,6 +27,7 @@ public class StabilityAnalyzerIrGenerationExtension( private val projectDependencies: String, private val traceAll: Boolean = false, private val traceAllThreshold: Int = 2, + private val stabilityConfigurationFiles: List = emptyList(), ) : IrGenerationExtension { override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { @@ -56,6 +57,21 @@ public class StabilityAnalyzerIrGenerationExtension( emptyList() } + // Construct matchers out of stability configuration files. Mirrors the project-dependencies + // scan above: a malformed or unreadable config file is skipped instead of aborting + // compilation, and the remaining valid files still contribute their matchers. + val stabilityConfigurationMatchers = stabilityConfigurationFiles.flatMap { file -> + try { + if (file.exists()) { + StabilityConfigParser.fromFile(file.absolutePath).stableTypeMatchers + } else { + emptyList() + } + } catch (e: Exception) { + emptyList() + } + } + // Create and run the stability analyzer transformer val transformer = StabilityAnalyzerTransformer( pluginContext = pluginContext, @@ -63,6 +79,7 @@ public class StabilityAnalyzerIrGenerationExtension( projectDependencies = dependencyModules, traceAll = traceAll, traceAllThreshold = traceAllThreshold, + stabilityConfigurationMatchers = stabilityConfigurationMatchers, ) moduleFragment.transformChildrenVoid(transformer) diff --git a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerPluginRegistrar.kt b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerPluginRegistrar.kt index 7646edd..1f4d15a 100644 --- a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerPluginRegistrar.kt +++ b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityAnalyzerPluginRegistrar.kt @@ -56,6 +56,10 @@ public class StabilityAnalyzerPluginRegistrar : CompilerPluginRegistrar() { 2, ) + val stabilityConfigurationFiles = configuration.getList( + StabilityAnalyzerConfigurationKeys.KEY_STABILITY_CONFIGURATION_FILES, + ) + // Register FIR extensions for frontend analysis (K2). // Kotlin 2.4.0 (KT-83341) moved K2 extension registration off the IntelliJ // ProjectExtensionDescriptor mechanism; use the ExtensionStorage-scoped helpers so the @@ -71,6 +75,7 @@ public class StabilityAnalyzerPluginRegistrar : CompilerPluginRegistrar() { projectDependencies = projectDependencies, traceAll = traceAll, traceAllThreshold = traceAllThreshold, + stabilityConfigurationFiles = stabilityConfigurationFiles, ), ) } diff --git a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityConfigParser.kt b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityConfigParser.kt new file mode 100644 index 0000000..f9741af --- /dev/null +++ b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/StabilityConfigParser.kt @@ -0,0 +1,239 @@ +/* + * Designed and developed by 2025 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.compose.stability.compiler + +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.File + +internal const val STABILITY_WILDCARD_SINGLE = '*' +internal const val STABILITY_WILDCARD_MULTI = "**" +internal const val STABILITY_GENERIC_OPEN = '<' +internal const val STABILITY_GENERIC_CLOSE = '>' +internal const val STABILITY_GENERIC_INCLUDE = "*" +internal const val STABILITY_GENERIC_EXCLUDE = "_" +internal const val STABILITY_GENERIC_SEPARATOR = "," +internal const val STABILITY_PACKAGE_SEPARATOR = '.' + +// Based on the https://cs.android.com/android-studio/kotlin/+/master:plugins/compose/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/StabilityConfigParser.kt?q=error%20parsing%20stability%20configuration%20file +internal interface StabilityConfigParser { + val stableTypeMatchers: Set + + companion object { + fun fromFile(filepath: String?): StabilityConfigParser { + if (filepath == null) return StabilityConfigParserImpl(emptyList()) + + // Fail open: a malformed or unreadable configuration file falls back to no matchers rather + // than throwing, so callers can skip instrumentation instead of aborting compilation. + return try { + val confFile = File(filepath) + StabilityConfigParserImpl(confFile.readLines()) + } catch (e: Exception) { + StabilityConfigParserImpl(emptyList()) + } + } + + fun fromLines(lines: List): StabilityConfigParser = StabilityConfigParserImpl(lines) + } +} + +private const val COMMENT_DELIMITER = "//" + +private class StabilityConfigParserImpl(lines: List) : StabilityConfigParser { + override val stableTypeMatchers: Set + + init { + val matchers: MutableSet = mutableSetOf() + + lines.forEachIndexed { index, line -> + val l = line.trim() + if (!l.startsWith(COMMENT_DELIMITER) && !l.isBlank()) { + if (l.contains(COMMENT_DELIMITER)) { // com.foo.bar //comment + error( + errorMessage( + line, + index, + "Comments are only supported at the start of a line.", + ), + ) + } + try { + matchers.add(FqNameMatcher(l)) + } catch (exception: IllegalStateException) { + error( + errorMessage(line, index, exception.message ?: ""), + ) + } + } + } + + stableTypeMatchers = matchers.toSet() + } + + fun errorMessage(line: String, lineNumber: Int, message: String): String = """ + Error parsing stability configuration file on line $lineNumber. + $message + $line + """.trimIndent() +} + +public class FqNameMatcher(private val pattern: String) { + /** + * A key for storing this matcher. + */ + private val key: String + + /** + * Mask for generic type inclusion in stability calculation + */ + private val mask: Int + + private val regex: Regex? + + init { + val matchResult = validPatternMatcher.matchEntire(pattern) + ?: error("$pattern is not a valid pattern") + + val regexPatternBuilder = StringBuilder() + val keyBuilder = StringBuilder() + var hasWildcard = false + + var index = 0 + var hitGenericOpener = false + while (index < pattern.length && !hitGenericOpener) { + when (val c = pattern[index]) { + STABILITY_WILDCARD_SINGLE -> { + hasWildcard = true + if (pattern.getOrNull(index + 1) == STABILITY_WILDCARD_SINGLE) { + regexPatternBuilder.append(PATTERN_MULTI_WILD) + index++ // Skip a char to take the multi + } else { + regexPatternBuilder.append(PATTERN_SINGLE_WILD) + } + } + + STABILITY_PACKAGE_SEPARATOR -> { + if (hasWildcard) { + regexPatternBuilder.append(PATTERN_PACKAGE_SEGMENT) + } else { + keyBuilder.append(STABILITY_PACKAGE_SEPARATOR) + } + } + + STABILITY_GENERIC_OPEN -> { + hitGenericOpener = true + } + + else -> { + if (hasWildcard) { + regexPatternBuilder.append(c) + } else { + keyBuilder.append(c) + } + } + } + + index++ + } + + // Pre-alloc regex for pattern having a wildcard at the end of the string + // because it should be common. + regex = if (regexPatternBuilder.isNotEmpty()) { + when (val regexPattern = regexPatternBuilder.toString()) { + singleWildcardSuffix.pattern -> singleWildcardSuffix + multiWildcardSuffix.pattern -> multiWildcardSuffix + else -> Regex(regexPattern) + } + } else { + null + } + + val genericMask = matchResult.groups["genericmask"] + if (genericMask == null) { + key = keyBuilder.toString() + mask = 0.inv() + } else { + mask = genericMask.value + .split(STABILITY_GENERIC_SEPARATOR) + .map { if (it == STABILITY_GENERIC_INCLUDE) 1 else 0 } + .reduceIndexed { i, acc, flag -> + acc or (flag shl i) + } + + key = keyBuilder.subSequence(0, genericMask.range.first - 1).toString() + } + } + + public fun matches(name: String?): Boolean { + if (pattern == STABILITY_WILDCARD_MULTI) return true + + val nameStr = name + ?.removeGenerics() + ?.removeSuffix("?") // Support for nullable types + ?: return false + if (key.length > nameStr.length) return false + + val suffix = nameStr.substring(key.length) + return when { + regex != null -> nameStr.startsWith(key) && regex.matches(suffix) + else -> key == nameStr + } + } + + private fun String.removeGenerics(): String { + val genericsStart = indexOf("<") + if (genericsStart < 0) { + return this + } + + return substring(0, genericsStart) + } + + override fun equals(other: Any?): Boolean { + val otherMatcher = other as? FqNameMatcher ?: return false + return this.pattern == otherMatcher.pattern + } + + override fun hashCode(): Int = pattern.hashCode() + + private companion object { + private const val PATTERN_SINGLE_WILD = "\\w+" + private const val PATTERN_MULTI_WILD = "[\\w\\.]+" + private const val PATTERN_PACKAGE_SEGMENT = "\\." + + private val validPatternMatcher = + Regex( + "((\\w+\\*{0,2}|\\*{1,2})\\.)*" + + "((\\w+(([*|_],)*[*|_])>)+)|(\\w+\\*{0,2}|\\*{1,2}))", + ) + private val singleWildcardSuffix = Regex(PATTERN_SINGLE_WILD) + private val multiWildcardSuffix = Regex(PATTERN_MULTI_WILD) + } +} diff --git a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt index 1d7bc46..a19a5c0 100644 --- a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt +++ b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt @@ -15,6 +15,7 @@ */ package com.skydoves.compose.stability.compiler.lower +import com.skydoves.compose.stability.compiler.FqNameMatcher import com.skydoves.compose.stability.compiler.StabilityInfoCollector import com.skydoves.compose.stability.runtime.ParameterStability import org.jetbrains.kotlin.backend.common.IrElementTransformerVoidWithContext @@ -49,6 +50,7 @@ public class StabilityAnalyzerTransformer( private val projectDependencies: List = emptyList(), private val traceAll: Boolean = false, private val traceAllThreshold: Int = 2, + private val stabilityConfigurationMatchers: List = emptyList(), ) : IrElementTransformerVoidWithContext() { private val composableFqName = FqName("androidx.compose.runtime.Composable") @@ -469,7 +471,7 @@ public class StabilityAnalyzerTransformer( * 1. Nullable types (MUST be first) * 2. Type parameters (T, E, K, V) - RUNTIME * 3. Function types (including suspend) - STABLE - * 4. Known stable types + * 4. Known stable types and stability configuration files types * 5. @Stable/@Immutable annotations * 6. Primitives * 7. String @@ -550,10 +552,13 @@ public class StabilityAnalyzerTransformer( return ParameterStability.UNSTABLE } - // 3. Known stable types + // 3. Known stable types and stability configuration files types if (isKnownStableType(type)) { return ParameterStability.STABLE } + if (isStabilityConfigurationFileType(type)) { + return ParameterStability.STABLE + } // 4. Check for @Stable or @Immutable annotations if (type.hasStableAnnotation()) { @@ -886,6 +891,11 @@ public class StabilityAnalyzerTransformer( return fqName in KNOWN_STABLE_TYPES } + private fun isStabilityConfigurationFileType(type: IrType): Boolean { + val fqName = type.classFqName?.asString() ?: return false + return stabilityConfigurationMatchers.any { it.matches(fqName) } + } + /** * Check if a function has @Preview annotation (directly or via meta-annotation). * This includes: @@ -1041,6 +1051,7 @@ public class StabilityAnalyzerTransformer( type.render().contains("SuspendFunction") -> "function type" type.hasStableAnnotation() -> "marked @Stable or @Immutable" isKnownStableType(type) -> "known stable type" + isStabilityConfigurationFileType(type) -> "stability configuration file type" else -> "class with no mutable properties" } "UNSTABLE" -> when { diff --git a/stability-gradle/api/stability-gradle.api b/stability-gradle/api/stability-gradle.api index 7dae643..8c6a6e2 100644 --- a/stability-gradle/api/stability-gradle.api +++ b/stability-gradle/api/stability-gradle.api @@ -1,6 +1,7 @@ public abstract class com/skydoves/compose/stability/gradle/StabilityAnalyzerExtension { public fun (Lorg/gradle/api/file/ProjectLayout;Lorg/gradle/api/model/ObjectFactory;Lorg/gradle/api/provider/ProviderFactory;)V public final fun getEnabled ()Lorg/gradle/api/provider/Property; + public final fun getStabilityConfigurationFiles ()Lorg/gradle/api/provider/ListProperty; public final fun getStabilityValidation ()Lcom/skydoves/compose/stability/gradle/StabilityValidationConfig; public final fun getTraceAll ()Lcom/skydoves/compose/stability/gradle/TraceAllConfig; public final fun stabilityValidation (Lorg/gradle/api/Action;)V diff --git a/stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerExtension.kt b/stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerExtension.kt index 1d4a09f..db5cf50 100644 --- a/stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerExtension.kt +++ b/stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerExtension.kt @@ -65,6 +65,18 @@ public abstract class StabilityAnalyzerExtension @Inject constructor( public fun traceAll(action: Action) { action.execute(traceAll) } + + /** + * List of paths to stability configuration files. + * + * For more information, see this link: + * - [AndroidX stability configuration file](https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file) + * + * Default: empty + */ + public val stabilityConfigurationFiles: ListProperty = objects + .listProperty(RegularFile::class.java) + .convention(emptyList()) } /** @@ -267,6 +279,10 @@ public abstract class StabilityValidationConfig @Inject constructor( * * Default: empty */ + @Deprecated( + message = "Replaced by stabilityConfigurationFiles option " + + "in top-level StabilityAnalyzerExtension", + ) public val stabilityConfigurationFiles: ListProperty = objects .listProperty(RegularFile::class.java) .convention(emptyList()) diff --git a/stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerGradlePlugin.kt b/stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerGradlePlugin.kt index fa57a60..f40217f 100644 --- a/stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerGradlePlugin.kt +++ b/stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerGradlePlugin.kt @@ -53,6 +53,7 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin { private const val OPTION_PROJECT_DEPENDENCIES = "projectDependencies" private const val OPTION_TRACE_ALL = "traceAll" private const val OPTION_TRACE_ALL_THRESHOLD = "traceAllThreshold" + private const val OPTION_STABILITY_CONFIGURATION_FILE = "stabilityConfigurationFile" /** * Get the runtime project if available. @@ -157,6 +158,10 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin { val traceAllEnabled = extension.traceAll.enabled.get() && compilationAcceptsTraceAll(kotlinCompilation, extension.traceAll.variants.get()) + val stabilityConfigurationFiles = extension + .stabilityConfigurationFiles + .getOrElse(emptyList()) + listOf( SubpluginOption( key = OPTION_ENABLED, @@ -178,7 +183,12 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin { key = OPTION_TRACE_ALL_THRESHOLD, value = extension.traceAll.threshold.get().toString(), ), - ) + ) + stabilityConfigurationFiles.map { file -> + SubpluginOption( + key = OPTION_STABILITY_CONFIGURATION_FILE, + value = file.asFile.absolutePath, + ) + } } }