1mod 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#[derive(Clone, Debug, Eq, Hash, PartialEq)]
37pub struct ModulePath(Vec<String>);
38
39impl FromStr for ModulePath {
40 type Err = ModuleDiagnostic;
42
43 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 #[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 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
88pub(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#[derive(Default)]
111pub struct ModuleSet {
112 entries: Vec<ModuleEntry>,
113 indices: HashMap<ModulePath, usize>,
114 sources: SourceMap,
115}
116
117struct ModuleEntry {
119 path: ModulePath,
120 source: Option<SourceId>,
121 imports: Vec<ast::UseDecl>,
122 definitions: Vec<Definition>,
123}
124
125struct Definition {
127 name: String,
128 body: DefinitionBody,
129}
130
131enum DefinitionBody {
133 Parsed {
134 sheet: Box<ast::Sheet>,
135 label: SourceLabel,
136 },
137 Native(TypedTemplate),
138}
139
140impl Definition {
141 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 pub fn new() -> Self {
156 Self::default()
157 }
158
159 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 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 pub fn check_resolution(&self) -> Result<(), Vec<ModuleDiagnostic>> {
272 resolve::resolve(self).map(|_| ())
273 }
274
275 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
293fn 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
306fn 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
328fn 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
349fn 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 #[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}