Skip to main content

only_syntax/
formatter.rs

1use rowan::NodeOrToken;
2use text_size::TextRange;
3
4use crate::{
5    DirectiveKind, DirectiveNode, DocCommentNode, NamespaceNode, ParameterNode, SyntaxKind,
6    SyntaxNode, TaskHeaderNode, TaskNode, snapshot,
7};
8
9const INDENT: &str = "    ";
10
11/// Formats an Onlyfile using the built-in deterministic style.
12pub fn format_source(source: &str) -> Result<String, String> {
13    let parsed = snapshot(source);
14    if let Some(diagnostic) = parsed
15        .diagnostics()
16        .iter()
17        .find(|item| item.severity == only_diagnostic::DiagnosticSeverity::Error)
18    {
19        return Err(diagnostic.message.clone());
20    }
21
22    let cst_source = parsed.root().text().to_string();
23    let mut formatter = DocumentFormatter::new(&cst_source);
24    formatter.format(parsed.root())?;
25    Ok(formatter.finish())
26}
27
28/// Formats the single top-level declaration touched by a source range.
29pub fn format_range(source: &str, range: TextRange) -> Result<Option<(TextRange, String)>, String> {
30    let parsed = snapshot(source);
31    if let Some(diagnostic) = parsed
32        .diagnostics()
33        .iter()
34        .find(|item| item.severity == only_diagnostic::DiagnosticSeverity::Error)
35    {
36        return Err(diagnostic.message.clone());
37    }
38
39    let cst_source = parsed.root().text().to_string();
40    let mut matches = parsed
41        .root()
42        .children()
43        .filter(|node| is_formattable_node(node.kind()))
44        .filter(|node| ranges_touch(node.text_range(), range));
45    let Some(node) = matches.next() else {
46        return Ok(None);
47    };
48    if matches.next().is_some() {
49        return Ok(None);
50    }
51
52    let syntax_range = node.text_range();
53    let node_range = include_leading_indent(&cst_source, syntax_range);
54    let indent = is_inside_braced_namespace(parsed.root(), syntax_range.start());
55    let (_, mut formatted) = format_top_level_node(node, &cst_source)?;
56    if indent {
57        formatted = indent_lines(&formatted);
58    }
59    formatted.push('\n');
60    Ok(Some((node_range, formatted)))
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum ItemKind {
65    Directive,
66    Comment,
67    DocComment,
68    LegacyNamespace,
69    NamespaceOpen,
70    NamespaceClose,
71    Task,
72}
73
74struct DocumentFormatter<'a> {
75    source: &'a str,
76    output: String,
77    previous: Option<ItemKind>,
78    pending_newlines: usize,
79    in_braced_namespace: bool,
80}
81
82impl<'a> DocumentFormatter<'a> {
83    fn new(source: &'a str) -> Self {
84        Self {
85            source,
86            output: String::new(),
87            previous: None,
88            pending_newlines: 0,
89            in_braced_namespace: false,
90        }
91    }
92
93    fn format(&mut self, root: &SyntaxNode) -> Result<(), String> {
94        for element in root.children_with_tokens() {
95            match element {
96                NodeOrToken::Token(token) => match token.kind() {
97                    SyntaxKind::Bom => self.output.push_str(token.text()),
98                    SyntaxKind::Newline => self.pending_newlines += 1,
99                    SyntaxKind::Comment => {
100                        self.push_item(ItemKind::Comment, token.text().trim_end())
101                    }
102                    SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Eof => {}
103                    _ => self.push_raw(token.text()),
104                },
105                NodeOrToken::Node(node) => self.format_node(node)?,
106            }
107        }
108        Ok(())
109    }
110
111    fn format_node(&mut self, node: SyntaxNode) -> Result<(), String> {
112        let (kind, text) = format_top_level_node(node, self.source)?;
113        self.push_item(kind, &text);
114        Ok(())
115    }
116
117    fn push_item(&mut self, kind: ItemKind, text: &str) {
118        if matches!(
119            kind,
120            ItemKind::LegacyNamespace | ItemKind::NamespaceOpen | ItemKind::NamespaceClose
121        ) {
122            self.in_braced_namespace = false;
123        }
124        if !self.output.is_empty() && !self.output.ends_with('\n') {
125            self.output.push('\n');
126        }
127
128        let line_breaks_after_comment = usize::from(self.previous == Some(ItemKind::Comment));
129        let source_has_blank = self.pending_newlines > line_breaks_after_comment;
130        let consecutive_directives =
131            self.previous == Some(ItemKind::Directive) && kind == ItemKind::Directive;
132        let namespace_boundary =
133            self.previous == Some(ItemKind::NamespaceOpen) || kind == ItemKind::NamespaceClose;
134        if !self.output.is_empty()
135            && ((source_has_blank && !consecutive_directives && !namespace_boundary)
136                || needs_structural_blank(self.previous, kind))
137            && !self.output.ends_with("\n\n")
138        {
139            self.output.push('\n');
140        }
141
142        let text = text.trim_end_matches(['\n', '\r']);
143        if self.in_braced_namespace {
144            self.output.push_str(&indent_lines(text));
145        } else {
146            self.output.push_str(text);
147        }
148        self.output.push('\n');
149        if kind == ItemKind::NamespaceOpen {
150            self.in_braced_namespace = true;
151        }
152        self.previous = Some(kind);
153        self.pending_newlines = 0;
154    }
155
156    fn push_raw(&mut self, text: &str) {
157        self.output.push_str(text);
158        self.pending_newlines = 0;
159    }
160
161    fn finish(mut self) -> String {
162        while self.output.ends_with("\n\n") {
163            self.output.pop();
164        }
165        if !self.output.is_empty() && !self.output.ends_with('\n') {
166            self.output.push('\n');
167        }
168        self.output
169    }
170}
171
172fn format_top_level_node(node: SyntaxNode, source: &str) -> Result<(ItemKind, String), String> {
173    match node.kind() {
174        SyntaxKind::Directive => {
175            let directive = DirectiveNode::cast(node).expect("directive kind must cast");
176            Ok((ItemKind::Directive, format_directive(&directive, source)?))
177        }
178        SyntaxKind::DocComment => {
179            let comment = DocCommentNode::cast(node).expect("doc comment kind must cast");
180            let text = comment
181                .text()
182                .map_or_else(|| "#".to_owned(), |text| format!("# {text}"));
183            Ok((ItemKind::DocComment, text))
184        }
185        SyntaxKind::NamespaceBlock => {
186            let namespace = NamespaceNode::cast(node).expect("namespace kind must cast");
187            if namespace.is_close() {
188                Ok((ItemKind::NamespaceClose, "}".to_owned()))
189            } else {
190                let name = namespace
191                    .name()
192                    .ok_or_else(|| "invalid namespace".to_owned())?;
193                if namespace.has_open_brace() {
194                    Ok((ItemKind::NamespaceOpen, format!("[{name}] {{")))
195                } else {
196                    Ok((ItemKind::LegacyNamespace, format!("[{name}]")))
197                }
198            }
199        }
200        SyntaxKind::TaskDecl => {
201            let task = TaskNode::cast(node).expect("task kind must cast");
202            Ok((ItemKind::Task, format_task(&task, source)?))
203        }
204        SyntaxKind::Error => Err("cannot format invalid syntax".to_owned()),
205        _ => Err("range does not contain a declaration".to_owned()),
206    }
207}
208
209fn is_formattable_node(kind: SyntaxKind) -> bool {
210    matches!(
211        kind,
212        SyntaxKind::Directive
213            | SyntaxKind::DocComment
214            | SyntaxKind::NamespaceBlock
215            | SyntaxKind::TaskDecl
216    )
217}
218
219fn ranges_touch(node: TextRange, requested: TextRange) -> bool {
220    if requested.is_empty() {
221        node.start() <= requested.start() && requested.start() < node.end()
222    } else {
223        node.start() < requested.end() && requested.start() < node.end()
224    }
225}
226
227fn needs_structural_blank(previous: Option<ItemKind>, current: ItemKind) -> bool {
228    let Some(previous) = previous else {
229        return false;
230    };
231    if current == ItemKind::NamespaceClose || previous == ItemKind::NamespaceOpen {
232        return false;
233    }
234    if previous == ItemKind::DocComment || previous == ItemKind::Comment {
235        return false;
236    }
237    if current == ItemKind::DocComment || current == ItemKind::Comment {
238        return !matches!(previous, ItemKind::DocComment | ItemKind::Comment);
239    }
240    if matches!(
241        previous,
242        ItemKind::LegacyNamespace | ItemKind::NamespaceClose
243    ) || matches!(current, ItemKind::LegacyNamespace | ItemKind::NamespaceOpen)
244    {
245        return true;
246    }
247    if previous == ItemKind::Directive && current == ItemKind::Directive {
248        return false;
249    }
250    previous == ItemKind::Task
251        || current == ItemKind::Task
252        || previous == ItemKind::Directive
253        || current == ItemKind::Directive
254}
255
256fn is_inside_braced_namespace(root: &SyntaxNode, offset: text_size::TextSize) -> bool {
257    let mut inside = false;
258    for node in root.children() {
259        if node.text_range().start() >= offset {
260            break;
261        }
262        let Some(namespace) = NamespaceNode::cast(node) else {
263            continue;
264        };
265        if namespace.is_close() {
266            inside = false;
267        } else {
268            inside = namespace.has_open_brace();
269        }
270    }
271    inside
272}
273
274fn indent_lines(text: &str) -> String {
275    text.lines()
276        .map(|line| {
277            if line.is_empty() {
278                String::new()
279            } else {
280                format!("{INDENT}{line}")
281            }
282        })
283        .collect::<Vec<_>>()
284        .join("\n")
285}
286
287fn include_leading_indent(source: &str, range: TextRange) -> TextRange {
288    let start = usize::from(range.start());
289    let line_start = source[..start].rfind('\n').map_or(0, |index| index + 1);
290    if source[line_start..start]
291        .chars()
292        .all(|character| matches!(character, ' ' | '\t'))
293    {
294        TextRange::new((line_start as u32).into(), range.end())
295    } else {
296        range
297    }
298}
299
300fn format_directive(directive: &DirectiveNode, source: &str) -> Result<String, String> {
301    let name = directive
302        .name()
303        .ok_or_else(|| "invalid directive".to_owned())?;
304    let raw_value = directive.raw_value().unwrap_or_default();
305    if directive.directive_kind() == Some(DirectiveKind::Var) {
306        let (variable, value) = raw_value
307            .split_once('=')
308            .ok_or_else(|| "invalid variable directive".to_owned())?;
309        return Ok(format!("!var {} = {}", variable.trim(), value.trim()));
310    }
311    if raw_value.is_empty() {
312        return Ok(format!("!{name}"));
313    }
314
315    // Slice CST-owned text to retain the original string spelling.
316    let raw = source_range(source, directive.range());
317    let value = raw
318        .trim()
319        .strip_prefix('!')
320        .and_then(|text| text.strip_prefix(name.as_str()))
321        .map(str::trim)
322        .unwrap_or(raw_value.as_str());
323    Ok(format!("!{name} {value}"))
324}
325
326fn format_task(task: &TaskNode, source: &str) -> Result<String, String> {
327    let header = task
328        .header()
329        .ok_or_else(|| "task has no header".to_owned())?;
330    let mut output = format_header(&header, source)?;
331    let body = source_range(
332        source,
333        TextRange::new(header.range().end(), task.range().end()),
334    );
335    let body = format_task_body(body);
336    if !body.is_empty() {
337        output.push('\n');
338        output.push_str(&body);
339    }
340    Ok(output)
341}
342
343fn format_header(header: &TaskHeaderNode, source: &str) -> Result<String, String> {
344    let name = header
345        .name()
346        .ok_or_else(|| "task header has no name".to_owned())?;
347    let parameters = header
348        .parameter_list()
349        .map(|list| {
350            list.parameters()
351                .map(|parameter| format_parameter(&parameter, source))
352                .collect::<Vec<_>>()
353        })
354        .unwrap_or_default();
355    let guards = header
356        .guards()
357        .map(|guard| format_guard(guard.text().as_str()))
358        .collect::<Vec<_>>();
359    let dependencies = header
360        .dependencies()
361        .map(|dependency| format_dependency(dependency.text().as_str()))
362        .collect::<Vec<_>>();
363    let shell = header
364        .shell()
365        .map(|shell| format_shell(shell.text().as_str()));
366
367    let params_inline = parameters.join(", ");
368    let prefix = format!("{name}({params_inline})");
369    let mut clauses =
370        Vec::with_capacity(guards.len() + dependencies.len() + usize::from(shell.is_some()));
371    clauses.extend(guards);
372    clauses.extend(dependencies);
373    if let Some(shell) = shell {
374        clauses.push(shell);
375    }
376
377    let mut inline = prefix.clone();
378    for clause in &clauses {
379        inline.push(' ');
380        inline.push_str(clause);
381    }
382    inline.push(':');
383    if clauses.len() < 3 {
384        return Ok(inline);
385    }
386
387    let mut output = prefix;
388    for clause in clauses {
389        output.push('\n');
390        output.push_str(INDENT);
391        output.push_str(&clause);
392    }
393    output.push_str("\n:");
394    Ok(output)
395}
396
397fn format_parameter(parameter: &ParameterNode, source: &str) -> String {
398    let raw = source_range(source, parameter.range()).trim();
399    let Some(equal) = find_unquoted(raw, '=') else {
400        return collapse_whitespace(raw);
401    };
402    let name = collapse_whitespace(raw[..equal].trim());
403    let value = raw[equal + 1..].trim();
404    format!("{name} = {value}")
405}
406
407fn format_guard(raw: &str) -> String {
408    let guard = raw.trim().trim_start_matches('?').trim();
409    format!("? {}", normalize_delimiters(guard))
410}
411
412fn format_dependency(raw: &str) -> String {
413    let dependency = raw.trim().trim_start_matches('&').trim();
414    if let Some(group) = dependency
415        .strip_prefix('(')
416        .and_then(|text| text.strip_suffix(')'))
417    {
418        let members = group
419            .split(',')
420            .map(str::trim)
421            .filter(|member| !member.is_empty())
422            .collect::<Vec<_>>()
423            .join(", ");
424        format!("& ({members})")
425    } else {
426        format!("& {}", collapse_whitespace(dependency))
427    }
428}
429
430fn format_shell(raw: &str) -> String {
431    let compact = raw
432        .chars()
433        .filter(|character| !character.is_whitespace())
434        .collect::<String>();
435    if let Some(shell) = compact.strip_prefix("shell~=") {
436        format!("shell~={shell}")
437    } else if let Some(shell) = compact.strip_prefix("shell=") {
438        format!("shell={shell}")
439    } else {
440        compact
441    }
442}
443
444fn format_task_body(raw: &str) -> String {
445    let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
446    let mut lines = normalized.split('\n').peekable();
447    if lines.peek().is_some_and(|line| line.is_empty()) {
448        lines.next();
449    }
450
451    let mut output = Vec::new();
452    let mut pending_blank = false;
453    for line in lines {
454        let body = line.trim_start_matches([' ', '\t']);
455        if body.is_empty() {
456            pending_blank = !output.is_empty();
457            continue;
458        }
459        if pending_blank {
460            output.push(String::new());
461            pending_blank = false;
462        }
463
464        let formatted = if let Some(block) = body.strip_prefix('|') {
465            let content = block.strip_prefix([' ', '\t']).unwrap_or(block);
466            if content.is_empty() {
467                format!("{INDENT}|")
468            } else {
469                format!("{INDENT}| {content}")
470            }
471        } else {
472            format!("{INDENT}{body}")
473        };
474        output.push(formatted);
475    }
476    output.join("\n")
477}
478
479fn source_range(source: &str, range: TextRange) -> &str {
480    &source[usize::from(range.start())..usize::from(range.end())]
481}
482
483fn find_unquoted(input: &str, needle: char) -> Option<usize> {
484    let mut quoted = false;
485    let mut escaped = false;
486    for (index, character) in input.char_indices() {
487        if quoted {
488            if escaped {
489                escaped = false;
490            } else if character == '\\' {
491                escaped = true;
492            } else if character == '"' {
493                quoted = false;
494            }
495        } else if character == '"' {
496            quoted = true;
497        } else if character == needle {
498            return Some(index);
499        }
500    }
501    None
502}
503
504fn collapse_whitespace(input: &str) -> String {
505    input.split_whitespace().collect::<Vec<_>>().join(" ")
506}
507
508fn normalize_delimiters(input: &str) -> String {
509    let mut output = String::new();
510    let mut quoted = false;
511    let mut escaped = false;
512    let mut pending_space = false;
513    for character in input.chars() {
514        if quoted {
515            output.push(character);
516            if escaped {
517                escaped = false;
518            } else if character == '\\' {
519                escaped = true;
520            } else if character == '"' {
521                quoted = false;
522            }
523            continue;
524        }
525        if character == '"' {
526            if pending_space && !matches!(output.chars().last(), Some('(')) {
527                output.push(' ');
528            }
529            pending_space = false;
530            quoted = true;
531            output.push(character);
532        } else if character.is_whitespace() {
533            pending_space = true;
534        } else if matches!(character, '(' | ')') {
535            while output.ends_with(' ') {
536                output.pop();
537            }
538            output.push(character);
539            pending_space = false;
540        } else {
541            if pending_space && !output.is_empty() && !output.ends_with('(') {
542                output.push(' ');
543            }
544            pending_space = false;
545            output.push(character);
546        }
547    }
548    output.trim().to_owned()
549}