Skip to main content

logicaffeine_language/
source_format.rs

1//! The canonical LOGOS source formatter.
2//!
3//! One rule set shared by every formatting surface (the LSP's
4//! `textDocument/formatting` provider, its on-type formatter, and
5//! `largo fmt`):
6//!
7//! - code lines reindent to **4 spaces per lexed nesting level** — the
8//!   depth comes from the lexer's own Indent/Dedent reading, so the
9//!   canonical form is the structure the program already has,
10//! - trailing whitespace is removed from code lines; CRLF normalizes to LF,
11//! - multiline-string interiors are **content** — not a byte is touched,
12//! - `## Note`/`## Example` bodies are the author's prose — untouched
13//!   (markdown hard-breaks and nested-list indents survive),
14//! - comment-only lines keep their author indent (tabs still normalize),
15//! - the presence or absence of a final newline is preserved.
16//!
17//! The prime directive, locked by `formatter_locks.rs` in the test suite:
18//! **formatting never changes the token stream** — and it is a fixed point
19//! (`format_source(format_source(s)) == format_source(s)`).
20
21use crate::lexer::Lexer;
22use crate::token::{BlockType, TokenType};
23use logicaffeine_base::Interner;
24
25/// Format a single line: normalize leading whitespace (tab → 4 spaces) and
26/// strip trailing whitespace. Content between the indent and the trailing
27/// whitespace is untouched. Line-local — the on-type formatting rule; the
28/// whole-document [`format_source`] adds structure awareness on top.
29pub fn format_line(line: &str) -> String {
30    let trimmed_start = line.trim_start();
31    let leading = &line[..line.len() - trimmed_start.len()];
32    let mut out = String::with_capacity(line.len());
33    for ch in leading.chars() {
34        if ch == '\t' {
35            out.push_str("    ");
36        } else {
37            out.push(ch);
38        }
39    }
40    out.push_str(trimmed_start.trim_end());
41    // A whitespace-only line reduces to empty.
42    if out.chars().all(|c| c == ' ') {
43        out.clear();
44    }
45    out
46}
47
48/// Per-line formatting plan, derived from one lexer pass.
49#[derive(Clone, Copy, PartialEq)]
50enum LinePlan {
51    /// Code with a known nesting depth: reindent to `depth * 4` spaces.
52    Code(usize),
53    /// Code-adjacent but depthless (blank, comment-only): line-local rules.
54    Plain,
55    /// String interior or documentation prose: not a byte changes.
56    Raw,
57}
58
59/// Format a whole source text: structural reindentation for code, raw
60/// passthrough for string interiors and prose, LF endings, preserved final
61/// newline.
62pub fn format_source(source: &str) -> String {
63    if source.is_empty() {
64        return String::new();
65    }
66
67    let plans = line_plans(source);
68    let mut out: String = source
69        .lines()
70        .enumerate()
71        .map(|(i, line)| match plans.get(i).copied().unwrap_or(LinePlan::Plain) {
72            LinePlan::Raw => line.to_string(),
73            LinePlan::Plain => format_line(line),
74            LinePlan::Code(depth) => {
75                let content = line.trim_start().trim_end();
76                if content.is_empty() {
77                    String::new()
78                } else {
79                    let mut formatted = " ".repeat(depth * 4);
80                    formatted.push_str(content);
81                    formatted
82                }
83            }
84        })
85        .collect::<Vec<_>>()
86        .join("\n");
87    if source.ends_with('\n') {
88        out.push('\n');
89    }
90    out
91}
92
93/// One lexer pass over the source decides how every line formats.
94fn line_plans(source: &str) -> Vec<LinePlan> {
95    let line_starts: Vec<usize> = std::iter::once(0)
96        .chain(source.match_indices('\n').map(|(i, _)| i + 1))
97        .collect();
98    let line_of = |offset: usize| match line_starts.binary_search(&offset) {
99        Ok(i) => i,
100        Err(i) => i - 1,
101    };
102    let line_count = source.lines().count();
103    let mut plans = vec![LinePlan::Plain; line_count];
104
105    let mut interner = Interner::new();
106    let mut lexer = Lexer::new(source, &mut interner);
107    let tokens = lexer.tokenize();
108
109    // Pass 1: nesting depth per line — the depth in effect at each line's
110    // first token, from the lexer's own Indent/Dedent reading.
111    let mut depth: usize = 0;
112    let mut current_line: Option<usize> = None;
113    for token in &tokens {
114        match &token.kind {
115            TokenType::Indent => depth += 1,
116            TokenType::Dedent => depth = depth.saturating_sub(1),
117            TokenType::Newline | TokenType::EOF => {}
118            _ => {
119                let line = line_of(token.span.start);
120                if current_line != Some(line) {
121                    current_line = Some(line);
122                    if let Some(plan) = plans.get_mut(line) {
123                        *plan = LinePlan::Code(depth);
124                    }
125                }
126            }
127        }
128    }
129
130    // Pass 2: multiline tokens (string/escape-block interiors) are content.
131    // Every line a token SPANS beyond its first is raw, and so is the first
132    // line of any multi-line token — its trailing side is inside the token.
133    for token in &tokens {
134        let start_line = line_of(token.span.start);
135        let end_line = line_of(token.span.end.saturating_sub(1).max(token.span.start));
136        if end_line > start_line {
137            for line in start_line..=end_line {
138                if let Some(plan) = plans.get_mut(line) {
139                    *plan = LinePlan::Raw;
140                }
141            }
142        }
143    }
144
145    // Pass 3: documentation prose (`## Note` / `## Example` bodies) belongs
146    // to the author — raw from the line after the header to the next block.
147    let mut in_prose = false;
148    let mut prose_from = 0usize;
149    let mut mark_prose = |plans: &mut Vec<LinePlan>, from: usize, to: usize| {
150        for plan in plans.iter_mut().take(to).skip(from) {
151            *plan = LinePlan::Raw;
152        }
153    };
154    for token in &tokens {
155        if let TokenType::BlockHeader { block_type } = &token.kind {
156            let header_line = line_of(token.span.start);
157            if in_prose {
158                mark_prose(&mut plans, prose_from, header_line);
159            }
160            in_prose = matches!(block_type, BlockType::Note | BlockType::Example);
161            prose_from = header_line + 1;
162        }
163    }
164    if in_prose {
165        mark_prose(&mut plans, prose_from, line_count);
166    }
167
168    plans
169}