1#![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#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct FormatOutput {
16 pub source: Option<String>,
18 pub diagnostics: Vec<Diagnostic>,
20}
21
22pub 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
53pub 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}