Skip to main content

aura_lang/fmt/
mod.rs

1//! `aura fmt`: canonical formatter (SPEC §7.2).
2//!
3//! Three things happen, all whitespace-only (the token stream never changes):
4//! 1. **Indentation** is normalized to 2 spaces per level.
5//! 2. **Intra-line spacing** is canonicalized: runs of spaces/tabs between tokens
6//!    collapse to a single space (strings and trailing comments are untouched).
7//! 3. **Column alignment**: consecutive `name = value`, `key: value` and `cond`
8//!    arms (`cond -> value`) are aligned in columns, together with their trailing
9//!    `# comments`. A blank line, a comment line, a different construct or an
10//!    indent change ends a run. The `else` arm is left unaligned.
11//!
12//! Block-string interiors (D16) are emitted verbatim.
13
14use 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/// One anchor kind for column alignment.
29#[derive(PartialEq, Clone, Copy)]
30enum Kind {
31    Assign, // name = value
32    Colon,  // key: value
33    Arrow,  // cond -> value
34}
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    /// Filled by the anchor pass; holds the aligned code without the comment.
49    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    // A line with an `end`: an arrow on it is an inline lambda (opens+closes there).
81    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    // D16 block strings are single multi-line string tokens; their interior lines
89    // are emitted verbatim (only the opener line is formatted).
90    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    // Depth deltas per line + the "opens/continues a block" flags.
102    let mut infos = vec![LineInfo::default(); n];
103    // Tokens on each physical line (excluding Newline/Eof), in order.
104    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    // Pass 0: classify and canonically render each physical line.
152    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(), // filled by the anchor pass
183        };
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    // Emit with indentation and blank-line normalization.
196    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                    // Drop a trailing bare `\r`: `str::lines()` keeps it only at EOF,
211                    // and re-emitting `\r\n` would then collapse it (non-idempotent).
212                    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    // Backstop: formatting is whitespace-only and must never change the token
230    // stream. On a pathological input where the added trailing newline or a
231    // collapsed gap would (e.g. a bare `text` RHS becoming a D16 block opener),
232    // leave the file untouched rather than corrupt it.
233    if token_shape(&out) != Some(token_shape_of(&tokens)) {
234        return Ok(src.to_string());
235    }
236    Ok(out)
237}
238
239/// Non-trivia token kinds as debug strings (lifetime-free, comparable across inputs).
240fn 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
261/// The `#…` trailing comment on a line, if any (never inside a multi-line token).
262fn 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; // a block-string opener: no room for a comment
266    }
267    let tail = src[last.span.end as usize..line_end].trim();
268    tail.starts_with('#').then(|| tail.to_string())
269}
270
271/// A token's text, truncated to its own line (a multi-line block-string token
272/// contributes only its opener word, e.g. `text`).
273fn 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
278/// Join tokens with canonical spacing: one space where the source had any
279/// whitespace between two tokens, none otherwise. Collapses runs and tabs.
280fn 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            // Any trivia between two tokens (space, tab, or a bare `\r` that
285            // `src.lines()` did not treat as a break) means they were separated,
286            // so emit exactly one space. Never zero — that could fuse two tokens.
287            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
297/// Decide a line's alignment anchor (or none). Block-opener lines never anchor.
298fn 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
339/// Column-align consecutive anchored lines of the same kind and indent level.
340fn 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        // Extend the run over same-kind, same-level anchored lines.
355        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
388/// Align trailing comments within runs of consecutive code lines at one level.
389fn 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        // Fuzz regression: `x =text` (no newline) has `text` as a plain ident;
464        // adding a trailing newline would make it a D16 block opener. The backstop
465        // returns the input unchanged rather than corrupt the token stream.
466        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        // Fuzz regression: a block-string terminator line ending in a bare `\r`
473        // (no `\n`) is emitted verbatim; without stripping it, `\r\n` on the first
474        // pass collapses on the second -> non-idempotent.
475        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        // Fuzz regression: a bare `\r` is trivia to the lexer but not a line break
483        // to src.lines(); the two idents must stay separated, and the output must
484        // re-lex identically (idempotence).
485        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    /// Safety invariant: formatting never changes the token stream.
521    #[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    /// Idempotence: running it again changes nothing.
528    #[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}