1use crate::error::Diagnostic;
15use crate::lexer::{Lexer, Token, TokenKind};
16
17const INDENT: &str = " ";
18
19#[derive(Default, Clone, Copy)]
20struct LineInfo {
21 delta: i32,
22 min_prefix: i32,
23 ends_with_colon: bool,
24 ends_with_arrow: bool,
25 continues: bool,
26}
27
28#[derive(PartialEq, Clone, Copy)]
30enum Kind {
31 Assign, Colon, Arrow, }
35
36enum Body {
37 Plain(String),
38 Anchored {
39 kind: Kind,
40 field: String,
41 joiner: &'static str,
42 rest: String,
43 },
44}
45
46struct CodeLine {
47 level: usize,
48 code: String,
50 body: Body,
51 comment: Option<String>,
52}
53
54enum Line {
55 Blank,
56 Verbatim(String),
57 Comment { level: usize, text: String },
58 Code(CodeLine),
59}
60
61pub fn format_source(src: &str) -> Result<String, Diagnostic> {
62 let tokens = Lexer::new(src, 0).tokenize()?;
63
64 let mut line_starts = vec![0usize];
65 for (i, b) in src.bytes().enumerate() {
66 if b == b'\n' {
67 line_starts.push(i + 1);
68 }
69 }
70 let n = line_starts.len();
71 let line_of = |off: u32| line_starts.partition_point(|s| *s <= off as usize) - 1;
72 let line_end = |idx: usize| -> usize {
73 if idx + 1 < line_starts.len() {
74 line_starts[idx + 1] - 1
75 } else {
76 src.len()
77 }
78 };
79
80 let mut line_has_end = vec![false; n];
82 for t in &tokens {
83 if matches!(t.kind, TokenKind::End) {
84 line_has_end[line_of(t.span.start)] = true;
85 }
86 }
87
88 let mut verbatim_line = vec![false; n];
91 for t in &tokens {
92 if matches!(t.kind, TokenKind::Str(_) | TokenKind::InterpStr(_)) {
93 let start = line_of(t.span.start);
94 let end = line_of(t.span.end.saturating_sub(1));
95 for ln in verbatim_line.iter_mut().take(end + 1).skip(start + 1) {
96 *ln = true;
97 }
98 }
99 }
100
101 let mut infos = vec![LineInfo::default(); n];
103 let mut toks_on: Vec<Vec<&Token>> = vec![Vec::new(); n];
105 for t in &tokens {
106 if matches!(t.kind, TokenKind::Newline | TokenKind::Eof) {
107 continue;
108 }
109 let line = line_of(t.span.start);
110 toks_on[line].push(t);
111 let d: i32 = match t.kind {
112 TokenKind::Domain
113 | TokenKind::Def
114 | TokenKind::Type
115 | TokenKind::Enum
116 | TokenKind::New
117 | TokenKind::Cond
118 | TokenKind::LBracket
119 | TokenKind::LParen => 1,
120 TokenKind::End | TokenKind::RBracket | TokenKind::RParen => -1,
121 TokenKind::Arrow if line_has_end[line] => 1,
122 _ => 0,
123 };
124 let info = &mut infos[line];
125 info.delta += d;
126 info.min_prefix = info.min_prefix.min(info.delta);
127 info.ends_with_colon = matches!(t.kind, TokenKind::Colon);
128 info.ends_with_arrow = matches!(t.kind, TokenKind::Arrow) && !line_has_end[line];
129 info.continues = matches!(
130 t.kind,
131 TokenKind::Comma
132 | TokenKind::Assign
133 | TokenKind::Dot
134 | TokenKind::Question
135 | TokenKind::Plus
136 | TokenKind::Minus
137 | TokenKind::Star
138 | TokenKind::Slash
139 | TokenKind::Percent
140 | TokenKind::EqEq
141 | TokenKind::NotEq
142 | TokenKind::Lt
143 | TokenKind::Gt
144 | TokenKind::LtEq
145 | TokenKind::GtEq
146 | TokenKind::And
147 | TokenKind::Or
148 );
149 }
150
151 let mut lines: Vec<Line> = Vec::with_capacity(n);
153 let mut depth: i32 = 0;
154 let mut prev_continues = false;
155 for (idx, raw) in src.lines().enumerate() {
156 if verbatim_line[idx] {
157 lines.push(Line::Verbatim(raw.to_string()));
158 continue;
159 }
160 if raw.trim().is_empty() {
161 lines.push(Line::Blank);
162 continue;
163 }
164 let info = infos[idx];
165 let level = (depth + info.min_prefix.min(0) + i32::from(prev_continues)).max(0) as usize;
166 depth += info.delta + i32::from(info.ends_with_colon) + i32::from(info.ends_with_arrow);
167 prev_continues = info.continues;
168
169 let toks = &toks_on[idx];
170 if toks.is_empty() {
171 lines.push(Line::Comment {
172 level,
173 text: raw.trim().to_string(),
174 });
175 continue;
176 }
177 let le = line_end(idx);
178 let comment = trailing_comment(src, toks, le);
179 let body = analyze_line(src, toks, le, &info);
180 let code = match &body {
181 Body::Plain(s) => s.clone(),
182 Body::Anchored { .. } => String::new(), };
184 lines.push(Line::Code(CodeLine {
185 level,
186 code,
187 body,
188 comment,
189 }));
190 }
191
192 align_anchors(&mut lines);
193 align_comments(&mut lines);
194
195 let mut out = String::with_capacity(src.len());
197 let mut pending_blank = false;
198 let mut wrote_any = false;
199 for line in &lines {
200 match line {
201 Line::Blank => {
202 pending_blank = wrote_any;
203 }
204 other => {
205 if pending_blank {
206 out.push('\n');
207 pending_blank = false;
208 }
209 match other {
210 Line::Verbatim(s) => out.push_str(s.strip_suffix('\r').unwrap_or(s)),
213 Line::Comment { level, text } => {
214 push_indent(&mut out, *level);
215 out.push_str(text);
216 }
217 Line::Code(c) => {
218 push_indent(&mut out, c.level);
219 out.push_str(&c.code);
220 }
221 Line::Blank => unreachable!(),
222 }
223 out.push('\n');
224 wrote_any = true;
225 }
226 }
227 }
228
229 if token_shape(&out) != Some(token_shape_of(&tokens)) {
234 return Ok(src.to_string());
235 }
236 Ok(out)
237}
238
239fn token_shape_of(tokens: &[Token]) -> Vec<String> {
241 tokens
242 .iter()
243 .filter(|t| !matches!(t.kind, TokenKind::Newline | TokenKind::Eof))
244 .map(|t| format!("{:?}", t.kind))
245 .collect()
246}
247
248fn token_shape(src: &str) -> Option<Vec<String>> {
249 Lexer::new(src, 0)
250 .tokenize()
251 .ok()
252 .map(|ts| token_shape_of(&ts))
253}
254
255fn push_indent(out: &mut String, level: usize) {
256 for _ in 0..level {
257 out.push_str(INDENT);
258 }
259}
260
261fn trailing_comment(src: &str, toks: &[&Token], line_end: usize) -> Option<String> {
263 let last = toks.last()?;
264 if (last.span.end as usize) > line_end {
265 return None; }
267 let tail = src[last.span.end as usize..line_end].trim();
268 tail.starts_with('#').then(|| tail.to_string())
269}
270
271fn tok_text<'s>(src: &'s str, t: &Token, line_end: usize) -> &'s str {
274 let end = (t.span.end as usize).min(line_end);
275 &src[t.span.start as usize..end]
276}
277
278fn render_core(src: &str, toks: &[&Token], line_end: usize) -> String {
281 let mut s = String::new();
282 for (i, t) in toks.iter().enumerate() {
283 if i > 0 {
284 let gap = &src[toks[i - 1].span.end as usize..t.span.start as usize];
288 if !gap.is_empty() {
289 s.push(' ');
290 }
291 }
292 s.push_str(tok_text(src, t, line_end));
293 }
294 s
295}
296
297fn analyze_line(src: &str, toks: &[&Token], line_end: usize, info: &LineInfo) -> Body {
299 let plain = || Body::Plain(render_core(src, toks, line_end));
300 if info.ends_with_colon || info.ends_with_arrow {
301 return plain();
302 }
303 let pos = |k: fn(&TokenKind) -> bool| toks.iter().position(|t| k(&t.kind));
304
305 if let Some(i) = pos(|k| matches!(k, TokenKind::Assign)) {
306 if i + 1 < toks.len() {
307 return Body::Anchored {
308 kind: Kind::Assign,
309 field: render_core(src, &toks[..i], line_end),
310 joiner: " = ",
311 rest: render_core(src, &toks[i + 1..], line_end),
312 };
313 }
314 }
315 if let Some(i) = pos(|k| matches!(k, TokenKind::Colon)) {
316 if i > 0 && i + 1 < toks.len() {
317 return Body::Anchored {
318 kind: Kind::Colon,
319 field: render_core(src, &toks[..i], line_end) + ":",
320 joiner: " ",
321 rest: render_core(src, &toks[i + 1..], line_end),
322 };
323 }
324 }
325 if let Some(i) = pos(|k| matches!(k, TokenKind::Arrow)) {
326 let field = render_core(src, &toks[..i], line_end);
327 if i + 1 < toks.len() && field != "else" && !matches!(toks[0].kind, TokenKind::Def) {
328 return Body::Anchored {
329 kind: Kind::Arrow,
330 field,
331 joiner: " -> ",
332 rest: render_core(src, &toks[i + 1..], line_end),
333 };
334 }
335 }
336 plain()
337}
338
339fn align_anchors(lines: &mut [Line]) {
341 let mut i = 0;
342 while i < lines.len() {
343 let (kind, level) = match &lines[i] {
344 Line::Code(CodeLine {
345 body: Body::Anchored { kind, .. },
346 level,
347 ..
348 }) => (*kind, *level),
349 _ => {
350 i += 1;
351 continue;
352 }
353 };
354 let mut j = i;
356 let mut width = 0usize;
357 while j < lines.len() {
358 match &lines[j] {
359 Line::Code(CodeLine {
360 body: Body::Anchored { kind: k, field, .. },
361 level: l,
362 ..
363 }) if *k == kind && *l == level => {
364 width = width.max(field.chars().count());
365 j += 1;
366 }
367 _ => break,
368 }
369 }
370 for line in &mut lines[i..j] {
371 if let Line::Code(c) = line {
372 if let Body::Anchored {
373 field,
374 joiner,
375 rest,
376 ..
377 } = &c.body
378 {
379 let pad = width - field.chars().count();
380 c.code = format!("{field}{}{joiner}{rest}", " ".repeat(pad));
381 }
382 }
383 }
384 i = j;
385 }
386}
387
388fn align_comments(lines: &mut [Line]) {
390 let mut i = 0;
391 while i < lines.len() {
392 let level = match &lines[i] {
393 Line::Code(CodeLine { level, .. }) => *level,
394 _ => {
395 i += 1;
396 continue;
397 }
398 };
399 let mut j = i;
400 let mut col = 0usize;
401 while j < lines.len() {
402 match &lines[j] {
403 Line::Code(c) if c.level == level => {
404 if c.comment.is_some() {
405 col = col.max(c.code.chars().count() + 1);
406 }
407 j += 1;
408 }
409 _ => break,
410 }
411 }
412 for line in &mut lines[i..j] {
413 if let Line::Code(c) = line {
414 if let Some(comment) = &c.comment {
415 let pad = col.saturating_sub(c.code.chars().count());
416 c.code = format!("{}{}{comment}", c.code, " ".repeat(pad));
417 }
418 }
419 }
420 i = j.max(i + 1);
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 const MANIFEST: &str = include_str!("../../tests/fixtures/production_deploy.aura");
429
430 fn kinds(src: &str) -> Vec<TokenKind<'_>> {
431 Lexer::new(src, 0)
432 .tokenize()
433 .expect("lex ok")
434 .into_iter()
435 .map(|t| t.kind)
436 .collect()
437 }
438
439 #[test]
440 fn aligns_assignments_and_comments() {
441 let messy = "a = 1 # one\nlonger_name = 2\nc = 3 # three\n";
442 let want = "a = 1 # one\nlonger_name = 2\nc = 3 # three\n";
443 assert_eq!(format_source(messy).unwrap(), want);
444 }
445
446 #[test]
447 fn aligns_properties_by_colon() {
448 let messy = "sum: 1\nfloat_div: 2\n";
449 let want = "sum: 1\nfloat_div: 2\n";
450 assert_eq!(format_source(messy).unwrap(), want);
451 }
452
453 #[test]
454 fn aligns_cond_arrows_but_not_else() {
455 let messy =
456 "t: cond\n region == \"eu-central\" -> \"a\"\n region == \"us\" -> \"b\"\n else -> \"c\"\nend\n";
457 let want = "t: cond\n region == \"eu-central\" -> \"a\"\n region == \"us\" -> \"b\"\n else -> \"c\"\nend\n";
458 assert_eq!(format_source(messy).unwrap(), want);
459 }
460
461 #[test]
462 fn backstop_leaves_token_changing_input_untouched() {
463 let src = "x =text";
467 assert_eq!(format_source(src).unwrap(), src);
468 }
469
470 #[test]
471 fn verbatim_line_with_trailing_cr_is_idempotent() {
472 let once = format_source("k: text\n body\nend\r").unwrap();
476 assert!(!once.contains('\r'));
477 assert_eq!(format_source(&once).unwrap(), once);
478 }
479
480 #[test]
481 fn bare_cr_between_tokens_does_not_fuse_them() {
482 let once = format_source("a\rb\n").unwrap();
486 assert_eq!(once, "a b\n");
487 assert_eq!(format_source(&once).unwrap(), once);
488 }
489
490 #[test]
491 fn collapses_extra_spaces_outside_strings() {
492 assert_eq!(
493 format_source("x = \"a b\" + 1\n").unwrap(),
494 "x = \"a b\" + 1\n"
495 );
496 }
497
498 #[test]
499 fn blank_line_breaks_an_alignment_run() {
500 let src = "a = 1\nbb = 2\n\nc = 3\n";
501 assert_eq!(format_source(src).unwrap(), "a = 1\nbb = 2\n\nc = 3\n");
502 }
503
504 #[test]
505 fn normalizes_indentation() {
506 let messy = "domain \"d\"\n x: 1\n security:\n tls: true\n end\nend\n";
507 let want = "domain \"d\"\n x: 1\n security:\n tls: true\n end\nend\n";
508 assert_eq!(format_source(messy).unwrap(), want);
509 }
510
511 #[test]
512 fn block_string_interior_is_preserved() {
513 let messy =
514 "domain \"d\"\n script: text\n #!/bin/sh\n echo hi\n end\nx: 1\nend\n";
515 let want = "domain \"d\"\n script: text\n #!/bin/sh\n echo hi\n end\n x: 1\nend\n";
516 assert_eq!(format_source(messy).unwrap(), want);
517 assert_eq!(kinds(messy), kinds(&format_source(messy).unwrap()));
518 }
519
520 #[test]
522 fn token_stream_is_preserved() {
523 let formatted = format_source(MANIFEST).unwrap();
524 assert_eq!(kinds(MANIFEST), kinds(&formatted), "fmt changed semantics");
525 }
526
527 #[test]
529 fn is_idempotent() {
530 let once = format_source(MANIFEST).unwrap();
531 let twice = format_source(&once).unwrap();
532 assert_eq!(once, twice);
533 }
534}