perl_parser_core/pir/extractor.rs
1//! Lexical reference extraction from PIR-lowered HirFile.
2//!
3//! This module defines the lexical fact extractor, which walks a per-body PIR lowering
4//! and collects all lexical reads and writes, grouped by body boundary to preserve scope isolation.
5//!
6//! The extractor is used by PR2 (#2634) for shadow comparison and later by providers for
7//! navigation and reference detection. PR1 defines the core API only; no provider changes.
8
9use crate::hir::{BodyOwnerKind, HirBodyId, HirFile};
10use crate::pir::lower::lower_single_body;
11use crate::pir::model::{LexicalName, PirOperation, PirSourceAnchor};
12
13/// Current schema version for lexical extractor receipts.
14pub const LEXICAL_EXTRACTOR_RECEIPT_VERSION: u32 = 1;
15
16/// A single lexical variable binding fact extracted from PIR.
17///
18/// Each fact represents one read or write of a lexical (`my`/`state`) variable,
19/// anchored to its source range and discriminated by body identity.
20#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct LexicalBindingFact {
23 /// The lexical variable (sigil + name).
24 pub name: LexicalName,
25 /// Whether this fact records a read or write.
26 pub role: LexicalRole,
27 /// Source anchor for this reference (always anchored in PR1).
28 pub source_anchor: PirSourceAnchor,
29 /// Body index where this binding occurs (0-based).
30 pub body_idx: usize,
31 /// Body owner kind (what construct owns this body).
32 pub body_owner: BodyOwnerKind,
33}
34
35/// Role of a lexical binding fact (read or write).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum LexicalRole {
39 /// Read access to a lexical variable.
40 Read,
41 /// Write access (declaration or assignment) to a lexical variable.
42 Write,
43}
44
45/// Extraction results for a single body.
46#[derive(Debug, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub struct BodyExtractionResult {
49 /// Index of this body (0-based, matches position in HirFile::bodies).
50 pub body_idx: usize,
51 /// Owner of this body (ProgramRoot or Subroutine).
52 pub owner: BodyOwnerKind,
53 /// All lexical binding facts in this body, in lowering order.
54 pub facts: Vec<LexicalBindingFact>,
55 /// Count of nodes that had source anchors.
56 pub anchored_node_count: usize,
57 /// Total count of all nodes processed (anchored + non-anchored).
58 pub total_node_count: usize,
59}
60
61/// Receipt from the lexical extractor, summarizing all bindings extracted from a file.
62///
63/// This is distinct from [`PirReceipt`](crate::pir::PirReceipt), which models lowering stats.
64/// `LexicalExtractorReceipt` is a compiler-substrate proof surface: it records what the
65/// extractor found, not what the lowerer did.
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct LexicalExtractorReceipt {
69 /// Schema version (currently 1).
70 pub schema_version: u32,
71 /// Per-body extraction results, in order.
72 pub bodies: Vec<BodyExtractionResult>,
73 /// Total count of lexical reads across all bodies.
74 pub total_read_count: usize,
75 /// Total count of lexical writes across all bodies.
76 pub total_write_count: usize,
77 /// Count of operations skipped (Modify, StashModify).
78 pub skipped_node_count: usize,
79 /// Whether provider behavior changed (always false for PR1).
80 pub provider_behavior_changed: bool,
81}
82
83/// Extract lexical binding facts from a HirFile.
84///
85/// Lowers each body independently and collects all `LexicalRead` and `LexicalWrite`
86/// operations, preserving body boundaries so scope isolation is unambiguous.
87///
88/// # Arguments
89///
90/// * `file` - A HIR file with populated `.bodies` (from the second pass of HIR lowering)
91///
92/// # Returns
93///
94/// A receipt containing all facts grouped by body, plus summary counts.
95///
96/// # Invariants
97///
98/// - All emitted facts have `source_anchor.is_anchored() == true`
99/// - `Modify`/`StashModify` operations are skipped (not counted as facts, but tracked in `skipped_node_count`)
100/// - `StashRead`/`StashWrite` are ignored (not extracted in PR1)
101/// - `provider_behavior_changed` is always `false`
102/// - `total_read_count + total_write_count == sum(bodies[].facts.len())`
103#[must_use]
104pub fn extract_lexical_facts(file: &HirFile) -> LexicalExtractorReceipt {
105 let mut bodies = Vec::new();
106 let mut total_read_count = 0usize;
107 let mut total_write_count = 0usize;
108 let mut skipped_node_count = 0usize;
109
110 for (body_idx, body) in file.bodies.iter().enumerate() {
111 let owner = body.owner.clone();
112 let mut facts = Vec::new();
113 let mut anchored_node_count = 0usize;
114 let mut total_node_count = 0usize;
115
116 // Lower this body in isolation — fresh lowerer per body preserves scope boundaries.
117 let pir_nodes = lower_single_body(body, HirBodyId(body_idx as u32), file);
118
119 for pir_node in pir_nodes {
120 total_node_count += 1;
121
122 match &pir_node.operation {
123 PirOperation::LexicalRead { name } => {
124 if pir_node.source_anchor.is_anchored() {
125 anchored_node_count += 1;
126 facts.push(LexicalBindingFact {
127 name: name.clone(),
128 role: LexicalRole::Read,
129 source_anchor: pir_node.source_anchor.clone(),
130 body_idx,
131 body_owner: owner.clone(),
132 });
133 total_read_count += 1;
134 }
135 }
136 PirOperation::LexicalWrite { name } => {
137 if pir_node.source_anchor.is_anchored() {
138 anchored_node_count += 1;
139 facts.push(LexicalBindingFact {
140 name: name.clone(),
141 role: LexicalRole::Write,
142 source_anchor: pir_node.source_anchor.clone(),
143 body_idx,
144 body_owner: owner.clone(),
145 });
146 total_write_count += 1;
147 }
148 }
149 PirOperation::Modify { .. } | PirOperation::StashModify { .. } => {
150 // Modify and StashModify are explicitly skipped: they are neither Read nor
151 // Write in the lexical-fact model (they represent compound RMW operations).
152 // Track them so the receipt surface is honest about what was filtered.
153 skipped_node_count += 1;
154 }
155 // StashRead, StashWrite, Call, MethodCall, Assign, DynamicBoundary, etc.
156 // are outside PR1 scope — silently ignored (not facts, not skipped).
157 _ => {}
158 }
159 }
160
161 bodies.push(BodyExtractionResult {
162 body_idx,
163 owner,
164 facts,
165 anchored_node_count,
166 total_node_count,
167 });
168 }
169
170 LexicalExtractorReceipt {
171 schema_version: LEXICAL_EXTRACTOR_RECEIPT_VERSION,
172 bodies,
173 total_read_count,
174 total_write_count,
175 skipped_node_count,
176 provider_behavior_changed: false,
177 }
178}