Skip to main content

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}