Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/7957-opt-report-source-locations.md
Original file line number Diff line number Diff line change
@@ -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.)
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/clone_suffix_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ fn module_with(functions: Vec<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(),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/emission_order_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/entry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}
Expand Down
5 changes: 4 additions & 1 deletion crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
// 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
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/number_exactness_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ fn module_with(functions: Vec<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(),
}
}
Expand Down
9 changes: 5 additions & 4 deletions crates/perry-codegen/src/codegen/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,11 @@ pub struct CompileOptions {
/// `js_set_call_location(file, line)` so the thrown TypeError's `.stack`
/// shows `at <file>:<line>`.
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 `<anonymous>` 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
/// `<anonymous>` frame or a raw byte offset.
pub module_source: Option<String>,
/// #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
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/native_root_coverage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}
Expand Down
154 changes: 136 additions & 18 deletions crates/perry-codegen/src/opt_report/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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).
Expand Down Expand Up @@ -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);
Expand All @@ -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 }
}
Expand Down Expand Up @@ -454,10 +458,10 @@ pub struct Entry {
pub invoked_per_element: Option<String>,
/// Extra collector-specific context (class name, offending use site).
pub detail: Option<String>,
/// 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<u32>,
/// For [`Outcome::Consumed`]: which codegen lowering applied the proof
/// (`ptr_shape_set`, `ptr_shape_update`, …). `None` for every other
Expand Down Expand Up @@ -590,6 +594,9 @@ struct Scope {
function: String,
region: RegionKind,
invoked_per_element: Option<String>,
/// Report-only declaration locations inherited by every nested region in
/// this module. `Arc` keeps region entry O(1).
local_source_spans: Arc<std::collections::HashMap<u32, perry_hir::LocalSourceSpan>>,
/// #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.
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -702,6 +710,35 @@ fn current_module() -> String {
})
}

fn current_local_source_spans() -> Arc<std::collections::HashMap<u32, perry_hir::LocalSourceSpan>> {
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<u32>, explicit: Option<u32>) -> Option<u32> {
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`).
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -812,6 +851,41 @@ fn per_element_role(func_id: Option<u32>) -> Option<String> {

static SINK: OnceLock<Mutex<Vec<Entry>>> = OnceLock::new();

#[derive(Debug, Clone)]
pub(super) struct ReportSource {
pub source: String,
pub line_offset: u32,
}

static REPORT_SOURCES: OnceLock<Mutex<std::collections::HashMap<String, ReportSource>>> =
OnceLock::new();

fn report_sources() -> &'static Mutex<std::collections::HashMap<String, ReportSource>> {
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<String, ReportSource> {
report_sources()
.lock()
.map(|sources| sources.clone())
.unwrap_or_default()
}

fn sink() -> &'static Mutex<Vec<Entry>> {
SINK.get_or_init(|| Mutex::new(Vec::new()))
}
Expand Down Expand Up @@ -903,7 +977,7 @@ fn deny_in_scope(d: Denial<'_>, alloc_context: Option<String>, 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,
Expand Down Expand Up @@ -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,
Expand All @@ -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, "<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("<module>"),
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading