diff --git a/src/utils.rs b/src/utils.rs index 94a5a1a..add9430 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,23 +7,27 @@ 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" +/// +/// 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>, ) -> 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)); + // 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)); + } } // If we have a reference file, derive extension from it @@ -44,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 bfeee9f..5251176 100644 --- a/tests/r8-source-file-edge-cases.rs +++ b/tests/r8-source-file-edge-cases.rs @@ -117,6 +117,33 @@ 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 `$`. +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 // =============================================================================