Skip to main content

perl_parser_core/pir/
model.rs

1//! PIR v0 data model.
2//!
3//! PIR ("Perl intermediate representation") is a source-anchored tooling IR
4//! lowered from [`HirFile`](crate::hir::HirFile). It is oriented around editor
5//! tooling and static analysis, not bytecode execution: it preserves source
6//! anchors, dynamic-boundary links, and expression context so later analyses
7//! (control flow, dead code, safe delete, rename safety) have an honest base.
8//!
9//! PIR v0 is a compiler-substrate data layer only. It never evaluates Perl,
10//! never runs `perldoc`/DAP/application code, never replaces HIR as the
11//! canonical syntax tree, and never changes LSP provider behavior. The
12//! authoritative contract is [`PLSP-SPEC-0025`](../../../../docs/specs/PLSP-SPEC-0025-pir-v0.md).
13
14use crate::SourceLocation;
15use crate::hir::{HirId, HirScopeId};
16use perl_semantic_facts::AnchorId;
17use std::collections::BTreeMap;
18
19/// Current PIR lowering-receipt schema version.
20pub const PIR_RECEIPT_VERSION: u32 = 1;
21
22/// Stable identifier for a PIR node within one lowering receipt.
23///
24/// IDs are internally deterministic for the same source, compiler environment,
25/// and configuration. They are not guaranteed stable across versions or
26/// unrelated workspaces.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
28#[non_exhaustive]
29pub struct PirId {
30    index: u32,
31}
32
33impl PirId {
34    /// Create an identifier from a zero-based lowering index.
35    #[inline]
36    pub const fn from_index(index: u32) -> Self {
37        Self { index }
38    }
39
40    /// Return the zero-based lowering index.
41    #[inline]
42    pub const fn index(self) -> u32 {
43        self.index
44    }
45}
46
47/// Provenance class for a PIR node's source anchor.
48///
49/// Every source-derived node anchors to the workspace range that caused it.
50/// Generated, framework, or ambient nodes only exist when their provenance is
51/// explicit, so a provider can never mistake a modeled fact for source text.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53#[non_exhaustive]
54pub enum PirAnchorKind {
55    /// Node anchors directly to source text.
56    ExplicitSource,
57    /// Node anchors to a framework declaration, not a fabricated method body.
58    SourceBackedGenerated,
59    /// Generated node with no source backing; receipt-only.
60    GeneratedNoSource,
61    /// Node anchors to a dynamic boundary range when available.
62    DynamicBoundary,
63    /// Node reports an ambient-input class rather than source text.
64    AmbientInput,
65    /// Fallback node whose anchor could not be classified.
66    Unknown,
67}
68
69impl PirAnchorKind {
70    /// Stable name used in receipts and snapshots.
71    #[must_use]
72    pub const fn name(self) -> &'static str {
73        match self {
74            Self::ExplicitSource => "ExplicitSource",
75            Self::SourceBackedGenerated => "SourceBackedGenerated",
76            Self::GeneratedNoSource => "GeneratedNoSource",
77            Self::DynamicBoundary => "DynamicBoundary",
78            Self::AmbientInput => "AmbientInput",
79            Self::Unknown => "Unknown",
80        }
81    }
82
83    /// Whether a node with this anchor kind is expected to carry a source range.
84    #[must_use]
85    pub const fn is_source_backed(self) -> bool {
86        matches!(self, Self::ExplicitSource | Self::SourceBackedGenerated | Self::DynamicBoundary)
87    }
88}
89
90/// Source anchor for a PIR node.
91///
92/// A source anchor records why a node exists and where it came from. Nodes
93/// without a concrete range (generated-no-source, ambient, unknown) keep
94/// `range` and `anchor_id` absent and explain themselves through `kind`.
95#[derive(Debug, Clone, PartialEq, Eq)]
96#[non_exhaustive]
97pub struct PirSourceAnchor {
98    /// Provenance class for this anchor.
99    pub kind: PirAnchorKind,
100    /// Workspace source range that caused the node, when source-backed.
101    pub range: Option<SourceLocation>,
102    /// Stable anchor id derived from the source range, when source-backed.
103    pub anchor_id: Option<AnchorId>,
104    /// HIR item this node lowered from, when available.
105    pub hir_item: Option<HirId>,
106}
107
108impl PirSourceAnchor {
109    /// Build a source-backed anchor from a HIR item and its range.
110    #[must_use]
111    pub fn explicit(range: SourceLocation, hir_item: HirId) -> Self {
112        Self {
113            kind: PirAnchorKind::ExplicitSource,
114            range: Some(range),
115            anchor_id: Some(AnchorId(range.start as u64)),
116            hir_item: Some(hir_item),
117        }
118    }
119
120    /// Build a dynamic-boundary anchor pointing at the boundary range.
121    #[must_use]
122    pub fn dynamic_boundary(range: SourceLocation, hir_item: HirId) -> Self {
123        Self {
124            kind: PirAnchorKind::DynamicBoundary,
125            range: Some(range),
126            anchor_id: Some(AnchorId(range.start as u64)),
127            hir_item: Some(hir_item),
128        }
129    }
130
131    /// Return true when this anchor preserves a concrete source range.
132    #[must_use]
133    pub fn is_anchored(&self) -> bool {
134        self.range.is_some()
135    }
136}
137
138/// Expression context modeled by PIR v0.
139///
140/// Unknown context is allowed when the compiler substrate cannot prove context
141/// without executing Perl. Unknown context is visible in receipts and is never
142/// silently promoted to scalar or list.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
144#[non_exhaustive]
145pub enum PirContext {
146    /// Scalar context.
147    Scalar,
148    /// List context.
149    List,
150    /// Void context.
151    Void,
152    /// Lvalue (assignment-target) context.
153    Lvalue,
154    /// Context that cannot be proven statically.
155    Unknown,
156}
157
158impl PirContext {
159    /// Stable name used in receipts and snapshots.
160    #[must_use]
161    pub const fn name(self) -> &'static str {
162        match self {
163            Self::Scalar => "Scalar",
164            Self::List => "List",
165            Self::Void => "Void",
166            Self::Lvalue => "Lvalue",
167            Self::Unknown => "Unknown",
168        }
169    }
170}
171
172/// A lexical (`my`/`state`) variable named by a PIR operation.
173#[derive(Debug, Clone, PartialEq, Eq)]
174#[non_exhaustive]
175pub struct LexicalName {
176    /// Variable sigil (`$`, `@`, `%`).
177    pub sigil: String,
178    /// Variable name without sigil.
179    pub name: String,
180}
181
182/// A package/stash symbol named by a PIR operation.
183#[derive(Debug, Clone, PartialEq, Eq)]
184#[non_exhaustive]
185pub struct SymbolName {
186    /// Symbol sigil when known.
187    pub sigil: String,
188    /// Symbol name without sigil.
189    pub name: String,
190    /// Package context active where the symbol is used, when known.
191    pub package: Option<String>,
192}
193
194/// Callee of a PIR call operation.
195#[derive(Debug, Clone, PartialEq, Eq)]
196#[non_exhaustive]
197pub enum PirCallee {
198    /// A statically named callee, optionally package-qualified.
199    Named {
200        /// Callee name without the package qualifier.
201        name: String,
202        /// Package qualifier when the callee was written `Pkg::name`.
203        package: Option<String>,
204    },
205    /// A coderef or otherwise dynamic callee; see the node's dynamic boundary.
206    Dynamic,
207}
208
209/// Receiver of a PIR method-call operation.
210#[derive(Debug, Clone, PartialEq, Eq)]
211#[non_exhaustive]
212pub enum PirReceiver {
213    /// A bareword class receiver such as `Foo->new`.
214    Class(String),
215    /// An expression receiver; the field records the parser AST kind.
216    Expression {
217        /// Parser AST kind name for the receiver expression.
218        kind: &'static str,
219    },
220    /// A dynamic receiver; see the node's dynamic boundary.
221    Dynamic,
222}
223
224/// Method named by a PIR method-call operation.
225#[derive(Debug, Clone, PartialEq, Eq)]
226#[non_exhaustive]
227pub enum PirMethod {
228    /// A statically named method.
229    Named(String),
230    /// A dynamic method name; see the node's dynamic boundary.
231    Dynamic,
232}
233
234/// Dynamic-boundary category preserved by PIR instead of guessing behavior.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
236#[non_exhaustive]
237pub enum PirDynamicBoundaryKind {
238    /// Coderef / dynamic callee call.
239    DynamicCallee,
240    /// Dynamic method receiver.
241    DynamicReceiver,
242    /// Dynamic (computed) method name.
243    DynamicMethodName,
244    /// Symbolic-reference dereference under disabled `strict refs`.
245    SymbolicReference,
246    /// Non-literal typeglob access or mutation.
247    TypeglobAccess,
248    /// Dynamic dereference boundary.
249    DynamicDereference,
250    /// Runtime stash/package-name mutation.
251    RuntimeStashMutation,
252    /// `eval` whose body is not a statically parsed block.
253    EvalExpression,
254    /// `do` whose body is not a statically parsed block.
255    DoExpression,
256    /// `AUTOLOAD`-driven dynamic dispatch.
257    Autoload,
258    /// Unclassified dynamic boundary.
259    Unknown,
260}
261
262impl PirDynamicBoundaryKind {
263    /// Stable name used in receipts and snapshots.
264    #[must_use]
265    pub const fn name(self) -> &'static str {
266        match self {
267            Self::DynamicCallee => "DynamicCallee",
268            Self::DynamicReceiver => "DynamicReceiver",
269            Self::DynamicMethodName => "DynamicMethodName",
270            Self::SymbolicReference => "SymbolicReference",
271            Self::TypeglobAccess => "TypeglobAccess",
272            Self::DynamicDereference => "DynamicDereference",
273            Self::RuntimeStashMutation => "RuntimeStashMutation",
274            Self::EvalExpression => "EvalExpression",
275            Self::DoExpression => "DoExpression",
276            Self::Autoload => "Autoload",
277            Self::Unknown => "Unknown",
278        }
279    }
280}
281
282/// A modeled PIR operation.
283///
284/// Operations model data access, calls, and control flow without executing
285/// Perl. Families that the current HIR substrate cannot prove (for example
286/// branch and loop conditions, or explicit returns) are part of the contract
287/// but are populated by later lowering passes; receipts make the gap visible.
288#[derive(Debug, Clone, PartialEq, Eq)]
289#[non_exhaustive]
290pub enum PirOperation {
291    /// Read a lexical variable.
292    LexicalRead {
293        /// The lexical being read.
294        name: LexicalName,
295    },
296    /// Write (declare or assign) a lexical variable.
297    LexicalWrite {
298        /// The lexical being written.
299        name: LexicalName,
300    },
301    /// Read a package/stash symbol.
302    StashRead {
303        /// The symbol being read.
304        symbol: SymbolName,
305    },
306    /// Write a package/stash symbol.
307    StashWrite {
308        /// The symbol being written.
309        symbol: SymbolName,
310    },
311    /// Compound read-modify-write on a lexical variable (`+=`, `-=`, `++`, etc.).
312    ///
313    /// The place is evaluated exactly once. The compound operator is preserved in
314    /// `op` so downstream analyses can distinguish `+=` from `++` without re-parsing.
315    Modify {
316        /// The lexical variable being modified.
317        name: LexicalName,
318        /// The compound operator text (`"+="`, `"-="`, `"*="`, `"++"`, `"--"`, etc.).
319        op: String,
320    },
321    /// Compound read-modify-write on a package/stash symbol.
322    ///
323    /// Mirrors [`Modify`](Self::Modify) for package slots.
324    StashModify {
325        /// The package symbol being modified.
326        symbol: SymbolName,
327        /// The compound operator text.
328        op: String,
329    },
330    /// An assignment expression.
331    Assign,
332    /// A subroutine or function call.
333    Call {
334        /// The callee.
335        callee: PirCallee,
336        /// Number of parsed arguments.
337        arg_count: usize,
338    },
339    /// A method call.
340    MethodCall {
341        /// The receiver.
342        receiver: PirReceiver,
343        /// The method.
344        method: PirMethod,
345        /// Number of parsed arguments.
346        arg_count: usize,
347    },
348    /// A branch (condition is populated by later control-flow lowering).
349    Branch {
350        /// PIR node computing the branch condition, when modeled.
351        condition: Option<PirId>,
352    },
353    /// A loop (condition is populated by later control-flow lowering).
354    Loop {
355        /// PIR node computing the loop condition, when modeled.
356        condition: Option<PirId>,
357    },
358    /// A return from the enclosing subroutine.
359    Return,
360    /// A preserved dynamic boundary.
361    DynamicBoundary {
362        /// Boundary category.
363        kind: PirDynamicBoundaryKind,
364        /// Short human-readable reason for the boundary.
365        reason: String,
366    },
367}
368
369impl PirOperation {
370    /// Stable operation-family name used in receipts and snapshots.
371    #[must_use]
372    pub const fn name(&self) -> &'static str {
373        match self {
374            Self::LexicalRead { .. } => "LexicalRead",
375            Self::LexicalWrite { .. } => "LexicalWrite",
376            Self::StashRead { .. } => "StashRead",
377            Self::StashWrite { .. } => "StashWrite",
378            Self::Modify { .. } => "Modify",
379            Self::StashModify { .. } => "StashModify",
380            Self::Assign => "Assign",
381            Self::Call { .. } => "Call",
382            Self::MethodCall { .. } => "MethodCall",
383            Self::Branch { .. } => "Branch",
384            Self::Loop { .. } => "Loop",
385            Self::Return => "Return",
386            Self::DynamicBoundary { .. } => "DynamicBoundary",
387        }
388    }
389
390    /// All operation-family names PIR v0 models.
391    ///
392    /// Receipts and status generators should use this list instead of keeping a
393    /// separate copy of the current PIR operation surface.
394    pub const ALL_OPERATION_NAMES: &[&'static str] = &[
395        "Assign",
396        "Branch",
397        "Call",
398        "DynamicBoundary",
399        "LexicalRead",
400        "LexicalWrite",
401        "Loop",
402        "MethodCall",
403        "Modify",
404        "Return",
405        "StashModify",
406        "StashRead",
407        "StashWrite",
408    ];
409}
410
411/// One PIR node.
412#[derive(Debug, Clone, PartialEq, Eq)]
413#[non_exhaustive]
414pub struct PirNode {
415    /// Stable node id within the lowering receipt.
416    pub id: PirId,
417    /// Source anchor explaining why and where this node exists.
418    pub source_anchor: PirSourceAnchor,
419    /// Modeled operation.
420    pub operation: PirOperation,
421    /// Expression context, possibly `Unknown`.
422    pub context: PirContext,
423    /// Link to a dynamic-boundary node this operation defers to, when any.
424    pub dynamic_boundary: Option<PirId>,
425    /// HIR scope this node belongs to, when known.
426    pub scope: Option<HirScopeId>,
427    /// Package context active at this node, when known.
428    pub package_context: Option<String>,
429}
430
431/// Control-flow edge category.
432#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
433#[non_exhaustive]
434pub enum PirEdgeKind {
435    /// Straight-line fallthrough to the next node in the same region.
436    Fallthrough,
437    /// Edge taken by a branch arm.
438    Branch,
439    /// Edge taken by a loop back-edge or entry.
440    Loop,
441    /// Edge taken by a return.
442    Return,
443    /// Edge leaving the modeled graph through a dynamic boundary.
444    DynamicExit,
445    /// Conservative unknown edge that must not be dropped silently.
446    Unknown,
447}
448
449impl PirEdgeKind {
450    /// Stable name used in receipts and snapshots.
451    #[must_use]
452    pub const fn name(self) -> &'static str {
453        match self {
454            Self::Fallthrough => "Fallthrough",
455            Self::Branch => "Branch",
456            Self::Loop => "Loop",
457            Self::Return => "Return",
458            Self::DynamicExit => "DynamicExit",
459            Self::Unknown => "Unknown",
460        }
461    }
462}
463
464/// One control-flow edge between PIR nodes.
465///
466/// A `to` of `None` represents an exit, unknown, or dynamic continuation that
467/// must remain visible rather than be dropped.
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469#[non_exhaustive]
470pub struct PirEdge {
471    /// Source node.
472    pub from: PirId,
473    /// Destination node, or `None` for an exit/unknown continuation.
474    pub to: Option<PirId>,
475    /// Edge category.
476    pub kind: PirEdgeKind,
477}
478
479/// Lowering pass that produced a PIR graph.
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
481#[non_exhaustive]
482pub enum PirLoweringMode {
483    /// First HIR-to-PIR v0 lowering pass.
484    HirV0,
485}
486
487impl PirLoweringMode {
488    /// Stable name used in receipts and snapshots.
489    #[must_use]
490    pub const fn name(self) -> &'static str {
491        match self {
492            Self::HirV0 => "HirV0",
493        }
494    }
495}
496
497/// Source-anchor coverage summary for a lowering receipt.
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
499#[non_exhaustive]
500pub struct PirAnchorCoverage {
501    /// Nodes that preserved a concrete source range.
502    pub anchored: usize,
503    /// Nodes without a concrete source range (generated, ambient, unknown).
504    pub unanchored: usize,
505}
506
507impl PirAnchorCoverage {
508    /// Total nodes counted.
509    #[must_use]
510    pub const fn total(&self) -> usize {
511        self.anchored + self.unanchored
512    }
513}
514
515/// A PIR lowering receipt.
516///
517/// Receipts explain what lowered, what fell back, and what was blocked. They
518/// are the proof surface for PIR work and assert that provider behavior did not
519/// change.
520#[derive(Debug, Clone, PartialEq, Eq)]
521#[non_exhaustive]
522pub struct PirReceipt {
523    /// Receipt schema version.
524    pub schema_version: u32,
525    /// Caller-supplied source file or workspace fixture identity.
526    pub source_identity: Option<String>,
527    /// Lowering mode that produced the graph.
528    pub lowering_mode: PirLoweringMode,
529    /// Number of PIR nodes.
530    pub node_count: usize,
531    /// Number of control-flow edges.
532    pub edge_count: usize,
533    /// Lowered operation counts, keyed by operation-family name.
534    pub operation_counts: BTreeMap<&'static str, usize>,
535    /// Context counts, keyed by context name.
536    pub context_counts: BTreeMap<&'static str, usize>,
537    /// Source-anchor coverage summary.
538    pub source_anchor_coverage: PirAnchorCoverage,
539    /// Dynamic-boundary counts, keyed by boundary-kind name.
540    pub dynamic_boundary_counts: BTreeMap<&'static str, usize>,
541    /// HIR constructs PIR v0 did not lower, keyed by HIR kind name.
542    pub unsupported_construct_counts: BTreeMap<&'static str, usize>,
543    /// Stale or ambient inputs that affected lowering.
544    pub ambient_inputs: Vec<String>,
545    /// Whether provider behavior changed. Always `false` under PIR v0.
546    pub provider_behavior_changed: bool,
547}
548
549/// A lowered PIR graph plus its receipt.
550#[derive(Debug, Clone, PartialEq, Eq)]
551#[non_exhaustive]
552pub struct PirGraph {
553    /// PIR nodes in stable lowering order.
554    pub nodes: Vec<PirNode>,
555    /// Control-flow edges.
556    pub edges: Vec<PirEdge>,
557    /// Lowering receipt describing this graph.
558    pub receipt: PirReceipt,
559}
560
561impl PirGraph {
562    /// Return true when no PIR nodes were lowered.
563    #[must_use]
564    pub fn is_empty(&self) -> bool {
565        self.nodes.is_empty()
566    }
567
568    /// Look up a node by id.
569    ///
570    /// This is O(1): lowering pushes nodes in stable index order, so `nodes[k]`
571    /// always has `id.index() == k`. Lowerings must preserve that invariant —
572    /// do not store nodes out of order or assign non-sequential ids.
573    #[must_use]
574    pub fn node(&self, id: PirId) -> Option<&PirNode> {
575        self.nodes.get(id.index() as usize)
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn pir_context_has_stable_names() {
585        assert_eq!(PirContext::Scalar.name(), "Scalar");
586        assert_eq!(PirContext::List.name(), "List");
587        assert_eq!(PirContext::Void.name(), "Void");
588        assert_eq!(PirContext::Lvalue.name(), "Lvalue");
589        assert_eq!(PirContext::Unknown.name(), "Unknown");
590    }
591
592    #[test]
593    fn pir_anchor_kind_has_stable_names() {
594        assert_eq!(PirAnchorKind::ExplicitSource.name(), "ExplicitSource");
595        assert_eq!(PirAnchorKind::SourceBackedGenerated.name(), "SourceBackedGenerated");
596        assert_eq!(PirAnchorKind::GeneratedNoSource.name(), "GeneratedNoSource");
597        assert_eq!(PirAnchorKind::DynamicBoundary.name(), "DynamicBoundary");
598        assert_eq!(PirAnchorKind::AmbientInput.name(), "AmbientInput");
599        assert_eq!(PirAnchorKind::Unknown.name(), "Unknown");
600    }
601
602    #[test]
603    fn pir_anchor_kind_is_source_backed() {
604        assert!(PirAnchorKind::ExplicitSource.is_source_backed());
605        assert!(PirAnchorKind::SourceBackedGenerated.is_source_backed());
606        assert!(!PirAnchorKind::GeneratedNoSource.is_source_backed());
607        assert!(PirAnchorKind::DynamicBoundary.is_source_backed());
608        assert!(!PirAnchorKind::AmbientInput.is_source_backed());
609        assert!(!PirAnchorKind::Unknown.is_source_backed());
610    }
611
612    #[test]
613    fn pir_source_anchor_explicit_creates_anchored() {
614        let loc = SourceLocation { start: 0, end: 5 };
615        let anchor = PirSourceAnchor::explicit(loc, HirId::from_index(1));
616        assert!(anchor.is_anchored());
617        assert_eq!(anchor.kind, PirAnchorKind::ExplicitSource);
618        assert_eq!(anchor.range, Some(loc));
619    }
620
621    #[test]
622    fn pir_source_anchor_dynamic_boundary_creates_anchored() {
623        let loc = SourceLocation { start: 10, end: 20 };
624        let anchor = PirSourceAnchor::dynamic_boundary(loc, HirId::from_index(2));
625        assert!(anchor.is_anchored());
626        assert_eq!(anchor.kind, PirAnchorKind::DynamicBoundary);
627        assert_eq!(anchor.range, Some(loc));
628    }
629
630    #[test]
631    fn pir_callee_named_equality() {
632        let callee1 =
633            PirCallee::Named { name: "foo".to_string(), package: Some("Bar".to_string()) };
634        let callee2 =
635            PirCallee::Named { name: "foo".to_string(), package: Some("Bar".to_string()) };
636        assert_eq!(callee1, callee2);
637    }
638
639    #[test]
640    fn pir_callee_dynamic_equality() {
641        assert_eq!(PirCallee::Dynamic, PirCallee::Dynamic);
642    }
643
644    #[test]
645    fn pir_method_named_equality() {
646        assert_eq!(PirMethod::Named("foo".to_string()), PirMethod::Named("foo".to_string()));
647    }
648
649    #[test]
650    fn pir_operation_has_all_names() {
651        let expected = vec![
652            "Assign",
653            "Branch",
654            "Call",
655            "DynamicBoundary",
656            "LexicalRead",
657            "LexicalWrite",
658            "Loop",
659            "MethodCall",
660            "Modify",
661            "Return",
662            "StashModify",
663            "StashRead",
664            "StashWrite",
665        ];
666        let actual: Vec<_> = PirOperation::ALL_OPERATION_NAMES.to_vec();
667        assert_eq!(actual, expected);
668    }
669
670    #[test]
671    fn pir_operation_lexical_read_name() {
672        let op = PirOperation::LexicalRead {
673            name: LexicalName { sigil: "$".to_string(), name: "x".to_string() },
674        };
675        assert_eq!(op.name(), "LexicalRead");
676    }
677
678    #[test]
679    fn pir_operation_lexical_write_name() {
680        let op = PirOperation::LexicalWrite {
681            name: LexicalName { sigil: "$".to_string(), name: "x".to_string() },
682        };
683        assert_eq!(op.name(), "LexicalWrite");
684    }
685
686    #[test]
687    fn pir_operation_stash_read_name() {
688        let op = PirOperation::StashRead {
689            symbol: SymbolName { sigil: "$".to_string(), name: "x".to_string(), package: None },
690        };
691        assert_eq!(op.name(), "StashRead");
692    }
693
694    #[test]
695    fn pir_operation_stash_write_name() {
696        let op = PirOperation::StashWrite {
697            symbol: SymbolName {
698                sigil: "@".to_string(),
699                name: "items".to_string(),
700                package: Some("Acme".to_string()),
701            },
702        };
703        assert_eq!(op.name(), "StashWrite");
704    }
705
706    #[test]
707    fn pir_operation_assign_name() {
708        let op = PirOperation::Assign;
709        assert_eq!(op.name(), "Assign");
710    }
711
712    #[test]
713    fn pir_operation_call_name() {
714        let op = PirOperation::Call {
715            callee: PirCallee::Named { name: "foo".to_string(), package: None },
716            arg_count: 2,
717        };
718        assert_eq!(op.name(), "Call");
719    }
720
721    #[test]
722    fn pir_operation_method_call_name() {
723        let op = PirOperation::MethodCall {
724            receiver: PirReceiver::Expression { kind: "Variable" },
725            method: PirMethod::Named("foo".to_string()),
726            arg_count: 1,
727        };
728        assert_eq!(op.name(), "MethodCall");
729    }
730
731    #[test]
732    fn pir_operation_branch_name() {
733        let op = PirOperation::Branch { condition: None };
734        assert_eq!(op.name(), "Branch");
735    }
736
737    #[test]
738    fn pir_operation_loop_name() {
739        let op = PirOperation::Loop { condition: None };
740        assert_eq!(op.name(), "Loop");
741    }
742
743    #[test]
744    fn pir_operation_return_name() {
745        let op = PirOperation::Return;
746        assert_eq!(op.name(), "Return");
747    }
748
749    #[test]
750    fn pir_operation_dynamic_boundary_name() {
751        let op = PirOperation::DynamicBoundary {
752            kind: PirDynamicBoundaryKind::DynamicCallee,
753            reason: "test".to_string(),
754        };
755        assert_eq!(op.name(), "DynamicBoundary");
756    }
757
758    #[test]
759    fn pir_dynamic_boundary_kind_has_stable_names() {
760        assert_eq!(PirDynamicBoundaryKind::DynamicCallee.name(), "DynamicCallee");
761        assert_eq!(PirDynamicBoundaryKind::DynamicReceiver.name(), "DynamicReceiver");
762        assert_eq!(PirDynamicBoundaryKind::DynamicMethodName.name(), "DynamicMethodName");
763        assert_eq!(PirDynamicBoundaryKind::SymbolicReference.name(), "SymbolicReference");
764        assert_eq!(PirDynamicBoundaryKind::TypeglobAccess.name(), "TypeglobAccess");
765        assert_eq!(PirDynamicBoundaryKind::DynamicDereference.name(), "DynamicDereference");
766        assert_eq!(PirDynamicBoundaryKind::RuntimeStashMutation.name(), "RuntimeStashMutation");
767        assert_eq!(PirDynamicBoundaryKind::EvalExpression.name(), "EvalExpression");
768        assert_eq!(PirDynamicBoundaryKind::DoExpression.name(), "DoExpression");
769        assert_eq!(PirDynamicBoundaryKind::Autoload.name(), "Autoload");
770        assert_eq!(PirDynamicBoundaryKind::Unknown.name(), "Unknown");
771    }
772
773    #[test]
774    fn pir_edge_kind_has_stable_names() {
775        assert_eq!(PirEdgeKind::Fallthrough.name(), "Fallthrough");
776        assert_eq!(PirEdgeKind::Branch.name(), "Branch");
777        assert_eq!(PirEdgeKind::Loop.name(), "Loop");
778        assert_eq!(PirEdgeKind::Return.name(), "Return");
779        assert_eq!(PirEdgeKind::DynamicExit.name(), "DynamicExit");
780        assert_eq!(PirEdgeKind::Unknown.name(), "Unknown");
781    }
782
783    #[test]
784    fn pir_lowering_mode_has_stable_name() {
785        assert_eq!(PirLoweringMode::HirV0.name(), "HirV0");
786    }
787
788    #[test]
789    fn pir_anchor_coverage_total() {
790        let coverage = PirAnchorCoverage { anchored: 5, unanchored: 3 };
791        assert_eq!(coverage.total(), 8);
792    }
793
794    #[test]
795    fn pir_anchor_coverage_default() {
796        let coverage = PirAnchorCoverage::default();
797        assert_eq!(coverage.anchored, 0);
798        assert_eq!(coverage.unanchored, 0);
799        assert_eq!(coverage.total(), 0);
800    }
801
802    #[test]
803    fn pir_id_from_index_round_trip() {
804        let id = PirId::from_index(42);
805        assert_eq!(id.index(), 42);
806    }
807
808    #[test]
809    fn pir_graph_empty_returns_true_for_no_nodes() {
810        let graph = PirGraph {
811            nodes: vec![],
812            edges: vec![],
813            receipt: PirReceipt {
814                schema_version: 1,
815                source_identity: None,
816                lowering_mode: PirLoweringMode::HirV0,
817                node_count: 0,
818                edge_count: 0,
819                operation_counts: Default::default(),
820                context_counts: Default::default(),
821                source_anchor_coverage: Default::default(),
822                dynamic_boundary_counts: Default::default(),
823                unsupported_construct_counts: Default::default(),
824                ambient_inputs: vec![],
825                provider_behavior_changed: false,
826            },
827        };
828        assert!(graph.is_empty());
829    }
830
831    #[test]
832    fn pir_graph_empty_returns_false_for_nodes() {
833        let loc = SourceLocation { start: 0, end: 1 };
834        let node = PirNode {
835            id: PirId::from_index(0),
836            source_anchor: PirSourceAnchor::explicit(loc, HirId::from_index(1)),
837            operation: PirOperation::Assign,
838            context: PirContext::Void,
839            dynamic_boundary: None,
840            scope: None,
841            package_context: None,
842        };
843        let graph = PirGraph {
844            nodes: vec![node],
845            edges: vec![],
846            receipt: PirReceipt {
847                schema_version: 1,
848                source_identity: None,
849                lowering_mode: PirLoweringMode::HirV0,
850                node_count: 1,
851                edge_count: 0,
852                operation_counts: Default::default(),
853                context_counts: Default::default(),
854                source_anchor_coverage: Default::default(),
855                dynamic_boundary_counts: Default::default(),
856                unsupported_construct_counts: Default::default(),
857                ambient_inputs: vec![],
858                provider_behavior_changed: false,
859            },
860        };
861        assert!(!graph.is_empty());
862    }
863
864    #[test]
865    fn pir_graph_node_lookup() {
866        let loc = SourceLocation { start: 0, end: 1 };
867        let node = PirNode {
868            id: PirId::from_index(0),
869            source_anchor: PirSourceAnchor::explicit(loc, HirId::from_index(1)),
870            operation: PirOperation::Assign,
871            context: PirContext::Void,
872            dynamic_boundary: None,
873            scope: None,
874            package_context: None,
875        };
876        let graph = PirGraph {
877            nodes: vec![node.clone()],
878            edges: vec![],
879            receipt: PirReceipt {
880                schema_version: 1,
881                source_identity: None,
882                lowering_mode: PirLoweringMode::HirV0,
883                node_count: 1,
884                edge_count: 0,
885                operation_counts: Default::default(),
886                context_counts: Default::default(),
887                source_anchor_coverage: Default::default(),
888                dynamic_boundary_counts: Default::default(),
889                unsupported_construct_counts: Default::default(),
890                ambient_inputs: vec![],
891                provider_behavior_changed: false,
892            },
893        };
894        let found = graph.node(PirId::from_index(0));
895        assert_eq!(found, Some(&node));
896    }
897
898    #[test]
899    fn pir_graph_node_lookup_invalid_id() {
900        let graph = PirGraph {
901            nodes: vec![],
902            edges: vec![],
903            receipt: PirReceipt {
904                schema_version: 1,
905                source_identity: None,
906                lowering_mode: PirLoweringMode::HirV0,
907                node_count: 0,
908                edge_count: 0,
909                operation_counts: Default::default(),
910                context_counts: Default::default(),
911                source_anchor_coverage: Default::default(),
912                dynamic_boundary_counts: Default::default(),
913                unsupported_construct_counts: Default::default(),
914                ambient_inputs: vec![],
915                provider_behavior_changed: false,
916            },
917        };
918        let found = graph.node(PirId::from_index(42));
919        assert_eq!(found, None);
920    }
921
922    #[test]
923    fn lexical_name_structure() {
924        let name = LexicalName { sigil: "$".to_string(), name: "x".to_string() };
925        assert_eq!(name.sigil, "$");
926        assert_eq!(name.name, "x");
927    }
928
929    #[test]
930    fn symbol_name_with_package() {
931        let symbol = SymbolName {
932            sigil: "@".to_string(),
933            name: "items".to_string(),
934            package: Some("Acme".to_string()),
935        };
936        assert_eq!(symbol.sigil, "@");
937        assert_eq!(symbol.name, "items");
938        assert_eq!(symbol.package.as_deref(), Some("Acme"));
939    }
940
941    #[test]
942    fn pir_receiver_class() {
943        let receiver = PirReceiver::Class("Foo".to_string());
944        assert_eq!(format!("{:?}", receiver), "Class(\"Foo\")");
945    }
946
947    #[test]
948    fn pir_receiver_expression() {
949        let receiver = PirReceiver::Expression { kind: "Variable" };
950        assert_eq!(format!("{:?}", receiver), "Expression { kind: \"Variable\" }");
951    }
952
953    #[test]
954    fn pir_receiver_dynamic() {
955        assert_eq!(format!("{:?}", PirReceiver::Dynamic), "Dynamic");
956    }
957}