1#![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#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ParseOutput {
25 pub document: Option<ast::Document>,
27 pub diagnostics: Vec<Diagnostic>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CompileOutput {
34 pub diagram: Option<ir::Diagram>,
36 pub diagnostics: Vec<Diagnostic>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct LosslessParseOutput {
43 pub document: Option<lossless::Document>,
45 pub diagnostics: Vec<Diagnostic>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct SourceMappedCompileOutput {
52 pub diagram: Option<ir::Diagram>,
54 pub source_map: Option<source_map::SourceMap>,
56 pub diagnostics: Vec<Diagnostic>,
58}
59
60pub 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
74pub 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
85pub 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
109pub 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
120pub fn validate(document: &ast::Document) -> CompileOutput {
122 validation::validate(document)
123}
124
125pub 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
137pub 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
149pub 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
184pub 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}