Skip to main content

polydat_core/kernel/subcontext/
error.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Diagnostic types: [`SourceContext`] and [`ContractViolation`].
5
6use crate::ast::PortType;
7
8use super::name::ChildName;
9
10/// Diagnostic context attached to a [`super::ScopeModule`] —
11/// where the module's source came from. Used in error messages
12/// when a contract violation surfaces at spawn or finalize.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct SourceContext {
15    /// Logical label — workload phase / op-template name / SRD
16    /// reference. Free-form; appears verbatim in diagnostics.
17    pub label: String,
18    /// Source file path, if applicable.
19    pub file: Option<String>,
20    /// Line range `(start, end)` if known.
21    pub line_range: Option<(usize, usize)>,
22}
23
24impl SourceContext {
25    /// A context with a label and no file or lines.
26    pub fn new(label: impl Into<String>) -> Self {
27        Self {
28            label: label.into(),
29            file: None,
30            line_range: None,
31        }
32    }
33
34    /// The context of a phase, labelled `phase:<name>`.
35    pub fn for_phase(name: &str) -> Self {
36        Self::new(format!("phase:{name}"))
37    }
38
39    /// The context of an op, labelled `op:<name>`.
40    pub fn for_op(name: &str) -> Self {
41        Self::new(format!("op:{name}"))
42    }
43
44    /// The same context with its source file.
45    pub fn with_file(mut self, file: impl Into<String>) -> Self {
46        self.file = Some(file.into());
47        self
48    }
49
50    /// The same context with its line range.
51    pub fn with_lines(mut self, start: usize, end: usize) -> Self {
52        self.line_range = Some((start, end));
53        self
54    }
55
56    /// Render as a single line for error messages.
57    pub fn display(&self) -> String {
58        let mut s = self.label.clone();
59        if let Some(f) = &self.file {
60            s.push_str(&format!(" ({f}"));
61            if let Some((a, b)) = self.line_range {
62                s.push_str(&format!(":{a}-{b}"));
63            }
64            s.push(')');
65        } else if let Some((a, b)) = self.line_range {
66            s.push_str(&format!(" ({a}-{b})"));
67        }
68        s
69    }
70}
71
72/// Contract violation surfaced at finalize or spawn.
73///
74/// Variants per SRD-67 §"Cross-binding rules" plus the umbrella
75/// [`Self::Compile`] for errors raised by the Polydat compiler when
76/// the body fragment is converted into a program (typically an
77/// unbound identifier in the body, which the compiler catches
78/// after `finalize`'s name-closure check on declared imports).
79///
80/// The active set is the design doc's §7 error contract:
81/// [`Self::UnboundImport`], [`Self::FinalShadow`],
82/// [`Self::DuplicateChild`], [`Self::Compile`], and
83/// [`Self::StrictNonePropagation`]. [`Self::Type`],
84/// [`Self::Modifier`], and [`Self::Phase2WriteThrough`] are
85/// retained as compatibility surface and are not emitted by the
86/// builder (design doc §2.2).
87#[derive(Debug, Clone)]
88pub enum ContractViolation {
89    /// Rule 1 — Import resolution: an artifact import has no
90    /// matching parent export.
91    UnboundImport {
92        /// The import's name.
93        import: String,
94        /// Where the import is declared.
95        site: SourceContext,
96    },
97    /// Rule 1 — Type mismatch on import.
98    Type {
99        /// The import's name.
100        import: String,
101        /// The type the import requires.
102        required: PortType,
103        /// The type the parent exports.
104        parent_export: PortType,
105        /// Where the import is declared.
106        site: SourceContext,
107    },
108    /// Rule 1 — Modifier mismatch (e.g. shared import against a
109    /// non-shared parent export).
110    Modifier {
111        /// The import's name.
112        import: String,
113        /// What differs.
114        detail: String,
115        /// Where the import is declared.
116        site: SourceContext,
117    },
118    /// Rule 2 — Final-shadow on export: a child can't redefine
119    /// an immutable parent export.
120    FinalShadow {
121        /// The export shadowed.
122        export: String,
123        /// Where the child redefines it.
124        site: SourceContext,
125    },
126    /// Rule 2 — Shared write-through rewrite was required but
127    /// could not be performed.
128    ///
129    /// Never emitted. The rewrite is implemented in
130    /// [`super::SubcontextBuilder::finalize`] (design doc §3.1),
131    /// which reports a rewrite that fails to produce its input
132    /// slot or synthetic output as [`Self::Compile`]; success is
133    /// visible as [`super::ScopeModule::write_throughs`]. The
134    /// variant is kept as compatibility surface for callers that
135    /// pattern-match on it (design doc §2.2).
136    Phase2WriteThrough {
137        /// The export the rewrite targeted.
138        export: String,
139        /// Where the export is declared.
140        site: SourceContext,
141        /// What the rewrite could not do.
142        note: &'static str,
143    },
144    /// Named-child registry: a duplicate spawn under the same
145    /// name (SRD-67 §"Named-child registry"). Reports both spawn
146    /// sites.
147    DuplicateChild {
148        /// The child's name.
149        name: ChildName,
150        /// Boxed: this is the only variant carrying two
151        /// `SourceContext`s — boxing one keeps the whole enum (and
152        /// every `Result<_, ContractViolation>`) small.
153        prior_site: Box<SourceContext>,
154        /// The second spawn site.
155        this_site: SourceContext,
156    },
157    /// Polydat compile-time error — the body failed to compile (most
158    /// commonly: unbound identifier; corresponds to Rule 1's
159    /// closure-binding economy detecting a free identifier with
160    /// no matching import).
161    Compile(String),
162    /// L2.f strict-mode hardening: an intermediate-layer
163    /// `const` binding's Plan B materialisation yielded
164    /// `Value::None`, and the build was running with strict
165    /// mode enabled. Per composition_substrate.md L2.f's
166    /// strict-mode hardening clause, silent fall-through to
167    /// the outer scope's binding is rejected in strict mode —
168    /// the author must either ensure the const yields a
169    /// defined value or remove the binding and declare an
170    /// explicit `extern <name>` if fall-through to outer was
171    /// intended. The `bindings` field carries every const
172    /// output that materialised to None.
173    StrictNonePropagation {
174        /// Every const output that materialised to `None`.
175        bindings: Vec<String>,
176        /// Where the bindings are declared.
177        site: SourceContext,
178    },
179}
180
181impl std::fmt::Display for ContractViolation {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        match self {
184            Self::UnboundImport { import, site } => write!(
185                f,
186                "unbound import `{import}` (parent does not export it) at {}",
187                site.display()
188            ),
189            Self::Type {
190                import,
191                required,
192                parent_export,
193                site,
194            } => write!(
195                f,
196                "type mismatch on import `{import}`: required {required:?}, parent exports {parent_export:?} at {}",
197                site.display()
198            ),
199            Self::Modifier {
200                import,
201                detail,
202                site,
203            } => write!(
204                f,
205                "modifier mismatch on import `{import}`: {detail} at {}",
206                site.display()
207            ),
208            Self::FinalShadow { export, site } => write!(
209                f,
210                "child export `{export}` shadows parent's `final` export at {}",
211                site.display()
212            ),
213            Self::Phase2WriteThrough { export, site, note } => write!(
214                f,
215                "write-through rewrite for shared export `{export}` could not be performed at {} — {note}",
216                site.display()
217            ),
218            Self::DuplicateChild {
219                name,
220                prior_site,
221                this_site,
222            } => write!(
223                f,
224                "duplicate spawn of child `{name}`: prior at {}, this at {}",
225                prior_site.display(),
226                this_site.display()
227            ),
228            Self::Compile(msg) => write!(f, "compile error: {msg}"),
229            Self::StrictNonePropagation { bindings, site } => {
230                let names = bindings.join(", ");
231                write!(
232                    f,
233                    "L2.f strict-mode violation: intermediate-layer const \
234                    binding(s) [{names}] yielded `Value::None` at scope-init \
235                    at {}; strict mode rejects silent fall-through to the \
236                    outer scope. Either ensure the binding yields a defined \
237                    value, or remove the binding and declare \
238                    `extern <name>` explicitly if fall-through to outer was \
239                    intended.",
240                    site.display()
241                )
242            }
243        }
244    }
245}
246
247impl std::error::Error for ContractViolation {}