Skip to main content

stack_formatter/
lib.rs

1//! Canonical, comment-preserving formatter for Stack source.
2
3#![forbid(unsafe_code)]
4
5use std::collections::BTreeMap;
6
7use stack_compiler::ast::{
8    self, DiagramMember, EdgeProperty, GroupMember, LayoutStatement, NodeProperty,
9};
10use stack_compiler::diagnostic::Diagnostic;
11use stack_compiler::lossless::{Document as LosslessDocument, Token, TokenKind};
12
13/// Result of formatting Stack source.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct FormatOutput {
16    /// Canonical source, absent when lexical or syntax errors prevent formatting.
17    pub source: Option<String>,
18    /// Compiler diagnostics for the original source.
19    pub diagnostics: Vec<Diagnostic>,
20}
21
22/// Formats UTF-8 Stack source into its canonical representation.
23pub fn format(source: &str) -> FormatOutput {
24    let parsed = stack_compiler::parse(source);
25    let document = match parsed.document {
26        Some(document) => document,
27        None => {
28            return FormatOutput {
29                source: None,
30                diagnostics: parsed.diagnostics,
31            };
32        }
33    };
34
35    let lossless = stack_compiler::parse_lossless(source);
36    let lossless = match lossless.document {
37        Some(document) => document,
38        None => {
39            return FormatOutput {
40                source: None,
41                diagnostics: lossless.diagnostics,
42            };
43        }
44    };
45
46    let formatted = Formatter::new(&document, &lossless).format();
47    FormatOutput {
48        source: Some(formatted),
49        diagnostics: stack_compiler::compile(source).diagnostics,
50    }
51}
52
53/// Decodes and formats Stack source bytes into the canonical UTF-8 representation.
54pub fn format_bytes(source: &[u8]) -> FormatOutput {
55    match std::str::from_utf8(source) {
56        Ok(source) => format(source),
57        Err(_) => {
58            let parsed = stack_compiler::parse_lossless_bytes(source);
59            FormatOutput {
60                source: None,
61                diagnostics: parsed.diagnostics,
62            }
63        }
64    }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
68enum Separator {
69    None,
70    Space,
71    Line,
72    Blank,
73}
74
75struct Formatter<'source> {
76    lossless: &'source LosslessDocument,
77    breaks: BTreeMap<usize, Separator>,
78}
79
80impl<'source> Formatter<'source> {
81    fn new(document: &ast::Document, lossless: &'source LosslessDocument) -> Self {
82        Self {
83            lossless,
84            breaks: collect_breaks(document),
85        }
86    }
87
88    fn format(&self) -> String {
89        let mut writer = Writer::default();
90        let mut previous = None;
91        let mut comments = Vec::new();
92        let mut depth = 0_usize;
93
94        for token in self.lossless.tokens() {
95            match &token.kind {
96                TokenKind::Whitespace => continue,
97                TokenKind::LineComment => {
98                    comments.push(token);
99                    continue;
100                }
101                TokenKind::End => {
102                    emit_gap(
103                        &mut writer,
104                        previous,
105                        token,
106                        &comments,
107                        Separator::None,
108                        0,
109                        0,
110                    );
111                    break;
112                }
113                _ => {}
114            }
115
116            if matches!(token.kind, TokenKind::RightBrace) {
117                depth = depth.saturating_sub(1);
118            }
119            let token_indent = depth * 2;
120            let comment_indent = if matches!(token.kind, TokenKind::RightBrace) {
121                (depth + 1) * 2
122            } else {
123                token_indent
124            };
125            let separator = self.separator(previous, token);
126            let effective_indent = emit_gap(
127                &mut writer,
128                previous,
129                token,
130                &comments,
131                separator,
132                token_indent,
133                comment_indent,
134            );
135            writer.indent(effective_indent);
136            writer.token(token);
137
138            if matches!(token.kind, TokenKind::LeftBrace) {
139                depth += 1;
140            }
141            previous = Some(token);
142            comments.clear();
143        }
144
145        writer.finish()
146    }
147
148    fn separator(&self, previous: Option<&Token>, current: &Token) -> Separator {
149        let Some(previous) = previous else {
150            return Separator::None;
151        };
152
153        if matches!(current.kind, TokenKind::RightBrace)
154            || matches!(previous.kind, TokenKind::LeftBrace)
155        {
156            Separator::Line
157        } else if let Some(separator) = self.breaks.get(&previous.span.end.byte_offset) {
158            *separator
159        } else if matches!(
160            current.kind,
161            TokenKind::RightBracket | TokenKind::Comma | TokenKind::Dot
162        ) || matches!(previous.kind, TokenKind::LeftBracket | TokenKind::Dot)
163        {
164            Separator::None
165        } else {
166            Separator::Space
167        }
168    }
169}
170
171fn emit_gap(
172    writer: &mut Writer,
173    previous: Option<&Token>,
174    current: &Token,
175    comments: &[&Token],
176    separator: Separator,
177    token_indent: usize,
178    comment_indent: usize,
179) -> usize {
180    if comments.is_empty() {
181        writer.separator(separator);
182        return token_indent;
183    }
184
185    let has_trailing =
186        previous.is_some_and(|previous| previous.span.end.line == comments[0].span.start.line);
187    let own_start = usize::from(has_trailing);
188
189    if has_trailing {
190        writer.separator(Separator::Space);
191        writer.raw(&comments[0].text);
192    }
193
194    let own_comments = &comments[own_start..];
195    if own_comments.is_empty() {
196        separator_after_comment(writer, current, separator);
197    } else {
198        let before_comments = if matches!(current.kind, TokenKind::End) && previous.is_some() {
199            Separator::Blank
200        } else if separator == Separator::Space {
201            Separator::Line
202        } else {
203            separator
204        };
205        writer.separator(before_comments);
206
207        let own_indent = if separator == Separator::Space {
208            token_indent + 2
209        } else {
210            comment_indent
211        };
212        for comment in own_comments {
213            writer.indent(own_indent);
214            writer.raw(&comment.text);
215            writer.separator(Separator::Line);
216        }
217    }
218
219    if separator == Separator::Space && !matches!(current.kind, TokenKind::End) {
220        token_indent + 2
221    } else {
222        token_indent
223    }
224}
225
226fn separator_after_comment(writer: &mut Writer, current: &Token, separator: Separator) {
227    if matches!(current.kind, TokenKind::End) || separator == Separator::Space {
228        writer.separator(Separator::Line);
229    } else {
230        writer.separator(separator);
231    }
232}
233
234fn collect_breaks(document: &ast::Document) -> BTreeMap<usize, Separator> {
235    let mut breaks = BTreeMap::new();
236    mark_break(
237        &mut breaks,
238        document.version.span.end.byte_offset,
239        Separator::Blank,
240    );
241    for member in &document.diagram.members {
242        collect_diagram_member(member, &mut breaks);
243        mark_break(
244            &mut breaks,
245            diagram_member_span(member).end.byte_offset,
246            Separator::Blank,
247        );
248    }
249    breaks
250}
251
252fn collect_diagram_member(member: &DiagramMember, breaks: &mut BTreeMap<usize, Separator>) {
253    match member {
254        DiagramMember::Node(node) => collect_node(node, breaks),
255        DiagramMember::Group(group) => collect_group(group, breaks),
256        DiagramMember::Edge(edge) => collect_edge(edge, breaks),
257        DiagramMember::Layout(layout) => collect_layout(layout, breaks),
258        DiagramMember::Theme(_) => {}
259    }
260}
261
262fn collect_group(group: &ast::Group, breaks: &mut BTreeMap<usize, Separator>) {
263    for member in &group.members {
264        match member {
265            GroupMember::Node(node) => collect_node(node, breaks),
266            GroupMember::Group(group) => collect_group(group, breaks),
267            GroupMember::Layout(layout) => collect_layout(layout, breaks),
268        }
269        mark_break(
270            breaks,
271            group_member_span(member).end.byte_offset,
272            Separator::Blank,
273        );
274    }
275}
276
277fn collect_node(node: &ast::Node, breaks: &mut BTreeMap<usize, Separator>) {
278    for property in &node.properties {
279        let span = match property {
280            NodeProperty::Kind(value) | NodeProperty::Icon(value) | NodeProperty::Detail(value) => {
281                value.span
282            }
283        };
284        mark_break(breaks, span.end.byte_offset, Separator::Line);
285    }
286}
287
288fn collect_edge(edge: &ast::Edge, breaks: &mut BTreeMap<usize, Separator>) {
289    for property in &edge.properties {
290        let span = match property {
291            EdgeProperty::Kind(value) => value.span,
292        };
293        mark_break(breaks, span.end.byte_offset, Separator::Line);
294    }
295}
296
297fn collect_layout(layout: &ast::Layout, breaks: &mut BTreeMap<usize, Separator>) {
298    for statement in &layout.statements {
299        let span = match statement {
300            LayoutStatement::Direction(value) => value.span,
301            LayoutStatement::RankSame(list) | LayoutStatement::Order(list) => list.span,
302        };
303        mark_break(breaks, span.end.byte_offset, Separator::Line);
304    }
305}
306
307fn mark_break(breaks: &mut BTreeMap<usize, Separator>, offset: usize, separator: Separator) {
308    breaks
309        .entry(offset)
310        .and_modify(|existing| *existing = (*existing).max(separator))
311        .or_insert(separator);
312}
313
314fn diagram_member_span(member: &DiagramMember) -> stack_compiler::diagnostic::Span {
315    match member {
316        DiagramMember::Node(node) => node.span,
317        DiagramMember::Group(group) => group.span,
318        DiagramMember::Edge(edge) => edge.span,
319        DiagramMember::Theme(theme) => theme.span,
320        DiagramMember::Layout(layout) => layout.span,
321    }
322}
323
324fn group_member_span(member: &GroupMember) -> stack_compiler::diagnostic::Span {
325    match member {
326        GroupMember::Node(node) => node.span,
327        GroupMember::Group(group) => group.span,
328        GroupMember::Layout(layout) => layout.span,
329    }
330}
331
332#[derive(Default)]
333struct Writer {
334    output: String,
335}
336
337impl Writer {
338    fn separator(&mut self, separator: Separator) {
339        match separator {
340            Separator::None => {}
341            Separator::Space => {
342                if !self.output.is_empty() && !self.output.ends_with([' ', '\n']) {
343                    self.output.push(' ');
344                }
345            }
346            Separator::Line => self.ensure_newlines(1),
347            Separator::Blank => self.ensure_newlines(2),
348        }
349    }
350
351    fn ensure_newlines(&mut self, count: usize) {
352        let existing = self
353            .output
354            .as_bytes()
355            .iter()
356            .rev()
357            .take_while(|byte| **byte == b'\n')
358            .count();
359        for _ in existing..count {
360            self.output.push('\n');
361        }
362    }
363
364    fn indent(&mut self, spaces: usize) {
365        if self.output.is_empty() || self.output.ends_with('\n') {
366            for _ in 0..spaces {
367                self.output.push(' ');
368            }
369        }
370    }
371
372    fn token(&mut self, token: &Token) {
373        if let TokenKind::String(value) = &token.kind {
374            self.output.push('"');
375            for character in value.chars() {
376                match character {
377                    '"' => self.output.push_str("\\\""),
378                    '\\' => self.output.push_str("\\\\"),
379                    _ => self.output.push(character),
380                }
381            }
382            self.output.push('"');
383        } else {
384            self.raw(&token.text);
385        }
386    }
387
388    fn raw(&mut self, text: &str) {
389        self.output.push_str(text);
390    }
391
392    fn finish(mut self) -> String {
393        while self.output.ends_with("\n\n") {
394            self.output.pop();
395        }
396        if !self.output.ends_with('\n') {
397            self.output.push('\n');
398        }
399        self.output
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use std::collections::BTreeSet;
406
407    use super::{format, format_bytes};
408
409    #[test]
410    fn formats_all_constructs_comments_and_strings() {
411        let source = concat!(
412            "// leading\r\n",
413            "stack 1 . 0// version\r\n",
414            "diagram \"\\u56F3\"{\r\n",
415            "group services \"Services\"{\r\n",
416            "// nested\r\n",
417            "node api \"API\"{detail \"quote: \\u0022 slash: \\\\\" icon \"service\" kind service}// node\r\n",
418            "node worker \"Worker\"\r\n",
419            "layout {order[api,worker] direction down}\r\n",
420            "}\r\n",
421            "theme dark\r\n",
422            "layout {order[services,client] rank same[services,client]}\r\n",
423            "node client \"Client\"\r\n",
424            "edge client->api \"HTTPS\"{kind request}\r\n",
425            "}\r\n",
426        );
427        let expected = concat!(
428            "// leading\n",
429            "stack 1.0 // version\n",
430            "\n",
431            "diagram \"図\" {\n",
432            "  group services \"Services\" {\n",
433            "    // nested\n",
434            "    node api \"API\" {\n",
435            "      detail \"quote: \\\" slash: \\\\\"\n",
436            "      icon \"service\"\n",
437            "      kind service\n",
438            "    } // node\n",
439            "\n",
440            "    node worker \"Worker\"\n",
441            "\n",
442            "    layout {\n",
443            "      order [api, worker]\n",
444            "      direction down\n",
445            "    }\n",
446            "  }\n",
447            "\n",
448            "  theme dark\n",
449            "\n",
450            "  layout {\n",
451            "    order [services, client]\n",
452            "    rank same [services, client]\n",
453            "  }\n",
454            "\n",
455            "  node client \"Client\"\n",
456            "\n",
457            "  edge client -> api \"HTTPS\" {\n",
458            "    kind request\n",
459            "  }\n",
460            "}\n",
461        );
462
463        let output = format(source);
464        assert!(output.diagnostics.is_empty(), "{:?}", output.diagnostics);
465        assert_eq!(output.source.as_deref(), Some(expected));
466        assert_eq!(format(expected).source, Some(expected.to_owned()));
467        assert_eq!(format_bytes(source.as_bytes()).source, output.source);
468    }
469
470    #[test]
471    fn preserves_comment_token_gaps_that_force_continuations() {
472        let source = concat!(
473            "stack 1.0\n",
474            "diagram \"Comments\" {\n",
475            "  node // identifier\n",
476            "    api \"API\"\n",
477            "  // before close\n",
478            "}\n",
479            "\n",
480            "// final\n",
481        );
482        let expected = concat!(
483            "stack 1.0\n",
484            "\n",
485            "diagram \"Comments\" {\n",
486            "  node // identifier\n",
487            "    api \"API\"\n",
488            "  // before close\n",
489            "}\n",
490            "\n",
491            "// final\n",
492        );
493
494        assert_eq!(format(source).source.as_deref(), Some(expected));
495    }
496
497    #[test]
498    fn semantic_errors_remain_formattable_with_the_same_codes() {
499        let source = concat!(
500            "stack 1.0 diagram \"Invalid\"{",
501            "node api \"First\" node api \"Second\" ",
502            "edge api->missing}",
503        );
504        let before = diagnostic_codes(source);
505        let output = format(source);
506        assert!(output.source.is_some());
507        let Some(formatted) = output.source else {
508            return;
509        };
510        assert_eq!(before, diagnostic_codes(&formatted));
511        assert_eq!(
512            output
513                .diagnostics
514                .iter()
515                .map(|diagnostic| diagnostic.code)
516                .collect::<BTreeSet<_>>(),
517            before
518        );
519    }
520
521    #[test]
522    fn rejects_lexical_syntax_and_encoding_errors_without_output() {
523        for source in [
524            "\u{feff}stack 1.0",
525            "stack 1.0 diagram \"Incomplete\" {",
526            "stack 1.0 diagram \"Bad escape\" { node api \"\\n\" }",
527        ] {
528            let output = format(source);
529            assert!(output.source.is_none());
530            assert!(!output.diagnostics.is_empty());
531        }
532
533        let encoding = format_bytes(b"stack 1.0\n\xff");
534        assert!(encoding.source.is_none());
535        assert_eq!(encoding.diagnostics[0].code, "STK1001");
536    }
537
538    fn diagnostic_codes(source: &str) -> BTreeSet<&'static str> {
539        stack_compiler::compile(source)
540            .diagnostics
541            .iter()
542            .map(|diagnostic| diagnostic.code)
543            .collect()
544    }
545}