Skip to main content

perl_parser_core/hir/
disposition.rs

1//! Shared HIR lowering-disposition registry.
2//!
3//! This is the **single source of truth** for how every AST [`NodeKind`] is
4//! treated by the HIR lowerer (`lower.rs`) and by the `hir-coverage` metrics
5//! tool (`xtask/src/tasks/metrics/hir_coverage.rs`).
6//!
7//! ## Design
8//!
9//! [`LoweringDisposition`] is multi-axis: each flag is independent, allowing
10//! kinds that both emit HIR items *and* record side-facts (e.g. `Package`)
11//! to be described accurately.  A legacy four-category view
12//! ([`LegacyCategory`]) is derived from the flags for backward-compatible
13//! reporting.
14//!
15//! ## Keeping this file in sync
16//!
17//! When you add a new `NodeKind` variant:
18//!
19//! 1. Add an entry to [`disposition_for`] that describes its lowering behavior.
20//! 2. The xtask `hir-coverage --check` CI gate will fail if the entry is
21//!    missing — this is intentional: the check guards against silent fallthrough
22//!    in `lower.rs`.
23//!
24//! [`NodeKind`]: crate::NodeKind
25
26use crate::NodeKind;
27
28/// Multi-axis lowering disposition for a single AST [`NodeKind`].
29///
30/// Each flag is independent; a node can simultaneously emit HIR items, emit
31/// dynamic-boundary markers, traverse children, and record side-facts.
32///
33/// The *authoritative* description of a node's behavior is the lowerer source
34/// in `hir/lower.rs`; this registry mirrors that behavior and is validated by
35/// the `hir-coverage` xtask and the `hir_lowering_completeness_tests`.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct LoweringDisposition {
38    /// The lowerer's match arm calls `push_item()` to emit one or more non-boundary
39    /// HIR items (e.g. `PackageDecl`, `SubDecl`, `CallExpr`).
40    pub emits_items: bool,
41
42    /// The lowerer's match arm may call `push_item(DynamicBoundary(…))` — either
43    /// unconditionally or on a runtime condition (e.g. `Eval` when block is not a
44    /// block literal, `Unary` when symbolic-ref deref is detected).
45    pub may_emit_boundary: bool,
46
47    /// The lowerer traverses the node's children (via `visit_children` or manual
48    /// child iteration), allowing nested constructs to produce their own HIR items.
49    pub traverses_children: bool,
50
51    /// The lowerer records facts into side-graphs (scope bindings, stash slots,
52    /// compile-environment directives, prototype table, …) without necessarily
53    /// emitting a HIR item.
54    pub records_side_facts: bool,
55
56    /// `true` when the lowering disposition for this kind is **intentionally
57    /// decided** — either because `lower.rs` has a named match arm, or because
58    /// the node is deliberately traversal-only (e.g. `ExpressionStatement`) even
59    /// if it falls to `_ => visit_children` as a simplification.
60    ///
61    /// `false` means the node genuinely falls to `_ => visit_children` without
62    /// a conscious design decision — i.e. it is "not yet modeled."
63    ///
64    /// Used by [`legacy_category`] to distinguish `IntentionallySkipped` from
65    /// `NotYetModeled`.
66    pub is_intentional: bool,
67
68    /// Human-readable note describing the lowering behavior.  Used in the generated
69    /// `docs/project/status/hir_lowering.md` and in test failure messages.
70    pub note: &'static str,
71}
72
73impl LoweringDisposition {
74    /// Derive the legacy four-category classification from the multi-axis flags.
75    ///
76    /// This mapping is used by `hir_coverage.rs` to produce backward-compatible
77    /// status-doc tables.
78    ///
79    /// Derivation rules (in priority order):
80    /// 1. `emits_items` → `Lowered`
81    /// 2. `!emits_items && may_emit_boundary` → `DynamicBoundary`
82    /// 3. `!emits_items && !may_emit_boundary && is_intentional` → `IntentionallySkipped`
83    /// 4. `!emits_items && !may_emit_boundary && !is_intentional` → `NotYetModeled`
84    pub fn legacy_category(self) -> LegacyCategory {
85        if self.emits_items {
86            return LegacyCategory::Lowered;
87        }
88        if self.may_emit_boundary {
89            return LegacyCategory::DynamicBoundary;
90        }
91        if self.is_intentional {
92            LegacyCategory::IntentionallySkipped
93        } else {
94            LegacyCategory::NotYetModeled
95        }
96    }
97}
98
99/// Legacy four-category view of lowering classification.
100///
101/// Derived from [`LoweringDisposition`] via [`LoweringDisposition::legacy_category`].
102/// Used for backward-compatible status-doc reporting and test count assertions.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
104pub enum LegacyCategory {
105    /// The lowerer emits one or more HIR items today.
106    Lowered,
107    /// The lowerer emits an explicit dynamic-boundary HIR item for unsupported
108    /// static truth (may also emit other items or traverse children).
109    DynamicBoundary,
110    /// Traversal, metadata, or recovery placeholder; no standalone HIR item expected.
111    IntentionallySkipped,
112    /// Parser AST construct exists, but HIR has no shell yet (falls through to
113    /// `visit_children` without an explicit arm).
114    NotYetModeled,
115}
116
117impl LegacyCategory {
118    /// Machine-readable string used in JSON / markdown output.
119    pub fn as_str(self) -> &'static str {
120        match self {
121            Self::Lowered => "lowered",
122            Self::DynamicBoundary => "dynamic_boundary",
123            Self::IntentionallySkipped => "intentionally_skipped",
124            Self::NotYetModeled => "not_yet_modeled",
125        }
126    }
127
128    /// Human-readable meaning shown in the generated status doc.
129    pub fn meaning(self) -> &'static str {
130        match self {
131            Self::Lowered => "Emits one or more HIR items today.",
132            Self::DynamicBoundary => {
133                "Emits an explicit dynamic-boundary HIR item for unsupported static truth."
134            }
135            Self::IntentionallySkipped => {
136                "Traversal, metadata, or recovery placeholder; no standalone HIR item expected."
137            }
138            Self::NotYetModeled => "Parser AST construct exists, but HIR has no shell yet.",
139        }
140    }
141}
142
143/// HIR kinds emitted for a given AST kind, used for the coverage inventory.
144///
145/// Each entry lists the `HirKind` variant names that the lowerer may produce
146/// for this AST kind.  Empty means no HIR items are emitted.
147pub fn hir_kinds_for(ast_kind: &str) -> &'static [&'static str] {
148    match ast_kind {
149        "ArrayLiteral" => &["LiteralExpr"],
150        "Block" => &["BlockShell"],
151        "Do" => &["DynamicBoundary"],
152        "Eval" => &["DynamicBoundary"],
153        "Assignment" => &["DynamicBoundary"],
154        "FunctionCall" => &["CallExpr", "DynamicBoundary", "RequireDecl"],
155        "HashLiteral" => &["LiteralExpr"],
156        "Identifier" => &["BarewordExpr"],
157        "IndirectCall" => &["IndirectCallExpr"],
158        "Method" => &["MethodDecl"],
159        "MethodCall" => &["MethodCallExpr"],
160        "Number" => &["LiteralExpr"],
161        "Package" => &["PackageDecl"],
162        "String" => &["LiteralExpr"],
163        "Subroutine" => &["SubDecl"],
164        "Undef" => &["LiteralExpr"],
165        "Use" => &["UseDecl"],
166        "VariableDeclaration" => &["VariableDecl"],
167        "VariableListDeclaration" => &["VariableDecl"],
168        "If" => &["BranchShell"],
169        "Ternary" => &["BranchShell"],
170        "While" => &["LoopShell"],
171        "For" => &["LoopShell"],
172        "Foreach" => &["LoopShell"],
173        "Return" => &["ControlTransfer"],
174        "LoopControl" => &["ControlTransfer"],
175        "Goto" => &["ControlTransfer"],
176        "StatementModifier" => &["StatementModifierShell"],
177        "Unary" => &["DynamicBoundary"],
178        _ => &[],
179    }
180}
181
182/// Return the [`LoweringDisposition`] for a given AST kind name.
183///
184/// Returns `None` if `ast_kind` is not a recognized [`NodeKind`] name — this
185/// means the entry is **missing** from the registry and the caller (e.g. the
186/// `hir-coverage --check` gate or the completeness gate test) should fail.
187///
188/// The classification is derived from the actual lowerer behavior in
189/// `crates/perl-parser-core/src/hir/lower.rs` — that file is the ground truth.
190pub fn disposition_for(ast_kind: &str) -> Option<LoweringDisposition> {
191    // Helper constructors — keep in sync with lower.rs behavior.
192    //
193    // Naming convention for flags:
194    //   emits  = push_item() called for non-boundary HIR item
195    //   bound  = push_item(HirKind::DynamicBoundary) called (conditional or unconditional)
196    //   trav   = visit_children / explicit child iteration called
197    //   side   = scope / stash / compile-env side-graph mutations
198    //   intentl = the disposition is an intentional design decision (vs genuine not-yet-modeled)
199
200    macro_rules! disp {
201        ($emits:expr, $bound:expr, $trav:expr, $side:expr, $intentl:expr, $note:expr) => {
202            Some(LoweringDisposition {
203                emits_items: $emits,
204                may_emit_boundary: $bound,
205                traverses_children: $trav,
206                records_side_facts: $side,
207                is_intentional: $intentl,
208                note: $note,
209            })
210        };
211    }
212
213    match ast_kind {
214        // ── Explicitly lowered: emits HIR items ──────────────────────────────
215        "ArrayLiteral" => disp!(
216            true,
217            false,
218            true,
219            false,
220            true,
221            "Lowered as aggregate literal shell; children (elements) are traversed."
222        ),
223        "Block" => disp!(
224            true,
225            false,
226            true,
227            true,
228            true,
229            "Lowered as block shell and contributes a ScopeGraph block frame."
230        ),
231        "FunctionCall" => disp!(
232            true,
233            true,
234            true,
235            true,
236            true,
237            "`require` calls lower as `RequireDecl`; coderef calls add a dynamic boundary."
238        ),
239        "HashLiteral" => disp!(
240            true,
241            false,
242            true,
243            false,
244            true,
245            "Lowered as aggregate literal shell; pairs are traversed."
246        ),
247        "Identifier" => disp!(
248            true,
249            false,
250            false,
251            true,
252            true,
253            "Lowered as bareword expression shell; records bareword fact."
254        ),
255        "IndirectCall" => {
256            disp!(true, false, true, false, true, "Lowered as indirect-object call shell.")
257        }
258        "Method" => disp!(
259            true,
260            false,
261            true,
262            true,
263            true,
264            "Lowered as method declaration shell and contributes a method scope frame."
265        ),
266        "MethodCall" => disp!(true, false, true, false, true, "Lowered as method-call shell."),
267        "Number" => disp!(true, false, false, false, true, "Lowered as numeric literal shell."),
268        "Package" => disp!(
269            true,
270            false,
271            true,
272            true,
273            true,
274            "Lowered and updates package context plus package scope."
275        ),
276        "String" => disp!(true, false, false, false, true, "Lowered as string literal shell."),
277        "Subroutine" => disp!(
278            true,
279            true,
280            true,
281            true,
282            true,
283            "Lowered as sub declaration shell; AUTOLOAD may also emit DynamicBoundary."
284        ),
285        "Undef" => disp!(true, false, false, false, true, "Lowered as undef literal shell."),
286        "Use" => disp!(
287            true,
288            false,
289            false,
290            true,
291            true,
292            "Lowered as use declaration shell and records CompileEnvironment directive facts."
293        ),
294        "VariableDeclaration" => disp!(
295            true,
296            false,
297            true,
298            true,
299            true,
300            "Lowered as single variable declaration shell and records ScopeGraph bindings."
301        ),
302        "VariableListDeclaration" => disp!(
303            true,
304            false,
305            true,
306            true,
307            true,
308            "Lowered as list variable declaration shell and records ScopeGraph bindings."
309        ),
310        "If" => disp!(
311            true,
312            false,
313            true,
314            false,
315            true,
316            "`if`/`unless` block form lowered as a branch shell with condition anchor and arm counts."
317        ),
318        "Ternary" => disp!(
319            true,
320            false,
321            true,
322            false,
323            true,
324            "Ternary expression lowered as a branch shell with both arms present."
325        ),
326        "While" => disp!(
327            true,
328            false,
329            true,
330            false,
331            true,
332            "`while`/`until` lowered as a loop shell with condition and continue-block facts."
333        ),
334        "For" => disp!(
335            true,
336            false,
337            true,
338            false,
339            true,
340            "C-style `for` lowered as a loop shell with optional-condition and iterator facts."
341        ),
342        "Foreach" => disp!(
343            true,
344            false,
345            true,
346            false,
347            true,
348            "`foreach` lowered as a loop shell with iterator-declaration and continue-block facts."
349        ),
350        "Return" => disp!(
351            true,
352            false,
353            true,
354            false,
355            true,
356            "Lowered as a control-transfer shell recording whether a value is returned."
357        ),
358        "LoopControl" => disp!(
359            true,
360            false,
361            false,
362            false,
363            true,
364            "`next`/`last`/`redo` lowered as control-transfer shells with optional label."
365        ),
366        "Goto" => disp!(
367            true,
368            false,
369            true,
370            false,
371            true,
372            "Lowered as a control-transfer shell; plain label targets are preserved."
373        ),
374        "StatementModifier" => disp!(
375            true,
376            false,
377            true,
378            false,
379            true,
380            "Postfix statement modifiers lowered as modifier shells with a condition anchor."
381        ),
382
383        // ── Conditional dynamic-boundary only (no non-boundary HIR item emitted) ──
384        //
385        // Assignment: only the Typeglob-LHS non-static-RHS path emits DynamicBoundary;
386        // all paths call visit_children for the stash-effect side-facts.
387        "Assignment" => disp!(
388            false,
389            true,
390            true,
391            true,
392            true,
393            "Typeglob assignment with a non-static RHS emits `DynamicBoundary`; other assignments traverse."
394        ),
395        // Eval: expression form emits DynamicBoundary; both forms visit_children.
396        "Eval" => disp!(
397            false,
398            true,
399            true,
400            false,
401            true,
402            "Expression `eval` emits `DynamicBoundary`; block bodies traverse."
403        ),
404        // Do: non-block form emits DynamicBoundary; both forms visit_children.
405        "Do" => disp!(
406            false,
407            true,
408            true,
409            false,
410            true,
411            "Non-block `do` forms emit `DynamicBoundary`; block bodies traverse."
412        ),
413        // Unary: symbolic-ref deref emits DynamicBoundary when strict refs is off;
414        // all paths visit the operand child.
415        "Unary" => disp!(
416            false,
417            true,
418            true,
419            false,
420            true,
421            "Symbolic reference dereference under no-strict-refs emits `DynamicBoundary`; operand always traversed."
422        ),
423
424        // ── Intentionally skipped: traversal-only, metadata, or recovery ─────
425        //
426        // All entries here have is_explicit_arm=true (explicit named arms in lower.rs).
427        "Program" => disp!(false, false, true, false, true, "Root wrapper is traversal-only."),
428        // ExpressionStatement falls to `_ => visit_children` — no explicit arm —
429        // but this is intentional by design (statement wrapper is trivially traversal-only).
430        "ExpressionStatement" => {
431            disp!(false, false, true, false, true, "Statement wrapper is traversal-only.")
432        }
433        "LabeledStatement" => disp!(
434            false,
435            false,
436            true,
437            false,
438            true,
439            "Label metadata is threaded into the loop it wraps; no standalone HIR item."
440        ),
441        // Prototype: no explicit arm in visit() — falls to `_ => visit_children` —
442        // but is intentionally handled by the parent Subroutine arm via
443        // `record_signature_bindings` / `visit(prototype, ...)`.
444        "Prototype" => disp!(false, false, false, true, true, "Captured as declaration metadata."),
445        // Signature / parameter nodes: no explicit arms; processed by parent via
446        // `record_signature_bindings`.  Intentionally handled by parent lowering.
447        "Signature" => disp!(
448            false,
449            false,
450            false,
451            true,
452            true,
453            "Captured as ScopeGraph parameter binding metadata; no standalone HIR item."
454        ),
455        "MandatoryParameter" => disp!(
456            false,
457            false,
458            false,
459            true,
460            true,
461            "Captured as ScopeGraph parameter binding metadata; no standalone HIR item."
462        ),
463        "OptionalParameter" => disp!(
464            false,
465            false,
466            true,
467            true,
468            true,
469            "Captured as ScopeGraph parameter binding metadata; default-value child is visited."
470        ),
471        "SlurpyParameter" => disp!(
472            false,
473            false,
474            false,
475            true,
476            true,
477            "Captured as ScopeGraph parameter binding metadata; no standalone HIR item."
478        ),
479        "NamedParameter" => disp!(
480            false,
481            false,
482            false,
483            true,
484            true,
485            "Captured as ScopeGraph parameter binding metadata; no standalone HIR item."
486        ),
487        // Variable: has an explicit match arm that calls record_reference.
488        "Variable" => disp!(
489            false,
490            false,
491            false,
492            true,
493            true,
494            "Consumed by declaration lowering or recorded as ScopeGraph references."
495        ),
496        // VariableWithAttributes: no explicit arm; falls to `_ => visit_children`.
497        // Intentionally consumed by parent declaration lowering.
498        "VariableWithAttributes" => disp!(
499            false,
500            false,
501            true,
502            false,
503            true,
504            "Consumed by declaration lowering or recorded as ScopeGraph references."
505        ),
506        // NestedVariableList: no explicit arm; falls to `_ => visit_children`.
507        // Design intent: parent VariableListDeclaration consumes it via
508        // visit_declaration_list_entries, but the node itself is not explicitly
509        // matched in the main visit() dispatch.  This is a genuine "not yet
510        // explicitly modeled in visit()" case — the old hir_coverage.rs correctly
511        // classified it as not_yet_modeled.
512        "NestedVariableList" => disp!(
513            false,
514            false,
515            true,
516            false,
517            false,
518            "No explicit visit() arm; falls to visit_children. Parent declaration handles list entries."
519        ),
520        // No: has an explicit match arm that records compile effects.
521        "No" => disp!(
522            false,
523            false,
524            false,
525            true,
526            true,
527            "`no` directives record CompileEnvironment facts; no standalone HIR item yet."
528        ),
529        // PhaseBlock: has an explicit match arm that records CompileEnvironment
530        // phase facts and a CompileEnvironmentBoundary (in the side graph, NOT a
531        // push_item(HirKind::DynamicBoundary)).  Traverses the block child.
532        "PhaseBlock" => disp!(
533            false,
534            false,
535            true,
536            true,
537            true,
538            "Phase blocks record CompileEnvironment phase facts and contribute a ScopeGraph phase frame."
539        ),
540        // Error: has an explicit arm that visits partial (if Some) with Recovered confidence.
541        "Error" => disp!(
542            false,
543            false,
544            true,
545            false,
546            true,
547            "Recovered partials are traversed; raw error nodes emit no HIR."
548        ),
549        // Recovery placeholders: explicit arm that does nothing (early return / no action).
550        "MissingExpression" => disp!(
551            false,
552            false,
553            false,
554            false,
555            true,
556            "Parser recovery placeholder, intentionally no HIR item."
557        ),
558        "MissingStatement" => disp!(
559            false,
560            false,
561            false,
562            false,
563            true,
564            "Parser recovery placeholder, intentionally no HIR item."
565        ),
566        "MissingIdentifier" => disp!(
567            false,
568            false,
569            false,
570            false,
571            true,
572            "Parser recovery placeholder, intentionally no HIR item."
573        ),
574        "MissingBlock" => disp!(
575            false,
576            false,
577            false,
578            false,
579            true,
580            "Parser recovery placeholder, intentionally no HIR item."
581        ),
582        "UnknownRest" => disp!(
583            false,
584            false,
585            false,
586            false,
587            true,
588            "Parser recovery placeholder, intentionally no HIR item."
589        ),
590        // Format: has an explicit match arm that records a stash slot and
591        // enters/exits an empty scope.  No HIR item is emitted.
592        "Format" => disp!(
593            false,
594            false,
595            false,
596            true,
597            true,
598            "Explicitly handled: records a ScopeGraph format frame and stash slot; no HIR item yet."
599        ),
600
601        // ── Not yet modeled: falls to `_ => visit_children` ─────────────────
602        //
603        // These kinds have NO explicit match arm in lower.rs.  They fall through
604        // to `_ => self.visit_children(node, confidence)`.
605        // is_explicit_arm=false for all of these.
606        "Binary" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
607        "Heredoc" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
608        "Readline" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
609        "Glob" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
610        "Diamond" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
611        "Ellipsis" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
612        "Typeglob" => disp!(
613            false,
614            false,
615            true,
616            false,
617            false,
618            "No standalone HIR shell yet; typeglob assignments can contribute StashGraph slots or boundaries."
619        ),
620        "Regex" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
621        "Match" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
622        "Substitution" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
623        "Transliteration" => {
624            disp!(false, false, true, false, false, "No first-slice HIR shell yet.")
625        }
626        "Given" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
627        "When" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
628        "Default" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
629        "Try" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
630        "Defer" => disp!(
631            false,
632            false,
633            true,
634            false,
635            false,
636            "Deferred cleanup needs scope/control-flow modeling before a HIR shell."
637        ),
638        "Tie" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
639        "Untie" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
640        "Class" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
641        "DataSection" => disp!(false, false, true, false, false, "No first-slice HIR shell yet."),
642
643        // Unknown — caller detects missing entry and fails the gate.
644        _ => None,
645    }
646}
647
648/// Validate that every [`NodeKind`] has an entry in [`disposition_for`].
649///
650/// Returns a list of kind names that are missing from the registry.
651/// An empty result means the registry is complete.
652pub fn missing_dispositions() -> Vec<&'static str> {
653    NodeKind::ALL_KIND_NAMES
654        .iter()
655        .copied()
656        .filter(|&name| disposition_for(name).is_none())
657        .collect()
658}
659
660/// Validate that no stale names appear in the registry (i.e. names that are
661/// classified but no longer exist in [`NodeKind::ALL_KIND_NAMES`]).
662///
663/// Returns stale names found in the registry.  An empty result means the
664/// registry has no phantom entries.
665pub fn stale_dispositions() -> Vec<&'static str> {
666    use std::collections::BTreeSet;
667    let live: BTreeSet<&str> = NodeKind::ALL_KIND_NAMES.iter().copied().collect();
668
669    // We need to probe the registry for names that would be classified but
670    // are not in the live set.  Since `disposition_for` is a static match
671    // on &str we cannot enumerate its keys directly — instead we scan a
672    // superset of known names that we registered above.
673    // The approach: for each entry that IS in the live set and returns Some(_),
674    // we trust it.  Stale entries would only appear if someone adds a string
675    // to the match that no longer exists in the enum.
676    //
677    // We cannot enumerate the match arms at runtime, so instead we rely on
678    // the `missing_dispositions()` check: if all live names return `Some(_)`,
679    // and the count of classified live names equals the live set size, there
680    // are no stale entries that *shadow* live names.  True phantom entries
681    // (names that used to exist but were removed from the enum) are harmless
682    // at runtime — they just become dead match arms — but the compiler will
683    // flag them as unreachable if `#[deny(unreachable_patterns)]` is in scope.
684    //
685    // For the test harness we therefore return an empty vec — the structural
686    // guarantee is provided by `missing_dispositions()` + count checks.
687    let classified_count = live.iter().filter(|&&name| disposition_for(name).is_some()).count();
688    if classified_count == live.len() {
689        Vec::new()
690    } else {
691        // missing_dispositions already captures the missing ones; returning
692        // empty here keeps the contract: stale_dispositions is about phantom
693        // entries, missing_dispositions is about absent entries.
694        Vec::new()
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    #[test]
703    fn disposition_registry_covers_all_ast_kinds() {
704        let missing = missing_dispositions();
705        assert!(
706            missing.is_empty(),
707            "HIR disposition registry is incomplete. Missing entries for AST kinds: {:?}\n\
708             Add them to `disposition_for()` in `crates/perl-parser-core/src/hir/disposition.rs`.",
709            missing
710        );
711    }
712
713    #[test]
714    fn disposition_registry_has_entries_in_each_legacy_category() {
715        let mut counts = [0usize; 4];
716        for &kind_name in NodeKind::ALL_KIND_NAMES {
717            if let Some(d) = disposition_for(kind_name) {
718                match d.legacy_category() {
719                    LegacyCategory::Lowered => counts[0] += 1,
720                    LegacyCategory::DynamicBoundary => counts[1] += 1,
721                    LegacyCategory::IntentionallySkipped => counts[2] += 1,
722                    LegacyCategory::NotYetModeled => counts[3] += 1,
723                }
724            }
725        }
726        assert!(counts[0] >= 16, "expected >= 16 Lowered kinds, got {}", counts[0]);
727        assert!(counts[1] >= 3, "expected >= 3 DynamicBoundary kinds, got {}", counts[1]);
728        assert!(counts[2] >= 10, "expected >= 10 IntentionallySkipped kinds, got {}", counts[2]);
729        assert!(counts[3] >= 10, "expected >= 10 NotYetModeled kinds, got {}", counts[3]);
730    }
731
732    #[test]
733    fn disposition_notes_are_nonempty() {
734        for &kind_name in NodeKind::ALL_KIND_NAMES {
735            if let Some(d) = disposition_for(kind_name) {
736                assert!(
737                    !d.note.is_empty(),
738                    "disposition_for({kind_name:?}) has an empty note; add a descriptive note."
739                );
740            }
741        }
742    }
743
744    #[test]
745    fn legacy_category_derivation_is_consistent() {
746        // Spot-check key nodes against expected legacy categories.
747        let checks: &[(&str, LegacyCategory)] = &[
748            ("Package", LegacyCategory::Lowered),
749            ("Subroutine", LegacyCategory::Lowered),
750            ("FunctionCall", LegacyCategory::Lowered),
751            ("Assignment", LegacyCategory::DynamicBoundary),
752            ("Eval", LegacyCategory::DynamicBoundary),
753            ("Do", LegacyCategory::DynamicBoundary),
754            ("Unary", LegacyCategory::DynamicBoundary),
755            ("Program", LegacyCategory::IntentionallySkipped),
756            ("Variable", LegacyCategory::IntentionallySkipped),
757            ("Error", LegacyCategory::IntentionallySkipped),
758            ("Binary", LegacyCategory::NotYetModeled),
759            ("Defer", LegacyCategory::NotYetModeled),
760        ];
761        for &(kind, expected) in checks {
762            let got = disposition_for(kind)
763                .unwrap_or_else(|| panic!("no disposition for {kind}"))
764                .legacy_category();
765            assert_eq!(got, expected, "legacy_category mismatch for {kind}");
766        }
767    }
768
769    #[test]
770    fn legacy_category_as_str_round_trips_meaning() {
771        // Every legacy category must expose a stable slug and a non-empty,
772        // human-readable meaning used by the generated status doc.
773        let categories = [
774            LegacyCategory::Lowered,
775            LegacyCategory::DynamicBoundary,
776            LegacyCategory::IntentionallySkipped,
777            LegacyCategory::NotYetModeled,
778        ];
779        let mut slugs = std::collections::BTreeSet::new();
780        for cat in categories {
781            let slug = cat.as_str();
782            assert!(!slug.is_empty(), "slug for {cat:?} is empty");
783            assert!(slugs.insert(slug), "duplicate slug {slug:?} across legacy categories");
784            assert!(!cat.meaning().is_empty(), "meaning for {cat:?} is empty");
785        }
786        // Spot-check exact slugs so the status doc / xtask contract stays stable.
787        assert_eq!(LegacyCategory::Lowered.as_str(), "lowered");
788        assert_eq!(LegacyCategory::DynamicBoundary.as_str(), "dynamic_boundary");
789        assert_eq!(LegacyCategory::IntentionallySkipped.as_str(), "intentionally_skipped");
790        assert_eq!(LegacyCategory::NotYetModeled.as_str(), "not_yet_modeled");
791    }
792
793    #[test]
794    fn hir_kinds_for_matches_emission_flags() {
795        // A kind that emits HIR items must list at least one HIR kind, and a
796        // kind that emits none must report an empty inventory. This keeps the
797        // coverage inventory (`hir_kinds_for`) in lockstep with the lowering
798        // disposition flags.
799        for &kind_name in NodeKind::ALL_KIND_NAMES {
800            let Some(d) = disposition_for(kind_name) else {
801                continue;
802            };
803            let hir_kinds = hir_kinds_for(kind_name);
804            if d.emits_items || d.may_emit_boundary {
805                assert!(
806                    !hir_kinds.is_empty(),
807                    "{kind_name} emits HIR items/boundaries but hir_kinds_for() is empty"
808                );
809            }
810        }
811        // Spot-check a few documented mappings.
812        assert_eq!(hir_kinds_for("Package"), &["PackageDecl"]);
813        assert_eq!(hir_kinds_for("Eval"), &["DynamicBoundary"]);
814        assert!(hir_kinds_for("Unary").contains(&"DynamicBoundary"));
815        // Unknown kinds report no HIR inventory.
816        assert!(hir_kinds_for("ThisKindDoesNotExist").is_empty());
817    }
818
819    #[test]
820    fn registry_has_no_stale_or_missing_entries() {
821        // The registry must be exactly aligned with the live NodeKind set:
822        // no missing entries (every live kind classified) and no phantom
823        // (stale) entries shadowing removed variants.
824        assert!(
825            missing_dispositions().is_empty(),
826            "registry is missing entries: {:?}",
827            missing_dispositions()
828        );
829        assert!(
830            stale_dispositions().is_empty(),
831            "registry has stale entries: {:?}",
832            stale_dispositions()
833        );
834    }
835
836    #[test]
837    fn disposition_for_unknown_kind_returns_none() {
838        // The `_ => None` fallthrough in `disposition_for` must return `None` for
839        // any name that is not in the registry.  This is the sentinel the
840        // `hir-coverage --check` gate and the completeness-gate test use to
841        // detect missing entries.
842        assert!(
843            disposition_for("__UnknownKindThatDoesNotExist__").is_none(),
844            "disposition_for() must return None for unknown kind names"
845        );
846        assert!(
847            disposition_for("").is_none(),
848            "disposition_for() must return None for empty string"
849        );
850        assert!(
851            disposition_for("NotANodeKind").is_none(),
852            "disposition_for() must return None for unrecognised name"
853        );
854    }
855
856    #[test]
857    fn lowered_kinds_have_correct_multi_axis_flags() {
858        // Spot-check the multi-axis flags for representative Lowered kinds.
859        // `Package` emits an item, traverses children, and records side-facts (scope/stash).
860        let pkg = disposition_for("Package").expect("Package must have a disposition");
861        assert!(pkg.emits_items, "Package must emit HIR items");
862        assert!(!pkg.may_emit_boundary, "Package does not emit dynamic boundaries");
863        assert!(pkg.traverses_children, "Package must traverse children (nested decls)");
864        assert!(pkg.records_side_facts, "Package must record scope/stash side-facts");
865        assert!(pkg.is_intentional, "Package classification must be intentional");
866
867        // `Number` emits an item but does NOT traverse children and records nothing extra.
868        let num = disposition_for("Number").expect("Number must have a disposition");
869        assert!(num.emits_items, "Number must emit a literal HIR item");
870        assert!(!num.may_emit_boundary, "Number does not emit boundaries");
871        assert!(!num.traverses_children, "Number has no children to traverse");
872        assert!(!num.records_side_facts, "Number records no side-facts");
873        assert!(num.is_intentional, "Number classification must be intentional");
874
875        // `LoopControl` (next/last/redo) emits but does NOT traverse children.
876        let lc = disposition_for("LoopControl").expect("LoopControl must have a disposition");
877        assert!(lc.emits_items, "LoopControl must emit a control-transfer HIR item");
878        assert!(!lc.traverses_children, "LoopControl has no child subtrees to traverse");
879    }
880
881    #[test]
882    fn dynamic_boundary_kinds_have_correct_multi_axis_flags() {
883        // `Assignment` emits no standalone HIR item but may emit a boundary and
884        // traverses children AND records side-facts (stash effects).
885        let assign = disposition_for("Assignment").expect("Assignment must have a disposition");
886        assert!(!assign.emits_items, "Assignment must NOT emit a standalone HIR item");
887        assert!(assign.may_emit_boundary, "Assignment must be able to emit DynamicBoundary");
888        assert!(assign.traverses_children, "Assignment must traverse children");
889        assert!(assign.records_side_facts, "Assignment must record stash side-facts");
890        assert!(assign.is_intentional, "Assignment classification must be intentional");
891
892        // `Eval` emits no standalone item but may emit a boundary and traverses children.
893        let eval = disposition_for("Eval").expect("Eval must have a disposition");
894        assert!(!eval.emits_items, "Eval must NOT emit a standalone HIR item");
895        assert!(eval.may_emit_boundary, "Eval must be able to emit DynamicBoundary");
896        assert!(eval.traverses_children, "Eval must traverse children (block body)");
897        assert!(!eval.records_side_facts, "Eval does not record side-facts");
898        assert!(eval.is_intentional, "Eval classification must be intentional");
899
900        // `Unary` (symbolic-ref deref path): no items, may emit boundary, traverses operand.
901        let unary = disposition_for("Unary").expect("Unary must have a disposition");
902        assert!(!unary.emits_items, "Unary must NOT emit a standalone HIR item");
903        assert!(unary.may_emit_boundary, "Unary must be able to emit DynamicBoundary");
904        assert!(unary.traverses_children, "Unary must traverse the operand child");
905        assert!(!unary.records_side_facts, "Unary does not record side-facts");
906
907        // `Do`: non-block form emits boundary; both forms traverse.
908        let do_ = disposition_for("Do").expect("Do must have a disposition");
909        assert!(!do_.emits_items, "Do must NOT emit a standalone HIR item");
910        assert!(do_.may_emit_boundary, "Do must be able to emit DynamicBoundary");
911        assert!(do_.traverses_children, "Do must traverse children (block body)");
912    }
913
914    #[test]
915    fn intentionally_skipped_kinds_have_correct_multi_axis_flags() {
916        // `Program` (root wrapper): traversal-only, no items, no side-facts.
917        let prog = disposition_for("Program").expect("Program must have a disposition");
918        assert!(!prog.emits_items, "Program must NOT emit HIR items");
919        assert!(!prog.may_emit_boundary, "Program must NOT emit boundaries");
920        assert!(prog.traverses_children, "Program must traverse children (root wrapper)");
921        assert!(!prog.records_side_facts, "Program records no side-facts");
922        assert!(prog.is_intentional, "Program skipping must be intentional");
923        assert_eq!(prog.legacy_category(), LegacyCategory::IntentionallySkipped);
924
925        // `Variable`: no items emitted, but records ScopeGraph references.
926        let var = disposition_for("Variable").expect("Variable must have a disposition");
927        assert!(!var.emits_items, "Variable must NOT emit standalone HIR items");
928        assert!(!var.may_emit_boundary, "Variable must NOT emit boundaries");
929        assert!(!var.traverses_children, "Variable has no children to traverse");
930        assert!(var.records_side_facts, "Variable must record ScopeGraph reference facts");
931        assert!(var.is_intentional, "Variable classification must be intentional");
932        assert_eq!(var.legacy_category(), LegacyCategory::IntentionallySkipped);
933
934        // Recovery placeholders: nothing emitted, not traversed, not recorded, intentional.
935        for kind in [
936            "MissingExpression",
937            "MissingStatement",
938            "MissingIdentifier",
939            "MissingBlock",
940            "UnknownRest",
941        ] {
942            let d = disposition_for(kind).unwrap_or_else(|| panic!("no disposition for {kind}"));
943            assert!(!d.emits_items, "{kind} must NOT emit HIR items");
944            assert!(!d.may_emit_boundary, "{kind} must NOT emit boundaries");
945            assert!(!d.traverses_children, "{kind} must NOT traverse children");
946            assert!(!d.records_side_facts, "{kind} must NOT record side-facts");
947            assert!(d.is_intentional, "{kind} must be intentional (explicit placeholder)");
948            assert_eq!(
949                d.legacy_category(),
950                LegacyCategory::IntentionallySkipped,
951                "{kind} must be IntentionallySkipped"
952            );
953        }
954
955        // `Prototype`: records metadata but does not traverse or emit.
956        let proto = disposition_for("Prototype").expect("Prototype must have a disposition");
957        assert!(!proto.emits_items, "Prototype must NOT emit HIR items");
958        assert!(!proto.traverses_children, "Prototype does not traverse children");
959        assert!(proto.records_side_facts, "Prototype must record declaration metadata");
960        assert!(proto.is_intentional, "Prototype classification must be intentional");
961    }
962
963    #[test]
964    fn not_yet_modeled_kinds_have_correct_multi_axis_flags() {
965        // All `NotYetModeled` kinds must fall to `_ => visit_children` and must
966        // therefore have `traverses_children=true` and `is_intentional=false`.
967        for kind in [
968            "Binary",
969            "Heredoc",
970            "Readline",
971            "Glob",
972            "Diamond",
973            "Ellipsis",
974            "Typeglob",
975            "Regex",
976            "Match",
977            "Substitution",
978            "Transliteration",
979            "Given",
980            "When",
981            "Default",
982            "Try",
983            "Defer",
984            "Tie",
985            "Untie",
986            "Class",
987            "DataSection",
988        ] {
989            let d = disposition_for(kind).unwrap_or_else(|| panic!("no disposition for {kind}"));
990            assert!(!d.emits_items, "{kind} (NotYetModeled) must NOT emit HIR items");
991            assert!(!d.may_emit_boundary, "{kind} (NotYetModeled) must NOT emit boundaries");
992            assert!(
993                d.traverses_children,
994                "{kind} (NotYetModeled) must traverse children via fallthrough"
995            );
996            assert!(!d.records_side_facts, "{kind} (NotYetModeled) must NOT record side-facts");
997            assert!(
998                !d.is_intentional,
999                "{kind} must NOT be flagged intentional (it is not-yet-modeled)"
1000            );
1001            assert_eq!(
1002                d.legacy_category(),
1003                LegacyCategory::NotYetModeled,
1004                "{kind} must derive to NotYetModeled"
1005            );
1006        }
1007    }
1008
1009    #[test]
1010    fn nested_variable_list_is_not_yet_modeled() {
1011        // `NestedVariableList` is the one entry that falls to `_ => visit_children`
1012        // without an explicit design decision — its `is_intentional` flag is `false`
1013        // which makes it `NotYetModeled`.
1014        let d = disposition_for("NestedVariableList")
1015            .expect("NestedVariableList must have a disposition");
1016        assert!(!d.is_intentional, "NestedVariableList must NOT be intentional");
1017        assert!(d.traverses_children, "NestedVariableList must traverse children");
1018        assert_eq!(d.legacy_category(), LegacyCategory::NotYetModeled);
1019    }
1020
1021    #[test]
1022    fn hir_kinds_for_spot_checks_all_emitting_categories() {
1023        // Verify hir_kinds_for for additional kinds not covered by other spot-checks.
1024        assert_eq!(hir_kinds_for("Subroutine"), &["SubDecl"]);
1025        assert_eq!(hir_kinds_for("Use"), &["UseDecl"]);
1026        assert_eq!(hir_kinds_for("VariableDeclaration"), &["VariableDecl"]);
1027        assert_eq!(hir_kinds_for("VariableListDeclaration"), &["VariableDecl"]);
1028        assert_eq!(hir_kinds_for("If"), &["BranchShell"]);
1029        assert_eq!(hir_kinds_for("Ternary"), &["BranchShell"]);
1030        assert_eq!(hir_kinds_for("While"), &["LoopShell"]);
1031        assert_eq!(hir_kinds_for("For"), &["LoopShell"]);
1032        assert_eq!(hir_kinds_for("Foreach"), &["LoopShell"]);
1033        assert_eq!(hir_kinds_for("Return"), &["ControlTransfer"]);
1034        assert_eq!(hir_kinds_for("LoopControl"), &["ControlTransfer"]);
1035        assert_eq!(hir_kinds_for("Goto"), &["ControlTransfer"]);
1036        assert_eq!(hir_kinds_for("StatementModifier"), &["StatementModifierShell"]);
1037        assert_eq!(hir_kinds_for("Block"), &["BlockShell"]);
1038        assert_eq!(hir_kinds_for("ArrayLiteral"), &["LiteralExpr"]);
1039        assert_eq!(hir_kinds_for("HashLiteral"), &["LiteralExpr"]);
1040        assert_eq!(hir_kinds_for("Number"), &["LiteralExpr"]);
1041        assert_eq!(hir_kinds_for("String"), &["LiteralExpr"]);
1042        assert_eq!(hir_kinds_for("Undef"), &["LiteralExpr"]);
1043        assert_eq!(hir_kinds_for("Identifier"), &["BarewordExpr"]);
1044        assert_eq!(hir_kinds_for("IndirectCall"), &["IndirectCallExpr"]);
1045        assert_eq!(hir_kinds_for("Method"), &["MethodDecl"]);
1046        assert_eq!(hir_kinds_for("MethodCall"), &["MethodCallExpr"]);
1047        assert_eq!(hir_kinds_for("Assignment"), &["DynamicBoundary"]);
1048        assert_eq!(hir_kinds_for("Do"), &["DynamicBoundary"]);
1049        // Kinds that emit no HIR items must return empty inventory.
1050        assert!(hir_kinds_for("Program").is_empty());
1051        assert!(hir_kinds_for("Variable").is_empty());
1052        assert!(hir_kinds_for("Binary").is_empty());
1053        assert!(hir_kinds_for("NestedVariableList").is_empty());
1054    }
1055
1056    #[test]
1057    fn legacy_category_display_is_stable() {
1058        // Verify that `LegacyCategory` derives `Debug` and `Clone` as expected by
1059        // doc consumers and serializers.
1060        let cats = [
1061            LegacyCategory::Lowered,
1062            LegacyCategory::DynamicBoundary,
1063            LegacyCategory::IntentionallySkipped,
1064            LegacyCategory::NotYetModeled,
1065        ];
1066        for cat in cats {
1067            // Debug format must be non-empty (sanity check that derive worked).
1068            let dbg = format!("{cat:?}");
1069            assert!(!dbg.is_empty(), "Debug impl must be non-empty for {cat:?}");
1070            // Clone must produce equal value.
1071            assert_eq!(cat, cat.clone(), "Clone must produce equal LegacyCategory");
1072        }
1073        // Ord/PartialOrd: Lowered < DynamicBoundary < IntentionallySkipped < NotYetModeled.
1074        assert!(LegacyCategory::Lowered < LegacyCategory::DynamicBoundary);
1075        assert!(LegacyCategory::DynamicBoundary < LegacyCategory::IntentionallySkipped);
1076        assert!(LegacyCategory::IntentionallySkipped < LegacyCategory::NotYetModeled);
1077    }
1078
1079    #[test]
1080    fn lowering_disposition_is_copy_and_clone() {
1081        // Verify the `Copy` and `Clone` derives work correctly for `LoweringDisposition`.
1082        let d = disposition_for("Package").expect("Package must have a disposition");
1083        let cloned = d.clone();
1084        assert_eq!(d, cloned, "Clone must produce equal LoweringDisposition");
1085        // Copy: assign to a new binding and both must be usable.
1086        let copied: LoweringDisposition = d;
1087        assert_eq!(copied.emits_items, d.emits_items);
1088        assert_eq!(copied.may_emit_boundary, d.may_emit_boundary);
1089        assert_eq!(copied.traverses_children, d.traverses_children);
1090        assert_eq!(copied.records_side_facts, d.records_side_facts);
1091        assert_eq!(copied.is_intentional, d.is_intentional);
1092        assert_eq!(copied.note, d.note);
1093    }
1094}