Skip to main content

stack_compiler/
lib.rs

1//! Reference compiler frontend for the Stack diagram language.
2//!
3//! The public pipeline deliberately stops at normalized, renderer-independent
4//! diagram IR. Theme resolution, layout, and rendering belong to downstream
5//! crates and applications.
6
7#![forbid(unsafe_code)]
8
9pub mod ast;
10pub mod diagnostic;
11pub mod ir;
12pub mod language_intelligence;
13pub mod lossless;
14pub mod source_map;
15
16mod lexer;
17mod parser;
18mod validation;
19
20use diagnostic::Diagnostic;
21
22/// Output of lexical and syntax parsing.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ParseOutput {
25    /// Parsed syntax tree, present only when no lexical or syntax error occurred.
26    pub document: Option<ast::Document>,
27    /// Lexical and syntax diagnostics.
28    pub diagnostics: Vec<Diagnostic>,
29}
30
31/// Output of the complete compiler frontend.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CompileOutput {
34    /// Normalized diagram, present only when no compiler-stage error occurred.
35    pub diagram: Option<ir::Diagram>,
36    /// Lexical, syntax, semantic, and complexity diagnostics.
37    pub diagnostics: Vec<Diagnostic>,
38}
39
40/// Output of syntactic parsing into the lossless source model.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct LosslessParseOutput {
43    /// Lossless document, present only when lexical and syntax parsing succeeds.
44    pub document: Option<lossless::Document>,
45    /// Lexical and syntax diagnostics.
46    pub diagnostics: Vec<Diagnostic>,
47}
48
49/// Output of compilation with the Rust-only source-map sidecar.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct SourceMappedCompileOutput {
52    /// Normalized diagram, present only when no compiler-stage error occurred.
53    pub diagram: Option<ir::Diagram>,
54    /// Source map corresponding to `diagram`, absent whenever `diagram` is absent.
55    pub source_map: Option<source_map::SourceMap>,
56    /// Lexical, syntax, semantic, and complexity diagnostics.
57    pub diagnostics: Vec<Diagnostic>,
58}
59
60/// Parses UTF-8 Stack source into a source-oriented AST.
61pub fn parse(source: &str) -> ParseOutput {
62    match parser::parse(source) {
63        Ok(document) => ParseOutput {
64            document: Some(document),
65            diagnostics: Vec::new(),
66        },
67        Err(diagnostic) => ParseOutput {
68            document: None,
69            diagnostics: vec![*diagnostic],
70        },
71    }
72}
73
74/// Decodes and parses Stack source bytes into a source-oriented AST.
75pub fn parse_bytes(source: &[u8]) -> ParseOutput {
76    match std::str::from_utf8(source) {
77        Ok(source) => parse(source),
78        Err(error) => ParseOutput {
79            document: None,
80            diagnostics: vec![invalid_utf8_diagnostic(source, error)],
81        },
82    }
83}
84
85/// Parses UTF-8 Stack source into exact authored tokens and trivia.
86pub fn parse_lossless(source: &str) -> LosslessParseOutput {
87    let tokens = match lexer::tokenize(source) {
88        Ok(tokens) => tokens,
89        Err(diagnostic) => {
90            return LosslessParseOutput {
91                document: None,
92                diagnostics: vec![*diagnostic],
93            };
94        }
95    };
96
97    match parser::parse_tokens(tokens.clone()) {
98        Ok(_) => LosslessParseOutput {
99            document: Some(lossless::Document::from_lexer_tokens(source, tokens)),
100            diagnostics: Vec::new(),
101        },
102        Err(diagnostic) => LosslessParseOutput {
103            document: None,
104            diagnostics: vec![*diagnostic],
105        },
106    }
107}
108
109/// Decodes and parses Stack source bytes into exact authored tokens and trivia.
110pub fn parse_lossless_bytes(source: &[u8]) -> LosslessParseOutput {
111    match std::str::from_utf8(source) {
112        Ok(source) => parse_lossless(source),
113        Err(error) => LosslessParseOutput {
114            document: None,
115            diagnostics: vec![invalid_utf8_diagnostic(source, error)],
116        },
117    }
118}
119
120/// Validates a parsed document and produces normalized IR when it is valid.
121pub fn validate(document: &ast::Document) -> CompileOutput {
122    validation::validate(document)
123}
124
125/// Parses, validates, and normalizes UTF-8 Stack source.
126pub fn compile(source: &str) -> CompileOutput {
127    let parsed = parse(source);
128    match parsed.document {
129        Some(document) => validate(&document),
130        None => CompileOutput {
131            diagram: None,
132            diagnostics: parsed.diagnostics,
133        },
134    }
135}
136
137/// Decodes, parses, validates, and normalizes Stack source bytes.
138pub fn compile_bytes(source: &[u8]) -> CompileOutput {
139    let parsed = parse_bytes(source);
140    match parsed.document {
141        Some(document) => validate(&document),
142        None => CompileOutput {
143            diagram: None,
144            diagnostics: parsed.diagnostics,
145        },
146    }
147}
148
149/// Parses, validates, and normalizes source with an engine-facing source map.
150pub fn compile_with_source_map(source: &str) -> SourceMappedCompileOutput {
151    let tokens = match lexer::tokenize(source) {
152        Ok(tokens) => tokens,
153        Err(diagnostic) => {
154            return SourceMappedCompileOutput {
155                diagram: None,
156                source_map: None,
157                diagnostics: vec![*diagnostic],
158            };
159        }
160    };
161    let document = match parser::parse_tokens(tokens.clone()) {
162        Ok(document) => document,
163        Err(diagnostic) => {
164            return SourceMappedCompileOutput {
165                diagram: None,
166                source_map: None,
167                diagnostics: vec![*diagnostic],
168            };
169        }
170    };
171    let compiled = validate(&document);
172    let source_map = compiled.diagram.as_ref().map(|_| {
173        let lossless = lossless::Document::from_lexer_tokens(source, tokens);
174        source_map::SourceMap::from_document(&document, &lossless)
175    });
176
177    SourceMappedCompileOutput {
178        diagram: compiled.diagram,
179        source_map,
180        diagnostics: compiled.diagnostics,
181    }
182}
183
184/// Decodes and compiles source bytes with an engine-facing source map.
185pub fn compile_bytes_with_source_map(source: &[u8]) -> SourceMappedCompileOutput {
186    match std::str::from_utf8(source) {
187        Ok(source) => compile_with_source_map(source),
188        Err(error) => SourceMappedCompileOutput {
189            diagram: None,
190            source_map: None,
191            diagnostics: vec![invalid_utf8_diagnostic(source, error)],
192        },
193    }
194}
195
196fn position_after_valid_prefix(prefix: &[u8]) -> diagnostic::SourcePosition {
197    let source = match std::str::from_utf8(prefix) {
198        Ok(source) => source,
199        Err(_) => return diagnostic::SourcePosition::start(),
200    };
201    let mut position = diagnostic::SourcePosition::start();
202    let mut characters = source.chars().peekable();
203
204    while let Some(character) = characters.next() {
205        position.byte_offset += character.len_utf8();
206        if character == '\r' && characters.peek() == Some(&'\n') {
207            if let Some(newline) = characters.next() {
208                position.byte_offset += newline.len_utf8();
209            }
210            position.line += 1;
211            position.column = 1;
212        } else if matches!(character, '\n' | '\r') {
213            position.line += 1;
214            position.column = 1;
215        } else {
216            position.column += 1;
217        }
218    }
219
220    position
221}
222
223fn invalid_utf8_diagnostic(source: &[u8], error: std::str::Utf8Error) -> Diagnostic {
224    let position = position_after_valid_prefix(&source[..error.valid_up_to()]);
225    Diagnostic::error(
226        "STK1001",
227        "Input is not valid UTF-8.",
228        diagnostic::Span::point(position),
229    )
230    .with_help("Save the source as UTF-8 and replace the invalid byte sequence.")
231}
232
233#[cfg(test)]
234mod tests {
235    use crate::lossless::TokenKind;
236    use crate::source_map::{LayoutScope, SourceOrigin};
237
238    use super::{
239        compile, compile_bytes, compile_bytes_with_source_map, compile_with_source_map, parse,
240        parse_bytes, parse_lossless, parse_lossless_bytes, position_after_valid_prefix, validate,
241    };
242
243    #[test]
244    fn reports_invalid_utf8_at_the_decoded_prefix_position() {
245        let output = parse_bytes(b"stack 1.0\ndiagram \"x\" {\n\xff}");
246
247        assert!(output.document.is_none());
248        assert_eq!(output.diagnostics[0].code, "STK1001");
249        assert_eq!(output.diagnostics[0].span.start.line, 3);
250        assert_eq!(output.diagnostics[0].span.start.column, 1);
251    }
252
253    #[test]
254    fn public_entry_points_cover_success_and_syntax_failure() {
255        let source = "stack 1.0 diagram \"API\" { node api \"API\" }";
256        let parsed = parse(source);
257        assert!(parsed.diagnostics.is_empty());
258        let Some(document) = parsed.document else {
259            return;
260        };
261        assert!(validate(&document).diagram.is_some());
262        assert!(parse_bytes(source.as_bytes()).document.is_some());
263        assert!(compile_bytes(source.as_bytes()).diagram.is_some());
264
265        let syntax_error = compile("stack 1.0 diagram \"API\" {");
266        assert!(syntax_error.diagram.is_none());
267        assert_eq!(syntax_error.diagnostics[0].code, "STK2003");
268    }
269
270    #[test]
271    fn utf8_error_positions_handle_crlf_and_defensive_invalid_prefixes() {
272        let output = parse_bytes(b"stack 1.0\r\n\xff");
273        assert_eq!(output.diagnostics[0].span.start.line, 2);
274        assert_eq!(output.diagnostics[0].span.start.column, 1);
275        assert_eq!(
276            position_after_valid_prefix(b"\xff"),
277            crate::diagnostic::SourcePosition::start()
278        );
279    }
280
281    #[test]
282    fn lossless_entry_points_preserve_trivia_escapes_and_crlf() {
283        let source = concat!(
284            "// leading\r\n",
285            "stack 1.0\r\n",
286            "diagram \"\\u56F3\" {\r\n",
287            "\tnode api \"API\" // trailing\r\n",
288            "}\r\n",
289        );
290        let output = parse_lossless(source);
291        assert!(output.diagnostics.is_empty());
292        let Some(document) = output.document else {
293            return;
294        };
295
296        assert_eq!(document.reconstruct(), source);
297        assert!(document.tokens().iter().any(|token| {
298            matches!(&token.kind, TokenKind::String(value) if value == "図")
299                && token.text == "\"\\u56F3\""
300        }));
301        assert!(
302            document.tokens().iter().any(|token| {
303                token.kind == TokenKind::Whitespace && token.text.contains("\r\n")
304            })
305        );
306        assert!(
307            document.tokens().iter().any(|token| {
308                token.kind == TokenKind::LineComment && token.text == "// trailing"
309            })
310        );
311
312        let bytes_output = parse_lossless_bytes(source.as_bytes());
313        assert_eq!(bytes_output.document, Some(document));
314    }
315
316    #[test]
317    fn lossless_entry_points_report_lexical_syntax_and_encoding_errors() {
318        let bom = parse_lossless("\u{feff}stack 1.0");
319        assert!(bom.document.is_none());
320        assert_eq!(bom.diagnostics[0].code, "STK1002");
321
322        let syntax = parse_lossless("stack 1.0 diagram \"x\" {");
323        assert!(syntax.document.is_none());
324        assert_eq!(syntax.diagnostics[0].code, "STK2003");
325
326        let encoding = parse_lossless_bytes(b"stack 1.0\r\n\xff");
327        assert!(encoding.document.is_none());
328        assert_eq!(encoding.diagnostics[0].code, "STK1001");
329        assert_eq!(encoding.diagnostics[0].span.start.line, 2);
330    }
331
332    #[test]
333    fn lossless_syntax_model_keeps_semantically_invalid_source() {
334        let source = concat!(
335            "stack 1.0\n",
336            "diagram \"Duplicate\" {\n",
337            "  node api \"First\"\n",
338            "  node api \"Second\"\n",
339            "}\n",
340        );
341
342        assert!(parse_lossless(source).document.is_some());
343        assert!(compile(source).diagram.is_none());
344    }
345
346    #[test]
347    fn source_map_resolves_authored_values_by_semantic_identity() {
348        let source = concat!(
349            "stack 1.0\n",
350            "diagram \"Mapped\" {\n",
351            "  node root \"Root\"\n",
352            "  group services \"Services\" {\n",
353            "    node api \"API\" { icon \"service\" }\n",
354            "    node worker \"Worker\"\n",
355            "    layout {\n",
356            "      order // Group order\n",
357            "        [api, worker]\n",
358            "    }\n",
359            "  }\n",
360            "  theme dark\n",
361            "  layout { order [root, services] }\n",
362            "}\n",
363        );
364
365        let mapped = compile_with_source_map(source);
366        let plain = compile(source);
367        assert_eq!(mapped.diagram, plain.diagram);
368        assert_eq!(mapped.diagnostics, plain.diagnostics);
369        let Some(source_map) = mapped.source_map else {
370            return;
371        };
372
373        assert_eq!(authored_text(source, source_map.theme()), Some("dark"));
374        assert_eq!(source_map.node_icon("root"), Some(SourceOrigin::Omitted));
375        assert_eq!(source_map.node_icon("worker"), Some(SourceOrigin::Omitted));
376        assert_eq!(source_map.node_icon("missing"), None);
377        let Some(api_icon) = source_map.node_icon("api") else {
378            return;
379        };
380        assert_eq!(authored_text(source, api_icon), Some("\"service\""));
381        assert_eq!(
382            source_map
383                .node_icons()
384                .iter()
385                .map(|entry| entry.node_id.as_str())
386                .collect::<Vec<_>>(),
387            vec!["root", "api", "worker"]
388        );
389
390        assert_eq!(
391            authored_text(source, source_map.diagram_order()),
392            Some("order [root, services]")
393        );
394        let Some(group_order) = source_map.group_order("services") else {
395            return;
396        };
397        assert_eq!(
398            authored_text(source, group_order),
399            Some("order // Group order\n        [api, worker]")
400        );
401        assert_eq!(source_map.group_order("missing"), None);
402        assert!(matches!(
403            source_map.layout_orders()[0].scope,
404            LayoutScope::Diagram
405        ));
406        assert!(matches!(
407            &source_map.layout_orders()[1].scope,
408            LayoutScope::Group(identifier) if identifier == "services"
409        ));
410
411        assert_eq!(
412            compile_with_source_map(source).source_map,
413            Some(source_map.clone())
414        );
415        assert_eq!(
416            compile_bytes_with_source_map(source.as_bytes()).source_map,
417            Some(source_map)
418        );
419    }
420
421    #[test]
422    fn source_map_distinguishes_defaults_and_rejects_error_results() {
423        let source = concat!(
424            "stack 1.0 ",
425            "diagram \"Default\" { ",
426            "group services \"Services\" { node api \"API\" } ",
427            "}",
428        );
429        let output = compile_with_source_map(source);
430        let Some(source_map) = output.source_map else {
431            return;
432        };
433        assert_eq!(source_map.theme(), SourceOrigin::Omitted);
434        assert_eq!(source_map.node_icon("api"), Some(SourceOrigin::Omitted));
435        assert_eq!(source_map.diagram_order(), SourceOrigin::Omitted);
436        assert_eq!(
437            source_map.group_order("services"),
438            Some(SourceOrigin::Omitted)
439        );
440        assert_eq!(source_map.layout_orders().len(), 2);
441
442        for invalid in [
443            "\u{feff}stack 1.0",
444            "stack 1.0 diagram \"Incomplete\" {",
445            "stack 1.0 diagram \"Duplicate\" { node api \"A\" node api \"B\" }",
446        ] {
447            let output = compile_with_source_map(invalid);
448            assert!(output.diagram.is_none());
449            assert!(output.source_map.is_none());
450            assert!(!output.diagnostics.is_empty());
451        }
452
453        let encoding = compile_bytes_with_source_map(b"stack 1.0\n\xff");
454        assert!(encoding.diagram.is_none());
455        assert!(encoding.source_map.is_none());
456        assert_eq!(encoding.diagnostics[0].code, "STK1001");
457    }
458
459    fn authored_text(source: &str, origin: SourceOrigin) -> Option<&str> {
460        origin
461            .span()
462            .map(|span| &source[span.start.byte_offset..span.end.byte_offset])
463    }
464}