Skip to main content

decl_lang/
fmt.rs

1//! Canonical formatter — a port of the reference implementation's fmt.ts
2//! (ROADMAP Phase 4; §2.1/D1): LF, 4-space indentation, no tabs,
3//! normalized intra-line spacing. The original line structure is
4//! preserved — §2.9 makes newlines separators, so where a construct
5//! breaks lines is the author's statement — and the formatter re-derives
6//! indentation and token spacing deterministically, which makes it
7//! idempotent by construction.
8use crate::parse::LANGUAGE;
9use tree_sitter::{Language, Node, Parser};
10
11struct Leaf {
12    text: String,
13    kind: String,
14    parent: String,
15    row: usize,
16    end_row: usize,
17    col: usize,
18}
19
20// atoms: leaves kept verbatim, including their internal whitespace
21const ATOMS: [&str; 7] = [
22    "string",
23    "template_string",
24    "pattern",
25    "unit_literal",
26    "doc_comment",
27    "line_comment",
28    "block_comment",
29];
30const BIN_OPS: [&str; 24] = [
31    "=", "==", "!=", "<=", ">=", "+", "*", "/", "%", "&&", "||", "??", "|>", "=>", "<<", ">>",
32    "in", "matches", "with", "then", "else", "for", "if", "as",
33];
34const BIN_OPS_EXTRA: [&str; 1] = ["from"];
35const CONT_STARTERS: [&str; 22] = [
36    "else", "=", "for", "if", "&&", "||", "|>", "??", ".", "?.", "+", "-", "*", "/", "==", "!=",
37    "<=", ">=", "<", ">", "=>", "then",
38];
39// a line whose last token leaves an expression open (`=`, `=>`, a binary
40// operator, `then`/`else`) makes the next line a continuation too
41const CONT_ENDERS: [&str; 29] = [
42    "=", "=>", "&&", "||", "|>", "??", "+", "-", "*", "/", "%", "==", "!=", "<=", ">=", "<", ">",
43    "&", "|", "^", "<<", ">>", "..", "..<", "then", "else", "in", "with", "matches",
44];
45const KEYWORDS: [&str; 27] = [
46    "type",
47    "const",
48    "func",
49    "output",
50    "input",
51    "export",
52    "import",
53    "diagnostic",
54    "dimension",
55    "unit",
56    "assert",
57    "when",
58    "if",
59    "then",
60    "else",
61    "match",
62    "for",
63    "in",
64    "with",
65    "as",
66    "from",
67    "true",
68    "false",
69    "null",
70    "error",
71    "warn",
72    "info",
73];
74const KEYWORDS_EXTRA: [&str; 1] = ["matches"];
75
76/// a JavaScript string's length: UTF-16 code units
77pub fn u16len(s: &str) -> usize {
78    s.encode_utf16().count()
79}
80fn is_atom(kind: &str) -> bool {
81    ATOMS.contains(&kind)
82}
83// name-like: `[A-Za-z_$][A-Za-z0-9_]*\$?` — a hidden member's name `x$` is name-like too (D34)
84fn keywordy(t: &str) -> bool {
85    let mut cs = t.chars();
86    let Some(c0) = cs.next() else { return false };
87    if !(c0.is_ascii_alphabetic() || c0 == '_' || c0 == '$') {
88        return false;
89    }
90    let rest: Vec<char> = cs.collect();
91    let body: &[char] = match rest.split_last() {
92        Some(('$', b)) => b,
93        _ => &rest[..],
94    };
95    body.iter().all(|c| c.is_ascii_alphanumeric() || *c == '_')
96}
97fn is_keyword(t: &str) -> bool {
98    KEYWORDS.contains(&t) || KEYWORDS_EXTRA.contains(&t)
99}
100fn is_bin_op(t: &str) -> bool {
101    BIN_OPS.contains(&t) || BIN_OPS_EXTRA.contains(&t)
102}
103
104fn collect(n: Node, src: &str, lines: &[&str], out: &mut Vec<Leaf>) {
105    if is_atom(n.kind()) || n.child_count() == 0 {
106        let text = n.utf8_text(src.as_bytes()).unwrap_or("");
107        if text.is_empty() {
108            return; // zero-width externals (NEWLINE)
109        }
110        let row = n.start_position().row;
111        let col = lines
112            .get(row)
113            .map(|l| u16len(l.get(..n.start_position().column).unwrap_or("")))
114            .unwrap_or(0);
115        out.push(Leaf {
116            text: text.to_string(),
117            kind: n.kind().to_string(),
118            parent: n.parent().map(|p| p.kind().to_string()).unwrap_or_default(),
119            row,
120            end_row: n.end_position().row,
121            col,
122        });
123        return;
124    }
125    let mut cur = n.walk();
126    for c in n.children(&mut cur) {
127        collect(c, src, lines, out);
128    }
129}
130
131fn is_type_angle(l: &Leaf) -> bool {
132    (l.text == "<" || l.text == ">")
133        && (l.parent == "type_arguments" || l.parent == "type_parameters")
134}
135
136/// spacing decision: does a space go between a and b on one line?
137fn spaced(a: &Leaf, b: &Leaf, prev: Option<&Leaf>) -> bool {
138    let (at, bt) = (a.text.as_str(), b.text.as_str());
139    // comments keep at least one space before them (handled by caller)
140    if b.kind.ends_with("comment") {
141        return true;
142    }
143    if is_type_angle(a) && at == "<" {
144        return false;
145    }
146    if is_type_angle(b) {
147        return false; // Vec<...>, no space before either angle
148    }
149    if at == "(" || at == "[" {
150        return false;
151    }
152    if bt == ")" || bt == "]" || bt == "," || bt == ":" {
153        return false;
154    }
155    if bt == "?" || at == "?" {
156        return false; // int?, name?:
157    }
158    if at == "." || bt == "." || at == "?." || bt == "?." {
159        return false;
160    }
161    if bt == ";" {
162        return false;
163    }
164    if at == ".." || at == "..<" || bt == ".." || bt == "..<" {
165        return false;
166    }
167    if bt == "(" {
168        // call/parameter parens attach to a name or closing bracket; grouping parens do not
169        return !(keywordy(at) && !is_keyword(at)) && at != ")" && at != "]" && !is_type_angle(a);
170    }
171    if bt == "[" {
172        // index/size brackets attach (also after a record type or literal: `{...}[]`); array literals
173        // stand off, and a keyword before a literal array (`in [1, 2]`) does not attach
174        return !((keywordy(at) && !is_keyword(at))
175            || at == ")"
176            || at == "]"
177            || at == "}"
178            || is_type_angle(a));
179    }
180    if at == "{" || bt == "}" {
181        return true; // { a: 1 }
182    }
183    if bt == "{" || at == "}" {
184        return true;
185    }
186    if at == "!" || at == "~" {
187        return false; // unary
188    }
189    if at == "-" || at == "+" {
190        // unary sign: previous token is an operator, opener, or keyword
191        let unary = match prev {
192            None => true,
193            Some(p) => {
194                let pt = p.text.as_str();
195                is_bin_op(pt)
196                    || [
197                        "(", "[", "{", ",", ":", "<", "..", "..<", "-", "+", "!", "~",
198                    ]
199                    .contains(&pt)
200                    || (keywordy(pt) && is_keyword(pt))
201            }
202        };
203        if unary {
204            return false;
205        }
206    }
207    true
208}
209
210pub fn format(src: &str) -> Result<String, String> {
211    let mut parser = Parser::new();
212    let lang: Language = LANGUAGE.into();
213    parser.set_language(&lang).map_err(|e| e.to_string())?;
214    let tree = parser.parse(src, None).ok_or("parse failed")?;
215    if tree.root_node().has_error() {
216        return Err("cannot format: file has parse errors".into());
217    }
218    let src_lines: Vec<&str> = src.split('\n').collect();
219    let mut leaves: Vec<Leaf> = vec![];
220    collect(tree.root_node(), src, &src_lines, &mut leaves);
221
222    // group leaves by their original starting row
223    let mut lines: Vec<Vec<Leaf>> = vec![];
224    for l in leaves {
225        match lines.last_mut() {
226            Some(bucket) if bucket[0].row == l.row => bucket.push(l),
227            _ => lines.push(vec![l]),
228        }
229    }
230
231    let mut out: Vec<String> = vec![];
232    let mut depth: usize = 0;
233    let mut last_row_end: i64 = -1; // last original row consumed (multiline atoms span rows)
234    let mut last_code: Option<&Leaf> = None; // the previous line's last non-comment token
235    for line in &lines {
236        let first = &line[0];
237        if (first.row as i64) <= last_row_end {
238            continue; // inside a multiline atom
239        }
240        // one blank line max between constructs
241        if !out.is_empty() && (first.row as i64) > last_row_end + 1 {
242            out.push(String::new());
243        }
244        // indentation: bracket depth, closers on the line start dedent first
245        let closers = line
246            .iter()
247            .take_while(|l| l.text == ")" || l.text == "]" || l.text == "}")
248            .count();
249        let mut indent = depth.saturating_sub(closers);
250        // a line starting with a continuation token, or following a line that
251        // left an expression open, hangs one level deeper
252        // (`ref<...>` closes a type, it opens nothing)
253        let after_open = last_code
254            .map(|l| {
255                !is_atom(&l.kind) && CONT_ENDERS.contains(&l.text.as_str()) && !is_type_angle(l)
256            })
257            .unwrap_or(false);
258        if closers == 0 && (CONT_STARTERS.contains(&first.text.as_str()) || after_open) {
259            indent = depth + 1;
260        }
261        let mut text = "    ".repeat(indent);
262        let mut prev: Option<&Leaf> = None;
263        let mut prev2: Option<&Leaf> = None;
264        for l in line {
265            if let Some(p) = prev {
266                if l.kind.ends_with("comment") {
267                    // inline comment: keep the author's alignment (min one space)
268                    let gap = (l.col as i64) - ((p.col + u16len(&p.text)) as i64);
269                    text.push_str(&" ".repeat(gap.max(1) as usize));
270                } else if spaced(p, l, prev2) {
271                    text.push(' ');
272                }
273            }
274            text.push_str(&l.text);
275            if !is_atom(&l.kind) {
276                for ch in l.text.chars() {
277                    match ch {
278                        '{' | '[' | '(' => depth += 1,
279                        '}' | ']' | ')' => depth = depth.saturating_sub(1),
280                        _ => {}
281                    }
282                }
283            }
284            prev2 = prev;
285            prev = Some(l);
286            if !l.kind.ends_with("comment") {
287                last_code = Some(l);
288            }
289            last_row_end = last_row_end.max(l.end_row as i64);
290        }
291        out.push(text.trim_end_matches([' ', '\t']).to_string());
292    }
293    Ok(out.join("\n") + "\n")
294}