Skip to main content

adam_lang/
module.rs

1//! Compiles supplied modules into immutable factories without filesystem access or execution.
2//!
3//! ```
4//! use adam_lang::{ModuleCompiler, ModulePath, ModuleSet, TypeRegistry};
5//! let path: ModulePath = "constraints".parse()?;
6//! let mut modules = ModuleSet::new();
7//! modules.insert_source(path.clone(), "constraints.adm2", "sheet empty() {}")?;
8//! modules.check_resolution().map_err(|errors| errors.into_iter().next().unwrap())?;
9//! let compiled = ModuleCompiler::new(TypeRegistry::new(), cel_parser::OpLookup::new())
10//!     .compile(modules).map_err(|mut errors| errors.remove(0))?;
11//! assert!(compiled.template(&path, "empty").is_some());
12//! # Ok::<(), adam_lang::ModuleDiagnostic>(())
13//! ```
14
15mod analysis;
16pub(crate) mod compile;
17mod resolve;
18
19pub use analysis::check_module;
20pub use compile::{CompiledModules, ModuleCompiler, PreparedRoot, RootCell};
21
22use std::{collections::HashMap, fmt, str::FromStr};
23
24use cel_parser::SourceSpan;
25
26use crate::{
27    AdamAstParser, ModuleDiagnostic, SourceId, SourceLabel, SourceLocation, SourceMap,
28    TypedTemplate, ast,
29};
30
31/// Identifies a supplied module by nonempty, `::`-separated identifiers.
32///
33/// Whitespace, literal tokens, raw identifiers, `_` alone, and `sheet`, `use`, `as`
34/// are not path segments.
35/// See the [module example](self).
36#[derive(Clone, Debug, Eq, Hash, PartialEq)]
37pub struct ModulePath(Vec<String>);
38
39impl FromStr for ModulePath {
40    /// Reports an invalid externally supplied module path.
41    type Err = ModuleDiagnostic;
42
43    /// Validates and retains every identifier segment.
44    ///
45    /// # Errors
46    /// Rejects empty paths or invalid segments.
47    /// - Complexity: O(n) path bytes.
48    fn from_str(path: &str) -> Result<Self, Self::Err> {
49        let segments: Vec<_> = path.split("::").collect();
50        if !segments.iter().all(|segment| valid_identifier(segment)) {
51            return Err(crate::composition::diagnostic("invalid module path"));
52        }
53        Ok(Self(segments.into_iter().map(str::to_owned).collect()))
54    }
55}
56
57#[cfg(test)]
58mod identifier_tests {
59    use super::*;
60
61    /// Module paths require lexical identifiers, not Boolean literal tokens.
62    #[test]
63    fn module_paths_reject_literal_tokens() {
64        for path in ["true", "false", "a::true", "false::b"] {
65            assert!(path.parse::<ModulePath>().is_err(), "{path}");
66        }
67        for path in ["if", "else", "Δ::constraints"] {
68            assert_eq!(path.parse::<ModulePath>().unwrap().to_string(), path);
69        }
70    }
71}
72
73impl fmt::Display for ModulePath {
74    /// Writes the validated path with `::` separators.
75    ///
76    /// - Complexity: O(n) path bytes.
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        for (index, segment) in self.0.iter().enumerate() {
79            if index != 0 {
80                formatter.write_str("::")?;
81            }
82            formatter.write_str(segment)?;
83        }
84        Ok(())
85    }
86}
87
88/// Checks one complete nonreserved module identifier without surrounding whitespace.
89///
90/// - Complexity: O(n) identifier bytes.
91pub(crate) fn valid_identifier(name: &str) -> bool {
92    if matches!(name, "" | "_" | "sheet" | "use" | "as") || name.starts_with("r#") {
93        return false;
94    }
95    let Ok(stream) = name.parse::<proc_macro2::TokenStream>() else {
96        return false;
97    };
98    let mut tokens = cel_parser::lex_lexer::LexLexer::new(stream.into_iter());
99    matches!(tokens.next(), Some(cel_parser::lex_lexer::Token::Identifier(ident)) if ident == name)
100        && tokens.next().is_none()
101}
102
103/// Owns parsed and native declarations under explicit module paths.
104///
105/// A module has at most one source file and may also contain native definitions.
106/// Insertion parses syntax only. [`Self::check_resolution`] checks names and cycles,
107/// not types, actual cells, executable bodies or graph solvability.
108/// [`check_module`] adds borrowed, nonexecuting signature and body analysis.
109/// See the [module example](self).
110#[derive(Default)]
111pub struct ModuleSet {
112    entries: Vec<ModuleEntry>,
113    indices: HashMap<ModulePath, usize>,
114    sources: SourceMap,
115}
116
117/// Owns a module's declared exports and explicit imports in authored order.
118struct ModuleEntry {
119    path: ModulePath,
120    source: Option<SourceId>,
121    imports: Vec<ast::UseDecl>,
122    definitions: Vec<Definition>,
123}
124
125/// Owns one source or native definition without expanding inclusion edges.
126struct Definition {
127    name: String,
128    body: DefinitionBody,
129}
130
131/// Distinguishes retained authored bodies from already checked native recipes.
132enum DefinitionBody {
133    Parsed {
134        sheet: Box<ast::Sheet>,
135        label: SourceLabel,
136    },
137    Native(TypedTemplate),
138}
139
140impl Definition {
141    /// Borrows the parsed declaration's or programmatic template's genuine label.
142    fn label(&self) -> &SourceLabel {
143        match &self.body {
144            DefinitionBody::Parsed { label, .. } => label,
145            DefinitionBody::Native(template) => template.definition_label(),
146        }
147    }
148}
149
150impl ModuleSet {
151    /// Creates an empty supplied module collection.
152    ///
153    /// # Examples
154    /// See the [module example](self).
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Parses one source file and adds its declarations without executing expressions.
160    ///
161    /// A rejected insertion leaves existing declarations unchanged.
162    /// # Errors
163    /// Rejects malformed syntax or a second source file for the same module.
164    /// Diagnostics own their source even after this collection is dropped.
165    /// The collection does not retain rejected malformed source revisions.
166    /// - Complexity: O(n) source and AST size, excluding embedded CEL parsing.
167    ///
168    /// # Examples
169    /// See the [module example](self).
170    pub fn insert_source(
171        &mut self,
172        path: ModulePath,
173        file_name: &str,
174        source: &str,
175    ) -> Result<(), ModuleDiagnostic> {
176        if let Some(index) = self.indices.get(&path)
177            && self.entries[*index].source.is_some()
178        {
179            return Err(crate::composition::diagnostic(
180                "module already has a source file",
181            ));
182        }
183        let parsed = AdamAstParser::new()
184            .parse_module_str(source)
185            .map_err(|error| rejected_source_error(file_name, source, &error))?;
186        if let Some(error) = parsed.errors.first() {
187            return Err(rejected_source_error(file_name, source, error));
188        }
189        let owner = self.sources.insert(file_name, source);
190        let entry = self.entry(path);
191        entry.source = Some(owner.clone());
192        for item in parsed.items {
193            match item {
194                ast::ModuleItem::Use(import) => entry.imports.push(import),
195                ast::ModuleItem::Sheet(sheet) => entry.definitions.push(Definition {
196                    name: sheet.name.clone(),
197                    body: DefinitionBody::Parsed {
198                        label: label(&owner, sheet.name_span, &sheet.name),
199                        sheet: Box::new(sheet),
200                    },
201                }),
202                ast::ModuleItem::Error(_) => unreachable!("recovered errors rejected above"),
203            }
204        }
205        Ok(())
206    }
207
208    /// Adds an immutable source-free definition to a supplied module.
209    ///
210    /// Native and parsed definitions share the module's export namespace.
211    /// # Errors
212    /// Rejects invalid identifiers or an already declared local definition.
213    /// Duplicate errors retain the incoming and earlier declaration labels.
214    /// - Complexity: O(d + b) existing local definitions and compared/copied name/label bytes.
215    ///
216    /// # Examples
217    /// ```
218    /// let mut modules = adam_lang::ModuleSet::new();
219    /// let native = adam_lang::CompositionBuilder::new(adam_lang::TypeRegistry::new()).finish()?;
220    /// modules.insert_template("native".parse()?, "empty", native)?;
221    /// # Ok::<(), adam_lang::ModuleDiagnostic>(())
222    /// ```
223    pub fn insert_template(
224        &mut self,
225        path: ModulePath,
226        name: &str,
227        template: TypedTemplate,
228    ) -> Result<(), ModuleDiagnostic> {
229        if !valid_identifier(name) {
230            return Err(crate::composition::diagnostic("invalid definition name"));
231        }
232        let entry = self.entry(path);
233        if let Some(existing) = entry
234            .definitions
235            .iter()
236            .find(|definition| definition.name == name)
237        {
238            return Err(ModuleDiagnostic {
239                cause: None,
240                message: "duplicate local definition".into(),
241                labels: vec![
242                    template.definition_label().clone(),
243                    existing.label().clone(),
244                ],
245            });
246        }
247        entry.definitions.push(Definition {
248            name: name.into(),
249            body: DefinitionBody::Native(template),
250        });
251        Ok(())
252    }
253
254    /// Checks definition names, explicit imports and actual component-use cycles.
255    ///
256    /// Resolves imported bodies in their defining module. Aliases do not re-export.
257    /// Checks every authored use, including unused definitions and conditional
258    /// branches. Import-only cycles are valid. No expression executes.
259    /// This phase does not check types, actual-cell bindings, executable bodies,
260    /// branch eligibility or whole-graph validity.
261    ///
262    /// # Errors
263    /// Reports duplicate names, missing modules/exports, uses of roots and actual-use
264    /// cycles. Returned labels retain sources independently of this collection.
265    /// - Complexity: O(n + p) time for file/body/branch declarations n and
266    ///   qualified-name bytes p; O(d + i + u + p) additional space for definitions,
267    ///   imports, uses and names.
268    ///
269    /// # Examples
270    /// See the [module example](self).
271    pub fn check_resolution(&self) -> Result<(), Vec<ModuleDiagnostic>> {
272        resolve::resolve(self).map(|_| ())
273    }
274
275    /// Returns or creates the unique module entry for a validated path.
276    ///
277    /// - Complexity: Amortized O(p) path bytes.
278    fn entry(&mut self, path: ModulePath) -> &mut ModuleEntry {
279        let index = *self.indices.entry(path.clone()).or_insert_with(|| {
280            let index = self.entries.len();
281            self.entries.push(ModuleEntry {
282                path,
283                source: None,
284                imports: Vec::new(),
285                definitions: Vec::new(),
286            });
287            index
288        });
289        &mut self.entries[index]
290    }
291}
292
293/// Labels an authored span while retaining its source owner.
294///
295/// - Complexity: O(n) label bytes.
296fn label(source: &SourceId, span: ast::ExprSpan, text: &str) -> SourceLabel {
297    SourceLabel {
298        location: Some(SourceLocation {
299            source: source.clone(),
300            span: SourceSpan::from_proc_macro2_range(span.start, span.end),
301        }),
302        label: text.into(),
303    }
304}
305
306/// Produces an owning diagnostic for a parser or CEL compiler span.
307///
308/// - Complexity: O(n) message bytes.
309fn source_error(
310    source: &SourceId,
311    span: SourceSpan,
312    message: impl fmt::Display,
313) -> ModuleDiagnostic {
314    let message = message.to_string();
315    ModuleDiagnostic {
316        cause: None,
317        labels: vec![SourceLabel {
318            location: Some(SourceLocation {
319                source: source.clone(),
320                span,
321            }),
322            label: message.clone(),
323        }],
324        message,
325    }
326}
327
328/// Retains the parser's primary range and every secondary source label.
329///
330/// - Complexity: O(n) message and label bytes.
331fn parse_error(source: &SourceId, error: &crate::ParseError) -> ModuleDiagnostic {
332    let span = SourceSpan::from_proc_macro2_range(
333        error.span(),
334        error.end_span().unwrap_or_else(|| error.span()),
335    );
336    let mut diagnostic = source_error(source, span, error.message());
337    diagnostic
338        .labels
339        .extend(error.secondary().iter().map(|secondary| SourceLabel {
340            location: Some(SourceLocation {
341                source: source.clone(),
342                span: secondary.span,
343            }),
344            label: secondary.label.clone(),
345        }));
346    diagnostic
347}
348
349/// Owns rejected text exclusively through the returned diagnostic's genuine labels.
350///
351/// - Complexity: O(f + t + l) file name, source text and diagnostic label bytes.
352fn rejected_source_error(
353    file_name: &str,
354    text: &str,
355    error: &crate::ParseError,
356) -> ModuleDiagnostic {
357    let owner = SourceMap::new().insert(file_name, text);
358    parse_error(&owner, error)
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    /// Retains only accepted source revisions while each failure owns its diagnostic source.
366    #[test]
367    fn rejected_source_revisions_do_not_enter_the_source_collection() {
368        let mut modules = ModuleSet::new();
369        let template = crate::CompositionBuilder::new(crate::TypeRegistry::new())
370            .finish()
371            .unwrap();
372        modules
373            .insert_template("app".parse().unwrap(), "helper", template)
374            .unwrap();
375        let mut retained_error = None;
376        for revision in 0..8usize {
377            let source = if revision % 2 == 0 {
378                format!("sheet broken( // revision {revision}")
379            } else {
380                format!("sheet broken {{ cell x: ; }} // revision {revision}")
381            };
382            let error = modules
383                .insert_source(
384                    "app".parse().unwrap(),
385                    &format!("rejected-{revision}.adm2"),
386                    &source,
387                )
388                .unwrap_err();
389            assert_eq!(
390                modules.sources.sources().len(),
391                0,
392                "failed revision {revision} retained"
393            );
394            retained_error = Some(error);
395        }
396        modules
397            .insert_source(
398                "app".parse().unwrap(),
399                "accepted.adm2",
400                "sheet wrapper() { relationship helper(); }",
401            )
402            .unwrap();
403        assert_eq!(modules.sources.sources().len(), 1);
404        assert_eq!(modules.sources.sources()[0].file_name(), "accepted.adm2");
405        modules.check_resolution().unwrap();
406        drop(modules);
407
408        let error = retained_error.unwrap();
409        let location = error.labels[0].location.as_ref().unwrap();
410        assert_eq!(location.source.file_name(), "rejected-7.adm2");
411        assert_eq!(
412            location.source.text(),
413            "sheet broken { cell x: ; } // revision 7"
414        );
415        assert_eq!(
416            &location.source.text()[location.span.to_byte_range(location.source.text())],
417            ";"
418        );
419    }
420}