Skip to content

Commit fbb94bf

Browse files
romtsnclaude
andauthored
feat(r8): Add line span expansion and outside-range fallback (#86)
## Summary - When a base entry (no minified range) maps to a span of original lines (e.g., `:42:44`), expand into one frame per original line instead of just the first - When a frame's line number falls outside all mapped member ranges, fall back to class-only remapping (keeping the obfuscated method name and original line) - Adds `matched_any` tracking in cache iterator to prevent false fallback triggers after successful matches Fixes `test_different_line_number_span_stacktrace` and `test_outside_line_range_stacktrace`. ## Test plan - [x] All existing tests pass (50 lib + all integration tests) - [x] `test_different_line_number_span_stacktrace` now passes (was failing: 1 frame instead of 3) - [x] `test_outside_line_range_stacktrace` now passes (was failing: unremapped frame instead of class-only remap) - [x] Only remaining failure is `test_single_line_no_line_number_stacktrace` (Group F — ordering) - [x] `make format` and `make lint` pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0a53eb0 commit fbb94bf

File tree

2 files changed

+100
-5
lines changed

2 files changed

+100
-5
lines changed

src/cache/mod.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ use crate::mapper::{format_cause, format_frames, format_throwable};
8383
use crate::utils::{class_name_to_descriptor, extract_class_name, synthesize_source_file};
8484
use crate::{java, stacktrace, DeobfuscatedSignature, StackFrame, StackTrace, Throwable};
8585

86+
/// Maximum number of frames emitted by span expansion for a single mapping entry.
87+
///
88+
/// R8 uses `0:65535` as the catch-all range for methods with a single unique position:
89+
/// <https://r8.googlesource.com/r8/+/refs/heads/main/doc/retrace.md#catch-all-range-for-methods-with-a-single-unique-position>
90+
///
91+
/// No real method would span more lines than this, so ranges exceeding this cap
92+
/// are treated as malformed and fall through to single-line handling.
93+
const MAX_SPAN_EXPANSION: u32 = 65_535;
94+
8695
pub use raw::{ProguardCache, PRGCACHE_VERSION};
8796

8897
/// Result of looking up member mappings for a frame.
@@ -838,6 +847,8 @@ pub struct RemappedFrameIter<'r, 'data> {
838847
had_mappings: bool,
839848
/// Whether this method has any line-based mappings.
840849
has_line_info: bool,
850+
/// Whether any frame was successfully matched by iterate_with_lines.
851+
matched_any: bool,
841852
/// The source file of the outer class for synthesis.
842853
outer_source_file: Option<&'data str>,
843854
}
@@ -851,6 +862,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> {
851862
skip_count: 0,
852863
had_mappings: false,
853864
has_line_info: false,
865+
matched_any: false,
854866
outer_source_file: None,
855867
}
856868
}
@@ -871,6 +883,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> {
871883
skip_count,
872884
had_mappings,
873885
has_line_info,
886+
matched_any: false,
874887
outer_source_file,
875888
}
876889
}
@@ -883,6 +896,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> {
883896
skip_count: 0,
884897
had_mappings: false,
885898
has_line_info: false,
899+
matched_any: false,
886900
outer_source_file: None,
887901
}
888902
}
@@ -932,9 +946,20 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> {
932946
&mut members,
933947
self.outer_source_file,
934948
self.has_line_info,
949+
&mut self.pending_frames,
935950
);
936-
self.inner = Some((cache, frame, members));
937-
mapped
951+
if mapped.is_some() {
952+
self.matched_any = true;
953+
self.inner = Some((cache, frame, members));
954+
mapped
955+
} else if !self.matched_any && self.has_line_info {
956+
// Outside-range fallback: no member matched the frame line.
957+
// Remap only the class name, keeping the obfuscated method name
958+
// and the original line.
959+
Some(cache.remap_class_only(&frame, self.outer_source_file))
960+
} else {
961+
None
962+
}
938963
} else {
939964
let mapped =
940965
iterate_without_lines(cache, &mut frame, &mut members, self.outer_source_file);
@@ -965,6 +990,7 @@ fn iterate_with_lines<'a>(
965990
members: &mut std::slice::Iter<'_, raw::Member>,
966991
outer_source_file: Option<&str>,
967992
has_line_info: bool,
993+
pending_frames: &mut Vec<StackFrame<'a>>,
968994
) -> Option<StackFrame<'a>> {
969995
let frame_line = frame.line.unwrap_or(0);
970996
for member in members {
@@ -974,10 +1000,46 @@ fn iterate_with_lines<'a>(
9741000
}
9751001
// If the mapping entry has no line range, determine output line.
9761002
if member.endline().unwrap_or(0) == 0 {
977-
let output_line = if member.original_startline().is_none() {
1003+
if member.original_startline().is_none() {
9781004
// Bare method mapping: pass through frame line.
979-
frame.line
980-
} else if member.original_startline().unwrap_or(0) > 0 {
1005+
return map_member_without_lines(
1006+
cache,
1007+
frame,
1008+
member,
1009+
outer_source_file,
1010+
frame.line,
1011+
);
1012+
}
1013+
// Span expansion: if the original range spans multiple lines,
1014+
// emit one frame per original line.
1015+
if member.original_endline != u32::MAX
1016+
&& member.original_endline > member.original_startline().unwrap_or(0)
1017+
&& (member.original_endline - member.original_startline().unwrap_or(0))
1018+
<= MAX_SPAN_EXPANSION
1019+
{
1020+
let first_line = member.original_startline().unwrap_or(0) as usize;
1021+
let last_line = member.original_endline as usize;
1022+
let mut first_frame = None;
1023+
for line in first_line..=last_line {
1024+
if let Some(f) = map_member_without_lines(
1025+
cache,
1026+
frame,
1027+
member,
1028+
outer_source_file,
1029+
Some(line),
1030+
) {
1031+
if first_frame.is_none() {
1032+
first_frame = Some(f);
1033+
} else {
1034+
pending_frames.push(f);
1035+
}
1036+
}
1037+
}
1038+
// Reverse so pop() drains in ascending line order.
1039+
pending_frames.reverse();
1040+
return first_frame;
1041+
}
1042+
let output_line = if member.original_startline().unwrap_or(0) > 0 {
9811043
Some(member.original_startline().unwrap_or(0) as usize)
9821044
} else {
9831045
None

src/mapper.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ use std::fmt;
44
use std::fmt::{Error as FmtError, Write};
55
use std::iter::FusedIterator;
66

7+
/// Maximum number of frames emitted by span expansion for a single mapping entry.
8+
///
9+
/// R8 uses `0:65535` as the catch-all range for methods with a single unique position:
10+
/// <https://r8.googlesource.com/r8/+/refs/heads/main/doc/retrace.md#catch-all-range-for-methods-with-a-single-unique-position>
11+
///
12+
/// No real method would span more lines than this, so ranges exceeding this cap
13+
/// are treated as malformed and fall through to single-line handling.
14+
const MAX_SPAN_EXPANSION: usize = 65_535;
15+
716
use crate::builder::{
817
Member, MethodReceiver, ParsedProguardMapping, RewriteAction, RewriteCondition, RewriteRule,
918
};
@@ -697,6 +706,22 @@ impl<'s> ProguardMapper<'s> {
697706
collected.rewrite_rules.extend(member.rewrite_rules.iter());
698707
continue;
699708
}
709+
// Span expansion: if the original range spans multiple lines,
710+
// emit one frame per original line.
711+
if let Some(oe) = member.original_endline {
712+
let os = member.original_startline.unwrap_or(0);
713+
if oe > os && (oe - os) <= MAX_SPAN_EXPANSION {
714+
for line in os..=oe {
715+
collected.frames.push(map_member_without_lines(
716+
&frame,
717+
member,
718+
Some(line),
719+
));
720+
}
721+
collected.rewrite_rules.extend(member.rewrite_rules.iter());
722+
continue;
723+
}
724+
}
700725
// Single-line: use original_startline if > 0, else None.
701726
let output_line = if member.original_startline.unwrap_or(0) > 0 {
702727
member.original_startline
@@ -712,6 +737,14 @@ impl<'s> ProguardMapper<'s> {
712737
collected.rewrite_rules.extend(member.rewrite_rules.iter());
713738
}
714739
}
740+
741+
// Outside-range fallback: if we had line mappings but nothing matched,
742+
// remap only the class name, keeping the obfuscated method name and original line.
743+
if collected.frames.is_empty() && has_line_info {
744+
collected
745+
.frames
746+
.push(remap_class_only(&frame, frame.file()));
747+
}
715748
} else {
716749
for member in mapping_entries {
717750
// For parameter-based lookups, use original_startline if > 0, else None

0 commit comments

Comments
 (0)