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 {}