From c1e0d61cf69bc37fa7e85818c7122fb226a1bd7c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 22 Apr 2026 21:06:20 +0200 Subject: [PATCH 1/3] fix(mapping): Synthesize filenames for ComposableSingletons wrappers When a Compose-compiler-generated class like `ComposableSingletons$MainActivityKt` is inlined through an R8 synthetic lambda, the outer Kotlin class has no top-level declaration in the mapping (only its `$$ExternalSyntheticLambda*` children). The synthesizer previously took `split('$').next()`, collapsing `ComposableSingletons$MainActivityKt` to `ComposableSingletons` and producing `ComposableSingletons.java`. Scan all `$`-separated segments for a Kotlin file-class marker (ends in `Kt`) so the inner `MainActivityKt` segment wins and the file resolves to `MainActivity.kt`. Bare top-level `FooKt` and non-Kotlin inner classes are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/utils.rs | 16 ++++++++++------ tests/r8-source-file-edge-cases.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 94a5a1a..937d589 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -13,17 +13,21 @@ pub(crate) fn extract_class_name(full_path: &str) -> Option<&str> { /// For example: ("com.example.Main", Some("Other.kt")) -> "Main.kt" /// For example: ("com.example.MainKt", None) -> "Main.kt" /// For inner classes: ("com.example.Main$Inner", None) -> "Main.java" +/// For Compose-wrapped Kotlin files: ("com.example.ComposableSingletons$MainKt", None) -> "Main.kt" pub(crate) fn synthesize_source_file( class_name: &str, reference_file: Option<&str>, ) -> Option { - let base = extract_class_name(class_name)?; + let last_segment = class_name.split('.').next_back()?; + let mut segments = last_segment.split('$'); + let base = segments.next()?; - // For Kotlin top-level classes (ending in "Kt"), always use .kt extension and strip suffix - // This takes precedence over reference_file since Kt suffix is a strong Kotlin indicator - if base.ends_with("Kt") && base.len() > 2 { - let kotlin_base = &base[..base.len() - 2]; - return Some(format!("{}.kt", kotlin_base)); + // Compiler-generated wrappers (e.g. `ComposableSingletons$MainActivityKt`) + // bury the `Kt` marker in an inner segment, so checking only `base` misses them. + for segment in std::iter::once(base).chain(segments) { + if let Some(kotlin_base) = segment.strip_suffix("Kt").filter(|s| !s.is_empty()) { + return Some(format!("{}.kt", kotlin_base)); + } } // If we have a reference file, derive extension from it diff --git a/tests/r8-source-file-edge-cases.rs b/tests/r8-source-file-edge-cases.rs index bfeee9f..151ade6 100644 --- a/tests/r8-source-file-edge-cases.rs +++ b/tests/r8-source-file-edge-cases.rs @@ -117,6 +117,35 @@ fn test_source_file_name_synthesize_stacktrace() { assert_remap_stacktrace(SOURCE_FILE_NAME_SYNTHESIZE_MAPPING, input, expected); } +// ============================================================================= +// ComposableSingletonsInlinedLambdaStackTrace +// +// When a lambda from a Compose `ComposableSingletons$FileKt` wrapper is inlined +// through an R8 synthetic class, the outer class has no `sourceFile` metadata of +// its own (only its `$$ExternalSyntheticLambda*` children appear in the mapping, +// carrying `R8$$SyntheticClass`). The synthesized filename must be driven by the +// inner `FooKt` segment, not by stripping after the first `$`. +// ============================================================================= + +const COMPOSABLE_SINGLETONS_INLINED_LAMBDA_MAPPING: &str = "\ +host.LocalKt$$ExternalSyntheticLambda0 -> a.b: +# {\"id\":\"sourceFile\",\"fileName\":\"R8$$SyntheticClass\"} + 1:1:kotlin.Unit io.example.ComposableSingletons$MyScreenKt.lambda_1$lambda$2():42:42 -> a +"; + +#[test] +fn test_composable_singletons_inlined_lambda_stacktrace() { + let input = " at a.b.a(SourceFile:1)\n"; + + let expected = " at io.example.ComposableSingletons$MyScreenKt.lambda_1$lambda$2(MyScreen.kt:42)\n"; + + assert_remap_stacktrace( + COMPOSABLE_SINGLETONS_INLINED_LAMBDA_MAPPING, + input, + expected, + ); +} + // ============================================================================= // SourceFileWithNumberAndEmptyStackTrace // ============================================================================= From 4dcda1d5adcddba68d57e3553f958a9df3e5944c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 22 Apr 2026 21:13:46 +0200 Subject: [PATCH 2/3] ref: Combine Kt-marker rationale into one comment, drop R8 fixture header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the restored "Kt suffix takes precedence" note back into the scan-loop comment, and rename the test block header from `ComposableSingletonsInlinedLambdaStackTrace` to a plain description — the sibling blocks in this file name upstream R8 retrace fixtures, which this case is not. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/utils.rs | 5 +++-- tests/r8-source-file-edge-cases.rs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 937d589..86e56ff 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -22,8 +22,9 @@ pub(crate) fn synthesize_source_file( let mut segments = last_segment.split('$'); let base = segments.next()?; - // Compiler-generated wrappers (e.g. `ComposableSingletons$MainActivityKt`) - // bury the `Kt` marker in an inner segment, so checking only `base` misses them. + // Kt suffix is a strong Kotlin indicator and takes precedence over reference_file. + // Compiler-generated wrappers (e.g. `ComposableSingletons$MainActivityKt`) bury the + // marker in an inner segment, so we scan every `$`-segment, not just `base`. for segment in std::iter::once(base).chain(segments) { if let Some(kotlin_base) = segment.strip_suffix("Kt").filter(|s| !s.is_empty()) { return Some(format!("{}.kt", kotlin_base)); diff --git a/tests/r8-source-file-edge-cases.rs b/tests/r8-source-file-edge-cases.rs index 151ade6..b1190b9 100644 --- a/tests/r8-source-file-edge-cases.rs +++ b/tests/r8-source-file-edge-cases.rs @@ -118,7 +118,7 @@ fn test_source_file_name_synthesize_stacktrace() { } // ============================================================================= -// ComposableSingletonsInlinedLambdaStackTrace +// ComposableSingletons inlined lambda // // When a lambda from a Compose `ComposableSingletons$FileKt` wrapper is inlined // through an R8 synthetic class, the outer class has no `sourceFile` metadata of @@ -137,7 +137,8 @@ host.LocalKt$$ExternalSyntheticLambda0 -> a.b: fn test_composable_singletons_inlined_lambda_stacktrace() { let input = " at a.b.a(SourceFile:1)\n"; - let expected = " at io.example.ComposableSingletons$MyScreenKt.lambda_1$lambda$2(MyScreen.kt:42)\n"; + let expected = + " at io.example.ComposableSingletons$MyScreenKt.lambda_1$lambda$2(MyScreen.kt:42)\n"; assert_remap_stacktrace( COMPOSABLE_SINGLETONS_INLINED_LAMBDA_MAPPING, From c75f326339c21db34071484b98342558ff419fae Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 23 Apr 2026 11:03:36 +0200 Subject: [PATCH 3/3] test(utils): Cover synthesize_source_file cases with unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the prose `For example:` list in the doc comment with actual unit tests covering the full matrix — top-level Kt, Kt-over-reference-file, reference-file extension, non-Kotlin inner class, ComposableSingletons wrapper, degenerate bare `Kt` segment, default `.java` fallback. Also convert the block `// ====` header on COMPOSABLE_SINGLETONS_INLINED_LAMBDA_MAPPING into a `///` doc comment on the constant, per review feedback. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/utils.rs | 75 +++++++++++++++++++++++++++--- tests/r8-source-file-edge-cases.rs | 17 +++---- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 86e56ff..add9430 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,13 +7,12 @@ pub(crate) fn extract_class_name(full_path: &str) -> Option<&str> { } /// Synthesizes a source file name from a class name. -/// For Kotlin top-level classes ending in "Kt", the suffix is stripped and ".kt" is used. -/// Otherwise, the extension is derived from the reference file, defaulting to ".java". -/// For example: ("com.example.MainKt", Some("Other.java")) -> "Main.kt" (Kt suffix takes precedence) -/// For example: ("com.example.Main", Some("Other.kt")) -> "Main.kt" -/// For example: ("com.example.MainKt", None) -> "Main.kt" -/// For inner classes: ("com.example.Main$Inner", None) -> "Main.java" -/// For Compose-wrapped Kotlin files: ("com.example.ComposableSingletons$MainKt", None) -> "Main.kt" +/// +/// Any `$`-segment ending in `Kt` wins with a `.kt` extension — this covers +/// top-level Kotlin classes and Compose-compiler wrappers alike. Otherwise the +/// extension comes from `reference_file`, defaulting to `.java`. +/// +/// See the tests at the bottom of this file for the full matrix of cases. pub(crate) fn synthesize_source_file( class_name: &str, reference_file: Option<&str>, @@ -49,3 +48,65 @@ pub fn class_name_to_descriptor(class: &str) -> String { descriptor.push(';'); descriptor } + +#[cfg(test)] +mod tests { + use super::synthesize_source_file; + + #[test] + fn kotlin_top_level_class_uses_kt_extension() { + assert_eq!( + synthesize_source_file("com.example.MainKt", None).as_deref(), + Some("Main.kt") + ); + } + + #[test] + fn kt_suffix_takes_precedence_over_reference_file() { + assert_eq!( + synthesize_source_file("com.example.MainKt", Some("Other.java")).as_deref(), + Some("Main.kt") + ); + } + + #[test] + fn reference_file_extension_is_used_when_no_kt_suffix() { + assert_eq!( + synthesize_source_file("com.example.Main", Some("Other.kt")).as_deref(), + Some("Main.kt") + ); + } + + #[test] + fn non_kotlin_inner_class_falls_back_to_outer_java() { + assert_eq!( + synthesize_source_file("com.example.Main$Inner", None).as_deref(), + Some("Main.java") + ); + } + + #[test] + fn composable_singletons_wrapper_uses_inner_kt_segment() { + assert_eq!( + synthesize_source_file("com.example.ComposableSingletons$MainKt", None).as_deref(), + Some("Main.kt") + ); + } + + #[test] + fn bare_kt_segment_is_not_a_kotlin_marker() { + // Degenerate "Kt" alone (length 2) must not strip to an empty base. + assert_eq!( + synthesize_source_file("com.example.Foo$Kt", None).as_deref(), + Some("Foo.java") + ); + } + + #[test] + fn default_extension_is_java() { + assert_eq!( + synthesize_source_file("com.example.Main", None).as_deref(), + Some("Main.java") + ); + } +} diff --git a/tests/r8-source-file-edge-cases.rs b/tests/r8-source-file-edge-cases.rs index b1190b9..5251176 100644 --- a/tests/r8-source-file-edge-cases.rs +++ b/tests/r8-source-file-edge-cases.rs @@ -117,16 +117,13 @@ fn test_source_file_name_synthesize_stacktrace() { assert_remap_stacktrace(SOURCE_FILE_NAME_SYNTHESIZE_MAPPING, input, expected); } -// ============================================================================= -// ComposableSingletons inlined lambda -// -// When a lambda from a Compose `ComposableSingletons$FileKt` wrapper is inlined -// through an R8 synthetic class, the outer class has no `sourceFile` metadata of -// its own (only its `$$ExternalSyntheticLambda*` children appear in the mapping, -// carrying `R8$$SyntheticClass`). The synthesized filename must be driven by the -// inner `FooKt` segment, not by stripping after the first `$`. -// ============================================================================= - +/// ComposableSingletons inlined lambda +/// +/// When a lambda from a Compose `ComposableSingletons$FileKt` wrapper is inlined +/// through an R8 synthetic class, the outer class has no `sourceFile` metadata of +/// its own (only its `$$ExternalSyntheticLambda*` children appear in the mapping, +/// carrying `R8$$SyntheticClass`). The synthesized filename must be driven by the +/// inner `FooKt` segment, not by stripping after the first `$`. const COMPOSABLE_SINGLETONS_INLINED_LAMBDA_MAPPING: &str = "\ host.LocalKt$$ExternalSyntheticLambda0 -> a.b: # {\"id\":\"sourceFile\",\"fileName\":\"R8$$SyntheticClass\"}