Skip to main content

adam_web_ui/
build.rs

1//! Parses adam-lang source into a live [`adam_lang::ParsedSheet`], formatting any failure as a
2//! diagnostic instead of a bare error.
3
4use crate::labels::{Labels, Renderer, format_adam_error, labels_from_cell_names};
5use adam_lang::{AdamParser, ParsedSheet, TypeRegistry};
6
7/// The result of parsing and building a sheet from adam-lang source.
8///
9/// `sheet_labels` is `None` only on parse failure. A successful parse that
10/// then fails to propagate still returns the built sheet and labels alongside
11/// the formatted error, matching how [`crate::SheetInspector`] already tolerates
12/// propagate failures during cell edits.
13pub struct BuildOutcome {
14    /// The built [`ParsedSheet`] and its UI labels, if parsing succeeded.
15    pub sheet_labels: Option<(ParsedSheet, Labels)>,
16    /// A formatted rustc-style diagnostic, if parsing or propagation failed.
17    pub error: Option<String>,
18}
19
20/// Builds a [`cel_parser::OpLookup`] with the CEL standard and Euclid libraries installed, so every
21/// adam-lang source [`build_sheet`] parses has the same function set (`min`, `max`, `clamp`,
22/// etc.) available.
23///
24/// Every source parsed through this function therefore has `cel-std` installed — a source
25/// that deliberately relies on the standard library being *absent* cannot use this function
26/// or [`build_sheet`]; construct an `AdamParser` directly with an empty `OpLookup` instead.
27///
28/// - Complexity: O(s²) in the number of installed signatures.
29///
30/// # Examples
31/// ```
32/// let lookup = adam_web_ui::build::op_lookup();
33/// assert!(lookup.record_schema("Rect").is_some());
34/// ```
35pub fn op_lookup() -> cel_parser::OpLookup {
36    let mut lookup = cel_parser::OpLookup::new();
37    cel_std::install(&mut lookup);
38    cel_euclid::install(&mut lookup);
39    lookup
40}
41
42/// Builds the matching scalar and Euclid cell-type environment.
43///
44/// - Complexity: O(t) in the number of registered types.
45///
46/// # Examples
47/// ```
48/// let types = adam_web_ui::build::type_registry();
49/// assert!(types.get("Rect").is_some());
50/// ```
51pub fn type_registry() -> TypeRegistry {
52    let mut types = TypeRegistry::new();
53    cel_euclid::register_adam_types(&mut types);
54    types
55}
56
57/// Parses `source` as adam-lang, builds a `Sheet` and `Labels`, and propagates
58/// once so initial derived values are populated. `file_name` is used only to
59/// build diagnostic headers (e.g. `--> begin/examples/toy_example.adm2:8:11`),
60/// not to locate `source` itself. `renderer` controls how a diagnostic is
61/// formatted — pass [`Renderer::styled`] for a real terminal or [`Renderer::plain`]
62/// for a context that can't display ANSI colors (e.g. a browser `<pre>` element).
63///
64/// - Complexity: O(n) in the length of `source` plus the cost of one `propagate()`.
65pub fn build_sheet(source: &str, file_name: &str, renderer: &Renderer) -> BuildOutcome {
66    let mut parser = AdamParser::new(type_registry(), op_lookup());
67    let mut parsed = match parser.parse_str(source) {
68        Ok(p) => p,
69        Err(e) => {
70            let msg = e.format_rustc_style(source, file_name, 1, renderer);
71            return BuildOutcome {
72                sheet_labels: None,
73                error: Some(msg),
74            };
75        }
76    };
77    let labels = labels_from_cell_names(&parsed.sheet, &parsed.cell_names);
78    match parsed.propagate() {
79        Ok(()) => {
80            parsed.clear_changed();
81            BuildOutcome {
82                sheet_labels: Some((parsed, labels)),
83                error: None,
84            }
85        }
86        Err(e) => {
87            let msg = format_adam_error(&e, &parsed, source, file_name, renderer);
88            BuildOutcome {
89                sheet_labels: Some((parsed, labels)),
90                error: Some(msg),
91            }
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    /// The opted-in host builds registered fields, members, and typed closures.
101    #[test]
102    fn geometry_sheet_builds_in_opted_in_host() {
103        let outcome = build_sheet(
104            "sheet s { out x := (|p: Point2D| p.x)(Point2D { y: 2.0, x: 1.0 }); out n := min(3.0, 4.0); }",
105            "geometry.adm2",
106            &Renderer::plain(),
107        );
108        assert!(outcome.error.is_none(), "{:?}", outcome.error);
109        let (parsed, _) = outcome.sheet_labels.unwrap();
110        assert_eq!(*parsed.read::<f64>(parsed.cell_names["x"].0).unwrap(), 1.0);
111        for actual in [
112            include_str!("../../cel-euclid/examples/crop_straighten.adm2"),
113            include_str!("../../cel-euclid/examples/pan_zoom.adm2"),
114        ] {
115            let outcome = build_sheet(actual, "geometry.adm2", &Renderer::plain());
116            assert!(outcome.error.is_none(), "{:?}", outcome.error);
117        }
118    }
119
120    const VALID_SOURCE: &str = r#"
121        sheet s {
122            cell a: f64 = 2.0;
123            cell b: f64 = 3.0;
124            cell c: f64;
125            relationship {
126                c := a * b;
127                a := c / b;
128                b := c / a;
129            }
130        }
131    "#;
132
133    #[test]
134    fn build_sheet_valid_source_succeeds_with_no_error() {
135        let outcome = build_sheet(VALID_SOURCE, "test.adm2", &Renderer::styled());
136        assert!(outcome.sheet_labels.is_some());
137        assert!(outcome.error.is_none());
138    }
139
140    #[test]
141    fn build_sheet_parse_error_has_no_sheet_and_formatted_message() {
142        let outcome = build_sheet("sheet s { cell x }", "test.adm2", &Renderer::styled());
143        assert!(outcome.sheet_labels.is_none());
144        let msg = outcome.error.expect("expected a parse error message");
145        assert!(msg.contains("error"), "{msg}");
146    }
147
148    #[test]
149    fn build_sheet_runtime_error_still_returns_sheet_and_message() {
150        let source = "sheet s { cell x: i32 = 0; cell y: i32; relationship { y := 10i32 / x; } }";
151        let outcome = build_sheet(source, "test.adm2", &Renderer::styled());
152        assert!(
153            outcome.sheet_labels.is_some(),
154            "sheet should still be built after a propagate error"
155        );
156        assert!(outcome.error.is_some());
157    }
158
159    #[test]
160    fn build_sheet_plain_renderer_parse_error_has_no_ansi_escape_codes() {
161        let outcome = build_sheet("sheet s { cell x }", "test.adm2", &Renderer::plain());
162        let msg = outcome.error.expect("expected a parse error message");
163        assert!(
164            !msg.contains('\u{1b}'),
165            "expected no ANSI escapes, got: {msg}"
166        );
167    }
168
169    #[test]
170    fn build_sheet_plain_renderer_runtime_error_has_no_ansi_escape_codes() {
171        let source = "sheet s { cell x: i32 = 0; cell y: i32; relationship { y := 10i32 / x; } }";
172        let outcome = build_sheet(source, "test.adm2", &Renderer::plain());
173        let msg = outcome.error.expect("expected a propagate error message");
174        assert!(
175            !msg.contains('\u{1b}'),
176            "expected no ANSI escapes, got: {msg}"
177        );
178    }
179
180    #[test]
181    fn build_sheet_cycle_names_both_relationships_bindings_in_the_backtrace() {
182        // Two relationships forming an algebraic loop with no external source for
183        // either cell: `x` needs `y` and `y` needs `x`, so `propagate` can't pick a
184        // valid method assignment and returns a multi-span `Error::Cycle` backtrace
185        // naming both bindings.
186        let source = "sheet s { cell x: i32 = 0; cell y: i32 = 0; \
187            relationship { x := y + 1i32; } relationship { y := x + 1i32; } }";
188        let outcome = build_sheet(source, "test.adm2", &Renderer::plain());
189        assert!(
190            outcome.sheet_labels.is_some(),
191            "sheet should still be built after a propagate error"
192        );
193        let msg = outcome.error.expect("expected a cycle error message");
194        assert!(msg.contains("x :=") && msg.contains("y :="), "{msg}");
195    }
196}