From e947a94a4df6eb548ee4f60b8f9a59873b04da84 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Wed, 12 Aug 2026 14:00:07 +0200 Subject: [PATCH 1/2] feat(opt-report): show local declaration locations --- .../src/codegen/clone_suffix_tests.rs | 1 + .../src/codegen/declared_string_add_tests.rs | 1 + .../src/codegen/emission_order_tests.rs | 1 + .../perry-codegen/src/codegen/entry/tests.rs | 1 + crates/perry-codegen/src/codegen/mod.rs | 5 +- .../src/codegen/number_exactness_tests.rs | 1 + crates/perry-codegen/src/codegen/opts.rs | 9 +- .../src/native_root_coverage/mod.rs | 1 + crates/perry-codegen/src/opt_report/mod.rs | 154 +++++++++++++-- crates/perry-codegen/src/opt_report/render.rs | 115 ++++++++++- .../src/temp_root_coverage/mod.rs | 1 + .../src/type_analysis/numeric/tests.rs | 1 + .../src/type_analysis/strings/tests.rs | 1 + .../tests/app_window_config_options.rs | 1 + .../tests/argless_builtin_extra_args.rs | 1 + .../tests/class_field_store_pointer_test.rs | 1 + .../perry-codegen/tests/class_keys_gc_root.rs | 1 + .../tests/constructor_recursion.rs | 1 + .../tests/i64_spec_ternary_recursion.rs | 1 + .../tests/large_object_barriers.rs | 2 + .../tests/loop_safepoint_purity.rs | 1 + .../tests/macos_bundle_chdir_gate.rs | 1 + .../tests/native_proof_buffer_views.rs | 1 + .../tests/native_proof_regressions.rs | 7 + .../tests/node_test_mock_property_presence.rs | 1 + .../tests/perry_builtin_name_collision.rs | 1 + .../tests/private_guard_declaring_class.rs | 1 + .../tests/scalar_replaced_slot_roots.rs | 1 + .../tests/shadow_slot_hygiene.rs | 7 + .../tests/static_symbol_hygiene.rs | 2 + .../tests/temp_root_operand_temporaries.rs | 1 + crates/perry-codegen/tests/typed_feedback.rs | 1 + .../typed_shape_declared_at_allocation.rs | 1 + .../tests/typed_shape_descriptor.rs | 1 + .../tests/typed_shape_descriptors.rs | 1 + .../src/destructuring/pattern_binding.rs | 2 + .../perry-hir/src/destructuring/var_decl.rs | 3 + crates/perry-hir/src/ir/mod.rs | 2 +- crates/perry-hir/src/ir/module.rs | 21 ++ crates/perry-hir/src/lower/context.rs | 29 +++ crates/perry-hir/src/lower/expr_function.rs | 8 +- crates/perry-hir/src/lower/expr_object.rs | 6 +- crates/perry-hir/src/lower/for_head.rs | 2 +- crates/perry-hir/src/lower/lower_module_fn.rs | 3 + .../perry-hir/src/lower/lowering_context.rs | 3 + crates/perry-hir/src/lower/module_decl.rs | 3 + .../src/lower/module_decl/namespace.rs | 2 + crates/perry-hir/src/lower/stmt.rs | 30 ++- crates/perry-hir/src/lower/stmt_loops.rs | 16 +- crates/perry-hir/src/lower/tests.rs | 64 +++++- crates/perry-hir/src/lower_decl/body_stmt.rs | 45 ++++- .../lower_decl/body_stmt/nested_fn_decl.rs | 3 +- .../perry-hir/src/lower_decl/class_members.rs | 10 +- crates/perry-hir/src/lower_decl/fn_decl.rs | 2 +- .../src/lower_decl/private_members.rs | 4 +- crates/perry-hir/src/stable_hash/module.rs | 3 + crates/perry-hir/src/stable_hash/tests.rs | 14 ++ crates/perry-transform/src/inline/mod.rs | 60 ++++++ .../perry-transform/src/inline/substitute.rs | 48 +++-- crates/perry-transform/src/lib.rs | 1 + crates/perry-transform/src/source_spans.rs | 121 ++++++++++++ crates/perry-transform/src/unroll/mod.rs | 183 ++++++++++++++++-- .../src/commands/compile/collect_modules.rs | 9 +- .../src/commands/compile/run_pipeline.rs | 24 ++- crates/perry/src/commands/compile/types.rs | 27 +-- 65 files changed, 955 insertions(+), 121 deletions(-) create mode 100644 crates/perry-transform/src/source_spans.rs diff --git a/crates/perry-codegen/src/codegen/clone_suffix_tests.rs b/crates/perry-codegen/src/codegen/clone_suffix_tests.rs index 4048953767..aa057999d5 100644 --- a/crates/perry-codegen/src/codegen/clone_suffix_tests.rs +++ b/crates/perry-codegen/src/codegen/clone_suffix_tests.rs @@ -98,6 +98,7 @@ fn module_with(functions: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/src/codegen/declared_string_add_tests.rs b/crates/perry-codegen/src/codegen/declared_string_add_tests.rs index 1d3a009f49..77306836ed 100644 --- a/crates/perry-codegen/src/codegen/declared_string_add_tests.rs +++ b/crates/perry-codegen/src/codegen/declared_string_add_tests.rs @@ -91,6 +91,7 @@ fn module_with(function: Function) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/src/codegen/emission_order_tests.rs b/crates/perry-codegen/src/codegen/emission_order_tests.rs index d4ffe4ce77..7d45f9b9d1 100644 --- a/crates/perry-codegen/src/codegen/emission_order_tests.rs +++ b/crates/perry-codegen/src/codegen/emission_order_tests.rs @@ -151,6 +151,7 @@ fn empty_module(name: &str) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/src/codegen/entry/tests.rs b/crates/perry-codegen/src/codegen/entry/tests.rs index b3e646dd59..4a4f32f794 100644 --- a/crates/perry-codegen/src/codegen/entry/tests.rs +++ b/crates/perry-codegen/src/codegen/entry/tests.rs @@ -86,6 +86,7 @@ fn empty_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 633e6d38a3..a7acd5a1da 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -207,10 +207,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // callbacks before any region is lowered, so their denials carry the // per-element hotness column. No-op when the report is off. crate::opt_report::scan_module(hir); + if let Some(source) = opts.module_source.as_deref() { + crate::opt_report::register_module_source(&hir.name, source, opts.debug_source_line_offset); + } // Module-wide fallback attribution scope. Per-region scopes nest inside // it and restore it on drop, so decisions taken outside any region (the // specialized-ABI entry decision) still know their module. - let _opt_report_module_scope = crate::opt_report::enter_module(&hir.name); + let _opt_report_module_scope = crate::opt_report::enter_module(hir); let mut llmod = LlModule::new_with_fp_flags(&triple, fp_flags); // Null guard global: a zeroed i32 used as a safe dereference target diff --git a/crates/perry-codegen/src/codegen/number_exactness_tests.rs b/crates/perry-codegen/src/codegen/number_exactness_tests.rs index 81e9213422..74ad0c16b6 100644 --- a/crates/perry-codegen/src/codegen/number_exactness_tests.rs +++ b/crates/perry-codegen/src/codegen/number_exactness_tests.rs @@ -141,6 +141,7 @@ fn module_with(functions: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index b47d8cea34..9d638f6362 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -410,10 +410,11 @@ pub struct CompileOptions { /// `js_set_call_location(file, line)` so the thrown TypeError's `.stack` /// shows `at :`. pub debug_locations: bool, - /// #5247: this module's original source text, used at codegen to resolve a - /// `Call`'s `byte_offset` to a 1-based line number. Only set when - /// `debug_locations` is on (avoids cloning source for every module in the - /// common build). `None` falls back to the `` frame. + /// #5247 / #7036: this module's original source text, used at codegen to + /// resolve byte offsets for debug call frames and text optimization-report + /// snippets. Set only when either consumer is active (avoids cloning source + /// for every module in the common build). `None` falls back to the + /// `` frame or a raw byte offset. pub module_source: Option, /// #5247 (CJS-wrap coordinate skew): for a CommonJS module rewritten by /// `cjs_wrap`, `module_source` is the WRAPPED text and `byte_offset`s are in diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs index 3ada630dc1..43dd46a832 100644 --- a/crates/perry-codegen/src/native_root_coverage/mod.rs +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -197,6 +197,7 @@ fn bare_module(name: &str) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/src/opt_report/mod.rs b/crates/perry-codegen/src/opt_report/mod.rs index 552a9c5957..f7a915479c 100644 --- a/crates/perry-codegen/src/opt_report/mod.rs +++ b/crates/perry-codegen/src/opt_report/mod.rs @@ -35,11 +35,9 @@ //! //! ## What it can and cannot see //! -//! v1 reports **function + variable name**, because HIR carries names but not -//! source spans (`Stmt::Let` is `{id, name, ty, mutable, init}`). A -//! `LocalId -> Span` side-table populated during AST→HIR lowering would add -//! `file:line` and source snippets; that is tracked separately and is -//! strictly additive to this output. +//! HIR carries a report-only `LocalId -> LocalSourceSpan` side table. The text +//! renderer resolves it to `file:line:column` plus a source snippet, while the +//! stable JSON schema continues to expose the existing `byte_offset` field. mod callbacks; mod render; @@ -48,7 +46,7 @@ pub(crate) use callbacks::scan_module; pub use render::{render_json, render_text}; use std::cell::RefCell; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; /// `PERRY_OPT_REPORT` gate. Off unless the value is one of `1` / `text` / /// `json` (`0`, `off`, `false`, empty, and unset are all off). @@ -136,6 +134,9 @@ pub(crate) mod test_support { FORCED.store(true, Ordering::Relaxed); claim_recording_thread(); let _ = super::take_entries(); + if let Ok(mut sources) = super::report_sources().lock() { + sources.clear(); + } // The drain above may itself have collapsed leftovers; zero the // counter so a hand-built render test never reads a neighbour's. super::MASKED_BY_DEDUP.store(0, Ordering::Relaxed); @@ -152,6 +153,9 @@ pub(crate) mod test_support { FORCED.store(false, Ordering::Relaxed); claim_recording_thread(); let _ = super::take_entries(); + if let Ok(mut sources) = super::report_sources().lock() { + sources.clear(); + } super::MASKED_BY_DEDUP.store(0, Ordering::Relaxed); Session { _guard: guard } } @@ -454,10 +458,10 @@ pub struct Entry { pub invoked_per_element: Option, /// Extra collector-specific context (class name, offending use site). pub detail: Option, - /// Byte offset in the module source, when the HIR node happens to carry - /// one. Only `Expr::New` does today (#5253, captured for constructor - /// TypeErrors), so this is populated for allocation sites and `None` for - /// ordinary locals — HIR drops positions at lowering. + /// Byte offset in the module source. Allocation sites use their node's + /// offset; named locals resolve through `Module::local_source_spans` to + /// their declaration identifier. The field predates declaration spans, so + /// JSON consumers get the new information without a schema change. pub byte_offset: Option, /// For [`Outcome::Consumed`]: which codegen lowering applied the proof /// (`ptr_shape_set`, `ptr_shape_update`, …). `None` for every other @@ -590,6 +594,9 @@ struct Scope { function: String, region: RegionKind, invoked_per_element: Option, + /// Report-only declaration locations inherited by every nested region in + /// this module. `Arc` keeps region entry O(1). + local_source_spans: Arc>, /// #7170 R0: this region is a function that carries a **return-shape fact** /// (`collectors/ptr_shape_returns.rs`, #7107), so its `return new C(...)` /// sites already feed an existing mechanism. @@ -670,6 +677,7 @@ pub(crate) fn enter_function_region(function: &str, return_shape_producer: bool) function: function.to_string(), region: RegionKind::Function, invoked_per_element: None, + local_source_spans: current_local_source_spans(), return_shape_producer, }; let previous = SCOPE.with(|s| s.borrow_mut().replace(scope)); @@ -702,6 +710,35 @@ fn current_module() -> String { }) } +fn current_local_source_spans() -> Arc> { + SCOPE.with(|s| { + s.borrow() + .as_ref() + .map(|sc| Arc::clone(&sc.local_source_spans)) + .unwrap_or_default() + }) +} + +fn local_byte_offset(local_id: Option, explicit: Option) -> Option { + let local = || { + let id = local_id?; + SCOPE.with(|s| { + s.borrow() + .as_ref() + .and_then(|sc| sc.local_source_spans.get(&id)) + .map(|span| span.start) + }) + }; + match explicit { + // Zero denotes a synthesized node. Prefer a real binding declaration + // when this entry has one, but preserve the existing zero for unbound + // allocation-site rows. + Some(0) => local().or(Some(0)), + Some(offset) => Some(offset), + None => local(), + } +} + /// Kind of the lowering region currently being emitted, for the one /// [`select_explicit`] caller that knows its function name but not its region /// kind (`slot_rep::note_canonical_local`, which holds only an `FnCtx`). @@ -734,6 +771,7 @@ pub(crate) fn enter(module: &str, function: &str, region: RegionKind) -> ScopeGu function: function.to_string(), region, invoked_per_element: None, + local_source_spans: current_local_source_spans(), return_shape_producer: false, }; let previous = SCOPE.with(|s| s.borrow_mut().replace(scope)); @@ -768,6 +806,7 @@ pub(crate) fn enter_closure( function: function.to_string(), region: RegionKind::Closure, invoked_per_element: per_element_role(Some(func_id)), + local_source_spans: current_local_source_spans(), return_shape_producer, }; let previous = SCOPE.with(|s| s.borrow_mut().replace(scope)); @@ -812,6 +851,41 @@ fn per_element_role(func_id: Option) -> Option { static SINK: OnceLock>> = OnceLock::new(); +#[derive(Debug, Clone)] +pub(super) struct ReportSource { + pub source: String, + pub line_offset: u32, +} + +static REPORT_SOURCES: OnceLock>> = + OnceLock::new(); + +fn report_sources() -> &'static Mutex> { + REPORT_SOURCES.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +pub(crate) fn register_module_source(module: &str, source: &str, line_offset: u32) { + if !enabled() { + return; + } + if let Ok(mut sources) = report_sources().lock() { + sources.insert( + module.to_string(), + ReportSource { + source: source.to_string(), + line_offset, + }, + ); + } +} + +pub(super) fn source_snapshot() -> std::collections::HashMap { + report_sources() + .lock() + .map(|sources| sources.clone()) + .unwrap_or_default() +} + fn sink() -> &'static Mutex> { SINK.get_or_init(|| Mutex::new(Vec::new())) } @@ -903,7 +977,7 @@ fn deny_in_scope(d: Denial<'_>, alloc_context: Option, alloc_ordinal: Op loop_depth: d.loop_depth, invoked_per_element: per_element, detail: d.detail, - byte_offset: d.byte_offset, + byte_offset: local_byte_offset(d.local_id, d.byte_offset), site: None, alloc_context, alloc_ordinal, @@ -935,7 +1009,7 @@ pub(crate) fn deny_named(function: &str, region: RegionKind, d: Denial<'_>) { loop_depth: d.loop_depth, invoked_per_element: None, detail: d.detail, - byte_offset: d.byte_offset, + byte_offset: local_byte_offset(d.local_id, d.byte_offset), site: None, alloc_context: None, alloc_ordinal: None, @@ -945,8 +1019,26 @@ pub(crate) fn deny_named(function: &str, region: RegionKind, d: Denial<'_>) { /// Open a module-wide fallback scope. Region scopes nest inside it and /// restore it on drop, so a site with no region of its own still knows which /// module it is in. -pub(crate) fn enter_module(module: &str) -> ScopeGuard { - enter(module, "", RegionKind::ModuleInit) +pub(crate) fn enter_module(module: &perry_hir::Module) -> ScopeGuard { + if !enabled() { + return ScopeGuard { + previous: None, + active: false, + }; + } + let scope = Scope { + module: module.name.clone(), + function: String::from(""), + region: RegionKind::ModuleInit, + invoked_per_element: None, + local_source_spans: Arc::new(module.local_source_spans.clone()), + return_shape_producer: false, + }; + let previous = SCOPE.with(|s| s.borrow_mut().replace(scope)); + ScopeGuard { + previous, + active: true, + } } /// Record a value that *did* get an unboxed representation, attributed to the @@ -995,7 +1087,7 @@ pub(crate) fn select( loop_depth, invoked_per_element: per_element, detail, - byte_offset: None, + byte_offset: local_byte_offset(local_id, None), site: None, alloc_context: None, alloc_ordinal: None, @@ -1033,7 +1125,7 @@ pub(crate) fn select_explicit( loop_depth: 0, invoked_per_element: None, detail: None, - byte_offset: None, + byte_offset: local_byte_offset(local_id, None), site: None, alloc_context: None, alloc_ordinal: None, @@ -1104,7 +1196,7 @@ pub(crate) fn consume( loop_depth: 0, invoked_per_element: None, detail: Some(format!("consumed at {site}")), - byte_offset: None, + byte_offset: local_byte_offset(local_id, None), site: Some(site.to_string()), alloc_context: None, alloc_ordinal: None, @@ -1154,7 +1246,7 @@ pub(crate) fn unconsumed(u: Unconsumed<'_>) { loop_depth: 0, invoked_per_element: None, detail: u.detail, - byte_offset: None, + byte_offset: local_byte_offset(u.local_id, None), site: None, alloc_context: None, alloc_ordinal: None, @@ -1241,6 +1333,32 @@ mod tests { assert!(entry("f", 3, None).rank() < entry("f", 1, None).rank()); } + #[test] + fn local_span_populates_the_existing_byte_offset_field() { + let session = test_support::Session::start(); + let mut module = perry_hir::Module::new("span.ts"); + module + .local_source_spans + .insert(7, perry_hir::LocalSourceSpan { start: 24, end: 29 }); + let _module_scope = enter_module(&module); + deny(Denial { + position: Position::Local, + name: "boxed", + local_id: Some(7), + analysis: Analysis::PtrShape, + rule: "rule 2 (containment)", + reason: "escapes", + tier: Tier::Fixable, + issue: None, + loop_depth: 0, + detail: None, + byte_offset: None, + }); + let entries = session.entries(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].byte_offset, Some(24)); + } + /// One analysis, one spelling. The summary rows use [`Analysis::as_str`] /// and the per-entry `analysis` field uses serde; a consumer that keys on /// the serde spelling (the census does, to split canonical-slot into its diff --git a/crates/perry-codegen/src/opt_report/render.rs b/crates/perry-codegen/src/opt_report/render.rs index 46932c6ee0..4cef2178a2 100644 --- a/crates/perry-codegen/src/opt_report/render.rs +++ b/crates/perry-codegen/src/opt_report/render.rs @@ -7,10 +7,10 @@ //! one. The wins are reported alongside the misses on purpose: a report that //! only nags is less useful, and less trusted, than one that shows the ratio. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt::Write as _; -use super::{Analysis, Entry, Outcome, Tier}; +use super::{Analysis, Entry, Outcome, Position, ReportSource, Tier}; /// Bump when a field is removed or its meaning changes. Additive fields do /// not require a bump — consumers must ignore unknown keys. @@ -72,6 +72,73 @@ fn hotness(e: &Entry) -> String { } } +struct ResolvedSource<'a> { + line: u32, + column: u32, + text: &'a str, +} + +fn resolve_source<'a>( + e: &Entry, + sources: &'a HashMap, +) -> Option> { + let source = sources.get(&e.module)?; + let offset = e.byte_offset?.checked_sub(1)? as usize; + if offset > source.source.len() || !source.source.is_char_boundary(offset) { + return None; + } + let before = &source.source[..offset]; + let wrapped_line = before.bytes().filter(|b| *b == b'\n').count() as u32 + 1; + if wrapped_line <= source.line_offset { + return None; + } + let line_start = before.rfind('\n').map_or(0, |index| index + 1); + let line_end = source.source[offset..] + .find('\n') + .map_or(source.source.len(), |relative| offset + relative); + let text = source.source[line_start..line_end].trim_end_matches('\r'); + Some(ResolvedSource { + line: wrapped_line - source.line_offset, + column: (offset - line_start + 1) as u32, + text, + }) +} + +fn write_source(out: &mut String, e: &Entry, sources: &HashMap) { + let Some(offset) = e.byte_offset else { + return; + }; + let Some(location) = resolve_source(e, sources) else { + let _ = writeln!(out, " source byte offset {offset}"); + return; + }; + let width = location.line.to_string().len(); + let desired_caret_width = match e.position { + Position::AllocSite => 3, // the existing offset points at `new` + _ => e.name.len(), + }; + let caret_width = desired_caret_width.max(1).min( + location + .text + .len() + .saturating_sub(location.column.saturating_sub(1) as usize) + .max(1), + ); + let _ = writeln!( + out, + " --> {}:{}:{}", + e.module, location.line, location.column + ); + let _ = writeln!(out, " {:>width$} | {}", location.line, location.text); + let _ = writeln!( + out, + " {:>width$} | {}{}", + "", + " ".repeat(location.column.saturating_sub(1) as usize), + "^".repeat(caret_width), + ); +} + /// Human-readable report. /// /// Reads the process-global de-duplication counter. **Tests must use @@ -80,11 +147,20 @@ fn hotness(e: &Entry) -> String { /// line without holding a `test_support::Session` would observe whatever a /// concurrently-running collector test last stored. pub fn render_text(entries: &[Entry]) -> String { - render_text_with(entries, super::masked_by_dedup()) + let sources = super::source_snapshot(); + render_text_with_sources(entries, super::masked_by_dedup(), &sources) } /// [`render_text`] with the de-duplication count supplied explicitly. pub fn render_text_with(entries: &[Entry], masked: usize) -> String { + render_text_with_sources(entries, masked, &HashMap::new()) +} + +fn render_text_with_sources( + entries: &[Entry], + masked: usize, + sources: &HashMap, +) -> String { let mut out = String::new(); let tallies = tally(entries); @@ -232,6 +308,7 @@ pub fn render_text_with(entries: &[Entry], masked: usize) -> String { if let Some(issue) = &e.issue { let _ = writeln!(out, " tracking: {issue}"); } + write_source(&mut out, e, sources); } if wasted.len() > MAX_ROWS_PER_TIER { let _ = writeln!(out, " ... and {} more", wasted.len() - MAX_ROWS_PER_TIER); @@ -294,9 +371,7 @@ pub fn render_text_with(entries: &[Entry], masked: usize) -> String { if let Some(ordinal) = e.alloc_ordinal { let _ = writeln!(out, " allocation site #{ordinal} in this region"); } - if let Some(offset) = e.byte_offset { - let _ = writeln!(out, " source byte offset {offset}"); - } + write_source(&mut out, e, sources); if let Some(issue) = &e.issue { let _ = writeln!(out, " tracking: {issue}"); } @@ -330,6 +405,7 @@ pub fn render_text_with(entries: &[Entry], masked: usize) -> String { e.region.as_str(), e.function, ); + write_source(&mut out, e, sources); } if wins.len() > MAX_ROWS_PER_TIER { let _ = writeln!(out, " ... and {} more", wins.len() - MAX_ROWS_PER_TIER); @@ -337,10 +413,7 @@ pub fn render_text_with(entries: &[Entry], masked: usize) -> String { out.push('\n'); } - out.push_str( - "Values are reported by function and binding name: HIR keeps names through\n\ - lowering but not source spans, so there is no file:line yet.\n", - ); + out.push_str("Values are reported by function, binding name, and declaration location.\n"); out } @@ -566,6 +639,28 @@ mod tests { } } + #[test] + fn declaration_offset_renders_file_line_column_and_snippet() { + let source = "function build() {\n const boxed = makeValue();\n}\n"; + let mut entry = denied(Analysis::PtrShape, "boxed", "rule 2 (containment)"); + entry.byte_offset = Some(source.find("boxed").unwrap() as u32 + 1); + let sources = HashMap::from([( + "batch.ts".to_string(), + ReportSource { + source: source.to_string(), + line_offset: 0, + }, + )]); + + let text = render_text_with_sources(&[entry], 0, &sources); + assert!(text.contains("--> batch.ts:2:9"), "got:\n{text}"); + assert!( + text.contains("2 | const boxed = makeValue();"), + "got:\n{text}" + ); + assert!(text.contains("| ^^^^^"), "got:\n{text}"); + } + /// The #7034 §0 acceptance case: when a representation promotes nothing, /// the text report must SAY so, not leave the reader to infer it from an /// absent section. diff --git a/crates/perry-codegen/src/temp_root_coverage/mod.rs b/crates/perry-codegen/src/temp_root_coverage/mod.rs index 72a464a2b0..dbd784dfdf 100644 --- a/crates/perry-codegen/src/temp_root_coverage/mod.rs +++ b/crates/perry-codegen/src/temp_root_coverage/mod.rs @@ -131,6 +131,7 @@ fn module_with_init(name: &str, init: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/src/type_analysis/numeric/tests.rs b/crates/perry-codegen/src/type_analysis/numeric/tests.rs index 83d637dc17..99ee0ac8f3 100644 --- a/crates/perry-codegen/src/type_analysis/numeric/tests.rs +++ b/crates/perry-codegen/src/type_analysis/numeric/tests.rs @@ -111,6 +111,7 @@ fn probe_module(name: &str, params: Vec, body: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/src/type_analysis/strings/tests.rs b/crates/perry-codegen/src/type_analysis/strings/tests.rs index 4cccf1b5ac..d234c6bf42 100644 --- a/crates/perry-codegen/src/type_analysis/strings/tests.rs +++ b/crates/perry-codegen/src/type_analysis/strings/tests.rs @@ -107,6 +107,7 @@ fn concat_probe_ir(property: &str) -> String { class_display_names: HashMap::new(), closure_source_text: HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: HashMap::new(), }; let opts = CompileOptions { diff --git a/crates/perry-codegen/tests/app_window_config_options.rs b/crates/perry-codegen/tests/app_window_config_options.rs index a2e887720a..6b66d87e7d 100644 --- a/crates/perry-codegen/tests/app_window_config_options.rs +++ b/crates/perry-codegen/tests/app_window_config_options.rs @@ -112,6 +112,7 @@ fn module(name: &str, body: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/argless_builtin_extra_args.rs b/crates/perry-codegen/tests/argless_builtin_extra_args.rs index 23eb8d64cb..421aec6800 100644 --- a/crates/perry-codegen/tests/argless_builtin_extra_args.rs +++ b/crates/perry-codegen/tests/argless_builtin_extra_args.rs @@ -93,6 +93,7 @@ fn module_with_init(init: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/class_field_store_pointer_test.rs b/crates/perry-codegen/tests/class_field_store_pointer_test.rs index 075060b99d..a9e60f9e4f 100644 --- a/crates/perry-codegen/tests/class_field_store_pointer_test.rs +++ b/crates/perry-codegen/tests/class_field_store_pointer_test.rs @@ -214,6 +214,7 @@ fn module_with_new(class: Class, args: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/class_keys_gc_root.rs b/crates/perry-codegen/tests/class_keys_gc_root.rs index 8487b930c0..cdd1cb3782 100644 --- a/crates/perry-codegen/tests/class_keys_gc_root.rs +++ b/crates/perry-codegen/tests/class_keys_gc_root.rs @@ -150,6 +150,7 @@ fn module_with_declared_field_class() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/constructor_recursion.rs b/crates/perry-codegen/tests/constructor_recursion.rs index 0445c2f3f3..e8aa5274a7 100644 --- a/crates/perry-codegen/tests/constructor_recursion.rs +++ b/crates/perry-codegen/tests/constructor_recursion.rs @@ -157,6 +157,7 @@ fn module_with_recursive_constructor_return() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs index afadc04af3..8e1d2bcfe0 100644 --- a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs +++ b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs @@ -153,6 +153,7 @@ fn module_with(functions: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/large_object_barriers.rs b/crates/perry-codegen/tests/large_object_barriers.rs index c76f5939ad..0d345cc417 100644 --- a/crates/perry-codegen/tests/large_object_barriers.rs +++ b/crates/perry-codegen/tests/large_object_barriers.rs @@ -139,6 +139,7 @@ fn module_with_large_pointer_array_literal(element_count: usize) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -209,6 +210,7 @@ fn module_with_large_local_array_push(element_count: usize) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/loop_safepoint_purity.rs b/crates/perry-codegen/tests/loop_safepoint_purity.rs index 488c91d315..1ca85e0752 100644 --- a/crates/perry-codegen/tests/loop_safepoint_purity.rs +++ b/crates/perry-codegen/tests/loop_safepoint_purity.rs @@ -122,6 +122,7 @@ fn module_with_init(name: &str, init: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs index 18169626c5..807a6a4de9 100644 --- a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs +++ b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs @@ -93,6 +93,7 @@ fn empty_entry_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/native_proof_buffer_views.rs b/crates/perry-codegen/tests/native_proof_buffer_views.rs index dbbdc4b18c..526028599b 100644 --- a/crates/perry-codegen/tests/native_proof_buffer_views.rs +++ b/crates/perry-codegen/tests/native_proof_buffer_views.rs @@ -140,6 +140,7 @@ fn module_with_classes_and_params( class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 21a46d5a82..c22b3b60c4 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -134,6 +134,7 @@ fn module_with_classes_and_params( class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -7513,6 +7514,7 @@ fn typed_f64_clone_test_module(use_any_param: bool) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -7687,6 +7689,7 @@ fn typed_i1_clone_test_module_named(name: &str) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -7779,6 +7782,7 @@ fn typed_string_clone_test_module(case: &str) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), }; match case { @@ -7893,6 +7897,7 @@ fn typed_i1_numeric_predicate_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -7970,6 +7975,7 @@ fn typed_i1_i32_predicate_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -8096,6 +8102,7 @@ fn typed_i32_return_module(case: &str) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/node_test_mock_property_presence.rs b/crates/perry-codegen/tests/node_test_mock_property_presence.rs index f846b76806..fd935ffab1 100644 --- a/crates/perry-codegen/tests/node_test_mock_property_presence.rs +++ b/crates/perry-codegen/tests/node_test_mock_property_presence.rs @@ -107,6 +107,7 @@ fn fixture_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/perry_builtin_name_collision.rs b/crates/perry-codegen/tests/perry_builtin_name_collision.rs index d6ebf81268..a195f6ec46 100644 --- a/crates/perry-codegen/tests/perry_builtin_name_collision.rs +++ b/crates/perry-codegen/tests/perry_builtin_name_collision.rs @@ -135,6 +135,7 @@ fn module_with(imports: Vec, init: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/private_guard_declaring_class.rs b/crates/perry-codegen/tests/private_guard_declaring_class.rs index 0cf816e968..72e3eedece 100644 --- a/crates/perry-codegen/tests/private_guard_declaring_class.rs +++ b/crates/perry-codegen/tests/private_guard_declaring_class.rs @@ -106,6 +106,7 @@ fn module_with(classes: Vec, body: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs b/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs index a9aae62d21..3c280825bc 100644 --- a/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs +++ b/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs @@ -143,6 +143,7 @@ fn module_with_init(name: &str, init: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/shadow_slot_hygiene.rs b/crates/perry-codegen/tests/shadow_slot_hygiene.rs index 210e7615c2..9f0196680c 100644 --- a/crates/perry-codegen/tests/shadow_slot_hygiene.rs +++ b/crates/perry-codegen/tests/shadow_slot_hygiene.rs @@ -162,6 +162,7 @@ fn shadow_hygiene_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -220,6 +221,7 @@ fn top_level_shadow_module(name: &str) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -339,6 +341,7 @@ fn flat_const_row_alias_shadow_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -399,6 +402,7 @@ fn reassigned_any_shadow_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -474,6 +478,7 @@ fn mixed_any_alias_shadow_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -557,6 +562,7 @@ fn closure_captured_write_shadow_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -1115,6 +1121,7 @@ fn canonical_str_shadow_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/static_symbol_hygiene.rs b/crates/perry-codegen/tests/static_symbol_hygiene.rs index 558f28152e..c4cf48fb46 100644 --- a/crates/perry-codegen/tests/static_symbol_hygiene.rs +++ b/crates/perry-codegen/tests/static_symbol_hygiene.rs @@ -154,6 +154,7 @@ fn duplicate_static_module() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } @@ -192,6 +193,7 @@ fn class_with_instance_and_static_method() -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs index 7e8aa469d8..d673774904 100644 --- a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs +++ b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs @@ -143,6 +143,7 @@ fn module_with_init(name: &str, init: Vec) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index ed5a368c1f..14bbbf3d2f 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -246,6 +246,7 @@ fn module_with_classes( class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs b/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs index 4399ee9fe3..21c015876c 100644 --- a/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs +++ b/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs @@ -218,6 +218,7 @@ fn module_with_new(class: Class, arg_count: usize) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/typed_shape_descriptor.rs b/crates/perry-codegen/tests/typed_shape_descriptor.rs index 1dbe6c89c5..a6edb548e5 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptor.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptor.rs @@ -150,6 +150,7 @@ fn module_with_new(class: Class) -> Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-codegen/tests/typed_shape_descriptors.rs b/crates/perry-codegen/tests/typed_shape_descriptors.rs index abf10582c4..6fda8ed9e2 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptors.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptors.rs @@ -128,6 +128,7 @@ fn base_module(name: &str, body: Vec, interfaces: Vec) -> Modul class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-hir/src/destructuring/pattern_binding.rs b/crates/perry-hir/src/destructuring/pattern_binding.rs index 7b1a08057a..24e18f1870 100644 --- a/crates/perry-hir/src/destructuring/pattern_binding.rs +++ b/crates/perry-hir/src/destructuring/pattern_binding.rs @@ -424,6 +424,7 @@ pub(crate) fn lower_pattern_binding_into( // binding so it shadows (never aliases) an outer hoisted `var`. None => ctx.define_local(name.clone(), ty.clone()), }; + ctx.record_local_source_span(id, ident.id.span); if !mutable { ctx.mark_local_immutable(id); } @@ -671,6 +672,7 @@ pub(crate) fn lower_pattern_binding_into( } None => ctx.define_local(name.clone(), ty.clone()), }; + ctx.record_local_source_span(id, assign.key.span); let init_value = if let Some(default_expr) = &assign.value { // Materialize the property read into a temp so we diff --git a/crates/perry-hir/src/destructuring/var_decl.rs b/crates/perry-hir/src/destructuring/var_decl.rs index b16f38beef..3edcfc0db7 100644 --- a/crates/perry-hir/src/destructuring/var_decl.rs +++ b/crates/perry-hir/src/destructuring/var_decl.rs @@ -1,6 +1,7 @@ //! Lowering of variable declarations that may carry destructuring patterns. use super::*; +use swc_common::Spanned; use super::var_decl_sources::*; @@ -231,6 +232,7 @@ pub(crate) fn lower_var_decl_with_destructuring( } else { ctx.define_local(name.clone(), ty.clone()) }; + ctx.record_local_source_span(id, ident.id.span); if !mutable { ctx.mark_local_immutable(id); } @@ -435,6 +437,7 @@ pub(crate) fn lower_var_decl_with_destructuring( } else { ctx.define_local(name.clone(), ty.clone()) }; + ctx.record_local_source_span(id, decl.name.span()); if !mutable { ctx.mark_local_immutable(id); } diff --git a/crates/perry-hir/src/ir/mod.rs b/crates/perry-hir/src/ir/mod.rs index 118a24ed19..46e46c530b 100644 --- a/crates/perry-hir/src/ir/mod.rs +++ b/crates/perry-hir/src/ir/mod.rs @@ -38,7 +38,7 @@ pub use constants::{ }; // ---- module.rs ---- -pub use module::Module; +pub use module::{LocalSourceSpan, Module}; // ---- widget.rs ---- pub use widget::{ diff --git a/crates/perry-hir/src/ir/module.rs b/crates/perry-hir/src/ir/module.rs index da1896ecf0..821909ba69 100644 --- a/crates/perry-hir/src/ir/module.rs +++ b/crates/perry-hir/src/ir/module.rs @@ -3,6 +3,19 @@ use super::*; use crate::types::{FuncId, Type}; +/// Source range of a user-visible local binding. +/// +/// Offsets use SWC's 1-based `BytePos` coordinates. Keeping this metadata in a +/// side table lets optimization reports resolve a `LocalId` without making +/// source locations part of every HIR statement or expression. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LocalSourceSpan { + /// Inclusive start byte position. + pub start: u32, + /// Exclusive end byte position. + pub end: u32, +} + /// A complete HIR module (corresponds to one TypeScript file) #[derive(Debug, Clone)] pub struct Module { @@ -138,6 +151,13 @@ pub struct Module { /// generator registry, which drives `%AsyncGeneratorFunction%` / `%Async /// Generator%` intrinsic resolution. Populated by `transform_generators`. pub async_generator_funcs: std::collections::HashSet, + /// Source declaration spans for user-visible locals, keyed by `LocalId`. + /// + /// This is observational metadata for diagnostics and optimization + /// reports. It deliberately does not participate in the stable HIR hash: + /// moving a declaration without changing its HIR must not invalidate an + /// otherwise reusable object file. + pub local_source_spans: std::collections::HashMap, /// Number of leading parameter-prologue statements (default-param guards + /// destructuring binding stmts) in each generator / async-generator /// function body, keyed by func_id. Per spec, generator parameter binding @@ -184,6 +204,7 @@ impl Module { class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), gen_param_prologue_len: std::collections::HashMap::new(), } } diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index ad92e0dce0..4f6eec2a90 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -70,6 +70,7 @@ impl LoweringContext { let tagged_template_site_salt = stable_module_salt(&module_identity); Self { next_local_id: 0, + local_source_spans: HashMap::new(), next_global_id: 0, next_func_id: 0, next_class_id: start_class_id, // Start from the provided ID to avoid collisions across modules @@ -793,6 +794,34 @@ impl LoweringContext { id } + /// Define a user-visible local and retain its source declaration span for + /// diagnostics and optimization reports. + pub(crate) fn define_local_spanned( + &mut self, + name: String, + ty: Type, + span: swc_common::Span, + ) -> LocalId { + let id = self.define_local(name, ty); + self.record_local_source_span(id, span); + id + } + + /// Attach a declaration span to an already-created local. This covers + /// forward/hoisted registrations whose `LocalId` is allocated before the + /// declaration itself is lowered. + pub(crate) fn record_local_source_span(&mut self, id: LocalId, span: swc_common::Span) { + if span.lo.0 == 0 || span.hi.0 <= span.lo.0 { + return; + } + self.local_source_spans + .entry(id) + .or_insert(LocalSourceSpan { + start: span.lo.0, + end: span.hi.0, + }); + } + pub(crate) fn define_sloppy_implicit_global(&mut self, name: String) -> LocalId { if let Some((_, id, _)) = self .locals diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 380884ac1a..c505b0fa19 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -17,6 +17,7 @@ use crate::types::{LocalId, Type}; use anyhow::Result; +use swc_common::Spanned; use swc_ecma_ast as ast; use crate::analysis::{ @@ -233,7 +234,7 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) -> let param_name = get_pat_name(param)?; let is_rest = is_rest_param(param); let param_ty = get_pat_type(param, ctx); - let param_id = ctx.define_local(param_name.clone(), param_ty.clone()); + let param_id = ctx.define_local_spanned(param_name.clone(), param_ty.clone(), param.span()); ctx.shadow_native_instance_if_present(¶m_name); params.push(Param { id: param_id, @@ -544,6 +545,9 @@ fn lower_named_fn_expr( ctx.exit_scope(wrapper_scope); return Ok(inner); } + if let Some(ident) = &fn_expr.ident { + ctx.record_local_source_span(self_id, ident.span); + } let wrapper_func_id = ctx.fresh_func(); let body = vec![ @@ -629,7 +633,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul continue; } let is_rest = is_rest_param(¶m.pat); - let param_id = ctx.define_local(param_name.clone(), Type::Any); + let param_id = ctx.define_local_spanned(param_name.clone(), Type::Any, param.span); ctx.shadow_native_instance_if_present(¶m_name); params.push(Param { id: param_id, diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index 2875b01186..96f2f922fe 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -15,6 +15,7 @@ use crate::types::{LocalId, Type}; use anyhow::Result; +use swc_common::Spanned; use swc_ecma_ast as ast; use crate::analysis::{ @@ -243,7 +244,7 @@ fn lower_method_prop( continue; } let param_type = extract_param_type_with_ctx(¶m.pat, Some(ctx)); - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + let param_id = ctx.define_local_spanned(param_name.clone(), param_type.clone(), param.span); ctx.shadow_native_instance_if_present(¶m_name); params.push(Param { id: param_id, @@ -531,7 +532,8 @@ fn lower_accessor_prop( if param_name != "this" { let param_type = extract_param_type_with_ctx(pat, Some(ctx)); let param_default = get_param_default(ctx, pat)?; - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + let param_id = + ctx.define_local_spanned(param_name.clone(), param_type.clone(), pat.span()); ctx.shadow_native_instance_if_present(¶m_name); params.push(Param { id: param_id, diff --git a/crates/perry-hir/src/lower/for_head.rs b/crates/perry-hir/src/lower/for_head.rs index 5e7b836aa4..9a101c3bf3 100644 --- a/crates/perry-hir/src/lower/for_head.rs +++ b/crates/perry-hir/src/lower/for_head.rs @@ -58,7 +58,7 @@ pub(crate) fn predefine_for_head( match &decl.name { ast::Pat::Ident(ident) => { let name = ident.id.sym.to_string(); - let id = ctx.define_local(name.clone(), elem_ty); + let id = ctx.define_local_spanned(name.clone(), elem_ty, ident.id.span); if var_decl.kind == ast::VarDeclKind::Const { // `for (const k in/of …) { k = 1; }` → TypeError. ctx.mark_local_immutable(id); diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 3011153d39..ca992b511b 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -627,6 +627,7 @@ pub fn lower_module_full( && ctx.lookup_local(&func_name).is_none() { let local_id = ctx.define_local(func_name.clone(), Type::Any); + ctx.record_local_source_span(local_id, fn_decl.ident.span); ctx.function_valued_locals.insert(local_id); module.init.push(Stmt::Let { id: local_id, @@ -1289,5 +1290,7 @@ pub fn lower_module_full( } } + module.local_source_spans = std::mem::take(&mut ctx.local_source_spans); + Ok((module, ctx.next_class_id)) } diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 617f1e4a0e..3a7f50a224 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -70,6 +70,9 @@ pub(crate) struct MixinFn { pub struct LoweringContext { /// Counter for generating unique local IDs pub(crate) next_local_id: LocalId, + /// User-visible declaration spans keyed by the `LocalId` allocated during + /// lowering. Synthetic compiler locals intentionally have no entry. + pub(crate) local_source_spans: HashMap, /// Counter for generating unique global IDs // #854: initialized in `new` but not yet read by the lowerer (globals are // allocated through a different path today). Kept for the ID-counter set. diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 8f30d150ce..b31ffc253b 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -6,6 +6,7 @@ use crate::types::{LocalId, Type}; use anyhow::Result; +use swc_common::Spanned; use swc_ecma_ast as ast; use super::*; @@ -1191,6 +1192,7 @@ pub(crate) fn lower_module_decl( } else { ctx.define_local(name.clone(), ty.clone()) }; + ctx.record_local_source_span(id, decl.name.span()); module.init.push(Stmt::Let { id, name: name.clone(), @@ -1269,6 +1271,7 @@ pub(crate) fn lower_module_decl( } else { ctx.define_local(name.clone(), ty.clone()) }; + ctx.record_local_source_span(id, decl.name.span()); module.init.push(Stmt::Let { id, name: name.clone(), diff --git a/crates/perry-hir/src/lower/module_decl/namespace.rs b/crates/perry-hir/src/lower/module_decl/namespace.rs index a29d05bfd1..a82a378cac 100644 --- a/crates/perry-hir/src/lower/module_decl/namespace.rs +++ b/crates/perry-hir/src/lower/module_decl/namespace.rs @@ -3,6 +3,7 @@ use crate::types::Type; use anyhow::Result; +use swc_common::Spanned; use swc_ecma_ast as ast; use super::*; @@ -318,6 +319,7 @@ pub(crate) fn lower_namespace_as_class( } else { ctx.define_local(name.clone(), ty.clone()) }; + ctx.record_local_source_span(id, decl.name.span()); module.init.push(Stmt::Let { id, name: name.clone(), diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 27f706d2d6..6a88de8fe7 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -6,6 +6,7 @@ use crate::types::{LocalId, Type}; use anyhow::{anyhow, Result}; +use swc_common::Spanned; use swc_ecma_ast as ast; use super::*; @@ -179,7 +180,7 @@ pub(crate) fn collect_for_of_pattern_leaves( match pat { ast::Pat::Ident(ident) => { let name = ident.id.sym.to_string(); - let id = ctx.define_local(name.clone(), Type::Any); + let id = ctx.define_local_spanned(name.clone(), Type::Any, ident.id.span); out.push((name, id)); } ast::Pat::Array(arr_pat) => { @@ -196,7 +197,7 @@ pub(crate) fn collect_for_of_pattern_leaves( match prop { ast::ObjectPatProp::Assign(assign) => { let name = assign.key.sym.to_string(); - let id = ctx.define_local(name.clone(), Type::Any); + let id = ctx.define_local_spanned(name.clone(), Type::Any, assign.key.span); out.push((name, id)); } ast::ObjectPatProp::KeyValue(kv) => { @@ -1188,6 +1189,7 @@ pub(crate) fn lower_stmt( Some(id) => id, None => ctx.define_local(binding_name.clone(), Type::Any), }; + ctx.record_local_source_span(class_local_id, class_decl.ident.span); module.init.push(Stmt::Let { id: class_local_id, name: binding_name, @@ -1514,7 +1516,11 @@ pub(crate) fn lower_stmt( let name = get_binding_name(&decl.name)?; let init_expr = decl.init.as_ref().map(|e| lower_expr(ctx, e)).transpose()?; - let id = ctx.define_local(name.clone(), Type::Any); + let id = ctx.define_local_spanned( + name.clone(), + Type::Any, + decl.name.span(), + ); ctx.var_hoisted_ids.insert(id); module.init.push(Stmt::Let { id, @@ -1554,7 +1560,11 @@ pub(crate) fn lower_stmt( let ty = for_init_binding_type(ctx, decl, &name); let init_expr = decl.init.as_ref().map(|e| lower_expr(ctx, e)).transpose()?; - let id = ctx.define_local(name.clone(), ty.clone()); + let id = ctx.define_local_spanned( + name.clone(), + ty.clone(), + decl.name.span(), + ); module.init.push(Stmt::Let { id, name, @@ -1596,7 +1606,11 @@ pub(crate) fn lower_stmt( .as_ref() .map(|e| lower_expr(ctx, e)) .transpose()?; - let id = ctx.define_local(name.clone(), ty.clone()); + let id = ctx.define_local_spanned( + name.clone(), + ty.clone(), + decl.name.span(), + ); Some(Box::new(Stmt::Let { id, name, @@ -1662,7 +1676,11 @@ pub(crate) fn lower_stmt( let mut binding_stmts: Vec = Vec::new(); let param = if let Some(ref pat) = catch_clause.param { let param_name = get_pat_name(pat)?; - let param_id = ctx.define_local(param_name.clone(), Type::Any); + let param_id = if matches!(pat, ast::Pat::Ident(_)) { + ctx.define_local_spanned(param_name.clone(), Type::Any, pat.span()) + } else { + ctx.define_local(param_name.clone(), Type::Any) + }; // Destructured catch binding — `catch ([a, b = d()])` / // `catch ({ message })`: bind the pattern leaves off the // exception value before the user body runs. diff --git a/crates/perry-hir/src/lower/stmt_loops.rs b/crates/perry-hir/src/lower/stmt_loops.rs index 70e9181efc..5e0602a4b4 100644 --- a/crates/perry-hir/src/lower/stmt_loops.rs +++ b/crates/perry-hir/src/lower/stmt_loops.rs @@ -12,6 +12,7 @@ use crate::types::{LocalId, Type}; use anyhow::{anyhow, Result}; +use swc_common::Spanned; use swc_ecma_ast as ast; use super::*; @@ -1569,7 +1570,11 @@ pub(super) fn lower_stmt_for_of_inner( match &decl.name { ast::Pat::Ident(ident) => { let name = ident.id.sym.to_string(); - let id = ctx.define_local(name.clone(), elem_type.clone()); + let id = ctx.define_local_spanned( + name.clone(), + elem_type.clone(), + ident.id.span, + ); if var_decl.kind == ast::VarDeclKind::Const { // `for (const x of …) { x = 1; }` → TypeError. ctx.mark_local_immutable(id); @@ -1592,7 +1597,11 @@ pub(super) fn lower_stmt_for_of_inner( } else { Type::Any }; - let id = ctx.define_local(name.clone(), var_type); + let id = ctx.define_local_spanned( + name.clone(), + var_type, + ident.id.span, + ); ids.push((name, id)); } } @@ -1608,7 +1617,8 @@ pub(super) fn lower_stmt_for_of_inner( } _ => { let name = get_binding_name(&decl.name)?; - let id = ctx.define_local(name.clone(), Type::Any); + let id = + ctx.define_local_spanned(name.clone(), Type::Any, decl.name.span()); vec![(name, id)] } } diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 48b5065d8e..1103a3a791 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -8,7 +8,7 @@ #![cfg(test)] use super::*; -use crate::ir::EnumValue; +use crate::ir::{EnumValue, Stmt}; use crate::types::{Type, TypeParam}; fn make_ctx() -> LoweringContext { @@ -25,6 +25,68 @@ fn test_lower_define_and_lookup_local() { assert_eq!(ctx.lookup_local_type("x"), Some(&Type::Number)); } +#[test] +fn local_declaration_span_survives_ast_to_hir_lowering() { + let source = "function build() {\n const boxed = makeValue();\n return boxed;\n}\n"; + let module = perry_parser::parse_typescript(source, "span.ts").expect("source parses"); + let hir = super::lower_module(&module, "span.ts", "span.ts").expect("source lowers"); + let function = hir + .functions + .iter() + .find(|function| function.name == "build") + .expect("function is lowered"); + let local_id = function + .body + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { id, name, .. } if name == "boxed" => Some(*id), + _ => None, + }) + .expect("boxed local is lowered"); + let span = hir + .local_source_spans + .get(&local_id) + .expect("boxed local retains its declaration span"); + let start = source.find("boxed").expect("binding occurs") as u32 + 1; + assert_eq!(span.start, start); + assert_eq!(span.end, start + "boxed".len() as u32); +} + +#[test] +fn source_spans_cover_export_loop_catch_and_method_bindings() { + let source = r#"export const exportedBox = {}; +function build(paramBox: unknown) { + for (let loopBox = 0; loopBox < 1; loopBox++) {} + try { throw 1; } catch (caughtBox) {} + const objBox = { method(methodBox: unknown) { return methodBox; } }; + return paramBox; +} +"#; + let module = perry_parser::parse_typescript(source, "span-kinds.ts").expect("source parses"); + let hir = + super::lower_module(&module, "span-kinds.ts", "span-kinds.ts").expect("source lowers"); + let starts: std::collections::HashSet = hir + .local_source_spans + .values() + .map(|span| span.start) + .collect(); + + for name in [ + "exportedBox", + "paramBox", + "loopBox", + "caughtBox", + "objBox", + "methodBox", + ] { + let expected = source.find(name).expect("binding occurs") as u32 + 1; + assert!( + starts.contains(&expected), + "missing declaration span for {name} at {expected}: {starts:?}" + ); + } +} + #[test] fn test_lower_function_registration() { let mut ctx = make_ctx(); diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index cee53b72ba..66142dd8cc 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1,5 +1,6 @@ use crate::types::{LocalId, Type}; use anyhow::{anyhow, Result}; +use swc_common::Spanned; use swc_ecma_ast as ast; use crate::analysis::*; @@ -372,6 +373,7 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Result Result Result Result = Vec::new(); let param = if let Some(ref pat) = catch_clause.param { let param_name = get_pat_name(pat)?; - let param_id = ctx.define_local(param_name.clone(), Type::Any); + let param_id = if matches!(pat, ast::Pat::Ident(_)) { + ctx.define_local_spanned(param_name.clone(), Type::Any, pat.span()) + } else { + ctx.define_local(param_name.clone(), Type::Any) + }; ctx.shadow_native_instance_if_present(¶m_name); ctx.shadow_native_module_if_present(¶m_name); // Destructured catch binding — `catch ([a, b = d()])` / @@ -1587,7 +1606,11 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { let name = ident.id.sym.to_string(); - let id = ctx.define_local(name.clone(), item_hir_type.clone()); + let id = ctx.define_local_spanned( + name.clone(), + item_hir_type.clone(), + ident.id.span, + ); if var_decl.kind == ast::VarDeclKind::Const { // `for (const x of …) { x = 1; }` → TypeError. ctx.mark_local_immutable(id); @@ -1604,7 +1627,11 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result { let name = get_binding_name(&decl.name)?; - let id = ctx.define_local(name.clone(), Type::Any); + let id = ctx.define_local_spanned( + name.clone(), + Type::Any, + decl.name.span(), + ); vec![(name, id)] } } diff --git a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs index 75a5f607b2..e6628de62a 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs @@ -60,6 +60,7 @@ pub(super) fn lower_nested_fn_decl( ctx.lookup_local(&func_name) .unwrap_or_else(|| ctx.define_local(func_name.clone(), Type::Any)) }; + ctx.record_local_source_span(local_id, fn_decl.ident.span); let scope_mark = ctx.enter_scope(); let class_expr_capture_mark = ctx.body_class_expr_captures.len(); @@ -78,7 +79,7 @@ pub(super) fn lower_nested_fn_decl( continue; } let is_rest = is_rest_param(¶m.pat); - let param_id = ctx.define_local(param_name.clone(), Type::Any); + let param_id = ctx.define_local_spanned(param_name.clone(), Type::Any, param.span); ctx.shadow_native_instance_if_present(¶m_name); ctx.shadow_native_module_if_present(¶m_name); params.push(Param { diff --git a/crates/perry-hir/src/lower_decl/class_members.rs b/crates/perry-hir/src/lower_decl/class_members.rs index b6ec139b2f..c17d613eaf 100644 --- a/crates/perry-hir/src/lower_decl/class_members.rs +++ b/crates/perry-hir/src/lower_decl/class_members.rs @@ -43,7 +43,8 @@ pub fn lower_constructor( let param_type = extract_param_type_with_ctx(&p.pat, Some(ctx)); let param_default = get_param_default(ctx, &p.pat)?; let is_rest = is_rest_param(&p.pat); - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + let param_id = + ctx.define_local_spanned(param_name.clone(), param_type.clone(), p.span); ctx.shadow_native_instance_if_present(¶m_name); ctx.shadow_native_module_if_present(¶m_name); params.push(Param { @@ -100,7 +101,8 @@ pub fn lower_constructor( (name, ty, default) } }; - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + let param_id = + ctx.define_local_spanned(param_name.clone(), param_type.clone(), ts_prop.span); ctx.shadow_native_instance_if_present(¶m_name); ctx.shadow_native_module_if_present(¶m_name); // Record this param for synthesizing `this.field = param` assignment @@ -508,7 +510,7 @@ pub fn lower_class_method_with_name( } let param_type = extract_param_type_with_ctx(¶m.pat, Some(ctx)); let is_rest = is_rest_param(¶m.pat); - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + let param_id = ctx.define_local_spanned(param_name.clone(), param_type.clone(), param.span); ctx.shadow_native_instance_if_present(¶m_name); ctx.shadow_native_module_if_present(¶m_name); params.push(Param { @@ -838,7 +840,7 @@ pub fn lower_setter_method_with_name( // mandates (test262 scope-*-setter-paramsbody-var-open) — then emit the // `if (param === undefined) param = ` prologue below. let param_default = get_param_default(ctx, ¶m.pat)?; - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + let param_id = ctx.define_local_spanned(param_name.clone(), param_type.clone(), param.span); ctx.shadow_native_instance_if_present(¶m_name); ctx.shadow_native_module_if_present(¶m_name); params.push(Param { diff --git a/crates/perry-hir/src/lower_decl/fn_decl.rs b/crates/perry-hir/src/lower_decl/fn_decl.rs index 8ddc8b07b2..7cce371dfb 100644 --- a/crates/perry-hir/src/lower_decl/fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/fn_decl.rs @@ -116,7 +116,7 @@ pub fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> Result continue; } let param_type = extract_param_type_with_ctx(¶m.pat, Some(ctx)); - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + let param_id = ctx.define_local_spanned(param_name.clone(), param_type.clone(), param.span); ctx.shadow_native_instance_if_present(¶m_name); ctx.shadow_native_module_if_present(¶m_name); let is_rest = is_rest_param(¶m.pat); diff --git a/crates/perry-hir/src/lower_decl/private_members.rs b/crates/perry-hir/src/lower_decl/private_members.rs index bce716e80b..e7d2685e40 100644 --- a/crates/perry-hir/src/lower_decl/private_members.rs +++ b/crates/perry-hir/src/lower_decl/private_members.rs @@ -105,7 +105,7 @@ pub fn lower_private_method( let param_name = get_pat_name(¶m.pat)?; let param_type = extract_param_type_with_ctx(¶m.pat, Some(ctx)); let is_rest = is_rest_param(¶m.pat); - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + let param_id = ctx.define_local_spanned(param_name.clone(), param_type.clone(), param.span); ctx.shadow_native_instance_if_present(¶m_name); ctx.shadow_native_module_if_present(¶m_name); params.push(Param { @@ -302,7 +302,7 @@ pub fn lower_private_setter( for param in &method.function.params { let param_name = get_pat_name(¶m.pat)?; let param_type = extract_param_type_with_ctx(¶m.pat, Some(ctx)); - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + let param_id = ctx.define_local_spanned(param_name.clone(), param_type.clone(), param.span); ctx.shadow_native_instance_if_present(¶m_name); ctx.shadow_native_module_if_present(¶m_name); params.push(Param { diff --git a/crates/perry-hir/src/stable_hash/module.rs b/crates/perry-hir/src/stable_hash/module.rs index 52a0ebbaa1..903fd32685 100644 --- a/crates/perry-hir/src/stable_hash/module.rs +++ b/crates/perry-hir/src/stable_hash/module.rs @@ -39,6 +39,9 @@ impl SH for Module { class_display_names, closure_source_text, async_generator_funcs, + // Observational source metadata does not affect emitted code and + // therefore must not invalidate the object cache. + local_source_spans: _, gen_param_prologue_len, } = self; name.hash(h); diff --git a/crates/perry-hir/src/stable_hash/tests.rs b/crates/perry-hir/src/stable_hash/tests.rs index 708a81a655..afdedb04c7 100644 --- a/crates/perry-hir/src/stable_hash/tests.rs +++ b/crates/perry-hir/src/stable_hash/tests.rs @@ -20,6 +20,20 @@ fn same_hir_hashes_identically() { assert_eq!(a, b, "same module must hash identically across calls"); } +#[test] +fn report_only_local_spans_do_not_change_the_stable_hash() { + let module = empty_module(); + let mut with_span = module.clone(); + with_span + .local_source_spans + .insert(4, LocalSourceSpan { start: 10, end: 15 }); + assert_eq!( + hash_module(&module), + hash_module(&with_span), + "source-only report metadata must not invalidate an object-cache key" + ); +} + #[test] fn behavior_change_changes_hash() { let mut m1 = empty_module(); diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 17785d2a37..81cb21bb06 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -102,6 +102,23 @@ pub fn inline_functions( extra_methods: &HashMap<(String, String), MethodCandidate>, extra_class_fields: &HashMap<(String, String), String>, extra_anon_classes: &HashMap, +) { + let first_fresh_id = find_max_local_id_in_module(module).saturating_add(1); + let span_remaps = crate::source_spans::RemapSession::start(first_fresh_id); + inline_functions_inner( + module, + extra_methods, + extra_class_fields, + extra_anon_classes, + ); + span_remaps.finish(&mut module.local_source_spans); +} + +fn inline_functions_inner( + module: &mut Module, + extra_methods: &HashMap<(String, String), MethodCandidate>, + extra_class_fields: &HashMap<(String, String), String>, + extra_anon_classes: &HashMap, ) { // ── Cross-module anon-class propagation ── // Anon-shape classes (`__AnonShape_`) are content-addressed by @@ -799,4 +816,47 @@ mod tests { let class_names: Vec<&str> = module.classes.iter().map(|c| c.name.as_str()).collect(); assert_eq!(class_names, vec!["__AnonShape_aaa", "__AnonShape_bbb"]); } + + #[test] + fn inlined_body_local_keeps_its_source_span() { + let original_id = 7; + let span = perry_hir::LocalSourceSpan { start: 30, end: 35 }; + let body = vec![ + Stmt::Let { + id: original_id, + name: "boxed".into(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Object(Vec::new())), + }, + Stmt::Return(Some(Expr::LocalGet(original_id))), + ]; + let mut module = Module::new("inline-spans.ts"); + module.functions.push(function(1, body)); + module.init.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + })); + module.local_source_spans.insert(original_id, span); + + inline_functions( + &mut module, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + + let cloned_id = module + .init + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { id, name, .. } if name == "boxed" => Some(*id), + _ => None, + }) + .expect("the function body should be inlined into module init"); + assert_ne!(cloned_id, original_id); + assert_eq!(module.local_source_spans.get(&cloned_id), Some(&span)); + } } diff --git a/crates/perry-transform/src/inline/substitute.rs b/crates/perry-transform/src/inline/substitute.rs index ac82a2be6a..9b0f87acdf 100644 --- a/crates/perry-transform/src/inline/substitute.rs +++ b/crates/perry-transform/src/inline/substitute.rs @@ -7,6 +7,15 @@ pub fn substitute_locals( expr: &mut Expr, param_map: &HashMap, next_local_id: &mut LocalId, +) { + crate::source_spans::record_expr_remaps(param_map); + substitute_locals_inner(expr, param_map, next_local_id); +} + +fn substitute_locals_inner( + expr: &mut Expr, + param_map: &HashMap, + next_local_id: &mut LocalId, ) { match expr { Expr::LocalGet(id) => { @@ -16,7 +25,7 @@ pub fn substitute_locals( return; } Expr::LocalSet(id, value) => { - substitute_locals(value, param_map, next_local_id); + substitute_locals_inner(value, param_map, next_local_id); if let Some(Expr::LocalGet(new_id)) = param_map.get(id) { *id = *new_id; } @@ -69,10 +78,10 @@ pub fn substitute_locals( } => { for p in params.iter_mut() { if let Some(d) = &mut p.default { - substitute_locals(d, param_map, next_local_id); + substitute_locals_inner(d, param_map, next_local_id); } } - substitute_locals_in_stmts(body, param_map, next_local_id); + substitute_locals_in_stmts_inner(body, param_map, next_local_id); captures.retain_mut(|id| match param_map.get(id) { Some(Expr::LocalGet(new_id)) => { *id = *new_id; @@ -100,7 +109,7 @@ pub fn substitute_locals( // The walker is exhaustive on Expr — adding a new variant to ir.rs // without updating walker.rs is a compile error. walk_expr_children_mut(expr, &mut |child| { - substitute_locals(child, param_map, next_local_id) + substitute_locals_inner(child, param_map, next_local_id) }); } @@ -288,6 +297,15 @@ pub fn substitute_locals_in_stmts( stmts: &mut Vec, param_map: &HashMap, next_local_id: &mut LocalId, +) { + crate::source_spans::record_expr_remaps(param_map); + substitute_locals_in_stmts_inner(stmts, param_map, next_local_id); +} + +fn substitute_locals_in_stmts_inner( + stmts: &mut Vec, + param_map: &HashMap, + next_local_id: &mut LocalId, ) { for stmt in stmts.iter_mut() { match stmt { @@ -297,26 +315,26 @@ pub fn substitute_locals_in_stmts( *id = *new_id; } if let Some(expr) = init { - substitute_locals(expr, param_map, next_local_id); + substitute_locals_inner(expr, param_map, next_local_id); } } Stmt::Expr(expr) | Stmt::Return(Some(expr)) | Stmt::Throw(expr) => { - substitute_locals(expr, param_map, next_local_id); + substitute_locals_inner(expr, param_map, next_local_id); } Stmt::If { condition, then_branch, else_branch, } => { - substitute_locals(condition, param_map, next_local_id); - substitute_locals_in_stmts(then_branch, param_map, next_local_id); + substitute_locals_inner(condition, param_map, next_local_id); + substitute_locals_in_stmts_inner(then_branch, param_map, next_local_id); if let Some(else_b) = else_branch { - substitute_locals_in_stmts(else_b, param_map, next_local_id); + substitute_locals_in_stmts_inner(else_b, param_map, next_local_id); } } Stmt::While { condition, body } => { - substitute_locals(condition, param_map, next_local_id); - substitute_locals_in_stmts(body, param_map, next_local_id); + substitute_locals_inner(condition, param_map, next_local_id); + substitute_locals_in_stmts_inner(body, param_map, next_local_id); } Stmt::For { init, @@ -326,18 +344,18 @@ pub fn substitute_locals_in_stmts( } => { if let Some(init_stmt) = init { let mut init_vec = vec![*init_stmt.clone()]; - substitute_locals_in_stmts(&mut init_vec, param_map, next_local_id); + substitute_locals_in_stmts_inner(&mut init_vec, param_map, next_local_id); if init_vec.len() == 1 { **init_stmt = init_vec.remove(0); } } if let Some(cond) = condition { - substitute_locals(cond, param_map, next_local_id); + substitute_locals_inner(cond, param_map, next_local_id); } if let Some(upd) = update { - substitute_locals(upd, param_map, next_local_id); + substitute_locals_inner(upd, param_map, next_local_id); } - substitute_locals_in_stmts(body, param_map, next_local_id); + substitute_locals_in_stmts_inner(body, param_map, next_local_id); } Stmt::PreallocateBoxes(ids) | Stmt::PreallocateTdzBoxes(ids) => { // Issue #569: remap each id in the prealloc list. Inlining diff --git a/crates/perry-transform/src/lib.rs b/crates/perry-transform/src/lib.rs index 99947dd0c1..03f00dd604 100644 --- a/crates/perry-transform/src/lib.rs +++ b/crates/perry-transform/src/lib.rs @@ -14,6 +14,7 @@ pub mod generator; pub mod i18n; pub mod inline; pub mod prop_cse; +mod source_spans; pub mod state_desugar; pub mod unroll; diff --git a/crates/perry-transform/src/source_spans.rs b/crates/perry-transform/src/source_spans.rs new file mode 100644 index 0000000000..766ebd0cc1 --- /dev/null +++ b/crates/perry-transform/src/source_spans.rs @@ -0,0 +1,121 @@ +//! Report-only source-span propagation for transforms that clone LocalIds. + +use perry_hir::types::LocalId; +use perry_hir::{Expr, LocalSourceSpan}; +use std::cell::RefCell; +use std::collections::HashMap; + +struct ActiveRemaps { + first_fresh_id: LocalId, + pairs: Vec<(LocalId, LocalId)>, +} + +thread_local! { + static ACTIVE_REMAPS: RefCell> = const { RefCell::new(None) }; +} + +/// Scoped collector used by a transform that recursively creates LocalIds. +/// +/// The inliner is synchronous but modules may be transformed on different +/// rayon workers, so the collector is thread-local. Only ids at or above the +/// transform's initial fresh-id boundary are recorded: a parameter mapped to +/// an existing caller local must keep the caller's location, not acquire the +/// callee parameter's location. +pub(crate) struct RemapSession { + finished: bool, +} + +impl RemapSession { + pub(crate) fn start(first_fresh_id: LocalId) -> Self { + ACTIVE_REMAPS.with(|active| { + assert!( + active.borrow().is_none(), + "nested source-span remap session" + ); + let _ = active.borrow_mut().replace(ActiveRemaps { + first_fresh_id, + pairs: Vec::new(), + }); + }); + Self { finished: false } + } + + pub(crate) fn finish(mut self, spans: &mut HashMap) { + let pairs = ACTIVE_REMAPS.with(|active| { + active + .borrow_mut() + .take() + .map(|state| state.pairs) + .unwrap_or_default() + }); + self.finished = true; + + // Nested inlining can clone a local that was itself minted by an + // earlier inline. Iterate to a fixed point so the original span walks + // through an arbitrarily long old -> fresh -> fresher chain. + loop { + let mut changed = false; + for &(old_id, new_id) in &pairs { + if spans.contains_key(&new_id) { + continue; + } + if let Some(span) = spans.get(&old_id).copied() { + spans.insert(new_id, span); + changed = true; + } + } + if !changed { + break; + } + } + } +} + +impl Drop for RemapSession { + fn drop(&mut self) { + if !self.finished { + ACTIVE_REMAPS.with(|active| { + active.borrow_mut().take(); + }); + } + } +} + +pub(crate) fn record_expr_remaps(remaps: &HashMap) { + ACTIVE_REMAPS.with(|active| { + let mut active = active.borrow_mut(); + let Some(state) = active.as_mut() else { + return; + }; + state + .pairs + .extend(remaps.iter().filter_map(|(&old_id, expr)| { + let Expr::LocalGet(new_id) = expr else { + return None; + }; + (*new_id >= state.first_fresh_id && *new_id != old_id).then_some((old_id, *new_id)) + })); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cloned_span_propagates_through_transitive_fresh_ids() { + let session = RemapSession::start(100); + record_expr_remaps(&HashMap::from([(7, Expr::LocalGet(100))])); + record_expr_remaps(&HashMap::from([(100, Expr::LocalGet(101))])); + // Existing caller ids are deliberately ignored. + record_expr_remaps(&HashMap::from([(7, Expr::LocalGet(9))])); + + let span = LocalSourceSpan { start: 20, end: 25 }; + let mut spans = HashMap::from([(7, span)]); + session.finish(&mut spans); + + assert_eq!(spans.get(&100), Some(&span)); + assert_eq!(spans.get(&101), Some(&span)); + assert_eq!(spans.get(&9), None); + } +} diff --git a/crates/perry-transform/src/unroll/mod.rs b/crates/perry-transform/src/unroll/mod.rs index 71dda219d3..d618175238 100644 --- a/crates/perry-transform/src/unroll/mod.rs +++ b/crates/perry-transform/src/unroll/mod.rs @@ -105,6 +105,7 @@ pub fn unroll_static_loops(module: &mut Module) { // unrolled iteration's `() => captured` read the same global.) let mut next_local_id = compute_max_local_id(module).saturating_add(1); let mut next_func_id = compute_max_func_id(module).saturating_add(1); + let mut source_span_remaps = Vec::new(); let mut init_changed = false; unroll_in_stmts( @@ -112,6 +113,7 @@ pub fn unroll_static_loops(module: &mut Module) { &mut init_changed, &mut next_local_id, &mut next_func_id, + &mut source_span_remaps, ); if init_changed { module.init_was_unrolled = true; @@ -123,6 +125,7 @@ pub fn unroll_static_loops(module: &mut Module) { &mut changed, &mut next_local_id, &mut next_func_id, + &mut source_span_remaps, ); if changed { f.was_unrolled = true; @@ -136,6 +139,7 @@ pub fn unroll_static_loops(module: &mut Module) { &mut changed, &mut next_local_id, &mut next_func_id, + &mut source_span_remaps, ); if changed { ctor.was_unrolled = true; @@ -148,6 +152,7 @@ pub fn unroll_static_loops(module: &mut Module) { &mut changed, &mut next_local_id, &mut next_func_id, + &mut source_span_remaps, ); if changed { m.was_unrolled = true; @@ -160,6 +165,7 @@ pub fn unroll_static_loops(module: &mut Module) { &mut changed, &mut next_local_id, &mut next_func_id, + &mut source_span_remaps, ); if changed { g.was_unrolled = true; @@ -172,6 +178,7 @@ pub fn unroll_static_loops(module: &mut Module) { &mut changed, &mut next_local_id, &mut next_func_id, + &mut source_span_remaps, ); if changed { s.was_unrolled = true; @@ -183,6 +190,19 @@ pub fn unroll_static_loops(module: &mut Module) { // benefit from unroll if they contained loops, but the gain is // marginal and we'd need an Expr-level unroll variant. Skip. } + + // An unrolled body gets fresh LocalIds for every per-iteration binding. + // Keep the report-only source side table in sync so an optimization + // decision about one of those clones still points to the declaration in + // the original source. Apply the mappings in production order: nested + // loops may clone an id that was itself minted by an earlier unroll. + for (old_id, new_id) in source_span_remaps { + if old_id != new_id { + if let Some(span) = module.local_source_spans.get(&old_id).copied() { + module.local_source_spans.insert(new_id, span); + } + } + } } /// Walk `stmts` and unroll any qualifying `Stmt::For` in place. Recurses @@ -200,6 +220,7 @@ fn unroll_in_stmts( changed: &mut bool, next_local_id: &mut LocalId, next_func_id: &mut FuncId, + source_span_remaps: &mut Vec<(LocalId, LocalId)>, ) { // #2308: compute the set of loop-body-declared ids that escape their // loop (function-scoped `var`s read outside the loop body) ONCE per @@ -217,7 +238,14 @@ fn unroll_in_stmts( // both wasteful and wrong (a nested block can't see all the `var`'s // use sites). The `_rec` variant carries the same set down. let protected = compute_loop_escaping_ids(stmts); - unroll_in_stmts_rec(stmts, changed, next_local_id, next_func_id, &protected); + unroll_in_stmts_rec( + stmts, + changed, + next_local_id, + next_func_id, + &protected, + source_span_remaps, + ); } fn unroll_in_stmts_rec( @@ -226,6 +254,7 @@ fn unroll_in_stmts_rec( next_local_id: &mut LocalId, next_func_id: &mut FuncId, protected: &std::collections::HashSet, + source_span_remaps: &mut Vec<(LocalId, LocalId)>, ) { let mut i = 0; while i < stmts.len() { @@ -242,9 +271,16 @@ fn unroll_in_stmts_rec( next_local_id, next_func_id, protected, + source_span_remaps, ); - if let Some(unrolled) = try_unroll_for(&stmts[i], next_local_id, next_func_id, protected) { + if let Some(unrolled) = try_unroll_for( + &stmts[i], + next_local_id, + next_func_id, + protected, + source_span_remaps, + ) { // Replace stmts[i] with `unrolled`'s contents. let inserted = unrolled.len(); stmts.splice(i..=i, unrolled); @@ -267,6 +303,7 @@ fn recurse_into_nested( next_local_id: &mut LocalId, next_func_id: &mut FuncId, protected: &std::collections::HashSet, + source_span_remaps: &mut Vec<(LocalId, LocalId)>, ) { match stmt { Stmt::If { @@ -274,22 +311,57 @@ fn recurse_into_nested( else_branch, .. } => { - unroll_in_stmts_rec(then_branch, changed, next_local_id, next_func_id, protected); + unroll_in_stmts_rec( + then_branch, + changed, + next_local_id, + next_func_id, + protected, + source_span_remaps, + ); if let Some(eb) = else_branch { - unroll_in_stmts_rec(eb, changed, next_local_id, next_func_id, protected); + unroll_in_stmts_rec( + eb, + changed, + next_local_id, + next_func_id, + protected, + source_span_remaps, + ); } } Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { - unroll_in_stmts_rec(body, changed, next_local_id, next_func_id, protected); + unroll_in_stmts_rec( + body, + changed, + next_local_id, + next_func_id, + protected, + source_span_remaps, + ); } Stmt::For { body, .. } => { // Inner-first: unroll any qualifying loops inside this for's // body before deciding whether to unroll this for itself. - unroll_in_stmts_rec(body, changed, next_local_id, next_func_id, protected); + unroll_in_stmts_rec( + body, + changed, + next_local_id, + next_func_id, + protected, + source_span_remaps, + ); } Stmt::Switch { cases, .. } => { for c in cases { - unroll_in_stmts_rec(&mut c.body, changed, next_local_id, next_func_id, protected); + unroll_in_stmts_rec( + &mut c.body, + changed, + next_local_id, + next_func_id, + protected, + source_span_remaps, + ); } } Stmt::Try { @@ -298,16 +370,44 @@ fn recurse_into_nested( finally, .. } => { - unroll_in_stmts_rec(body, changed, next_local_id, next_func_id, protected); + unroll_in_stmts_rec( + body, + changed, + next_local_id, + next_func_id, + protected, + source_span_remaps, + ); if let Some(c) = catch { - unroll_in_stmts_rec(&mut c.body, changed, next_local_id, next_func_id, protected); + unroll_in_stmts_rec( + &mut c.body, + changed, + next_local_id, + next_func_id, + protected, + source_span_remaps, + ); } if let Some(f) = finally { - unroll_in_stmts_rec(f, changed, next_local_id, next_func_id, protected); + unroll_in_stmts_rec( + f, + changed, + next_local_id, + next_func_id, + protected, + source_span_remaps, + ); } } Stmt::Labeled { body, .. } => { - recurse_into_nested(body, changed, next_local_id, next_func_id, protected); + recurse_into_nested( + body, + changed, + next_local_id, + next_func_id, + protected, + source_span_remaps, + ); } _ => {} } @@ -322,6 +422,7 @@ fn try_unroll_for( next_local_id: &mut LocalId, next_func_id: &mut FuncId, protected: &std::collections::HashSet, + source_span_remaps: &mut Vec<(LocalId, LocalId)>, ) -> Option> { let (init, condition, update, body) = match stmt { Stmt::For { @@ -410,7 +511,12 @@ fn try_unroll_for( for s in &mut cloned { substitute_localget_with_int_in_stmt(s, iv_id, value); } - refresh_local_ids(&mut cloned, next_local_id, next_func_id, protected); + let remap = refresh_local_ids(&mut cloned, next_local_id, next_func_id, protected); + source_span_remaps.extend( + remap + .into_iter() + .filter(|(old_id, new_id)| old_id != new_id), + ); out.extend(cloned); } Some(out) @@ -688,7 +794,7 @@ fn refresh_local_ids( next_id: &mut LocalId, next_func_id: &mut FuncId, protected: &std::collections::HashSet, -) { +) -> HashMap { // #2308: seed the remap with identity mappings for every protected // (loop-escaping `var`) id. `alloc_fresh` reuses an existing remap // entry instead of minting a new id, so a protected declaration keeps @@ -705,6 +811,7 @@ fn refresh_local_ids( for s in stmts.iter_mut() { refresh_in_stmt(s, &mut remap, next_id, next_func_id); } + remap } fn alloc_fresh(remap: &mut HashMap, next_id: &mut LocalId, id: &mut LocalId) { @@ -975,7 +1082,7 @@ fn refresh_in_expr( mod tests { use super::*; use perry_hir::types::Type; - use perry_hir::BinaryOp; + use perry_hir::{BinaryOp, LocalSourceSpan}; fn ivar(id: LocalId) -> Expr { Expr::LocalGet(id) @@ -996,7 +1103,14 @@ mod tests { // These tests exercise a single for-loop in isolation with no // enclosing scope, so there are no escaping ids to protect (#2308). let protected = std::collections::HashSet::new(); - try_unroll_for(stmt, &mut next_id, &mut next_func_id, &protected) + let mut source_span_remaps = Vec::new(); + try_unroll_for( + stmt, + &mut next_id, + &mut next_func_id, + &protected, + &mut source_span_remaps, + ) } /// Test helper: wrap `unroll_in_stmts` with the same throwaway counters. @@ -1005,7 +1119,14 @@ mod tests { const FUNC_START: FuncId = 10_000; let mut next_id: LocalId = START; let mut next_func_id: FuncId = FUNC_START; - unroll_in_stmts(stmts, changed, &mut next_id, &mut next_func_id); + let mut source_span_remaps = Vec::new(); + unroll_in_stmts( + stmts, + changed, + &mut next_id, + &mut next_func_id, + &mut source_span_remaps, + ); } /// Build `for (let i = lo; i <= hi; i++) { body }`. @@ -1526,4 +1647,34 @@ mod tests { "each copy's `let x` must be a distinct id" ); } + + #[test] + fn unrolled_local_copies_keep_their_source_span() { + let i = 1u32; + let x = 2u32; + let span = LocalSourceSpan { start: 42, end: 43 }; + let body = vec![Stmt::Let { + id: x, + name: "x".into(), + ty: Type::Number, + mutable: false, + init: Some(ivar(i)), + }]; + + let mut module = Module::new("unroll-spans.ts"); + module.init = vec![make_for(i, 0, 3, body, CompareOp::Lt)]; + module.local_source_spans.insert(x, span); + + unroll_static_loops(&mut module); + + assert_eq!(module.init.len(), 3); + for stmt in &module.init { + let id = match stmt { + Stmt::Let { id, .. } => *id, + other => panic!("expected Stmt::Let, got {other:?}"), + }; + assert_ne!(id, x, "each copy should have a fresh LocalId"); + assert_eq!(module.local_source_spans.get(&id), Some(&span)); + } + } } diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 2a2a6b7b64..0306d635df 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -472,11 +472,10 @@ fn collect_module_one( // left untouched. let was_cjs_wrapped = (is_in_compiled_pkg || !is_in_node_modules) && super::cjs_wrap::is_commonjs(&raw_source); - // #5247: when `--debug-symbols` is on, capture where the original module - // body lands inside the wrapped output so source-location resolution can - // map a wrapped-coordinate byte offset back to an original-source line. - // `None` unless we both wrapped this module AND debug symbols are on, so - // the default build does no extra work. + // #5247 / #7036: when source locations are requested, capture where the + // original module body lands inside the wrapped output so debug frames and + // opt reports can map a wrapped-coordinate byte offset back to an + // original-source line. The default build does no extra work. let mut cjs_wrap_body_prefix_lines: Option = None; let source = if was_cjs_wrapped { if ctx.debug_symbols { diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index bb3feea107..f0528f62e0 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -303,9 +303,11 @@ pub fn run_with_parse_cache( .clone() .or_else(|| object_cache::cache_dir_override(&ctx.cache_root)); ctx.cache_dir = object_cache::resolve_cache_dir(&ctx.cache_root, cache_dir_override.as_deref()); - // #5247: propagate `--debug-symbols` so `collect_modules` records the - // CJS-wrap source mapping needed to render original-source line numbers. - ctx.debug_symbols = args.debug_symbols; + // #5247 / #7036: ask `collect_modules` to retain the CJS wrapper source + // mapping whenever a later consumer needs original-source line numbers. + // Text opt reports use the mapping for declaration snippets but do not + // otherwise enable debug locations or symbols. + ctx.debug_symbols = args.debug_symbols || opt_report_format == Some(OptReportFormat::Text); let build_cache_probe = BuildCacheProbe::new(&args, &project_root, &ctx.cache_root, &ctx.cache_dir); @@ -4304,15 +4306,19 @@ pub fn run_with_parse_cache( // the module's original source so codegen can map a Call's byte // offset to a 1-based line. debug_locations: args.debug_symbols, - // #5247: source consulted to turn a node's `byte_offset` into a - // line. For a CommonJS module the offsets are in WRAPPED-source + // #5247 / #7036: source consulted to turn a node's `byte_offset` + // into a debug frame or opt-report snippet. For a CommonJS + // module the offsets are in WRAPPED-source // coordinates (perry parsed the injected-IIFE text), so we hand // codegen the WRAPPED source — counting newlines up to a wrapped // offset against the original would be off by the preamble byte // length. `debug_source_line_offset` (below) then converts the // wrapped line back to the original line. Non-wrapped modules - // read the original from disk. - module_source: if args.debug_symbols { + // read the original from disk. Text reports need this source; + // JSON reports only need the offset already carried by HIR. + module_source: if args.debug_symbols + || opt_report_format == Some(OptReportFormat::Text) + { match ctx.cjs_wrap_debug_sources.get(path) { Some(w) => Some(w.wrapped_source.clone()), None => std::fs::read_to_string(path).ok(), @@ -4325,7 +4331,9 @@ pub fn run_with_parse_cache( // Codegen subtracts this from the wrapped line number so the // rendered location is in original-source coordinates. `0` for // non-wrapped modules (and the entire default build). - debug_source_line_offset: if args.debug_symbols { + debug_source_line_offset: if args.debug_symbols + || opt_report_format == Some(OptReportFormat::Text) + { ctx.cjs_wrap_debug_sources .get(path) .map(|w| w.prefix_line_count) diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 0facb1591c..7dbe0054a6 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -483,7 +483,9 @@ pub struct CompileArgs { /// fast paths silently do not fire. This prints, per value: its position /// (local / param / return / allocation site), the representation it got, /// the collector rule that denied it, an actionability tier, and a static - /// hotness proxy. Wins are reported too, so you can see the ratio. + /// hotness proxy. Named locals include their declaration file, line, + /// column, and source snippet. Wins are reported too, so you can see the + /// ratio. /// /// `--opt-report` prints human-readable text; `--opt-report=json` emits a /// stable schema for tooling (CI can diff two builds to catch a silent @@ -1016,24 +1018,23 @@ pub struct CompilationContext { /// overridden. Keyed by the full `process.env.` string. pub define: HashMap, /// #5247 (CJS-wrap coordinate skew): for each CommonJS module rewritten by - /// `cjs_wrap::wrap_commonjs_for_target`, the ORIGINAL (pre-wrap) source + /// `cjs_wrap::wrap_commonjs_for_target`, the final wrapped source /// text plus the number of newline characters the injected wrapper prefix - /// prepended before the original module body. Under `--debug-symbols`, - /// codegen resolves a node's `byte_offset` (which is in WRAPPED - /// coordinates) to a line by deducting this prefix line count and looking - /// up the original source — so a throw renders `at :` + /// prepended before the original module body. Under `--debug-symbols` or a + /// text opt report, codegen resolves a node's wrapped-coordinate + /// `byte_offset` to a line by deducting this prefix line count and looking + /// up the wrapped source — so a throw renders `at :` /// rather than a line shifted by the preamble. Empty unless - /// `--debug-symbols` is set (the map is only populated then), keeping the - /// default build allocation-free. + /// source locations are requested, keeping the default build allocation-free. pub cjs_wrap_debug_sources: HashMap, - /// #5247: mirror of the CLI `--debug-symbols` flag, set after construction. - /// Gates the CJS-wrap source mapping capture in `collect_modules` so the - /// default build never records `cjs_wrap_debug_sources`. + /// #5247 / #7036: whether a later compiler stage needs source locations. + /// Gates CJS-wrap source mapping capture for debug symbols and text opt + /// reports, so the default build never records `cjs_wrap_debug_sources`. pub debug_symbols: bool, } -/// #5247: source mapping for a CJS-wrapped module, used only by the -/// `--debug-symbols` source-location path. See `cjs_wrap_debug_sources`. +/// #5247 / #7036: source mapping for a CJS-wrapped module, used by debug +/// locations and text opt reports. See `cjs_wrap_debug_sources`. #[derive(Debug, Clone)] pub struct CjsWrapDebugSource { /// The WRAPPED module source text (the injected-IIFE text perry parsed). From b3f9cb1892c7a60103ee82a7cd2ea91a18b1732c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 14:05:20 +0200 Subject: [PATCH 2/2] docs: changelog fragment for #7957 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- changelog.d/7957-opt-report-source-locations.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/7957-opt-report-source-locations.md diff --git a/changelog.d/7957-opt-report-source-locations.md b/changelog.d/7957-opt-report-source-locations.md new file mode 100644 index 0000000000..b5c8b6b513 --- /dev/null +++ b/changelog.d/7957-opt-report-source-locations.md @@ -0,0 +1,7 @@ +**`--opt-report` names where a local lives (#7036).** Report-only +LocalId→source-span metadata survives HIR lowering and the local-cloning +transforms, fills the report's existing `byte_offset` field for named locals +(JSON schema unchanged), and text reports render file:line:column with a +source/caret snippet — including CJS wrapper line correction — so a denial +points at the declaration instead of leaving the reader to grep for it. +(Fragment added at merge.)