Skip to main content

graphcal_compiler/ir/
lower.rs

1//! Intermediate Representation (IR) — the result of lowering an AST.
2//!
3//! `lower()` combines declaration collection (`resolve`), registry
4//! construction (dimensions, units, indexes, structs), and function
5//! registration into a single `IR` value. Reference resolution happens at
6//! [`UnfrozenIR::freeze`], which lowers every assembled declaration body to
7//! HIR — the frozen `IR` carries no syntax-AST expression.
8
9use std::collections::{HashMap, HashSet};
10use std::num::NonZeroUsize;
11use std::sync::Arc;
12
13use miette::NamedSource;
14use petgraph::algo::toposort;
15use petgraph::graph::DiGraph;
16
17use crate::desugar::desugared_ast::{
18    AssertBody, DeclKind, Expr, ExprKind, FigureDecl, File, IndexDeclKind, LayerDecl, PlotDecl,
19    TypeExpr,
20};
21use crate::ir::resolve::{
22    DeclCategory, ExpectedFail, ImportedValueNames, ResolvedFile, resolve_with_imported_values,
23};
24use crate::ir::resolve::{ImportedNames, resolve_with_imports};
25use crate::registry::declared_type::DeclaredType;
26use crate::registry::error::GraphcalError;
27use crate::registry::format::format_unit_expr;
28use crate::registry::prelude::load_prelude;
29use crate::registry::runtime_value::RuntimeValue;
30use crate::registry::types::{
31    self, PositiveFiniteScale, PositiveFiniteScaleError, Registry, RegistryBuilder, UnitScale,
32};
33use crate::syntax::dimension::Rational;
34use crate::syntax::names::{
35    ConstructorName, DeclName, DimName, IndexName, NameAtom, ScopedName, StructTypeName,
36};
37use crate::syntax::span::{Span, Spanned};
38use crate::syntax::visitor::{ExprVisitor, ExprVisitorMut};
39
40// ---------------------------------------------------------------------------
41// Entry types for IR declarations
42// ---------------------------------------------------------------------------
43
44/// One plot declaration's expressions lowered to HIR, in source order.
45#[derive(Debug, Clone, Default)]
46pub struct LoweredPlotBody {
47    /// Encoding channel expressions (`x: ...`, `y: ...`).
48    pub encodings: Vec<(crate::syntax::ast::EncodingChannel, crate::hir::Expr)>,
49    /// Mark property expressions (`stroke_width: ...`).
50    pub mark_properties: Vec<LoweredPlotField>,
51    /// Plot-level property expressions (`title: ...`).
52    pub properties: Vec<LoweredPlotField>,
53}
54
55/// A named plot/figure/layer field expression lowered to HIR.
56#[derive(Debug, Clone)]
57pub struct LoweredPlotField {
58    pub name: crate::syntax::names::PlotPropertyName,
59    /// Span of the property name in the source, for validation diagnostics.
60    pub name_span: crate::syntax::span::Span,
61    pub value: crate::hir::Expr,
62}
63
64/// Where a merged declaration body's spans index into (#868).
65///
66/// A declaration written in the file being compiled spans into that file's
67/// own [`NamedSource`], which every lowering/type stage already threads as its
68/// ambient `src` — those entries carry [`BodySource::own`]. An instantiated
69/// `include` merges a dependency's declaration bodies into the importer's IR
70/// (`merge_dependency`); those bodies keep the *dependency's* byte offsets, so
71/// they carry [`BodySource::dependency`] naming the dependency file. Rendering
72/// a diagnostic for such a body against the importer's source produces an
73/// out-of-bounds (or simply wrong) label; [`BodySource::resolve`] hands back
74/// the correct source to anchor against.
75#[derive(Debug, Clone, Default)]
76pub struct BodySource(Option<NamedSource<Arc<String>>>);
77
78impl BodySource {
79    /// The declaration belongs to the file being compiled; its span indexes
80    /// into the ambient `src` threaded through the pipeline.
81    #[must_use]
82    pub const fn own() -> Self {
83        Self(None)
84    }
85
86    /// The declaration was merged from a dependency body whose spans index
87    /// into `src`.
88    #[must_use]
89    pub const fn dependency(src: NamedSource<Arc<String>>) -> Self {
90        Self(Some(src))
91    }
92
93    /// Resolve the source the span should render against, falling back to the
94    /// ambient `default` source for declarations native to the compiled file.
95    #[must_use]
96    pub fn resolve<'a>(
97        &'a self,
98        default: &'a NamedSource<Arc<String>>,
99    ) -> &'a NamedSource<Arc<String>> {
100        self.0.as_ref().unwrap_or(default)
101    }
102
103    /// Carry an already-merged provenance forward, or attribute a still-native
104    /// body to `dep_src` as it crosses one merge boundary (#868).
105    ///
106    /// A dependency's own declarations carry [`BodySource::own`] until they are
107    /// merged, at which point their spans become foreign to the importer and
108    /// must name `dep_src`. A body already tagged with a deeper dependency
109    /// source (a transitively-merged include) keeps that attribution.
110    #[must_use]
111    pub fn or_dependency(self, dep_src: &NamedSource<Arc<String>>) -> Self {
112        match self.0 {
113            Some(_) => self,
114            None => Self::dependency(dep_src.clone()),
115        }
116    }
117}
118
119/// A const declaration with type annotation and lowered body.
120#[derive(Debug, Clone)]
121pub struct ConstEntry {
122    pub name: ScopedName,
123    pub type_ann: TypeExpr,
124    pub expr: crate::hir::Expr,
125    pub span: Span,
126    /// Provenance of this declaration's `span` (#868). `None` means the span
127    /// indexes into the IR's own file source; `Some` carries the source of a
128    /// dependency body merged in by an instantiated include, so diagnostics
129    /// anchored on the span render against the right file.
130    pub src: BodySource,
131}
132
133/// A param declaration with type annotation and lowered default.
134#[derive(Debug, Clone)]
135pub struct ParamEntry {
136    pub name: ScopedName,
137    pub type_ann: TypeExpr,
138    pub default_expr: Option<crate::hir::Expr>,
139    pub span: Span,
140    /// Source provenance of `span`; see [`ConstEntry::src`] (#868).
141    pub src: BodySource,
142}
143
144/// A node declaration with type annotation and lowered body.
145#[derive(Debug, Clone)]
146pub struct NodeEntry {
147    pub name: ScopedName,
148    pub type_ann: TypeExpr,
149    pub expr: crate::hir::Expr,
150    pub span: Span,
151    /// Source provenance of `span`; see [`ConstEntry::src`] (#868).
152    pub src: BodySource,
153}
154
155/// An assert declaration with lowered body.
156#[derive(Debug, Clone)]
157pub struct AssertEntry {
158    pub name: ScopedName,
159    pub body: crate::hir::AssertBody,
160    pub span: Span,
161    /// Source provenance of `span`; see [`ConstEntry::src`] (#868).
162    pub src: BodySource,
163}
164
165/// A const declaration awaiting body lowering at [`UnfrozenIR::freeze`].
166///
167/// Pre-freeze bodies stay syntactic so include instantiation can rewrite
168/// reference paths (prefixing, index/type rebinding) before resolution.
169#[derive(Debug, Clone)]
170pub struct UnfrozenConstEntry {
171    pub name: ScopedName,
172    pub type_ann: TypeExpr,
173    pub expr: Expr,
174    pub span: Span,
175    /// Source provenance of `span`; see [`BodySource`] (#868).
176    pub src: BodySource,
177}
178
179/// A param declaration awaiting default lowering at [`UnfrozenIR::freeze`].
180#[derive(Debug, Clone)]
181pub struct UnfrozenParamEntry {
182    pub name: ScopedName,
183    pub type_ann: TypeExpr,
184    pub default_expr: Option<Expr>,
185    pub span: Span,
186    /// Source provenance of `span`; see [`BodySource`] (#868).
187    pub src: BodySource,
188}
189
190/// A node declaration awaiting body lowering at [`UnfrozenIR::freeze`].
191#[derive(Debug, Clone)]
192pub struct UnfrozenNodeEntry {
193    pub name: ScopedName,
194    pub type_ann: TypeExpr,
195    pub expr: Expr,
196    pub span: Span,
197    /// Source provenance of `span`; see [`BodySource`] (#868).
198    pub src: BodySource,
199}
200
201/// An assert declaration awaiting body lowering at [`UnfrozenIR::freeze`].
202#[derive(Debug, Clone)]
203pub struct UnfrozenAssertEntry {
204    pub name: ScopedName,
205    pub body: AssertBody,
206    pub span: Span,
207    /// Source provenance of `span`; see [`BodySource`] (#868).
208    pub src: BodySource,
209}
210
211/// A plot declaration with lowered body.
212#[derive(Debug, Clone)]
213pub struct PlotEntry {
214    pub name: ScopedName,
215    /// Mark shape rendered for this plot.
216    pub mark_type: crate::syntax::ast::MarkType,
217    /// Lowered body, or `None` when an expression failed to lower. Plots
218    /// are best-effort at evaluation time: an incomplete body is skipped by
219    /// the runtime instead of failing the compile.
220    pub body: Option<LoweredPlotBody>,
221    pub span: Span,
222    /// Whether this plot is `pub` (exported across the file boundary,
223    /// requestable by include brace lists). Says nothing about display.
224    pub is_pub: bool,
225    /// Whether this plot renders standalone when its file is the entry
226    /// point. `true` unless the declaration carries `#[hidden]` (#847).
227    pub displayed: bool,
228}
229
230/// A plot alias brought into this DAG by an include brace list (#847).
231///
232/// The plot itself is evaluated in its owning instance; this entry only
233/// makes the alias known to the DAG so figures/layers can reference it and
234/// duplicate-name checks see it.
235#[derive(Debug, Clone)]
236pub struct IncludedPlotEntry {
237    /// The local alias the plot is visible under.
238    pub name: ScopedName,
239    /// The include item's span.
240    pub span: Span,
241}
242
243/// A plot requested by an include brace list item (#847).
244#[derive(Debug, Clone)]
245pub struct RequestedPlot {
246    /// The local alias the plot enters the root namespace under.
247    pub alias: DeclName,
248    /// Whether the include item carried `#[hidden]` (composition-only).
249    pub hidden: bool,
250}
251
252/// A figure declaration with lowered fields.
253#[derive(Debug, Clone)]
254pub struct FigureEntry {
255    pub name: ScopedName,
256    /// Plots composed by this figure, in source order.
257    pub plot_names: Vec<Spanned<ScopedName>>,
258    /// Lowered field expressions; fields that failed to lower are omitted
259    /// (best-effort, matching plots).
260    pub fields: Vec<LoweredPlotField>,
261    pub span: Span,
262}
263
264/// A layer declaration with lowered fields.
265#[derive(Debug, Clone)]
266pub struct LayerEntry {
267    pub name: ScopedName,
268    /// Plots composed by this layer, in source order.
269    pub plot_names: Vec<Spanned<ScopedName>>,
270    /// Lowered field expressions; fields that failed to lower are omitted
271    /// (best-effort, matching plots).
272    pub fields: Vec<LoweredPlotField>,
273    pub span: Span,
274}
275
276/// A plot declaration awaiting body lowering at [`UnfrozenIR::freeze`].
277#[derive(Debug, Clone)]
278pub struct UnfrozenPlotEntry {
279    pub name: ScopedName,
280    pub decl: PlotDecl,
281    pub span: Span,
282    /// Whether this plot is `pub` (visible in standalone output).
283    pub is_pub: bool,
284    /// Whether this plot renders standalone (no `#[hidden]`).
285    pub displayed: bool,
286}
287
288/// A figure declaration awaiting field lowering at [`UnfrozenIR::freeze`].
289#[derive(Debug, Clone)]
290pub struct UnfrozenFigureEntry {
291    pub name: ScopedName,
292    pub decl: FigureDecl,
293    pub span: Span,
294}
295
296/// A layer declaration awaiting field lowering at [`UnfrozenIR::freeze`].
297#[derive(Debug, Clone)]
298pub struct UnfrozenLayerEntry {
299    pub name: ScopedName,
300    pub decl: LayerDecl,
301    pub span: Span,
302}
303
304/// Intermediate Representation produced by [`lower`].
305///
306/// Contains everything downstream stages need:
307/// - A `Registry` with dimensions, units, indexes, structs, and functions
308/// - Declarations (consts, params, nodes) with their expressions
309/// - Dependency graphs for const and runtime evaluation ordering
310/// - Source-order tracking for deterministic output
311#[derive(Debug)]
312pub struct IR {
313    /// The type/unit/dimension/index/struct/function registry.
314    pub registry: Registry,
315    /// Const declarations in source order.
316    pub consts: Vec<ConstEntry>,
317    /// Param declarations in source order.
318    pub params: Vec<ParamEntry>,
319    /// Node declarations in source order.
320    pub nodes: Vec<NodeEntry>,
321    /// Assert declarations in source order.
322    pub asserts: Vec<AssertEntry>,
323    /// Plot declarations in source order.
324    pub plots: Vec<PlotEntry>,
325    /// Figure declarations in source order.
326    pub figures: Vec<FigureEntry>,
327    /// Layer declarations in source order.
328    pub layers: Vec<LayerEntry>,
329    /// Plot aliases from include brace lists (#847).
330    pub included_plots: Vec<IncludedPlotEntry>,
331    /// All declaration names in source order with their category.
332    pub source_order: Vec<(ScopedName, DeclCategory)>,
333    /// Set of all assert names.
334    pub assert_names: HashSet<ScopedName>,
335    /// Mapping from assert name to the list of declarations that assume it.
336    pub assumes_map: HashMap<ScopedName, Vec<ScopedName>>,
337    /// Mapping from assert name to its expected-fail configuration.
338    pub expected_fail: HashMap<ScopedName, ExpectedFail>,
339    /// Pre-evaluated values imported from dependency files.
340    /// These are injected directly into the execution plan rather than compiled.
341    /// Each entry carries the runtime value and its declared type (for `dim_check`).
342    pub imported_values: HashMap<ScopedName, (RuntimeValue, DeclaredType)>,
343    /// Declared types for imported names that are not backed by a pre-evaluated
344    /// value at this compilation boundary.
345    ///
346    /// Inline DAG bodies use this for `import parent.{const}`: the body needs
347    /// the imported name's type during dim-checking, while the concrete value is
348    /// supplied later by the caller or by the dependency that owns the DAG.
349    pub imported_decl_types: HashMap<ScopedName, DeclaredType>,
350    /// Source bindings for imported values whose runtime value is supplied
351    /// outside this IR.
352    pub imported_value_sources: HashMap<ScopedName, ImportedValueSource>,
353    /// Names of declarations marked `pub` (or `pub(bind)`) in the file.
354    ///
355    /// Carried through from the resolver so downstream stages — most
356    /// notably `preprocess_dag_body_self_imports` — can enforce
357    /// visibility on `import <self>.{...}` items: a dag inside a file
358    /// can only reach the file's `pub`-marked top-level declarations,
359    /// matching the rules for cross-file imports. Implicit visibility
360    /// (params are visible by default) is already baked in.
361    pub pub_names: HashSet<DeclName>,
362}
363
364/// Runtime source of an imported value visible inside a DAG body.
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct ImportedValueSource {
367    /// DAG that owns the original declaration.
368    pub dag_id: crate::dag_id::DagId,
369    /// Original declaration name in the owning DAG.
370    pub source_name: DeclName,
371}
372
373/// Lower an AST into an [`IR`].
374///
375/// This combines:
376/// 1. Name resolution (`resolve`) — checks duplicates, extracts deps
377/// 2. Registry construction — registers dimensions, units, indexes, structs from declarations
378/// 3. Function registration — registers user-defined functions into the registry
379///
380/// # Errors
381///
382/// Returns a [`GraphcalError`] if declaration collection or registry construction fails
383/// (e.g., unknown dimension in a type annotation, duplicate names, etc.).
384pub fn lower(ast: &File, src: &NamedSource<Arc<String>>) -> Result<IR, GraphcalError> {
385    let dag_id = crate::dag_id::DagId::from_relative_path(std::path::Path::new(src.name()))
386        .map_err(|e| GraphcalError::EvalError {
387            message: format!("invalid source name `{}`: {e}", src.name()),
388            src: src.clone(),
389            span: crate::syntax::span::Span::new(0, 0).into(),
390        })?;
391    lower_with_imports(ast, src, &ImportedNames::default(), &dag_id)
392}
393
394/// Lower an AST with imported declarations into an [`IR`].
395///
396/// Same as [`lower`] but accepts imported names from other files.
397/// The registry is frozen (via `build()`) before returning.
398///
399/// # Errors
400///
401/// Returns a [`GraphcalError`] if declaration collection or registry construction fails.
402fn lower_with_imports(
403    ast: &File,
404    src: &NamedSource<Arc<String>>,
405    imported: &ImportedNames,
406    dag_id: &crate::dag_id::DagId,
407) -> Result<IR, GraphcalError> {
408    let (builder, resolved_ir) = lower_to_builder(ast, src, imported, dag_id)?;
409    let resolver = single_module_resolver(ast, dag_id, src)?;
410    resolved_ir.freeze(builder.build(), dag_id, &resolver, src)
411}
412
413/// Build a resolver covering only this file's own module.
414///
415/// Single-file lowering has no project loader, so imported modules are not
416/// resolvable; bodies that reference them fail at the freeze boundary just
417/// as they previously failed during type resolution.
418fn single_module_resolver(
419    ast: &File,
420    dag_id: &crate::dag_id::DagId,
421    src: &NamedSource<Arc<String>>,
422) -> Result<crate::syntax::module_resolve::ModuleResolver, GraphcalError> {
423    fn add_module_with_dags(
424        target: &mut crate::syntax::module_resolve::ModuleResolver,
425        owner: &crate::dag_id::DagId,
426        declarations: &[crate::desugar::desugared_ast::Declaration],
427        src: &NamedSource<Arc<String>>,
428    ) -> Result<(), GraphcalError> {
429        target
430            .add_module(owner.clone(), declarations)
431            .map_err(|err| GraphcalError::EvalError {
432                message: err.to_string(),
433                src: src.clone(),
434                span: Span::new(0, 0).into(),
435            })?;
436        for decl in declarations {
437            if let crate::desugar::desugared_ast::DeclKind::Dag(dag) = &decl.kind {
438                add_module_with_dags(
439                    target,
440                    &owner.child(dag.name.value.as_str()),
441                    &dag.body,
442                    src,
443                )?;
444            }
445        }
446        Ok(())
447    }
448
449    let mut resolver = crate::syntax::module_resolve::ModuleResolver::default();
450    add_module_with_dags(&mut resolver, dag_id, &ast.declarations, src)?;
451    Ok(resolver)
452}
453
454/// Lower an AST with imported declarations, returning a `RegistryBuilder`
455/// that can be further mutated (e.g., to register imported type-system
456/// declarations) before freezing.
457///
458/// Call [`UnfrozenIR::freeze`] with the final [`Registry`] to produce an [`IR`].
459///
460/// # Errors
461///
462/// Returns a [`GraphcalError`] if declaration collection or registry construction fails.
463pub(crate) fn lower_to_builder(
464    ast: &File,
465    src: &NamedSource<Arc<String>>,
466    imported: &ImportedNames,
467    dag_id: &crate::dag_id::DagId,
468) -> Result<(RegistryBuilder, UnfrozenIR), GraphcalError> {
469    // Step 1: Declaration collection
470    let resolved = resolve_with_imports(ast, src, imported)?;
471
472    // Step 2: Extract type annotations from AST + imported declarations.
473    // Imported lists still carry flat-string names (a wider typing pass is
474    // tracked separately); wrap them at the boundary so the map stays
475    // DeclName-keyed.
476    let mut type_anns = extract_type_annotations(ast);
477    for (name, type_ann, _, _) in &imported.consts {
478        type_anns.insert(DeclName::new(name.clone()), type_ann.clone());
479    }
480    for (name, type_ann, _, _) in &imported.params {
481        type_anns.insert(DeclName::new(name.clone()), type_ann.clone());
482    }
483    for (name, type_ann, _, _) in &imported.nodes {
484        type_anns.insert(DeclName::new(name.clone()), type_ann.clone());
485    }
486
487    // Step 3: Build registry, augment deps, and construct IR
488    build_ir_from_resolved(
489        ast,
490        src,
491        resolved,
492        type_anns,
493        HashMap::new(),
494        HashMap::new(),
495        HashMap::new(),
496        dag_id,
497        None,
498        None,
499    )
500}
501
502/// Hook that merges imported type-system declarations into the registry builder.
503///
504/// Invoked after the prelude is loaded but before the file's own
505/// declarations are registered, so local declarations (e.g. a `unit`
506/// definition referencing an imported unit) resolve against the imported
507/// entries.
508pub type RegistrySeed<'a> = &'a mut dyn FnMut(&mut RegistryBuilder) -> Result<(), GraphcalError>;
509
510/// Lower an AST with pre-evaluated imported values, returning a `RegistryBuilder`
511/// that can be further mutated before freezing.
512///
513/// Unlike `lower_to_builder`, this uses `resolve_with_imported_values` which
514/// only adds imported names to the scope (not their expressions). The actual
515/// imported values are stored in `UnfrozenIR::imported_values` and injected
516/// into the execution plan at runtime.
517///
518/// # Errors
519///
520/// Returns a [`GraphcalError`] if declaration collection or registry construction fails.
521#[expect(
522    clippy::implicit_hasher,
523    reason = "internal API always uses default hasher"
524)]
525pub fn lower_to_builder_with_imported_values(
526    ast: &File,
527    src: &NamedSource<Arc<String>>,
528    imported_names: &ImportedValueNames,
529    imported_values: HashMap<ScopedName, (RuntimeValue, DeclaredType)>,
530    dag_id: &crate::dag_id::DagId,
531    registry_seed: Option<RegistrySeed<'_>>,
532) -> Result<(RegistryBuilder, UnfrozenIR), GraphcalError> {
533    let imported_decl_types = imported_values
534        .iter()
535        .map(|(name, (_value, ty))| (name.clone(), ty.clone()))
536        .collect();
537    lower_to_builder_with_imported_value_decls(
538        ast,
539        src,
540        imported_names,
541        imported_values,
542        imported_decl_types,
543        HashMap::new(),
544        dag_id,
545        registry_seed,
546    )
547}
548
549/// Lower an AST with imported value names plus declared types for imports whose
550/// runtime values will be supplied later.
551///
552/// This is used for inline DAG bodies that import a parent const. The resolver
553/// needs the local imported name in scope, dim-checking needs its declared type,
554/// and evaluation gets the concrete value from `imported_value_sources`.
555///
556/// # Errors
557///
558/// Returns a [`GraphcalError`] if declaration collection or registry construction fails.
559#[expect(
560    clippy::implicit_hasher,
561    reason = "internal API always uses default hasher"
562)]
563#[expect(
564    clippy::too_many_arguments,
565    reason = "lowering threads imported value metadata plus the registry seed hook"
566)]
567pub fn lower_to_builder_with_imported_value_decls(
568    ast: &File,
569    src: &NamedSource<Arc<String>>,
570    imported_names: &ImportedValueNames,
571    imported_values: HashMap<ScopedName, (RuntimeValue, DeclaredType)>,
572    imported_decl_types: HashMap<ScopedName, DeclaredType>,
573    imported_value_sources: HashMap<ScopedName, ImportedValueSource>,
574    dag_id: &crate::dag_id::DagId,
575    registry_seed: Option<RegistrySeed<'_>>,
576) -> Result<(RegistryBuilder, UnfrozenIR), GraphcalError> {
577    // Step 1: Declaration collection with imported value names in scope
578    let resolved = resolve_with_imported_values(ast, src, imported_names)?;
579
580    // Step 2: Extract type annotations from local declarations only
581    let type_anns = extract_type_annotations(ast);
582
583    // Step 3: Build registry, augment deps, and construct IR
584    let (builder, mut unfrozen) = build_ir_from_resolved(
585        ast,
586        src,
587        resolved,
588        type_anns,
589        imported_values,
590        imported_decl_types,
591        imported_value_sources,
592        dag_id,
593        None,
594        registry_seed,
595    )?;
596
597    // Plot aliases from include brace lists become known to this DAG so
598    // figures/layers can reference them (#847).
599    unfrozen.included_plots = imported_names
600        .plot_names
601        .iter()
602        .map(|(name, span)| IncludedPlotEntry {
603            name: name.clone(),
604            span: *span,
605        })
606        .collect();
607
608    Ok((builder, unfrozen))
609}
610
611/// Lower a `dag { ... }` body as if it were a standalone file.
612///
613/// The dag body is a virtual [`File`] whose registry is seeded with the
614/// enclosing file's frozen registry (dimensions, units, types, indexes, and
615/// sibling dags) so that reference resolution and type checking behave exactly as
616/// they would for a top-level declaration. Per Concept 9, the dag body cannot
617/// implicitly reference the enclosing file's `const`/`param`/`node` values
618/// — cross-scope values must be either passed in via the dag's own params or
619/// brought into scope explicitly via `import <self>.{...}`.
620///
621/// The caller is responsible for pre-processing dag-body `import` declarations
622/// (resolving self-imports to local names, classifying items against the
623/// parent's value/type-system surface, recording source bindings) and passing
624/// in:
625///
626/// - `stripped_body`: the dag body with self-import declarations removed.
627///   Cross-file imports inside dag bodies (if any) are still left for the
628///   downstream resolver to handle through the regular import machinery.
629/// - `imported_names`: the resolver scope contribution from preprocessed
630///   self-imports.
631/// - `imported_decl_types`: per-name declared types for those self-imports.
632/// - `imported_value_sources`: per-name source bindings for those
633///   self-imports — recording that the value comes from the parent DAG at
634///   runtime.
635///
636/// The returned `IR` has a `dag_id` formed by appending `dag_name` to
637/// `parent_dag_id`, so nested-scope diagnostics have a stable source location.
638///
639/// # Errors
640///
641/// Returns a [`GraphcalError`] if declaration collection or type-system construction
642/// fails for the dag body.
643#[expect(
644    clippy::implicit_hasher,
645    reason = "internal API always uses default hasher"
646)]
647#[expect(
648    clippy::too_many_arguments,
649    reason = "dag-module lowering threads pre-processed import metadata + optional parent registry"
650)]
651pub fn lower_dag_module_to_builder_with_imported_value_decls(
652    dag_body: &File,
653    parent_registry: Option<&Registry>,
654    imported_names: &ImportedValueNames,
655    imported_values: HashMap<ScopedName, (RuntimeValue, DeclaredType)>,
656    imported_decl_types: HashMap<ScopedName, DeclaredType>,
657    imported_value_sources: HashMap<ScopedName, ImportedValueSource>,
658    src: &NamedSource<Arc<String>>,
659    dag_id: &crate::dag_id::DagId,
660    registry_seed: Option<RegistrySeed<'_>>,
661) -> Result<(RegistryBuilder, UnfrozenIR), GraphcalError> {
662    let resolved = resolve_with_imported_values(dag_body, src, imported_names)?;
663    let type_anns = extract_type_annotations(dag_body);
664
665    build_ir_from_resolved(
666        dag_body,
667        src,
668        resolved,
669        type_anns,
670        imported_values,
671        imported_decl_types,
672        imported_value_sources,
673        dag_id,
674        parent_registry,
675        registry_seed,
676    )
677}
678
679#[expect(
680    clippy::implicit_hasher,
681    reason = "internal API always uses default hasher"
682)]
683#[expect(
684    clippy::too_many_arguments,
685    reason = "dag-body lowering threads pre-processed import metadata + parent registry"
686)]
687pub fn lower_dag_body_to_ir(
688    dag_name: &str,
689    stripped_body: &[crate::desugar::desugared_ast::Declaration],
690    parent_registry: &Registry,
691    resolver: &crate::syntax::module_resolve::ModuleResolver,
692    imported_names: &ImportedValueNames,
693    imported_decl_types: HashMap<ScopedName, DeclaredType>,
694    imported_value_sources: HashMap<ScopedName, ImportedValueSource>,
695    src: &NamedSource<Arc<String>>,
696    parent_dag_id: &crate::dag_id::DagId,
697) -> Result<IR, GraphcalError> {
698    let virtual_file = File {
699        declarations: stripped_body.to_vec(),
700    };
701    let dag_dag_id = parent_dag_id.child(dag_name);
702    let (builder, unfrozen) = lower_dag_module_to_builder_with_imported_value_decls(
703        &virtual_file,
704        Some(parent_registry),
705        imported_names,
706        HashMap::new(),
707        imported_decl_types,
708        imported_value_sources,
709        src,
710        &dag_dag_id,
711        None,
712    )?;
713    unfrozen.freeze(builder.build(), &dag_dag_id, resolver, src)
714}
715
716/// Result of `preprocess_dag_body_self_imports`: imported names, declared
717/// types, source bindings, and the body with self-import declarations stripped.
718pub struct DagBodySelfImports {
719    pub names: ImportedValueNames,
720    pub decl_types: HashMap<ScopedName, DeclaredType>,
721    pub value_sources: HashMap<ScopedName, ImportedValueSource>,
722    pub stripped_body: Vec<crate::desugar::desugared_ast::Declaration>,
723}
724
725/// Remove and return the type annotation for `name`, or raise an internal error
726/// if it was dropped during resolution. The parser and resolver jointly
727/// guarantee that every top-level const/param/node ends up in `type_anns`;
728/// a missing entry is a compiler invariant violation.
729fn take_type_ann(
730    type_anns: &mut HashMap<DeclName, TypeExpr>,
731    name: &DeclName,
732    span: Span,
733    src: &NamedSource<Arc<String>>,
734) -> Result<TypeExpr, GraphcalError> {
735    type_anns
736        .remove(name)
737        .ok_or_else(|| GraphcalError::InternalError {
738            message: format!("missing type annotation for `{name}`"),
739            src: src.clone(),
740            span: span.into(),
741        })
742}
743
744/// Shared implementation for `lower_to_builder` and `lower_to_builder_with_imported_values`.
745///
746/// Builds the registry, augments runtime deps for dynamic units, pairs resolved
747/// declarations with type annotations, and constructs the `UnfrozenIR`.
748#[expect(
749    clippy::too_many_lines,
750    reason = "single linear pipeline — splitting would obscure the flow"
751)]
752#[expect(
753    clippy::too_many_arguments,
754    reason = "IR construction threads imported value type/source metadata"
755)]
756fn build_ir_from_resolved(
757    ast: &File,
758    src: &NamedSource<Arc<String>>,
759    resolved: ResolvedFile,
760    mut type_anns: HashMap<DeclName, TypeExpr>,
761    imported_values: HashMap<ScopedName, (RuntimeValue, DeclaredType)>,
762    imported_decl_types: HashMap<ScopedName, DeclaredType>,
763    imported_value_sources: HashMap<ScopedName, ImportedValueSource>,
764    dag_id: &crate::dag_id::DagId,
765    parent_registry: Option<&Registry>,
766    registry_seed: Option<RegistrySeed<'_>>,
767) -> Result<(RegistryBuilder, UnfrozenIR), GraphcalError> {
768    // Build registry (prelude + user-declared dimensions/units/indexes/structs).
769    // When a parent registry is provided (inline-dag bodies), its entries are
770    // merged in before registering the virtual file's own declarations so that
771    // type annotations and dynamic-unit dep augmentation see the enclosing
772    // file's type system.
773    let mut builder = RegistryBuilder::new();
774    load_prelude(&mut builder).map_err(|e| GraphcalError::EvalError {
775        message: format!("internal: prelude failed to load: {e}"),
776        src: src.clone(),
777        span: Span::new(0, 0).into(),
778    })?;
779    if let Some(parent) = parent_registry {
780        builder.merge_from_registry(parent);
781    }
782    // Imported type-system declarations merge before the file's own so that
783    // local declarations (e.g. `const unit halfmile: Length = 0.5 u.mile;`) resolve
784    // against them.
785    if let Some(seed) = registry_seed {
786        seed(&mut builder)?;
787    }
788    register_file_declarations(ast, &mut builder, src, dag_id)?;
789
790    // Pair resolved declarations with type annotations. The resolved entries
791    // still carry flat-string names (a wider typing pass is tracked separately);
792    // wrap each into a `DeclName` once so both `take_type_ann` and the
793    // `ScopedName::from` lift see the typed form.
794    let consts = resolved
795        .consts
796        .into_iter()
797        .map(|entry| {
798            let decl_name = DeclName::new(entry.name);
799            let type_ann = take_type_ann(&mut type_anns, &decl_name, entry.span, src)?;
800            Ok(UnfrozenConstEntry {
801                name: ScopedName::from(decl_name),
802                type_ann,
803                expr: entry.expr,
804                span: entry.span,
805                src: BodySource::own(),
806            })
807        })
808        .collect::<Result<Vec<_>, GraphcalError>>()?;
809    let params = resolved
810        .params
811        .into_iter()
812        .map(|entry| {
813            let decl_name = DeclName::new(entry.name);
814            let type_ann = take_type_ann(&mut type_anns, &decl_name, entry.span, src)?;
815            Ok(UnfrozenParamEntry {
816                name: ScopedName::from(decl_name),
817                type_ann,
818                default_expr: entry.default_expr,
819                span: entry.span,
820                src: BodySource::own(),
821            })
822        })
823        .collect::<Result<Vec<_>, GraphcalError>>()?;
824    let nodes = resolved
825        .nodes
826        .into_iter()
827        .map(|entry| {
828            let decl_name = DeclName::new(entry.name);
829            let type_ann = take_type_ann(&mut type_anns, &decl_name, entry.span, src)?;
830            Ok(UnfrozenNodeEntry {
831                name: ScopedName::from(decl_name),
832                type_ann,
833                expr: entry.expr,
834                span: entry.span,
835                src: BodySource::own(),
836            })
837        })
838        .collect::<Result<Vec<_>, GraphcalError>>()?;
839
840    let unfrozen = UnfrozenIR {
841        consts,
842        params,
843        nodes,
844        asserts: resolved
845            .asserts
846            .into_iter()
847            .map(|entry| UnfrozenAssertEntry {
848                name: ScopedName::local(entry.name),
849                body: entry.body,
850                span: entry.span,
851                src: BodySource::own(),
852            })
853            .collect(),
854        plots: resolved
855            .plots
856            .into_iter()
857            .map(|entry| {
858                let is_pub = resolved.pub_names.contains(entry.name.as_str());
859                let displayed = !resolved.hidden_plots.contains(entry.name.as_str());
860                UnfrozenPlotEntry {
861                    name: ScopedName::local(entry.name),
862                    decl: entry.decl,
863                    span: entry.span,
864                    is_pub,
865                    displayed,
866                }
867            })
868            .collect(),
869        figures: resolved
870            .figures
871            .into_iter()
872            .map(|entry| UnfrozenFigureEntry {
873                name: ScopedName::local(entry.name),
874                decl: entry.decl,
875                span: entry.span,
876            })
877            .collect(),
878        layers: resolved
879            .layers
880            .into_iter()
881            .map(|entry| UnfrozenLayerEntry {
882                name: ScopedName::local(entry.name),
883                decl: entry.decl,
884                span: entry.span,
885            })
886            .collect(),
887        included_plots: Vec::new(),
888        source_order: resolved
889            .source_order
890            .into_iter()
891            .map(|(name, cat)| (ScopedName::from(name), cat))
892            .collect(),
893        assert_names: resolved
894            .assert_names
895            .into_iter()
896            .map(ScopedName::from)
897            .collect(),
898        assumes_map: resolved
899            .assumes_map
900            .into_iter()
901            .map(|(k, v)| {
902                (
903                    ScopedName::from(k),
904                    v.into_iter().map(ScopedName::from).collect(),
905                )
906            })
907            .collect(),
908        expected_fail: resolved
909            .expected_fail
910            .into_iter()
911            .map(|(k, v)| (ScopedName::from(k), v))
912            .collect(),
913        imported_values,
914        imported_decl_types,
915        imported_value_sources,
916        pub_names: resolved.pub_names,
917    };
918
919    Ok((builder, unfrozen))
920}
921
922/// An IR without a frozen registry, awaiting a call to [`freeze`](Self::freeze).
923pub struct UnfrozenIR {
924    consts: Vec<UnfrozenConstEntry>,
925    params: Vec<UnfrozenParamEntry>,
926    nodes: Vec<UnfrozenNodeEntry>,
927    asserts: Vec<UnfrozenAssertEntry>,
928    plots: Vec<UnfrozenPlotEntry>,
929    figures: Vec<UnfrozenFigureEntry>,
930    layers: Vec<UnfrozenLayerEntry>,
931    /// Plot aliases from include brace lists (#847).
932    pub included_plots: Vec<IncludedPlotEntry>,
933    /// All declaration names in source order with their category.
934    pub source_order: Vec<(ScopedName, DeclCategory)>,
935    assert_names: HashSet<ScopedName>,
936    // Key-lookup only, order irrelevant.
937    assumes_map: HashMap<ScopedName, Vec<ScopedName>>,
938    // Key-lookup only, order irrelevant.
939    expected_fail: HashMap<ScopedName, ExpectedFail>,
940    // Key-lookup only, order irrelevant.
941    imported_values: HashMap<ScopedName, (RuntimeValue, DeclaredType)>,
942    // Key-lookup only, order irrelevant.
943    imported_decl_types: HashMap<ScopedName, DeclaredType>,
944    // Key-lookup only, order irrelevant.
945    imported_value_sources: HashMap<ScopedName, ImportedValueSource>,
946    // Names of declarations marked `pub`/`pub(bind)` (plus implicit-pub
947    // params). Used by `preprocess_dag_body_self_imports` to enforce
948    // visibility on dag-body `import <self>.{...}` items.
949    pub_names: HashSet<DeclName>,
950}
951
952impl UnfrozenIR {
953    /// Freeze into a complete [`IR`] by providing a built [`Registry`] and
954    /// the resolution context.
955    ///
956    /// This is the lowering boundary of the pipeline: every declaration body
957    /// assembled so far (including merged include instances and applied
958    /// overrides) is lowered to HIR here, so the frozen [`IR`] carries no
959    /// syntax-AST expression.
960    ///
961    /// # Errors
962    ///
963    /// Returns a [`GraphcalError`] if any body contains a reference that
964    /// cannot be resolved.
965    #[expect(
966        clippy::too_many_lines,
967        reason = "single lowering boundary over every declaration kind"
968    )]
969    pub fn freeze(
970        self,
971        registry: Registry,
972        owner: &crate::dag_id::DagId,
973        resolver: &crate::syntax::module_resolve::ModuleResolver,
974        src: &NamedSource<Arc<String>>,
975    ) -> Result<IR, GraphcalError> {
976        // Entries already visible in this IR (including prefixed include
977        // instances and dag self-imports) bind their written names to
978        // canonical identities for the lowering below.
979        let mut decl_bindings = HashMap::new();
980        for name in self
981            .consts
982            .iter()
983            .map(|entry| &entry.name)
984            .chain(self.params.iter().map(|entry| &entry.name))
985            .chain(self.nodes.iter().map(|entry| &entry.name))
986        {
987            let canonical =
988                crate::hir::diagnostics::resolved_decl_key(owner, name).ok_or_else(|| {
989                    GraphcalError::InternalError {
990                        message: format!("could not build canonical declaration key for `{name}`"),
991                        src: src.clone(),
992                        span: Span::new(0, 0).into(),
993                    }
994                })?;
995            decl_bindings.insert(name.clone(), canonical);
996        }
997        for (name, source) in &self.imported_value_sources {
998            decl_bindings.insert(
999                name.clone(),
1000                crate::syntax::names::ResolvedName::from_def(
1001                    source.dag_id.clone(),
1002                    source.source_name.clone(),
1003                ),
1004            );
1005        }
1006
1007        let generic_scope = crate::hir::GenericScope::new();
1008        let prelude = crate::hir::PreludeTypeScope::graphcal();
1009        let expr_ctx = crate::hir::ExprLoweringContext::new(owner, resolver, &generic_scope)
1010            .with_prelude(&prelude)
1011            .with_decl_bindings(&decl_bindings);
1012        // A merged dependency body keeps the dependency file's byte offsets, so
1013        // a lowering error must render against that body's own source rather
1014        // than the importer's `src` (#868); `BodySource::resolve` selects it.
1015        let lower_in = |expr: &Expr, body_src: &NamedSource<Arc<String>>| {
1016            crate::hir::lower_expr(expr, expr_ctx).map_err(|err| {
1017                crate::hir::diagnostics::expr_lower_error_to_graphcal(&err, body_src)
1018            })
1019        };
1020
1021        let consts = self
1022            .consts
1023            .iter()
1024            .map(|entry| {
1025                Ok(ConstEntry {
1026                    name: entry.name.clone(),
1027                    type_ann: entry.type_ann.clone(),
1028                    expr: lower_in(&entry.expr, entry.src.resolve(src))?,
1029                    span: entry.span,
1030                    src: entry.src.clone(),
1031                })
1032            })
1033            .collect::<Result<Vec<_>, GraphcalError>>()?;
1034        let params = self
1035            .params
1036            .iter()
1037            .map(|entry| {
1038                Ok(ParamEntry {
1039                    name: entry.name.clone(),
1040                    type_ann: entry.type_ann.clone(),
1041                    default_expr: entry
1042                        .default_expr
1043                        .as_ref()
1044                        .map(|expr| lower_in(expr, entry.src.resolve(src)))
1045                        .transpose()?,
1046                    span: entry.span,
1047                    src: entry.src.clone(),
1048                })
1049            })
1050            .collect::<Result<Vec<_>, GraphcalError>>()?;
1051        let nodes = self
1052            .nodes
1053            .iter()
1054            .map(|entry| {
1055                Ok(NodeEntry {
1056                    name: entry.name.clone(),
1057                    type_ann: entry.type_ann.clone(),
1058                    expr: lower_in(&entry.expr, entry.src.resolve(src))?,
1059                    span: entry.span,
1060                    src: entry.src.clone(),
1061                })
1062            })
1063            .collect::<Result<Vec<_>, GraphcalError>>()?;
1064        let asserts = self
1065            .asserts
1066            .iter()
1067            .map(|entry| {
1068                let body_src = entry.src.resolve(src);
1069                Ok(AssertEntry {
1070                    name: entry.name.clone(),
1071                    body: crate::hir::lower_assert_body(&entry.body, expr_ctx).map_err(|err| {
1072                        crate::hir::diagnostics::expr_lower_error_to_graphcal(&err, body_src)
1073                    })?,
1074                    span: entry.span,
1075                    src: entry.src.clone(),
1076                })
1077            })
1078            .collect::<Result<Vec<_>, GraphcalError>>()?;
1079
1080        // Plots and figure/layer fields are best-effort at evaluation time:
1081        // an expression that fails to lower leaves the body incomplete (the
1082        // runtime skips it) instead of failing the compile.
1083        let lower_optional = |expr: &Expr| crate::hir::lower_expr(expr, expr_ctx).ok();
1084        let plots = self
1085            .plots
1086            .iter()
1087            .map(|entry| {
1088                let mut body = LoweredPlotBody::default();
1089                let mut complete = true;
1090                for encoding in &entry.decl.encodings {
1091                    match lower_optional(&encoding.value) {
1092                        Some(lowered) => body.encodings.push((encoding.channel, lowered)),
1093                        None => complete = false,
1094                    }
1095                }
1096                for field in &entry.decl.mark.properties {
1097                    match lower_optional(&field.value) {
1098                        Some(lowered) => body.mark_properties.push(LoweredPlotField {
1099                            name: field.name.value.clone(),
1100                            name_span: field.name.span,
1101                            value: lowered,
1102                        }),
1103                        None => complete = false,
1104                    }
1105                }
1106                for field in &entry.decl.properties {
1107                    match lower_optional(&field.value) {
1108                        Some(lowered) => body.properties.push(LoweredPlotField {
1109                            name: field.name.value.clone(),
1110                            name_span: field.name.span,
1111                            value: lowered,
1112                        }),
1113                        None => complete = false,
1114                    }
1115                }
1116                PlotEntry {
1117                    name: entry.name.clone(),
1118                    mark_type: entry.decl.mark.mark_type,
1119                    body: complete.then_some(body),
1120                    span: entry.span,
1121                    is_pub: entry.is_pub,
1122                    displayed: entry.displayed,
1123                }
1124            })
1125            .collect();
1126        let lower_fields = |fields: &[crate::desugar::desugared_ast::PlotField]| {
1127            fields
1128                .iter()
1129                .filter_map(|field| {
1130                    Some(LoweredPlotField {
1131                        name: field.name.value.clone(),
1132                        name_span: field.name.span,
1133                        value: lower_optional(&field.value)?,
1134                    })
1135                })
1136                .collect::<Vec<_>>()
1137        };
1138        let figures = self
1139            .figures
1140            .iter()
1141            .map(|entry| FigureEntry {
1142                name: entry.name.clone(),
1143                plot_names: entry.decl.plot_names.clone(),
1144                fields: lower_fields(&entry.decl.fields),
1145                span: entry.span,
1146            })
1147            .collect();
1148        let layers = self
1149            .layers
1150            .iter()
1151            .map(|entry| LayerEntry {
1152                name: entry.name.clone(),
1153                plot_names: entry.decl.plot_names.clone(),
1154                fields: lower_fields(&entry.decl.fields),
1155                span: entry.span,
1156            })
1157            .collect();
1158
1159        Ok(IR {
1160            registry,
1161            consts,
1162            params,
1163            nodes,
1164            asserts,
1165            plots,
1166            figures,
1167            layers,
1168            included_plots: self.included_plots,
1169            source_order: self.source_order,
1170            assert_names: self.assert_names,
1171            assumes_map: self.assumes_map,
1172            expected_fail: self.expected_fail,
1173            imported_values: self.imported_values,
1174            imported_decl_types: self.imported_decl_types,
1175            imported_value_sources: self.imported_value_sources,
1176            pub_names: self.pub_names,
1177        })
1178    }
1179
1180    /// Replace a param's default expression with an override.
1181    ///
1182    /// Returns `false` when no param entry with that leaf name exists.
1183    pub fn override_param_default(&mut self, name: &str, expr: Expr) -> bool {
1184        match self
1185            .params
1186            .iter_mut()
1187            .find(|entry| entry.name.member() == name)
1188        {
1189            Some(entry) => {
1190                entry.default_expr = Some(expr);
1191                true
1192            }
1193            None => false,
1194        }
1195    }
1196
1197    /// Add a const alias: a synthetic const declaration that references another const.
1198    ///
1199    /// Used for selective instantiated imports where `delta_v` aliases `prefix.delta_v`.
1200    pub fn add_const_alias(
1201        &mut self,
1202        name: ScopedName,
1203        type_ann: TypeExpr,
1204        expr: Expr,
1205        span: Span,
1206    ) {
1207        self.consts.push(UnfrozenConstEntry {
1208            name: name.clone(),
1209            type_ann,
1210            expr,
1211            span,
1212            // Alias bodies are synthesized from the importer's include
1213            // statement, so their span belongs to the importer's source.
1214            src: BodySource::own(),
1215        });
1216        self.source_order.push((name, DeclCategory::Const));
1217    }
1218
1219    /// Add a node alias: a synthetic node declaration that references another node/param.
1220    ///
1221    /// Used for selective instantiated imports where `delta_v` aliases `prefix.delta_v`.
1222    pub fn add_node_alias(&mut self, name: ScopedName, type_ann: TypeExpr, expr: Expr, span: Span) {
1223        self.nodes.push(UnfrozenNodeEntry {
1224            name: name.clone(),
1225            type_ann,
1226            expr,
1227            span,
1228            // Alias bodies are synthesized from the importer's include
1229            // statement, so their span belongs to the importer's source.
1230            src: BodySource::own(),
1231        });
1232        self.source_order.push((name, DeclCategory::Node));
1233    }
1234
1235    /// Scan param defaults for variant literals of overridden `pub(bind)`
1236    /// indexes (and nominally-tied names of overridden types) whose owning
1237    /// `param` is not itself re-bound — axiom A8 / diagnostic V005.
1238    ///
1239    /// Per axiom §1, only `index` and `type` overrides have nominal
1240    /// substructure; `dim` and `param` overrides substitute totally and
1241    /// never trigger A8.
1242    ///
1243    /// Other non-bindable declaration kinds (`node`, `const`) are
1244    /// guarded at library compile time by V004 (A10c), so their bodies
1245    /// cannot mention overridden-symbol nominals once a library is
1246    /// accepted. Sink-kind declarations (`assert`, `plot`, `figure`,
1247    /// `layer`) pick up the A10(b) private-only carve-out; this check
1248    /// stays focused on `param` for that reason.
1249    pub fn check_include_reconciles_overrides(
1250        &self,
1251        bindings: &HashMap<DeclName, Expr>,
1252        index_bindings: &HashMap<IndexName, IndexName>,
1253        type_bindings: &HashMap<StructTypeName, StructTypeName>,
1254        importer_src: &NamedSource<Arc<String>>,
1255        include_span: Span,
1256    ) -> Result<(), GraphcalError> {
1257        if index_bindings.is_empty() && type_bindings.is_empty() {
1258            return Ok(());
1259        }
1260        for param in &self.params {
1261            if bindings.contains_key(param.name.member()) {
1262                continue;
1263            }
1264            let Some(default_expr) = &param.default_expr else {
1265                continue;
1266            };
1267            let mut checker = OverrideReconciliationChecker {
1268                index_bindings,
1269                type_bindings,
1270                orphan_decl: param.name.member(),
1271                importer_src,
1272                include_span,
1273            };
1274            checker.visit_expr(default_expr)?;
1275        }
1276        Ok(())
1277    }
1278
1279    /// Merge an instantiated dependency's IR into this IR.
1280    ///
1281    /// All declarations from the dependency are prefixed with `prefix.` and
1282    /// appended to this IR's declaration lists. Param bindings replace the
1283    /// dependency's param default expressions. Internal references within the
1284    /// dependency's expressions are rewritten to use prefixed names.
1285    ///
1286    /// `dep_names` is the set of all declaration names in the dependency (before
1287    /// prefixing), used to determine which references should be rewritten.
1288    ///
1289    /// `dep_src` is the dependency body's [`NamedSource`]: merged declarations
1290    /// keep the dependency file's byte offsets, so each is tagged with it (via
1291    /// [`BodySource::or_dependency`]) for diagnostics raised at the importer's
1292    /// freeze/TIR boundary (#868). For inline-DAG includes the body shares the
1293    /// importer's source, so `dep_src` equals `importer_src` there.
1294    #[expect(
1295        clippy::too_many_lines,
1296        reason = "single logical operation: prefix and merge all declaration kinds"
1297    )]
1298    #[expect(
1299        clippy::too_many_arguments,
1300        reason = "merge_dependency coordinates every binding kind plus prefixing state"
1301    )]
1302    pub fn merge_dependency(
1303        &mut self,
1304        dep: Self,
1305        prefix: &str,
1306        bindings: &HashMap<DeclName, Expr>,
1307        dep_names: &HashSet<DeclName>,
1308        index_bindings: &HashMap<IndexName, IndexName>,
1309        type_bindings: &HashMap<StructTypeName, StructTypeName>,
1310        dim_bindings: &HashMap<DimName, DimName>,
1311        import_item_attributes: &HashMap<DeclName, Vec<crate::desugar::desugared_ast::Attribute>>,
1312        requested_plots: &HashMap<DeclName, RequestedPlot>,
1313        importer_src: &NamedSource<Arc<String>>,
1314        dep_src: &NamedSource<Arc<String>>,
1315    ) -> Result<(), GraphcalError> {
1316        /// Prefix a `ScopedName` if it is an unqualified member owned by
1317        /// the dependency.
1318        ///
1319        /// Mirrors [`RefPrefixer::rewrite`]: already-qualified names (e.g. a
1320        /// transitively-imported `module.x` inside the dep) belong to another
1321        /// namespace and must keep their qualifier — `with_prefix` would
1322        /// silently replace it, diverging from the merged expressions whose
1323        /// qualified refs are left untouched.
1324        fn prefix_dep(d: &ScopedName, prefix: &str, dep_names: &HashSet<DeclName>) -> ScopedName {
1325            if !d.is_qualified() && dep_names.contains(d.member()) {
1326                d.with_prefix(prefix)
1327            } else {
1328                d.clone()
1329            }
1330        }
1331
1332        let mut all_dep_names = dep_names.clone();
1333        all_dep_names.extend(
1334            dep.imported_values
1335                .keys()
1336                .map(|name| DeclName::new(name.member())),
1337        );
1338        all_dep_names.extend(
1339            dep.imported_decl_types
1340                .keys()
1341                .map(|name| DeclName::new(name.member())),
1342        );
1343        all_dep_names.extend(
1344            dep.imported_value_sources
1345                .keys()
1346                .map(|name| DeclName::new(name.member())),
1347        );
1348        let dep_names = &all_dep_names;
1349
1350        // Merge consts
1351        for mut entry in dep.consts {
1352            substitute_index_names(&mut entry.expr, index_bindings);
1353            substitute_type_names_in_expr(&mut entry.expr, type_bindings);
1354            prefix_expr_refs(&mut entry.expr, prefix, dep_names);
1355            substitute_type_expr_index_names(&mut entry.type_ann, index_bindings);
1356            substitute_type_expr_nominal_names(&mut entry.type_ann, type_bindings);
1357            substitute_type_expr_nominal_names(&mut entry.type_ann, dim_bindings);
1358            let prefixed = entry.name.with_prefix(prefix);
1359            self.consts.push(UnfrozenConstEntry {
1360                name: prefixed.clone(),
1361                type_ann: entry.type_ann,
1362                expr: entry.expr,
1363                span: entry.span,
1364                src: entry.src.or_dependency(dep_src),
1365            });
1366            self.source_order.push((prefixed, DeclCategory::Const));
1367        }
1368
1369        // Merge params — replace defaults with bindings where provided
1370        for mut entry in dep.params {
1371            let prefixed = entry.name.with_prefix(prefix);
1372            if let Some(binding_expr) = bindings.get(entry.name.member()) {
1373                // Use the binding expression (from the importer's scope, no prefixing needed
1374                // for refs that belong to the importer — only dep-internal refs get prefixed).
1375                // The declared type (the diagnostic anchor for an annotation
1376                // mismatch) still belongs to the dependency, so the entry keeps
1377                // dependency provenance below (#868).
1378                entry.default_expr = Some(binding_expr.clone());
1379            } else if let Some(ref mut expr) = entry.default_expr {
1380                // Keep default, but substitute index names and prefix internal refs
1381                substitute_index_names(expr, index_bindings);
1382                substitute_type_names_in_expr(expr, type_bindings);
1383                prefix_expr_refs(expr, prefix, dep_names);
1384            } else {
1385                // Required param without binding — stays None, caught later in exec_plan
1386            }
1387            substitute_type_expr_index_names(&mut entry.type_ann, index_bindings);
1388            substitute_type_expr_nominal_names(&mut entry.type_ann, type_bindings);
1389            substitute_type_expr_nominal_names(&mut entry.type_ann, dim_bindings);
1390            self.params.push(UnfrozenParamEntry {
1391                name: prefixed.clone(),
1392                type_ann: entry.type_ann,
1393                default_expr: entry.default_expr,
1394                span: entry.span,
1395                src: entry.src.or_dependency(dep_src),
1396            });
1397            self.source_order.push((prefixed, DeclCategory::Param));
1398        }
1399
1400        // Merge nodes
1401        for mut entry in dep.nodes {
1402            substitute_index_names(&mut entry.expr, index_bindings);
1403            substitute_type_names_in_expr(&mut entry.expr, type_bindings);
1404            prefix_expr_refs(&mut entry.expr, prefix, dep_names);
1405            substitute_type_expr_index_names(&mut entry.type_ann, index_bindings);
1406            substitute_type_expr_nominal_names(&mut entry.type_ann, type_bindings);
1407            substitute_type_expr_nominal_names(&mut entry.type_ann, dim_bindings);
1408            let prefixed = entry.name.with_prefix(prefix);
1409            self.nodes.push(UnfrozenNodeEntry {
1410                name: prefixed.clone(),
1411                type_ann: entry.type_ann,
1412                expr: entry.expr,
1413                span: entry.span,
1414                src: entry.src.or_dependency(dep_src),
1415            });
1416            self.source_order.push((prefixed, DeclCategory::Node));
1417        }
1418
1419        // Merge asserts
1420        for mut entry in dep.asserts {
1421            match &mut entry.body {
1422                crate::desugar::desugared_ast::AssertBody::Expr(e) => {
1423                    substitute_index_names(e, index_bindings);
1424                    substitute_type_names_in_expr(e, type_bindings);
1425                    prefix_expr_refs(e, prefix, dep_names);
1426                }
1427                crate::desugar::desugared_ast::AssertBody::Tolerance {
1428                    actual,
1429                    expected,
1430                    tolerance,
1431                    ..
1432                } => {
1433                    substitute_index_names(actual, index_bindings);
1434                    substitute_type_names_in_expr(actual, type_bindings);
1435                    prefix_expr_refs(actual, prefix, dep_names);
1436                    substitute_index_names(expected, index_bindings);
1437                    substitute_type_names_in_expr(expected, type_bindings);
1438                    prefix_expr_refs(expected, prefix, dep_names);
1439                    substitute_index_names(tolerance, index_bindings);
1440                    substitute_type_names_in_expr(tolerance, type_bindings);
1441                    prefix_expr_refs(tolerance, prefix, dep_names);
1442                }
1443            }
1444            let prefixed = entry.name.with_prefix(prefix);
1445            self.asserts.push(UnfrozenAssertEntry {
1446                name: prefixed.clone(),
1447                body: entry.body,
1448                span: entry.span,
1449                src: entry.src.or_dependency(dep_src),
1450            });
1451            self.assert_names.insert(prefixed.clone());
1452            self.source_order.push((prefixed, DeclCategory::Assert));
1453        }
1454
1455        // Merge only the plots requested by the include's brace list (#847):
1456        // display is a consumer-side opt-in, so unrequested dep plots do not
1457        // travel with the instance. A requested plot enters the root
1458        // namespace under its local alias, evaluating against this instance's
1459        // bindings; `#[hidden]` on the include item keeps it composition-only.
1460        for mut entry in dep.plots {
1461            let Some(requested) = requested_plots.get(entry.name.member()) else {
1462                continue;
1463            };
1464            for encoding in &mut entry.decl.encodings {
1465                substitute_index_names(&mut encoding.value, index_bindings);
1466                substitute_type_names_in_expr(&mut encoding.value, type_bindings);
1467                prefix_expr_refs(&mut encoding.value, prefix, dep_names);
1468            }
1469            for prop in &mut entry.decl.mark.properties {
1470                substitute_index_names(&mut prop.value, index_bindings);
1471                substitute_type_names_in_expr(&mut prop.value, type_bindings);
1472                prefix_expr_refs(&mut prop.value, prefix, dep_names);
1473            }
1474            for prop in &mut entry.decl.properties {
1475                substitute_index_names(&mut prop.value, index_bindings);
1476                substitute_type_names_in_expr(&mut prop.value, type_bindings);
1477                prefix_expr_refs(&mut prop.value, prefix, dep_names);
1478            }
1479            let local = ScopedName::local(requested.alias.as_str());
1480            self.plots.push(UnfrozenPlotEntry {
1481                name: local.clone(),
1482                decl: entry.decl,
1483                span: entry.span,
1484                // The alias is root-local; re-export requires its own `pub`
1485                // include item, resolved at the import surface.
1486                is_pub: false,
1487                displayed: !requested.hidden,
1488            });
1489            self.source_order.push((local, DeclCategory::Plot));
1490        }
1491
1492        // Dep figures and layers do not merge: they cannot be requested by a
1493        // brace list, and display is controlled by the consumer (#847).
1494
1495        // Merge assumes_map and expected_fail
1496        for (assert_name, assumers) in dep.assumes_map {
1497            let prefixed_assert = assert_name.with_prefix(prefix);
1498            let prefixed_assumers: Vec<ScopedName> =
1499                assumers.iter().map(|a| a.with_prefix(prefix)).collect();
1500            self.assumes_map
1501                .entry(prefixed_assert)
1502                .or_default()
1503                .extend(prefixed_assumers);
1504        }
1505        for (assert_name, ef) in dep.expected_fail {
1506            let prefixed = assert_name.with_prefix(prefix);
1507
1508            // If the expected_fail references overridden indexes, filter or drop.
1509            if index_bindings.is_empty() {
1510                self.expected_fail.insert(prefixed, ef);
1511            } else {
1512                match ef {
1513                    ExpectedFail::All => {
1514                        self.expected_fail.insert(prefixed, ExpectedFail::All);
1515                    }
1516                    ExpectedFail::Variants(keys) => {
1517                        let filtered: Vec<_> = keys
1518                            .into_iter()
1519                            .filter(|key| {
1520                                // Drop keys that reference any overridden index.
1521                                // `#N` range segments never name an index, so
1522                                // they cannot reference an overridden one.
1523                                !key.iter().any(|part| {
1524                                    part.named_index().is_some_and(|index| {
1525                                        index_bindings.contains_key(index.display_name().as_str())
1526                                    })
1527                                })
1528                            })
1529                            .collect();
1530                        if !filtered.is_empty() {
1531                            self.expected_fail
1532                                .insert(prefixed, ExpectedFail::Variants(filtered));
1533                        }
1534                        // If all keys were dropped, don't insert any expected_fail.
1535                    }
1536                }
1537            }
1538        }
1539
1540        // Apply import-item expected_fail attributes (from the importing file).
1541        // Malformed args are surfaced as `ExpectedFailInvalidArg`, matching the
1542        // behavior for non-imported `#[expected_fail]` attributes.
1543        for (orig_name, attrs) in import_item_attributes {
1544            for attr in attrs {
1545                if attr
1546                    .name
1547                    .name
1548                    .parse::<crate::syntax::attribute::AttributeName>()
1549                    == Ok(crate::syntax::attribute::AttributeName::ExpectedFail)
1550                {
1551                    let prefixed_assert = ScopedName::local(orig_name.as_str()).with_prefix(prefix);
1552                    let ef = crate::ir::resolve::names::parse_expected_fail_args(
1553                        &attr.args,
1554                        importer_src,
1555                    )?;
1556                    self.expected_fail.insert(prefixed_assert, ef);
1557                }
1558            }
1559        }
1560
1561        // Propagate the dep's imported-value metadata. Hidden imports used by
1562        // the dep's expressions are instance-scoped together with the merged
1563        // expressions, preventing two DAG include instances from sharing an
1564        // unqualified synthetic name.
1565        for (name, value) in dep.imported_values {
1566            self.imported_values
1567                .entry(prefix_dep(&name, prefix, dep_names))
1568                .or_insert(value);
1569        }
1570        for (name, dt) in dep.imported_decl_types {
1571            self.imported_decl_types
1572                .entry(prefix_dep(&name, prefix, dep_names))
1573                .or_insert(dt);
1574        }
1575        for (name, source) in dep.imported_value_sources {
1576            self.imported_value_sources
1577                .entry(prefix_dep(&name, prefix, dep_names))
1578                .or_insert(source);
1579        }
1580        Ok(())
1581    }
1582}
1583
1584/// Visitor that detects V005 / A8 violations in a param default expression.
1585///
1586/// Emits [`GraphcalError::IncludeMustReconcileOverride`] on the first
1587/// occurrence of a variant literal `s.v` where `s` is in
1588/// `index_bindings`, or of a constructor / as-cast / generic type
1589/// argument whose type name is in `type_bindings`. The spans reported
1590/// point at the importer's include statement — the error blames the
1591/// importer for omitting the required re-binding.
1592struct OverrideReconciliationChecker<'a> {
1593    index_bindings: &'a HashMap<IndexName, IndexName>,
1594    type_bindings: &'a HashMap<StructTypeName, StructTypeName>,
1595    orphan_decl: &'a str,
1596    importer_src: &'a NamedSource<Arc<String>>,
1597    include_span: Span,
1598}
1599
1600impl OverrideReconciliationChecker<'_> {
1601    fn orphan_error(
1602        &self,
1603        overridden_kind: &str,
1604        overridden: &str,
1605        detail: String,
1606    ) -> GraphcalError {
1607        GraphcalError::IncludeMustReconcileOverride {
1608            overridden: overridden.to_string(),
1609            overridden_kind: overridden_kind.to_string(),
1610            orphan_decl: self.orphan_decl.to_string(),
1611            detail,
1612            src: self.importer_src.clone(),
1613            span: self.include_span.into(),
1614        }
1615    }
1616
1617    fn check_type_expr(&self, type_expr: &TypeExpr) -> Result<(), GraphcalError> {
1618        use crate::desugar::desugared_ast::TypeExprKind;
1619        match &type_expr.kind {
1620            TypeExprKind::DimExpr(dim_expr) => {
1621                for item in &dim_expr.terms {
1622                    let name = &item.term.name.value;
1623                    if let Some(atom) = name.as_bare()
1624                        && self.type_bindings.contains_key(atom.as_str())
1625                    {
1626                        return Err(self.orphan_error(
1627                            "type",
1628                            atom.as_str(),
1629                            format!("type `{name}`"),
1630                        ));
1631                    }
1632                }
1633                Ok(())
1634            }
1635            TypeExprKind::TypeApplication { name, type_args } => {
1636                if let Some(atom) = name.value.as_bare()
1637                    && self.type_bindings.contains_key(atom.as_str())
1638                {
1639                    return Err(self.orphan_error(
1640                        "type",
1641                        atom.as_str(),
1642                        format!("type `{}`", name.value),
1643                    ));
1644                }
1645                for arg in type_args {
1646                    self.check_type_expr(arg)?;
1647                }
1648                Ok(())
1649            }
1650            TypeExprKind::DatetimeApplication { type_args } => {
1651                for arg in type_args {
1652                    self.check_type_expr(arg)?;
1653                }
1654                Ok(())
1655            }
1656            TypeExprKind::Indexed { base, .. } => self.check_type_expr(base),
1657            TypeExprKind::Dimensionless
1658            | TypeExprKind::Bool
1659            | TypeExprKind::Int
1660            | TypeExprKind::Datetime => Ok(()),
1661        }
1662    }
1663}
1664
1665impl ExprVisitor<crate::syntax::phase::Desugared> for OverrideReconciliationChecker<'_> {
1666    type Error = GraphcalError;
1667
1668    fn visit_unresolved_ref(&mut self, expr: &Expr) -> Result<(), Self::Error> {
1669        let ExprKind::UnresolvedRef(crate::syntax::ast::UnresolvedRef::Path(path)) = &expr.kind
1670        else {
1671            return Ok(());
1672        };
1673        // A two-segment path whose head names a rebound index is a variant
1674        // literal of that index.
1675        if let [head, variant] = path.segments()
1676            && self.index_bindings.contains_key(head.name.as_str())
1677        {
1678            return Err(self.orphan_error(
1679                "index",
1680                head.name.as_str(),
1681                format!("`{}.{}`", head.name, variant.name),
1682            ));
1683        }
1684        // A bare path naming a rebound type is a nullary constructor use.
1685        if let Some(ident) = path.as_bare()
1686            && self.type_bindings.contains_key(ident.name.as_str())
1687        {
1688            let n = ident.name.as_str();
1689            return Err(self.orphan_error("type", n, format!("constructor `{n}`")));
1690        }
1691        Ok(())
1692    }
1693
1694    fn visit_single_child(&mut self, expr: &Expr, inner: &Expr) -> Result<(), Self::Error> {
1695        if let ExprKind::IndexAccess { args, .. } = &expr.kind {
1696            for arg in args {
1697                if let crate::desugar::desugared_ast::IndexArg::Variant { index, variant } = arg
1698                    && self
1699                        .index_bindings
1700                        .contains_key(index.value.leaf().as_str())
1701                {
1702                    return Err(self.orphan_error(
1703                        "index",
1704                        index.value.leaf().as_str(),
1705                        format!("`{}.{}`", index.value, variant.value),
1706                    ));
1707                }
1708            }
1709        }
1710        self.visit_expr(inner)
1711    }
1712
1713    fn visit_map_entries(
1714        &mut self,
1715        _expr: &Expr,
1716        entries: &[crate::desugar::desugared_ast::MapEntry],
1717    ) -> Result<(), Self::Error> {
1718        for entry in entries {
1719            let key = entry.keys.first();
1720            if let crate::syntax::ast::MapEntryIndex::Named(index_name) = &key.index.value
1721                && self.index_bindings.contains_key(index_name.leaf().as_str())
1722            {
1723                return Err(self.orphan_error(
1724                    "index",
1725                    index_name.leaf().as_str(),
1726                    format!("`{}.{}`", index_name, key.variant.value),
1727                ));
1728            }
1729            self.visit_expr(&entry.value)?;
1730        }
1731        Ok(())
1732    }
1733
1734    fn visit_match(
1735        &mut self,
1736        _expr: &Expr,
1737        scrutinee: &Expr,
1738        arms: &[crate::desugar::desugared_ast::MatchArm],
1739    ) -> Result<(), Self::Error> {
1740        self.visit_expr(scrutinee)?;
1741        for arm in arms {
1742            match &arm.pattern {
1743                crate::desugar::desugared_ast::MatchPattern::IndexLabel {
1744                    index, variant, ..
1745                } if self
1746                    .index_bindings
1747                    .contains_key(index.value.leaf().as_str()) =>
1748                {
1749                    return Err(self.orphan_error(
1750                        "index",
1751                        index.value.leaf().as_str(),
1752                        format!("`{}.{}`", index.value, variant.value),
1753                    ));
1754                }
1755                crate::desugar::desugared_ast::MatchPattern::Path { path, .. } => {
1756                    if let [head, variant] = path.segments()
1757                        && self.index_bindings.contains_key(head.name.as_str())
1758                    {
1759                        return Err(self.orphan_error(
1760                            "index",
1761                            head.name.as_str(),
1762                            format!("`{}.{}`", head.name, variant.name),
1763                        ));
1764                    }
1765                }
1766                _ => {}
1767            }
1768            self.visit_expr(&arm.body)?;
1769        }
1770        Ok(())
1771    }
1772
1773    fn visit_constructor_call(
1774        &mut self,
1775        expr: &Expr,
1776        fields: &[crate::desugar::desugared_ast::FieldInit],
1777    ) -> Result<(), Self::Error> {
1778        if let ExprKind::ConstructorCall {
1779            callee,
1780            generic_args,
1781            ..
1782        } = &expr.kind
1783        {
1784            if let Some(constructor) = callee.as_bare() {
1785                let n = constructor.name.as_str();
1786                if self.type_bindings.contains_key(n) {
1787                    return Err(self.orphan_error("type", n, format!("constructor `{n}(...)`")));
1788                }
1789            }
1790            for arg in generic_args {
1791                if let crate::desugar::desugared_ast::GenericArg::Type(ty) = arg {
1792                    self.check_type_expr(ty)?;
1793                }
1794            }
1795        }
1796        for f in fields {
1797            self.visit_expr(&f.value)?;
1798        }
1799        Ok(())
1800    }
1801
1802    fn visit_fn_call(&mut self, expr: &Expr, args: &[Expr]) -> Result<(), Self::Error> {
1803        if let ExprKind::FnCall { type_args, .. } = &expr.kind {
1804            for ga in type_args {
1805                if let crate::desugar::desugared_ast::GenericArg::Type(ty) = ga {
1806                    self.check_type_expr(ty)?;
1807                }
1808            }
1809        }
1810        for arg in args {
1811            self.visit_expr(arg)?;
1812        }
1813        Ok(())
1814    }
1815}
1816
1817/// Visitor that prefixes references to dependency declarations.
1818///
1819/// When a `@name` (or bare const `NAME`) refers to a name owned by the
1820/// dependency being merged, rewrite the typed [`ScopedName`] payload via
1821/// [`ScopedName::with_prefix`] so the merged-IR key matches the prefixed
1822/// declaration name. No flat separator strings are constructed here — the
1823/// local/qualified distinction lives in the structured qualifier path.
1824struct RefPrefixer<'a> {
1825    prefix: &'a str,
1826    prefix_atom: NameAtom,
1827    dep_names: &'a HashSet<DeclName>,
1828}
1829
1830impl RefPrefixer<'_> {
1831    fn rewrite(&self, scoped: &ScopedName) -> Option<ScopedName> {
1832        // Only rewrite refs that are local to the dep (i.e. unqualified
1833        // members owned by the dependency). Already-qualified refs (e.g.
1834        // a transitively-imported `@module.x` inside the dep) belong to
1835        // some other namespace and are left untouched.
1836        if !scoped.is_qualified() && self.dep_names.contains(scoped.member()) {
1837            Some(scoped.with_prefix(self.prefix))
1838        } else {
1839            None
1840        }
1841    }
1842}
1843
1844impl ExprVisitorMut<crate::syntax::phase::Desugared> for RefPrefixer<'_> {
1845    type Error = std::convert::Infallible;
1846
1847    fn visit_graph_ref_mut(&mut self, expr: &mut Expr) -> Result<(), Self::Error> {
1848        if let ExprKind::GraphRef(ident) = &mut expr.kind
1849            && let Some(prefixed) = self.rewrite(&ident.value)
1850        {
1851            ident.value = prefixed;
1852        }
1853        Ok(())
1854    }
1855
1856    fn visit_unresolved_ref_mut(&mut self, expr: &mut Expr) -> Result<(), Self::Error> {
1857        // A bare reference path owned by the dependency becomes a qualified
1858        // path under the merge prefix, mirroring the prefixed entry name it
1859        // resolves to. Already-qualified paths belong to another namespace
1860        // (a transitive import inside the dep) and keep their qualifier.
1861        if let ExprKind::UnresolvedRef(crate::syntax::ast::UnresolvedRef::Path(path)) =
1862            &mut expr.kind
1863            && let Some(ident) = path.as_bare()
1864            && self.dep_names.contains(ident.name.as_str())
1865        {
1866            let leaf = ident.clone();
1867            let prefix_segment = crate::syntax::ast::Ident {
1868                name: self.prefix_atom.clone(),
1869                span: leaf.span,
1870            };
1871            *path = crate::syntax::ast::IdentPath::new(crate::syntax::non_empty::NonEmpty::new(
1872                prefix_segment,
1873                vec![leaf],
1874            ));
1875        }
1876        Ok(())
1877    }
1878
1879    // Function calls don't need rewriting: built-ins (`sqrt`, `sum`, …)
1880    // are unqualified and never appear in `dep_names`, and there are no
1881    // user-defined functions in graphcal. The default `visit_fn_call_mut`
1882    // (which recurses into args) is correct.
1883}
1884
1885/// Rewrite `@`-references and const/fn references within an expression to use
1886/// prefixed names, but only for names that belong to the dependency.
1887///
1888/// For example, `GraphRef("dry_mass")` becomes `GraphRef("r.dry_mass")` when
1889/// `"dry_mass"` is in `dep_names` and `prefix` is `"r"`.
1890///
1891/// Built-in names and names from the importer's scope are left unchanged.
1892pub(crate) fn prefix_expr_refs(expr: &mut Expr, prefix: &str, dep_names: &HashSet<DeclName>) {
1893    let Ok(prefix_atom) = NameAtom::parse(prefix) else {
1894        // The prefix comes from a validated include alias; a non-identifier
1895        // prefix cannot name any reference, so there is nothing to rewrite.
1896        return;
1897    };
1898    let mut prefixer = RefPrefixer {
1899        prefix,
1900        prefix_atom,
1901        dep_names,
1902    };
1903    let _ = prefixer.visit_expr_mut(expr);
1904}
1905
1906/// Visitor that rewrites index names in expressions according to a binding map.
1907///
1908/// Overrides the per-variant handler methods for nodes that carry index name
1909/// fields (`VariantLiteral`, `ForComp`, `IndexAccess`, `MapLiteral`,
1910/// `TableLiteral`, `Match`) to rewrite those names before recursing into
1911/// child expressions.
1912struct IndexSubstituter<'a> {
1913    bindings: &'a HashMap<IndexName, IndexName>,
1914}
1915
1916impl ExprVisitorMut<crate::syntax::phase::Desugared> for IndexSubstituter<'_> {
1917    type Error = std::convert::Infallible;
1918
1919    fn visit_unresolved_ref_mut(&mut self, expr: &mut Expr) -> Result<(), Self::Error> {
1920        // A two-segment path whose head names a rebound index is a variant
1921        // literal of that index (`Phase.Burn`); rewrite the head segment so
1922        // the literal points at the importer's index.
1923        if let ExprKind::UnresolvedRef(crate::syntax::ast::UnresolvedRef::Path(path)) =
1924            &mut expr.kind
1925            && let [head, _variant] = path.segments.as_mut_slice()
1926            && let Some(new) = self.bindings.get(head.name.as_str())
1927            && let Ok(new_atom) = NameAtom::parse(new.as_str())
1928        {
1929            head.name = new_atom;
1930        }
1931        Ok(())
1932    }
1933
1934    fn visit_for_comp_mut(&mut self, expr: &mut Expr) -> Result<(), Self::Error> {
1935        if let ExprKind::ForComp { bindings, body } = &mut expr.kind {
1936            for b in bindings {
1937                if let crate::desugar::desugared_ast::ForBindingIndex::Named(ref mut spanned_idx) =
1938                    b.index
1939                    && let Some(new) = self.bindings.get(spanned_idx.value.leaf().as_str())
1940                {
1941                    spanned_idx.value = new.clone().into();
1942                }
1943            }
1944            self.visit_expr_mut(body)?;
1945        }
1946        Ok(())
1947    }
1948
1949    fn visit_index_access_mut(&mut self, expr: &mut Expr) -> Result<(), Self::Error> {
1950        use crate::desugar::desugared_ast::IndexArg;
1951        if let ExprKind::IndexAccess { expr: inner, args } = &mut expr.kind {
1952            for arg in args.iter_mut() {
1953                match arg {
1954                    IndexArg::Variant { index, .. } => {
1955                        if let Some(new) = self.bindings.get(index.value.leaf().as_str()) {
1956                            index.value = new.clone().into();
1957                        }
1958                    }
1959                    IndexArg::Expr(e) => {
1960                        self.visit_expr_mut(e)?;
1961                    }
1962                    IndexArg::Var(_) => {}
1963                }
1964            }
1965            self.visit_expr_mut(inner)?;
1966        }
1967        Ok(())
1968    }
1969
1970    fn visit_map_literal_mut(&mut self, expr: &mut Expr) -> Result<(), Self::Error> {
1971        if let ExprKind::MapLiteral { entries } = &mut expr.kind {
1972            for entry in entries.iter_mut() {
1973                for key in &mut entry.keys {
1974                    if let crate::syntax::ast::MapEntryIndex::Named(index_name) = &key.index.value
1975                        && let Some(new) = self.bindings.get(index_name.leaf().as_str())
1976                    {
1977                        key.index.value =
1978                            crate::syntax::ast::MapEntryIndex::Named(new.clone().into());
1979                    }
1980                }
1981                self.visit_expr_mut(&mut entry.value)?;
1982            }
1983        }
1984        Ok(())
1985    }
1986
1987    fn visit_match_mut(&mut self, expr: &mut Expr) -> Result<(), Self::Error> {
1988        if let ExprKind::Match { scrutinee, arms } = &mut expr.kind {
1989            self.visit_expr_mut(scrutinee)?;
1990            for arm in arms {
1991                match &mut arm.pattern {
1992                    crate::desugar::desugared_ast::MatchPattern::IndexLabel { index, .. } => {
1993                        if let Some(new) = self.bindings.get(index.value.leaf().as_str()) {
1994                            index.value = new.clone().into();
1995                        }
1996                    }
1997                    // A two-segment path pattern whose head names a rebound
1998                    // index is an index-label pattern; rewrite the head.
1999                    crate::desugar::desugared_ast::MatchPattern::Path { path, .. } => {
2000                        if let [head, _variant] = path.segments.as_mut_slice()
2001                            && let Some(new) = self.bindings.get(head.name.as_str())
2002                            && let Ok(new_atom) = NameAtom::parse(new.as_str())
2003                        {
2004                            head.name = new_atom;
2005                        }
2006                    }
2007                    crate::desugar::desugared_ast::MatchPattern::Constructor { .. } => {}
2008                }
2009                self.visit_expr_mut(&mut arm.body)?;
2010            }
2011        }
2012        Ok(())
2013    }
2014}
2015
2016/// Rewrite index names within an expression according to a binding map.
2017///
2018/// For example, if `bindings` maps `"Phase"` to `"MyPhase"`, then
2019/// `VariantLiteral { index: Phase, variant: A }` becomes
2020/// `VariantLiteral { index: MyPhase, variant: A }`.
2021///
2022/// This must be called **before** `prefix_expr_refs` so that index names are
2023/// correct before ref-prefixing adds the `prefix.` qualifier.
2024pub(crate) fn substitute_index_names(expr: &mut Expr, bindings: &HashMap<IndexName, IndexName>) {
2025    if bindings.is_empty() {
2026        return;
2027    }
2028    let mut sub = IndexSubstituter { bindings };
2029    let _ = sub.visit_expr_mut(expr);
2030}
2031
2032/// Rewrite index names within a type expression according to a binding map.
2033///
2034/// `TypeExpr` is not part of the `Expr` tree, so it needs a separate
2035/// substitution pass. This rewrites index identifiers in `Indexed` types
2036/// (e.g., `Dimensionless[Phase]` → `Dimensionless[MyPhase]`) and recurses
2037/// into `TypeApplication` arguments.
2038#[expect(
2039    clippy::implicit_hasher,
2040    reason = "internal API always uses default hasher"
2041)]
2042pub fn substitute_type_expr_index_names(
2043    type_expr: &mut TypeExpr,
2044    bindings: &HashMap<IndexName, IndexName>,
2045) {
2046    use crate::desugar::desugared_ast::TypeExprKind;
2047
2048    if bindings.is_empty() {
2049        return;
2050    }
2051    match &mut type_expr.kind {
2052        TypeExprKind::Indexed { base, indexes } => {
2053            for idx_expr in indexes.iter_mut() {
2054                if let crate::desugar::desugared_ast::IndexExpr::Name(path) = idx_expr
2055                    && let Some(atom) = path.value.as_bare()
2056                    && let Some(new_name) = bindings.get(atom.as_str())
2057                {
2058                    path.value = crate::syntax::names::NamePath::from(new_name.as_str());
2059                }
2060            }
2061            substitute_type_expr_index_names(base, bindings);
2062        }
2063        TypeExprKind::TypeApplication { type_args, .. }
2064        | TypeExprKind::DatetimeApplication { type_args } => {
2065            for arg in type_args {
2066                substitute_type_expr_index_names(arg, bindings);
2067            }
2068        }
2069        TypeExprKind::Dimensionless
2070        | TypeExprKind::Bool
2071        | TypeExprKind::Int
2072        | TypeExprKind::Datetime
2073        | TypeExprKind::DimExpr(_) => {}
2074    }
2075}
2076
2077/// Rewrite nominally-tied names (types or dimensions) within a type expression.
2078///
2079/// `TypeExpr` uses `DimExpr` to carry single-identifier type references (the
2080/// resolver disambiguates them into `StructType` / `Dim` later). Both type and
2081/// dimension bindings therefore need to walk `DimExpr` terms and rewrite their
2082/// names. `TypeApplication.name` is rewritten for type bindings (generic
2083/// parametric types like `Vec3<Length>`), which is harmless for dim bindings
2084/// because type and dim names can't collide (A6 nominal identity).
2085#[expect(
2086    clippy::implicit_hasher,
2087    reason = "internal API always uses default hasher"
2088)]
2089pub fn substitute_type_expr_nominal_names<K>(type_expr: &mut TypeExpr, bindings: &HashMap<K, K>)
2090where
2091    K: std::hash::Hash + Eq + std::borrow::Borrow<str> + AsRef<str>,
2092{
2093    use crate::desugar::desugared_ast::TypeExprKind;
2094
2095    if bindings.is_empty() {
2096        return;
2097    }
2098    match &mut type_expr.kind {
2099        TypeExprKind::DimExpr(dim_expr) => {
2100            for item in &mut dim_expr.terms {
2101                if let Some(atom) = item.term.name.value.as_bare()
2102                    && let Some(new_name) = bindings.get(atom.as_str())
2103                {
2104                    item.term.name.value = crate::syntax::names::NamePath::from(new_name.as_ref());
2105                }
2106            }
2107        }
2108        TypeExprKind::Indexed { base, .. } => {
2109            substitute_type_expr_nominal_names(base, bindings);
2110        }
2111        TypeExprKind::TypeApplication { name, type_args } => {
2112            if let Some(atom) = name.value.as_bare()
2113                && let Some(new_name) = bindings.get(atom.as_str())
2114            {
2115                name.value = crate::syntax::names::NamePath::from(new_name.as_ref());
2116            }
2117            for arg in type_args {
2118                substitute_type_expr_nominal_names(arg, bindings);
2119            }
2120        }
2121        TypeExprKind::DatetimeApplication { type_args } => {
2122            // The built-in `Datetime` name is fixed; only the type args can
2123            // carry user-bindable nominal names.
2124            for arg in type_args {
2125                substitute_type_expr_nominal_names(arg, bindings);
2126            }
2127        }
2128        TypeExprKind::Dimensionless
2129        | TypeExprKind::Bool
2130        | TypeExprKind::Int
2131        | TypeExprKind::Datetime => {}
2132    }
2133}
2134
2135/// Rewrite struct-type names within an expression according to a binding map.
2136///
2137/// Covers `ConstructorCall.constructor`, `ConstructorCall.generic_args`,
2138/// and `FnCall.type_args`. Recurses through child expressions so nested
2139/// constructor calls are also rewritten.
2140#[expect(
2141    clippy::too_many_lines,
2142    reason = "single recursion covering every ExprKind variant"
2143)]
2144pub(crate) fn substitute_type_names_in_expr(
2145    expr: &mut Expr,
2146    bindings: &HashMap<StructTypeName, StructTypeName>,
2147) {
2148    use crate::desugar::desugared_ast::{GenericArg, IndexArg};
2149
2150    if bindings.is_empty() {
2151        return;
2152    }
2153    match &mut expr.kind {
2154        ExprKind::Number(_)
2155        | ExprKind::Integer(_)
2156        | ExprKind::Bool(_)
2157        | ExprKind::StringLiteral(_)
2158        | ExprKind::UnitLiteral { .. }
2159        | ExprKind::GraphRef(_) => {}
2160
2161        // A bare reference path naming a rebound type is a nullary
2162        // constructor use; rewrite it to the importer's constructor name.
2163        ExprKind::UnresolvedRef(crate::syntax::ast::UnresolvedRef::Path(path)) => {
2164            if let Some(ident) = path.as_bare_mut()
2165                && let Some(new_name) = bindings.get(ident.name.as_str())
2166                && let Ok(parsed_name) = NameAtom::parse(new_name.as_ref())
2167            {
2168                ident.name = parsed_name;
2169            }
2170        }
2171
2172        ExprKind::InlineDagRef { args, .. } => {
2173            for binding in args {
2174                substitute_type_names_in_expr(&mut binding.value, bindings);
2175            }
2176        }
2177
2178        ExprKind::ConstructorCall {
2179            callee,
2180            generic_args,
2181            fields,
2182        } => {
2183            if let Some(constructor) = callee.as_bare_mut()
2184                && let Some(new_name) = bindings.get(constructor.name.as_str())
2185                && let Ok(parsed_name) = NameAtom::parse(new_name.as_ref())
2186            {
2187                constructor.name = parsed_name;
2188            }
2189            for arg in generic_args.iter_mut() {
2190                if let GenericArg::Type(ty) = arg {
2191                    substitute_type_expr_nominal_names(ty, bindings);
2192                }
2193            }
2194            for field in fields {
2195                substitute_type_names_in_expr(&mut field.value, bindings);
2196            }
2197        }
2198
2199        ExprKind::FnCall {
2200            type_args, args, ..
2201        } => {
2202            for ga in type_args.iter_mut() {
2203                if let GenericArg::Type(ty) = ga {
2204                    substitute_type_expr_nominal_names(ty, bindings);
2205                }
2206            }
2207            for arg in args {
2208                substitute_type_names_in_expr(arg, bindings);
2209            }
2210        }
2211
2212        ExprKind::BinOp { lhs, rhs, .. } => {
2213            substitute_type_names_in_expr(lhs, bindings);
2214            substitute_type_names_in_expr(rhs, bindings);
2215        }
2216        ExprKind::UnaryOp { operand, .. } => {
2217            substitute_type_names_in_expr(operand, bindings);
2218        }
2219        ExprKind::If {
2220            condition,
2221            then_branch,
2222            else_branch,
2223        } => {
2224            substitute_type_names_in_expr(condition, bindings);
2225            substitute_type_names_in_expr(then_branch, bindings);
2226            substitute_type_names_in_expr(else_branch, bindings);
2227        }
2228        ExprKind::Convert { expr: inner, .. }
2229        | ExprKind::DisplayTimezone { expr: inner, .. }
2230        | ExprKind::FieldAccess { expr: inner, .. } => {
2231            substitute_type_names_in_expr(inner, bindings);
2232        }
2233        ExprKind::IndexAccess { expr: inner, args } => {
2234            substitute_type_names_in_expr(inner, bindings);
2235            for arg in args {
2236                if let IndexArg::Expr(e) = arg {
2237                    substitute_type_names_in_expr(e, bindings);
2238                }
2239            }
2240        }
2241        ExprKind::MapLiteral { entries } => {
2242            for entry in entries {
2243                substitute_type_names_in_expr(&mut entry.value, bindings);
2244            }
2245        }
2246        ExprKind::ForComp { body, .. } => {
2247            substitute_type_names_in_expr(body, bindings);
2248        }
2249        ExprKind::Scan {
2250            source, init, body, ..
2251        } => {
2252            substitute_type_names_in_expr(source, bindings);
2253            substitute_type_names_in_expr(init, bindings);
2254            substitute_type_names_in_expr(body, bindings);
2255        }
2256        ExprKind::Unfold { init, body, .. } => {
2257            substitute_type_names_in_expr(init, bindings);
2258            substitute_type_names_in_expr(body, bindings);
2259        }
2260        ExprKind::Match { scrutinee, arms } => {
2261            substitute_type_names_in_expr(scrutinee, bindings);
2262            for arm in arms {
2263                substitute_type_names_in_expr(&mut arm.body, bindings);
2264            }
2265        }
2266        // `Sugar` payload is `Infallible` post-desugar — statically
2267        // unreachable.
2268        #[expect(
2269            clippy::uninhabited_references,
2270            reason = "Sugar(Infallible) — proof of unreachability"
2271        )]
2272        ExprKind::Sugar(s) => match *s {},
2273    }
2274}
2275
2276/// Register dimensions, units, indexes, and struct types from a file's declarations
2277/// into the registry.
2278///
2279/// # Errors
2280///
2281/// Returns a [`GraphcalError`] if a referenced dimension or unit is unknown.
2282pub(crate) fn register_file_declarations(
2283    file: &File,
2284    registry: &mut RegistryBuilder,
2285    src: &NamedSource<Arc<String>>,
2286    dag_id: &crate::dag_id::DagId,
2287) -> Result<(), GraphcalError> {
2288    register_declarations_impl(file, registry, src, None, dag_id)
2289}
2290
2291/// Names selected from a dependency's type-system registry.
2292///
2293/// The sets span several namespaces by design (dims, units, indexes, and
2294/// types share the selective-import surface), so entries are kept as the
2295/// namespace-agnostic [`NameAtom`] rather than coerced into one name type.
2296#[derive(Debug, Default, Clone)]
2297pub struct SelectedDeclarations {
2298    /// Names imported from the default compile-time namespace.
2299    pub default: HashSet<crate::syntax::names::NameAtom>,
2300    /// Names imported from the explicit `type` namespace.
2301    pub types: HashSet<crate::syntax::names::NameAtom>,
2302}
2303
2304impl SelectedDeclarations {
2305    #[must_use]
2306    pub fn is_empty(&self) -> bool {
2307        self.default.is_empty() && self.types.is_empty()
2308    }
2309
2310    pub fn insert_default(&mut self, name: impl Into<crate::syntax::names::NameAtom>) {
2311        self.default.insert(name.into());
2312    }
2313
2314    pub fn insert_type(&mut self, name: impl Into<crate::syntax::names::NameAtom>) {
2315        self.types.insert(name.into());
2316    }
2317}
2318
2319/// Register only the named type-system declarations (dimensions, units, indexes, types)
2320/// from a file into the registry.
2321///
2322/// This is the selective counterpart to `register_file_declarations`: instead of
2323/// registering everything, it filters default-namespace declarations and type
2324/// declarations independently.
2325///
2326/// # Errors
2327///
2328/// Returns a [`GraphcalError`] if a referenced dimension or unit is unknown.
2329pub fn register_selected_declarations(
2330    file: &File,
2331    registry: &mut RegistryBuilder,
2332    src: &NamedSource<Arc<String>>,
2333    names: &SelectedDeclarations,
2334    dag_id: &crate::dag_id::DagId,
2335) -> Result<(), GraphcalError> {
2336    register_declarations_impl(file, registry, src, Some(names), dag_id)
2337}
2338
2339/// Shared implementation for registering type-system declarations.
2340///
2341/// Registration is split into phases to allow forward references between
2342/// declarations of the same kind (e.g., a derived dimension referencing another
2343/// derived dimension declared later in the file). The phases are:
2344///
2345/// 1. Base dimensions, types, union types, named/required-named indexes
2346/// 2. Derived dimensions (topologically sorted by inter-dependency)
2347/// 3. Required-range indexes (depend only on dimensions)
2348/// 4. Units (topologically sorted by inter-dependency)
2349/// 5. Range indexes (depend on dimensions and units)
2350///
2351/// When `filter` is `None`, all declarations are registered.
2352/// When `filter` is `Some(names)`, default-namespace declarations and type
2353/// declarations are filtered independently.
2354fn register_declarations_impl(
2355    file: &File,
2356    registry: &mut RegistryBuilder,
2357    src: &NamedSource<Arc<String>>,
2358    filter: Option<&SelectedDeclarations>,
2359    dag_id: &crate::dag_id::DagId,
2360) -> Result<(), GraphcalError> {
2361    use crate::desugar::desugared_ast::{DimDecl, IndexDecl, UnitDecl};
2362
2363    let should_register_default =
2364        |name: &str| filter.is_none_or(|names| names.default.contains(name));
2365    let should_register_type = |name: &str| filter.is_none_or(|names| names.types.contains(name));
2366
2367    // Collect declarations by kind for phased registration.
2368    let mut derived_dims: Vec<&DimDecl> = Vec::new();
2369    let mut units: Vec<&UnitDecl> = Vec::new();
2370    let mut required_range_indexes: Vec<(&IndexDecl, Span)> = Vec::new();
2371    let mut range_indexes: Vec<(&IndexDecl, Span)> = Vec::new();
2372
2373    // Phase 1: Register base dimensions, types, union types, named/required-named indexes.
2374    // Also collect derived dims, units, and dependent indexes for later phases.
2375    for decl in &file.declarations {
2376        match &decl.kind {
2377            DeclKind::BaseDimension(d) if should_register_default(d.name.value.as_str()) => {
2378                register_base_dimension_decl(d, registry, dag_id);
2379            }
2380            DeclKind::Dimension(d) if should_register_default(d.name.value.as_str()) => {
2381                if d.definition.is_some() {
2382                    derived_dims.push(d);
2383                } else {
2384                    // Required dim (`dim D;`) — no body. Compile as an opaque
2385                    // base dimension so the library checks out in isolation;
2386                    // substitution via include-time dim bindings happens in a
2387                    // later phase (see visibility/bindability axioms plan §C2).
2388                    register_required_dimension_decl(d, registry, dag_id);
2389                }
2390            }
2391            DeclKind::Unit(u) if should_register_default(u.name.value.as_str()) => {
2392                units.push(u);
2393            }
2394            DeclKind::Index(idx) if should_register_default(idx.name.value.as_str()) => {
2395                match &idx.kind {
2396                    IndexDeclKind::RequiredRange { .. } => {
2397                        required_range_indexes.push((idx, decl.span));
2398                    }
2399                    IndexDeclKind::Range { .. } => {
2400                        range_indexes.push((idx, decl.span));
2401                    }
2402                    IndexDeclKind::Named { .. } | IndexDeclKind::RequiredNamed => {
2403                        register_index_decl(idx, registry, src, decl.span)?;
2404                    }
2405                }
2406            }
2407            DeclKind::Type(t) if should_register_type(t.name.value.as_str()) => {
2408                register_type_decl(t, registry);
2409            }
2410            DeclKind::Dag(d) if should_register_default(d.name.value.as_str()) => {
2411                registry.register_dag(d.name.value.clone(), d.clone());
2412            }
2413            _ => {}
2414        }
2415    }
2416
2417    // Phase 2: Topologically sort and register derived dimensions.
2418    if !derived_dims.is_empty() {
2419        let sorted = topo_sort_derived_dims(&derived_dims, src)?;
2420        for d in sorted {
2421            register_dimension_decl(d, registry, src)?;
2422        }
2423    }
2424
2425    // Phase 3: Register required-range indexes (depend only on dimensions).
2426    for (idx, span) in &required_range_indexes {
2427        register_index_decl(idx, registry, src, *span)?;
2428    }
2429
2430    // Phase 4: Topologically sort and register units.
2431    if !units.is_empty() {
2432        let sorted = topo_sort_units(&units, src)?;
2433        for u in sorted {
2434            register_unit_decl(u, registry, src)?;
2435        }
2436    }
2437
2438    // Phase 5: Register range indexes (depend on dimensions and units).
2439    for (idx, span) in &range_indexes {
2440        register_index_decl(idx, registry, src, *span)?;
2441    }
2442
2443    // Phase 6: Register synthetic nat range indexes for any integer literals
2444    // appearing in type position (e.g., `param A: Length[3, 4]`) or
2445    // for-range expressions (e.g., `for i: range(3) { ... }`).
2446    for decl in &file.declarations {
2447        match &decl.kind {
2448            DeclKind::Param(d) => {
2449                collect_nat_ranges_from_type_expr(&d.type_ann, registry, src)?;
2450                if let Some(ref value) = d.value {
2451                    collect_nat_ranges_from_expr(value, registry, src)?;
2452                }
2453            }
2454            DeclKind::Node(d) => {
2455                collect_nat_ranges_from_type_expr(&d.type_ann, registry, src)?;
2456                collect_nat_ranges_from_expr(&d.value, registry, src)?;
2457            }
2458            DeclKind::ConstNode(d) => {
2459                collect_nat_ranges_from_type_expr(&d.type_ann, registry, src)?;
2460                collect_nat_ranges_from_expr(&d.value, registry, src)?;
2461            }
2462            _ => {}
2463        }
2464    }
2465
2466    Ok(())
2467}
2468
2469/// Topologically sort derived dimension declarations by their inter-dependencies.
2470///
2471/// Dependencies on dimensions already in the registry (e.g., from preludes or imports)
2472/// are considered satisfied and do not create graph edges. Only dependencies between
2473/// the file-local derived dimensions are edges.
2474fn topo_sort_derived_dims<'a>(
2475    dims: &[&'a crate::desugar::desugared_ast::DimDecl],
2476    src: &NamedSource<Arc<String>>,
2477) -> Result<Vec<&'a crate::desugar::desugared_ast::DimDecl>, GraphcalError> {
2478    let mut graph = DiGraph::<&str, ()>::new();
2479    let mut name_to_idx: HashMap<&str, petgraph::graph::NodeIndex> = HashMap::new();
2480    let mut idx_to_pos: HashMap<petgraph::graph::NodeIndex, usize> = HashMap::new();
2481
2482    // Add a node for each derived dimension.
2483    for (pos, d) in dims.iter().enumerate() {
2484        let name = d.name.value.as_str();
2485        let idx = graph.add_node(name);
2486        name_to_idx.insert(name, idx);
2487        idx_to_pos.insert(idx, pos);
2488    }
2489
2490    // Add edges: if dim A references dim B (and B is a *different* file-local dim), add A → B.
2491    // Self-references (e.g., `dimension Mass = Mass;` aliasing a prelude dimension) are
2492    // excluded — they resolve against the existing registry during registration.
2493    for d in dims {
2494        let self_name = d.name.value.as_str();
2495        let from = name_to_idx[self_name];
2496        // Only derived dims reach this sort; required dims are routed
2497        // directly to the base-dim registry in Phase 1.
2498        let Some(definition) = &d.definition else {
2499            continue;
2500        };
2501        for item in &definition.terms {
2502            let Some(dep_name) = item
2503                .term
2504                .name
2505                .value
2506                .as_bare()
2507                .map(super::super::syntax::names::NameAtom::as_str)
2508            else {
2509                continue;
2510            };
2511            if dep_name != self_name
2512                && let Some(&to) = name_to_idx.get(dep_name)
2513            {
2514                graph.add_edge(from, to, ());
2515            }
2516        }
2517    }
2518
2519    // Topologically sort (reversed, since edges point from dependent → dependency).
2520    let sorted_indices = toposort(&graph, None).map_err(|cycle| {
2521        let cycle_name = graph[cycle.node_id()];
2522        let pos = idx_to_pos[&cycle.node_id()];
2523        GraphcalError::CyclicDimension {
2524            name: DimName::new(cycle_name),
2525            src: src.clone(),
2526            span: dims[pos].name.span.into(),
2527        }
2528    })?;
2529
2530    // toposort returns dependencies-last order; reverse for dependencies-first.
2531    Ok(sorted_indices
2532        .into_iter()
2533        .rev()
2534        .map(|idx| dims[idx_to_pos[&idx]])
2535        .collect())
2536}
2537
2538/// Topologically sort unit declarations by their inter-dependencies.
2539///
2540/// A unit depends on other units through its `definition.unit_expr` (e.g., `const unit km: Length = 1000 m;`
2541/// depends on `m`). Dependencies on units already in the registry are satisfied and
2542/// do not create graph edges.
2543fn topo_sort_units<'a>(
2544    units: &[&'a crate::desugar::desugared_ast::UnitDecl],
2545    src: &NamedSource<Arc<String>>,
2546) -> Result<Vec<&'a crate::desugar::desugared_ast::UnitDecl>, GraphcalError> {
2547    let mut graph = DiGraph::<&str, ()>::new();
2548    let mut name_to_idx: HashMap<&str, petgraph::graph::NodeIndex> = HashMap::new();
2549    let mut idx_to_pos: HashMap<petgraph::graph::NodeIndex, usize> = HashMap::new();
2550
2551    // Add a node for each unit.
2552    for (pos, u) in units.iter().enumerate() {
2553        let name = u.name.value.as_str();
2554        let idx = graph.add_node(name);
2555        name_to_idx.insert(name, idx);
2556        idx_to_pos.insert(idx, pos);
2557    }
2558
2559    // Add edges: if unit A's definition references unit B (a *different* file-local unit), add A → B.
2560    for u in units {
2561        let self_name = u.name.value.as_str();
2562        let from = name_to_idx[self_name];
2563        if let Some(def) = &u.definition {
2564            for item in &def.unit_expr.terms {
2565                // Module-qualified references can never name a file-local
2566                // unit, so only bare references create graph edges.
2567                if item.name.value.is_qualified() {
2568                    continue;
2569                }
2570                let dep_name = item.name.value.name().as_str();
2571                if dep_name != self_name
2572                    && let Some(&to) = name_to_idx.get(dep_name)
2573                {
2574                    graph.add_edge(from, to, ());
2575                }
2576            }
2577        }
2578    }
2579
2580    let sorted_indices = toposort(&graph, None).map_err(|cycle| {
2581        let pos = idx_to_pos[&cycle.node_id()];
2582        GraphcalError::CyclicUnit {
2583            name: units[pos].name.value.clone(),
2584            src: src.clone(),
2585            span: units[pos].name.span.into(),
2586        }
2587    })?;
2588
2589    // toposort returns dependencies-last order; reverse for dependencies-first.
2590    Ok(sorted_indices
2591        .into_iter()
2592        .rev()
2593        .map(|idx| units[idx_to_pos[&idx]])
2594        .collect())
2595}
2596
2597fn register_base_dimension_decl(
2598    d: &crate::desugar::desugared_ast::BaseDimDecl,
2599    registry: &mut RegistryBuilder,
2600    dag_id: &crate::dag_id::DagId,
2601) {
2602    let dim_id = crate::syntax::dimension::BaseDimId::UserDefined {
2603        dag: dag_id.clone(),
2604        name: d.name.value.to_string(),
2605    };
2606    registry.register_base_dimension(d.name.value.clone(), dim_id);
2607}
2608
2609fn register_dimension_decl(
2610    d: &crate::desugar::desugared_ast::DimDecl,
2611    registry: &mut RegistryBuilder,
2612    src: &NamedSource<Arc<String>>,
2613) -> Result<(), GraphcalError> {
2614    // Only derived dims reach this function; required dims (`dim D;`)
2615    // are routed to `register_required_dimension_decl` in Phase 1 and
2616    // never end up in the topo-sorted derived-dim list.
2617    let Some(definition) = d.definition.as_ref() else {
2618        return Ok(());
2619    };
2620    let dim = registry
2621        .resolve_dim_expr(definition)
2622        .map_err(|_| GraphcalError::DimensionOverflow {
2623            src: src.clone(),
2624            span: d.name.span.into(),
2625        })?
2626        .ok_or_else(|| GraphcalError::UnknownDimension {
2627            name: d.name.value.clone(),
2628            src: src.clone(),
2629            span: d.name.span.into(),
2630        })?;
2631    registry.register_dimension(d.name.value.clone(), dim);
2632    Ok(())
2633}
2634
2635/// Register a required dim (`dim D;`) as an opaque base dimension.
2636///
2637/// The library treats the required dim like a base SI dimension while
2638/// compiling standalone. Later include-time substitution rewires
2639/// references through the importer's dim bindings.
2640fn register_required_dimension_decl(
2641    d: &crate::desugar::desugared_ast::DimDecl,
2642    registry: &mut RegistryBuilder,
2643    dag_id: &crate::dag_id::DagId,
2644) {
2645    let dim_id = crate::syntax::dimension::BaseDimId::UserDefined {
2646        dag: dag_id.clone(),
2647        name: d.name.value.to_string(),
2648    };
2649    registry.register_base_dimension(d.name.value.clone(), dim_id);
2650}
2651
2652fn eval_error(
2653    message: impl Into<String>,
2654    src: &NamedSource<Arc<String>>,
2655    span: Span,
2656) -> GraphcalError {
2657    GraphcalError::EvalError {
2658        message: message.into(),
2659        src: src.clone(),
2660        span: span.into(),
2661    }
2662}
2663
2664fn validate_positive_finite_scale(
2665    value: f64,
2666    context: &str,
2667    src: &NamedSource<Arc<String>>,
2668    span: Span,
2669) -> Result<PositiveFiniteScale, GraphcalError> {
2670    PositiveFiniteScale::new(value).map_err(|err| {
2671        let reason = match err {
2672            PositiveFiniteScaleError::NonFinite => "must be finite",
2673            PositiveFiniteScaleError::NonPositive => "must be greater than zero",
2674        };
2675        eval_error(format!("{context} {reason}, got {value}"), src, span)
2676    })
2677}
2678
2679fn multiply_positive_scales(
2680    lhs: PositiveFiniteScale,
2681    rhs: PositiveFiniteScale,
2682    context: &str,
2683    src: &NamedSource<Arc<String>>,
2684    span: Span,
2685) -> Result<PositiveFiniteScale, GraphcalError> {
2686    validate_positive_finite_scale(lhs.get() * rhs.get(), context, src, span)
2687}
2688
2689fn register_unit_decl(
2690    u: &crate::desugar::desugared_ast::UnitDecl,
2691    registry: &mut RegistryBuilder,
2692    src: &NamedSource<Arc<String>>,
2693) -> Result<(), GraphcalError> {
2694    let dim = registry
2695        .resolve_dim_expr(&u.dim_type)
2696        .map_err(|_| GraphcalError::DimensionOverflow {
2697            src: src.clone(),
2698            span: u.name.span.into(),
2699        })?
2700        .ok_or_else(|| GraphcalError::UnknownDimension {
2701            name: DimName::new(u.name.value.as_str()),
2702            src: src.clone(),
2703            span: u.name.span.into(),
2704        })?;
2705    if u.definition.is_some() && registry.is_affine_prone(&dim) {
2706        return Err(GraphcalError::AffineProneUnitDefinition {
2707            dim: registry.format_dimension(&dim),
2708            src: src.clone(),
2709            span: u.name.span.into(),
2710        });
2711    }
2712    let scale = if let Some(def) = &u.definition {
2713        if u.constness.is_const() {
2714            if let Some(graph_ref) = first_graph_ref(&def.scale_expr) {
2715                return Err(GraphcalError::GraphRefInConstUnit {
2716                    name: graph_ref.value,
2717                    src: src.clone(),
2718                    span: graph_ref.span.into(),
2719                });
2720            }
2721            if let Some(unit_name) = first_non_const_unit_ref(registry, &def.unit_expr) {
2722                return Err(GraphcalError::NonConstUnitInConst {
2723                    name: unit_name.value.clone(),
2724                    src: src.clone(),
2725                    span: unit_name.span.into(),
2726                });
2727            }
2728        }
2729        if contains_graph_ref(&def.scale_expr) {
2730            // Dynamic unit: scale depends on runtime values (e.g., `(@rate) USD`).
2731            // Resolve the base unit's dimension and static scale factor.
2732            let base_scale = resolve_base_unit_static_scale(registry, &def.unit_expr, src)?;
2733            UnitScale::Dynamic {
2734                scale_expr: def.scale_expr.clone(),
2735                base_unit_scale: base_scale,
2736            }
2737        } else {
2738            // Static scale value. A plain `unit` with no `@` still remains a
2739            // runtime unit for const-context policy; `const unit` is the
2740            // surface marker that makes it available to `const node`.
2741            let (_unit_dim, base_scale) = registry
2742                .resolve_unit_expr(&def.unit_expr)
2743                .map_err(|err| unit_resolve_to_graphcal(err, src, def.span))?;
2744            let scale_expr = validate_positive_finite_scale(
2745                eval_scale_expr(&def.scale_expr, src)?,
2746                "unit scale expression",
2747                src,
2748                def.scale_expr.span,
2749            )?;
2750            let base_scale = validate_positive_finite_scale(
2751                base_scale,
2752                "base unit scale",
2753                src,
2754                def.unit_expr.span,
2755            )?;
2756            let scale =
2757                multiply_positive_scales(scale_expr, base_scale, "unit scale", src, def.span)?;
2758            UnitScale::Static(scale)
2759        }
2760    } else {
2761        UnitScale::Static(validate_positive_finite_scale(
2762            1.0,
2763            "base unit scale",
2764            src,
2765            u.name.span,
2766        )?)
2767    };
2768    // If this is a base unit (scale=1, no definition) for a single
2769    // base dimension, record the unit name as the SI symbol for
2770    // that dimension. This handles user-defined dimensions like
2771    // `base unit bit: Information;` → symbol "bit" for Information.
2772    if u.definition.is_none() {
2773        // Check if this dimension is a single base dimension
2774        let mut iter = dim.iter();
2775        if let Some((id, &exp)) = iter.next()
2776            && iter.next().is_none()
2777            && exp == Rational::ONE
2778        {
2779            registry.set_base_dim_symbol(id.clone(), u.name.value.to_string());
2780        }
2781    }
2782    registry.register_unit_with_scale(u.name.value.clone(), dim, scale, u.constness);
2783    Ok(())
2784}
2785
2786fn first_graph_ref(expr: &Expr) -> Option<Spanned<ScopedName>> {
2787    struct FirstGraphRef(Option<Spanned<ScopedName>>);
2788
2789    impl ExprVisitor<crate::syntax::phase::Desugared> for FirstGraphRef {
2790        type Error = std::convert::Infallible;
2791
2792        fn visit_graph_ref(&mut self, expr: &Expr) -> Result<(), Self::Error> {
2793            if self.0.is_none()
2794                && let ExprKind::GraphRef(name) = &expr.kind
2795            {
2796                self.0 = Some(name.clone());
2797            }
2798            Ok(())
2799        }
2800    }
2801
2802    let mut visitor = FirstGraphRef(None);
2803    let _ = visitor.visit_expr(expr);
2804    visitor.0
2805}
2806
2807fn first_non_const_unit_ref<'a>(
2808    registry: &RegistryBuilder,
2809    unit_expr: &'a crate::desugar::desugared_ast::UnitExpr,
2810) -> Option<&'a Spanned<crate::syntax::names::UnitRef>> {
2811    unit_expr.terms.iter().find_map(|term| {
2812        registry
2813            .get_unit(&term.name.value)
2814            .is_some_and(|info| !info.constness.is_const())
2815            .then_some(&term.name)
2816    })
2817}
2818
2819/// Resolve the static scale factor of the base unit expression in a unit definition.
2820///
2821/// For `unit EUR: Money = (@rate) USD;`, the base unit expr is `USD` with scale 1.0.
2822/// The base unit itself must be static (not dynamic).
2823fn resolve_base_unit_static_scale(
2824    registry: &RegistryBuilder,
2825    unit_expr: &crate::desugar::desugared_ast::UnitExpr,
2826    src: &NamedSource<Arc<String>>,
2827) -> Result<PositiveFiniteScale, GraphcalError> {
2828    let (_dim, base_scale) = registry
2829        .resolve_unit_expr(unit_expr)
2830        .map_err(|err| unit_resolve_to_graphcal(err, src, unit_expr.span))?;
2831    validate_positive_finite_scale(base_scale, "base unit scale", src, unit_expr.span)
2832}
2833
2834/// Convert a typed unit-resolution failure into a spanned diagnostic.
2835fn unit_resolve_to_graphcal(
2836    err: crate::registry::types::UnitResolveError,
2837    src: &NamedSource<Arc<String>>,
2838    span: Span,
2839) -> GraphcalError {
2840    use crate::registry::types::UnitResolveError;
2841    match err {
2842        UnitResolveError::UnknownUnit(name) => GraphcalError::UnknownUnit {
2843            name,
2844            src: src.clone(),
2845            span: span.into(),
2846        },
2847        UnitResolveError::DynamicScale(name) => GraphcalError::EvalError {
2848            message: format!("unit `{name}` has a dynamic scale and cannot be used here"),
2849            src: src.clone(),
2850            span: span.into(),
2851        },
2852        UnitResolveError::Overflow(_) => GraphcalError::DimensionOverflow {
2853            src: src.clone(),
2854            span: span.into(),
2855        },
2856    }
2857}
2858
2859/// Check if an expression contains any `@`-references (graph refs).
2860fn contains_graph_ref(expr: &Expr) -> bool {
2861    crate::ir::resolve::contains_graph_ref(expr)
2862}
2863
2864/// Convert an AST-level `u64` nat literal to the `usize` size the registry
2865/// stores, raising a graceful runtime error if the value doesn't fit in
2866/// `usize` on the current target (e.g., a > 4G literal on a 32-bit build).
2867fn nat_size_to_usize(
2868    n: u64,
2869    span: Span,
2870    src: &NamedSource<Arc<String>>,
2871) -> Result<NonZeroUsize, GraphcalError> {
2872    let size = usize::try_from(n).map_err(|_| GraphcalError::EvalError {
2873        message: format!("nat range size {n} does not fit in usize on this target"),
2874        src: src.clone(),
2875        span: span.into(),
2876    })?;
2877    NonZeroUsize::new(size).ok_or_else(|| {
2878        eval_error(
2879            "range(0) is not allowed; indexes must contain at least one element",
2880            src,
2881            span,
2882        )
2883    })
2884}
2885
2886/// Recursively scan a type expression for nat literals in index position
2887/// and register the corresponding synthetic nat range indexes in the registry.
2888fn collect_nat_ranges_from_type_expr(
2889    type_expr: &crate::desugar::desugared_ast::TypeExpr,
2890    registry: &mut RegistryBuilder,
2891    src: &NamedSource<Arc<String>>,
2892) -> Result<(), GraphcalError> {
2893    if let crate::desugar::desugared_ast::TypeExprKind::Indexed { base, indexes } = &type_expr.kind
2894    {
2895        collect_nat_ranges_from_type_expr(base, registry, src)?;
2896        for idx in indexes {
2897            match idx {
2898                crate::desugar::desugared_ast::IndexExpr::NatExpr(nat_expr) => {
2899                    collect_nat_range_literals_from_nat_expr(nat_expr, registry, src)?;
2900                }
2901                crate::desugar::desugared_ast::IndexExpr::Name(_) => {}
2902            }
2903        }
2904    }
2905    if let crate::desugar::desugared_ast::TypeExprKind::TypeApplication { type_args, .. }
2906    | crate::desugar::desugared_ast::TypeExprKind::DatetimeApplication { type_args } =
2907        &type_expr.kind
2908    {
2909        for arg in type_args {
2910            collect_nat_ranges_from_type_expr(arg, registry, src)?;
2911        }
2912    }
2913    Ok(())
2914}
2915
2916/// Collect nat range literal values from a `NatExpr` tree.
2917///
2918/// Only literal-only expressions can be registered at compile time;
2919/// expressions containing variables are resolved at call sites.
2920fn collect_nat_range_literals_from_nat_expr(
2921    expr: &crate::desugar::desugared_ast::NatExpr,
2922    registry: &mut RegistryBuilder,
2923    src: &NamedSource<Arc<String>>,
2924) -> Result<(), GraphcalError> {
2925    use crate::desugar::desugared_ast::NatExpr;
2926    match expr {
2927        NatExpr::Literal(n, span) => {
2928            let size = nat_size_to_usize(*n, *span, src)?;
2929            registry.ensure_nat_range_index(size);
2930        }
2931        NatExpr::Var(_) => {}
2932        NatExpr::Add(lhs, rhs, _) | NatExpr::Mul(lhs, rhs, _) => {
2933            collect_nat_range_literals_from_nat_expr(lhs, registry, src)?;
2934            collect_nat_range_literals_from_nat_expr(rhs, registry, src)?;
2935        }
2936    }
2937    Ok(())
2938}
2939
2940/// Recursively scan an expression for `for i: range(N)` and register
2941/// nat range indexes for concrete nat literals.
2942fn collect_nat_ranges_from_expr(
2943    expr: &crate::desugar::desugared_ast::Expr,
2944    registry: &mut RegistryBuilder,
2945    src: &NamedSource<Arc<String>>,
2946) -> Result<(), GraphcalError> {
2947    use crate::desugar::desugared_ast::{ExprKind, ForBindingIndex};
2948
2949    // Use the visitor trait to walk all sub-expressions
2950    struct NatRangeCollector<'a> {
2951        registry: &'a mut RegistryBuilder,
2952        src: &'a NamedSource<Arc<String>>,
2953    }
2954
2955    impl crate::syntax::visitor::ExprVisitor<crate::syntax::phase::Desugared>
2956        for NatRangeCollector<'_>
2957    {
2958        type Error = GraphcalError;
2959
2960        fn visit_expr(
2961            &mut self,
2962            expr: &crate::desugar::desugared_ast::Expr,
2963        ) -> Result<(), GraphcalError> {
2964            match &expr.kind {
2965                ExprKind::ForComp { bindings, .. } => {
2966                    for binding in bindings {
2967                        if let ForBindingIndex::Range { arg, .. } = &binding.index {
2968                            collect_nat_range_literals_from_nat_expr(arg, self.registry, self.src)?;
2969                        }
2970                    }
2971                }
2972                ExprKind::MapLiteral { entries } => {
2973                    for entry in entries {
2974                        for key in &entry.keys {
2975                            if let crate::syntax::ast::MapEntryIndex::NatRange(n) = &key.index.value
2976                            {
2977                                let size = nat_size_to_usize(*n, key.index.span, self.src)?;
2978                                self.registry.ensure_nat_range_index(size);
2979                            }
2980                        }
2981                    }
2982                }
2983                _ => {}
2984            }
2985            self.dispatch(expr)
2986        }
2987    }
2988
2989    let mut collector = NatRangeCollector { registry, src };
2990    collector.visit_expr(expr)
2991}
2992
2993fn register_index_decl(
2994    idx: &crate::desugar::desugared_ast::IndexDecl,
2995    registry: &mut RegistryBuilder,
2996    src: &NamedSource<Arc<String>>,
2997    decl_span: Span,
2998) -> Result<(), GraphcalError> {
2999    let kind = match &idx.kind {
3000        crate::desugar::desugared_ast::IndexDeclKind::Named { variants } => {
3001            types::IndexKind::Named {
3002                variants: variants.iter().map(|v| v.value.clone()).collect(),
3003            }
3004        }
3005        crate::desugar::desugared_ast::IndexDeclKind::Range {
3006            start: start_expr,
3007            end: end_expr,
3008            step: step_expr,
3009        } => lower_range_index(
3010            &idx.name.value,
3011            start_expr,
3012            end_expr,
3013            step_expr,
3014            registry,
3015            src,
3016            decl_span,
3017        )?,
3018        crate::desugar::desugared_ast::IndexDeclKind::RequiredNamed => {
3019            types::IndexKind::RequiredNamed
3020        }
3021        crate::desugar::desugared_ast::IndexDeclKind::RequiredRange { dimension } => {
3022            let dim = registry
3023                .resolve_dim_expr(dimension)
3024                .map_err(|_| GraphcalError::DimensionOverflow {
3025                    src: src.clone(),
3026                    span: dimension.span.into(),
3027                })?
3028                .ok_or_else(|| GraphcalError::UnknownDimension {
3029                    name: crate::syntax::names::DimName::new(idx.name.value.as_str()),
3030                    src: src.clone(),
3031                    span: dimension.span.into(),
3032                })?;
3033            types::IndexKind::RequiredRange { dimension: dim }
3034        }
3035    };
3036    registry.register_index(types::IndexDef {
3037        name: idx.name.value.clone(),
3038        kind,
3039    });
3040    Ok(())
3041}
3042
3043fn register_type_decl(t: &crate::desugar::desugared_ast::TypeDecl, registry: &mut RegistryBuilder) {
3044    let generic_params: Vec<types::TypeGenericParam> = t
3045        .generic_params
3046        .iter()
3047        .map(|g| types::TypeGenericParam {
3048            name: g.name.value.clone(),
3049            constraint: g.constraint.into(),
3050            default: g.default.clone(),
3051        })
3052        .collect();
3053
3054    let kind = match &t.body {
3055        crate::desugar::desugared_ast::TypeDeclBody::Required => types::TypeDefKind::Required,
3056        crate::desugar::desugared_ast::TypeDeclBody::Constructors(type_members) => {
3057            // Every constructor carries its payload inline; no per-constructor
3058            // TypeDef is synthesized. The constructor namespace lives on the
3059            // registry and points back to this type.
3060            let members = type_members
3061                .iter()
3062                .map(|m| {
3063                    let fields = m.payload.as_ref().map_or_else(Vec::new, |fs| {
3064                        fs.iter()
3065                            .map(|f| types::StructField {
3066                                name: f.name.value.clone(),
3067                                type_ann: f.type_ann.clone(),
3068                            })
3069                            .collect()
3070                    });
3071                    types::UnionMemberDef {
3072                        name: ConstructorName::new(m.name.value.as_str()),
3073                        fields,
3074                    }
3075                })
3076                .collect();
3077            types::TypeDefKind::Union { members }
3078        }
3079    };
3080
3081    registry.register_type(types::TypeDef {
3082        name: t.name.value.clone(),
3083        generic_params,
3084        kind,
3085    });
3086}
3087
3088/// Evaluate a constant scale expression (e.g. `1000`, `PI / 180`) to `f64`.
3089///
3090/// Scale expressions appear in unit definitions and are restricted to numeric
3091/// literals, built-in constants (`PI`, `E`), and basic arithmetic.
3092fn eval_scale_expr(expr: &Expr, src: &NamedSource<Arc<String>>) -> Result<f64, GraphcalError> {
3093    match &expr.kind {
3094        ExprKind::Number(n) => Ok(*n),
3095        #[expect(clippy::cast_precision_loss, reason = "unit scale constant expression")]
3096        ExprKind::Integer(n) => Ok(*n as f64),
3097        ExprKind::UnresolvedRef(crate::syntax::ast::UnresolvedRef::Path(path)) => {
3098            // Route through the typed builtin-constant table instead of
3099            // string-matching a hand-picked subset: all built-in constants
3100            // (PI, E, TAU, SQRT2, LN2, LN10) are legal in scale expressions.
3101            let builtin = path
3102                .as_bare()
3103                .and_then(|ident| crate::hir::BuiltinConst::parse(ident.name.as_str()));
3104            builtin
3105                .map(crate::hir::BuiltinConst::value)
3106                .ok_or_else(|| GraphcalError::EvalError {
3107                    message: format!(
3108                        "unknown constant `{}` in scale expression; only built-in \
3109                         constants (PI, E, TAU, SQRT2, LN2, LN10) are supported",
3110                        path.display_path()
3111                    ),
3112                    src: src.clone(),
3113                    span: path.span().into(),
3114                })
3115        }
3116        ExprKind::BinOp { op, lhs, rhs } => {
3117            use crate::desugar::desugared_ast::BinOp;
3118            let l = eval_scale_expr(lhs, src)?;
3119            let r = eval_scale_expr(rhs, src)?;
3120            match op {
3121                BinOp::Add => Ok(l + r),
3122                BinOp::Sub => Ok(l - r),
3123                BinOp::Mul => Ok(l * r),
3124                BinOp::Div => Ok(l / r),
3125                BinOp::Pow => Ok(l.powf(r)),
3126                _ => Err(GraphcalError::EvalError {
3127                    message: format!(
3128                        "unsupported operator `{op:?}` in scale expression; \
3129                         only `+`, `-`, `*`, `/`, `^` are allowed"
3130                    ),
3131                    src: src.clone(),
3132                    span: expr.span.into(),
3133                }),
3134            }
3135        }
3136        ExprKind::UnaryOp {
3137            op: crate::desugar::desugared_ast::UnaryOp::Neg,
3138            operand,
3139        } => Ok(-eval_scale_expr(operand, src)?),
3140        _ => Err(GraphcalError::EvalError {
3141            message: "scale expression must be a constant expression \
3142                      (numbers, PI, E, and arithmetic)"
3143                .to_string(),
3144            src: src.clone(),
3145            span: expr.span.into(),
3146        }),
3147    }
3148}
3149
3150/// Evaluate a range expression (e.g. `0.0 s`) to get its SI value and dimension.
3151///
3152/// Range expressions are syntactically restricted to numeric literals and
3153/// unit-annotated literals, so we evaluate them directly against the
3154/// `RegistryBuilder` instead of going through the full `eval_expr` pipeline.
3155///
3156/// Returns `(si_value, dimension)`.
3157fn eval_range_expr(
3158    expr: &Expr,
3159    registry: &RegistryBuilder,
3160    src: &NamedSource<Arc<String>>,
3161) -> Result<(f64, crate::syntax::dimension::Dimension), GraphcalError> {
3162    use crate::syntax::dimension::Dimension;
3163
3164    let ensure_finite = |value: f64, span: Span| {
3165        if value.is_finite() {
3166            Ok(value)
3167        } else {
3168            Err(eval_error(
3169                format!("range expression must be finite, got {value}"),
3170                src,
3171                span,
3172            ))
3173        }
3174    };
3175
3176    match &expr.kind {
3177        ExprKind::Number(n) => Ok((ensure_finite(*n, expr.span)?, Dimension::dimensionless())),
3178        ExprKind::UnitLiteral { value, unit } => {
3179            let (dim, scale) = registry
3180                .resolve_unit_expr(unit)
3181                .map_err(|err| unit_resolve_to_graphcal(err, src, unit.span))?;
3182            let scale = validate_positive_finite_scale(scale, "range unit scale", src, unit.span)?;
3183            Ok((ensure_finite(*value * scale.get(), expr.span)?, dim))
3184        }
3185        ExprKind::UnaryOp {
3186            op: crate::desugar::desugared_ast::UnaryOp::Neg,
3187            operand,
3188        } => {
3189            let (val, dim) = eval_range_expr(operand, registry, src)?;
3190            Ok((ensure_finite(-val, expr.span)?, dim))
3191        }
3192        _ => Err(GraphcalError::EvalError {
3193            message: "range expression must be a numeric or unit literal".to_string(),
3194            src: src.clone(),
3195            span: expr.span.into(),
3196        }),
3197    }
3198}
3199
3200fn checked_range_step_count(
3201    name: &IndexName,
3202    start: f64,
3203    end: f64,
3204    step: f64,
3205    src: &NamedSource<Arc<String>>,
3206    span: Span,
3207) -> Result<NonZeroUsize, GraphcalError> {
3208    let raw_steps = (end - start) / step;
3209    if !raw_steps.is_finite() {
3210        return Err(GraphcalError::RangeIndexInvalid {
3211            name: name.clone(),
3212            message: "range cardinality is not finite".to_string(),
3213            src: src.clone(),
3214            span: span.into(),
3215        });
3216    }
3217
3218    let nearest = raw_steps.round();
3219    let tolerance = f64::EPSILON.mul_add(raw_steps.abs().max(1.0) * 16.0, 1e-12);
3220    let whole_steps = if (raw_steps - nearest).abs() <= tolerance {
3221        nearest
3222    } else {
3223        raw_steps.floor()
3224    };
3225    if whole_steps < 0.0 {
3226        return Err(GraphcalError::RangeIndexInvalid {
3227            name: name.clone(),
3228            message: "range cardinality is negative".to_string(),
3229            src: src.clone(),
3230            span: span.into(),
3231        });
3232    }
3233
3234    let count = whole_steps + 1.0;
3235    #[expect(
3236        clippy::cast_precision_loss,
3237        reason = "usize upper bound check for f64 range count"
3238    )]
3239    let max_count = usize::MAX as f64;
3240    if count >= max_count {
3241        return Err(GraphcalError::RangeIndexInvalid {
3242            name: name.clone(),
3243            message: format!("range has too many steps ({count})"),
3244            src: src.clone(),
3245            span: span.into(),
3246        });
3247    }
3248
3249    #[expect(
3250        clippy::cast_possible_truncation,
3251        clippy::cast_sign_loss,
3252        reason = "range count is finite, non-negative, and bounded by usize::MAX"
3253    )]
3254    let count = count as usize;
3255    NonZeroUsize::new(count).ok_or_else(|| GraphcalError::RangeIndexInvalid {
3256        name: name.clone(),
3257        message: "range must contain at least one step".to_string(),
3258        src: src.clone(),
3259        span: span.into(),
3260    })
3261}
3262
3263/// Lower a range index declaration, evaluating start/end/step and validating dimensions.
3264fn lower_range_index(
3265    name: &crate::syntax::names::IndexName,
3266    start_expr: &Expr,
3267    end_expr: &Expr,
3268    step_expr: &Expr,
3269    registry: &RegistryBuilder,
3270    src: &NamedSource<Arc<String>>,
3271    decl_span: crate::syntax::span::Span,
3272) -> Result<types::IndexKind, GraphcalError> {
3273    let (start_val, start_dim) = eval_range_expr(start_expr, registry, src)?;
3274    let (end_val, end_dim) = eval_range_expr(end_expr, registry, src)?;
3275    let (step_val, step_dim) = eval_range_expr(step_expr, registry, src)?;
3276
3277    // All three must have the same dimension
3278    if start_dim != end_dim || start_dim != step_dim {
3279        return Err(GraphcalError::RangeIndexDimensionMismatch {
3280            name: name.clone(),
3281            start_dim: format!("Dimension({})", registry.format_dimension(&start_dim)),
3282            end_dim: format!("Dimension({})", registry.format_dimension(&end_dim)),
3283            step_dim: format!("Dimension({})", registry.format_dimension(&step_dim)),
3284            src: src.clone(),
3285            span: decl_span.into(),
3286        });
3287    }
3288
3289    for (label, value) in [("start", start_val), ("end", end_val), ("step", step_val)] {
3290        if !value.is_finite() {
3291            return Err(GraphcalError::RangeIndexInvalid {
3292                name: name.clone(),
3293                message: format!("{label} ({value}) must be finite"),
3294                src: src.clone(),
3295                span: decl_span.into(),
3296            });
3297        }
3298    }
3299
3300    // Validate: start <= end
3301    if start_val > end_val {
3302        return Err(GraphcalError::RangeIndexInvalid {
3303            name: name.clone(),
3304            message: format!("start ({start_val}) must be <= end ({end_val})"),
3305            src: src.clone(),
3306            span: decl_span.into(),
3307        });
3308    }
3309
3310    // Validate: step > 0
3311    if step_val <= 0.0 {
3312        return Err(GraphcalError::RangeIndexInvalid {
3313            name: name.clone(),
3314            message: format!("step ({step_val}) must be > 0"),
3315            src: src.clone(),
3316            span: decl_span.into(),
3317        });
3318    }
3319
3320    let step_count = checked_range_step_count(name, start_val, end_val, step_val, src, decl_span)?;
3321
3322    // Extract display unit from the start expression's unit annotation.
3323    let (display_label, display_scale) = match &start_expr.kind {
3324        ExprKind::UnitLiteral { unit, .. } => {
3325            // Unknown/dynamic units have no static display scale; the
3326            // expression itself is validated elsewhere.
3327            match registry.resolve_unit_expr(unit) {
3328                Ok((_dim, scale)) => {
3329                    let scale = validate_positive_finite_scale(
3330                        scale,
3331                        "range display unit scale",
3332                        src,
3333                        unit.span,
3334                    )?;
3335                    (Some(format_unit_expr(unit)), scale.get())
3336                }
3337                Err(crate::registry::types::UnitResolveError::Overflow(_)) => {
3338                    return Err(GraphcalError::DimensionOverflow {
3339                        src: src.clone(),
3340                        span: unit.span.into(),
3341                    });
3342                }
3343                Err(_) => (None, 1.0),
3344            }
3345        }
3346        _ => (None, 1.0),
3347    };
3348
3349    Ok(types::IndexKind::Range(types::RangeIndexData {
3350        start: start_val,
3351        end: end_val,
3352        step: step_val,
3353        step_count,
3354        dimension: start_dim,
3355        display_label,
3356        display_scale,
3357    }))
3358}
3359
3360/// Extract a map of type annotations from const/param/node declarations,
3361/// keyed by their typed declaration names.
3362fn extract_type_annotations(ast: &File) -> HashMap<DeclName, TypeExpr> {
3363    let mut type_anns = HashMap::new();
3364    for decl in &ast.declarations {
3365        match &decl.kind {
3366            DeclKind::Param(p) => {
3367                type_anns.insert(p.name.value.clone(), p.type_ann.clone());
3368            }
3369            DeclKind::Node(n) => {
3370                type_anns.insert(n.name.value.clone(), n.type_ann.clone());
3371            }
3372            DeclKind::ConstNode(c) => {
3373                type_anns.insert(c.name.value.clone(), c.type_ann.clone());
3374            }
3375            _ => {}
3376        }
3377    }
3378    type_anns
3379}
3380
3381#[cfg(test)]
3382mod tests {
3383    use super::*;
3384    use crate::syntax::parser::Parser;
3385
3386    fn make_src(source: &str) -> NamedSource<Arc<String>> {
3387        NamedSource::new("test.gcl", Arc::new(source.to_string()))
3388    }
3389
3390    fn parse_and_lower(source: &str) -> Result<IR, GraphcalError> {
3391        let raw_file = Parser::new(source).parse_file().unwrap();
3392        let desugared = crate::syntax::desugar::desugar_multi_decls_in_file(raw_file);
3393        let file = desugared;
3394        lower(&file, &make_src(source))
3395    }
3396
3397    #[test]
3398    fn lower_rocket() {
3399        let source = include_str!("../../../../tests/fixtures/valid/rocket.gcl");
3400        let ir = parse_and_lower(source).unwrap();
3401        assert_eq!(ir.consts.len(), 1); // G0
3402        assert_eq!(ir.params.len(), 3); // dry_mass, fuel_mass, isp
3403        assert_eq!(ir.nodes.len(), 3); // v_exhaust, mass_ratio, delta_v
3404        assert!(ir.registry.dimensions.get_dimension("Length").is_some());
3405        assert!(
3406            ir.registry
3407                .units
3408                .get_unit(&crate::syntax::names::UnitRef::local("km"))
3409                .is_some()
3410        );
3411    }
3412
3413    #[test]
3414    fn lower_constants() {
3415        let source = include_str!("../../../../tests/fixtures/valid/constants.gcl");
3416        let ir = parse_and_lower(source).unwrap();
3417        assert_eq!(ir.consts.len(), 4);
3418        assert_eq!(ir.params.len(), 1);
3419        assert_eq!(ir.nodes.len(), 2);
3420    }
3421
3422    #[test]
3423    fn lower_indexed() {
3424        let source = include_str!("../../../../tests/fixtures/valid/indexed.gcl");
3425        let ir = parse_and_lower(source).unwrap();
3426        assert!(ir.registry.indexes.get_index("Maneuver").is_some());
3427    }
3428
3429    #[test]
3430    fn lower_hohmann() {
3431        // hohmann.gcl uses DAG+include. The full project pipeline accepts
3432        // it (see the CLI tests), but single-file IR lowering rejects it at
3433        // the freeze boundary: include expansion is a higher-phase concern,
3434        // so `@transfer` (the include's projected node) cannot resolve.
3435        let source = include_str!("../../../../tests/fixtures/valid/hohmann.gcl");
3436        let err = parse_and_lower(source).unwrap_err();
3437        assert!(matches!(err, GraphcalError::UnknownGraphRef { .. }));
3438    }
3439
3440    #[test]
3441    fn lower_duplicate_name_error() {
3442        let err = parse_and_lower("param x: Dimensionless = 1.0;\nnode x: Dimensionless = 2.0;")
3443            .unwrap_err();
3444        assert!(matches!(err, GraphcalError::DuplicateName { .. }));
3445    }
3446
3447    #[test]
3448    fn lower_source_order_preserved() {
3449        let ir = parse_and_lower(
3450            "param b: Dimensionless = 2.0;\nparam a: Dimensionless = 1.0;\nnode z: Dimensionless = @a + @b;",
3451        )
3452        .unwrap();
3453        let names: Vec<String> = ir.source_order.iter().map(|(n, _)| n.to_string()).collect();
3454        assert_eq!(names, vec!["b", "a", "z"]);
3455    }
3456
3457    #[test]
3458    fn merge_dependency_keeps_qualified_imported_value_keys() {
3459        // Regression: `prefix_dep` used to re-key a dep's *qualified*
3460        // imported value (e.g. `mission.C` from `import lib as mission;`)
3461        // with the include-instance prefix, dropping the qualifier — while
3462        // the merged expressions kept referencing `@mission.C` (RefPrefixer
3463        // skips qualified refs), so the value map and the expressions
3464        // diverged.
3465        let dep_source = "node out: Dimensionless = 2.0;";
3466        let dep_src = make_src(dep_source);
3467        let raw_file = Parser::new(dep_source).parse_file().unwrap();
3468        let dep_file = crate::syntax::desugar::desugar_multi_decls_in_file(raw_file);
3469        let (_dep_builder, mut dep_unfrozen) = lower_to_builder(
3470            &dep_file,
3471            &dep_src,
3472            &ImportedNames {
3473                consts: vec![],
3474                params: vec![],
3475                nodes: vec![],
3476                asserts: vec![],
3477            },
3478            &crate::dag_id::DagId::root("dep"),
3479        )
3480        .unwrap();
3481        // Simulate the loader having pre-evaluated `import lib as mission;`
3482        // inside the dep: the imported value is keyed by a qualified name.
3483        let qualified = ScopedName::qualified("mission", "C");
3484        dep_unfrozen.imported_values.insert(
3485            qualified.clone(),
3486            (
3487                RuntimeValue::Scalar(7.0),
3488                DeclaredType::Scalar(crate::syntax::dimension::Dimension::dimensionless()),
3489            ),
3490        );
3491
3492        let importer_source = "node anchor: Dimensionless = 1.0;";
3493        let importer_src = make_src(importer_source);
3494        let raw_importer = Parser::new(importer_source).parse_file().unwrap();
3495        let importer_file = crate::syntax::desugar::desugar_multi_decls_in_file(raw_importer);
3496        let (_importer_builder, mut unfrozen) = lower_to_builder(
3497            &importer_file,
3498            &importer_src,
3499            &ImportedNames {
3500                consts: vec![],
3501                params: vec![],
3502                nodes: vec![],
3503                asserts: vec![],
3504            },
3505            &crate::dag_id::DagId::root("main"),
3506        )
3507        .unwrap();
3508
3509        let dep_names: HashSet<DeclName> = dep_unfrozen
3510            .source_order
3511            .iter()
3512            .map(|(n, _)| DeclName::new(n.member()))
3513            .collect();
3514        unfrozen
3515            .merge_dependency(
3516                dep_unfrozen,
3517                "inst",
3518                &HashMap::new(),
3519                &dep_names,
3520                &HashMap::new(),
3521                &HashMap::new(),
3522                &HashMap::new(),
3523                &HashMap::new(),
3524                &HashMap::new(),
3525                &importer_src,
3526                &dep_src,
3527            )
3528            .unwrap();
3529
3530        assert!(
3531            unfrozen.imported_values.contains_key(&qualified),
3532            "qualified imported value must keep its qualifier"
3533        );
3534        assert!(
3535            !unfrozen
3536                .imported_values
3537                .contains_key(&ScopedName::qualified("inst", "C")),
3538            "imported value must not be re-keyed with the instance prefix"
3539        );
3540    }
3541}