Skip to main content

only_syntax/
formatter.rs

1use rowan::NodeOrToken;
2use text_size::TextRange;
3
4use crate::{
5    DirectiveKind, DirectiveNode, MetadataKind, MetadataNode, NamespaceNode, ParameterNode,
6    SyntaxKind, 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    Metadata,
68    LegacyNamespace,
69    NamespaceOpen,
70    GroupOpen,
71    NamespaceClose,
72    Task,
73}
74
75struct DocumentFormatter<'a> {
76    source: &'a str,
77    output: String,
78    previous: Option<ItemKind>,
79    pending_newlines: usize,
80    in_braced_namespace: bool,
81    pending_metadata: Vec<(usize, String)>,
82}
83
84impl<'a> DocumentFormatter<'a> {
85    fn new(source: &'a str) -> Self {
86        Self {
87            source,
88            output: String::new(),
89            previous: None,
90            pending_newlines: 0,
91            in_braced_namespace: false,
92            pending_metadata: Vec::new(),
93        }
94    }
95
96    fn format(&mut self, root: &SyntaxNode) -> Result<(), String> {
97        for element in root.children_with_tokens() {
98            match element {
99                NodeOrToken::Token(token) => match token.kind() {
100                    SyntaxKind::Bom => self.output.push_str(token.text()),
101                    SyntaxKind::Newline => {
102                        self.flush_metadata();
103                        self.pending_newlines += 1;
104                    }
105                    SyntaxKind::Comment => {
106                        self.flush_metadata();
107                        self.push_item(ItemKind::Comment, token.text().trim_end())
108                    }
109                    SyntaxKind::Whitespace | SyntaxKind::Indent => {}
110                    SyntaxKind::Eof => self.flush_metadata(),
111                    _ => self.push_raw(token.text()),
112                },
113                NodeOrToken::Node(node) if node.kind() == SyntaxKind::MetadataComment => {
114                    self.queue_metadata(node)?;
115                }
116                NodeOrToken::Node(node) => {
117                    self.flush_metadata();
118                    self.format_node(node)?;
119                }
120            }
121        }
122        Ok(())
123    }
124
125    fn queue_metadata(&mut self, node: SyntaxNode) -> Result<(), String> {
126        let comment = MetadataNode::cast(node.clone()).expect("metadata kind must cast");
127        let (field, _) = comment
128            .field()
129            .ok_or_else(|| "invalid metadata field".to_owned())?;
130        let order = match MetadataKind::parse(field.as_str()) {
131            MetadataKind::Help => 0,
132            MetadataKind::Desc => 1,
133            MetadataKind::Pass => 2,
134            MetadataKind::Fail => 3,
135            MetadataKind::Unknown(_) => 4,
136        };
137        let (_, text) = format_top_level_node(node, self.source)?;
138        self.pending_metadata.push((order, text));
139        Ok(())
140    }
141
142    fn flush_metadata(&mut self) {
143        self.pending_metadata.sort_by_key(|(order, _)| *order);
144        let pending = std::mem::take(&mut self.pending_metadata);
145        for (_, text) in pending {
146            self.push_item(ItemKind::Metadata, &text);
147        }
148    }
149
150    fn format_node(&mut self, node: SyntaxNode) -> Result<(), String> {
151        let (kind, text) = format_top_level_node(node, self.source)?;
152        self.push_item(kind, &text);
153        Ok(())
154    }
155
156    fn push_item(&mut self, kind: ItemKind, text: &str) {
157        if matches!(
158            kind,
159            ItemKind::LegacyNamespace
160                | ItemKind::NamespaceOpen
161                | ItemKind::GroupOpen
162                | ItemKind::NamespaceClose
163        ) {
164            self.in_braced_namespace = false;
165        }
166        if !self.output.is_empty() && !self.output.ends_with('\n') {
167            self.output.push('\n');
168        }
169
170        let line_breaks_after_comment = usize::from(self.previous == Some(ItemKind::Comment));
171        let source_has_blank = self.pending_newlines > line_breaks_after_comment;
172        let consecutive_directives =
173            self.previous == Some(ItemKind::Directive) && kind == ItemKind::Directive;
174        let namespace_boundary =
175            self.previous == Some(ItemKind::NamespaceOpen) || kind == ItemKind::NamespaceClose;
176        let metadata_boundary =
177            self.previous == Some(ItemKind::Metadata) || kind == ItemKind::Metadata;
178        if !self.output.is_empty()
179            && ((source_has_blank
180                && !consecutive_directives
181                && !namespace_boundary
182                && !metadata_boundary)
183                || needs_structural_blank(self.previous, kind))
184            && !self.output.ends_with("\n\n")
185        {
186            self.output.push('\n');
187        }
188
189        let text = text.trim_end_matches(['\n', '\r']);
190        if self.in_braced_namespace {
191            self.output.push_str(&indent_lines(text));
192        } else {
193            self.output.push_str(text);
194        }
195        self.output.push('\n');
196        if matches!(kind, ItemKind::NamespaceOpen | ItemKind::GroupOpen) {
197            self.in_braced_namespace = true;
198        }
199        self.previous = Some(kind);
200        self.pending_newlines = 0;
201    }
202
203    fn push_raw(&mut self, text: &str) {
204        self.output.push_str(text);
205        self.pending_newlines = 0;
206    }
207
208    fn finish(mut self) -> String {
209        while self.output.ends_with("\n\n") {
210            self.output.pop();
211        }
212        if !self.output.is_empty() && !self.output.ends_with('\n') {
213            self.output.push('\n');
214        }
215        self.output
216    }
217}
218
219fn format_top_level_node(node: SyntaxNode, source: &str) -> Result<(ItemKind, String), String> {
220    match node.kind() {
221        SyntaxKind::Directive => {
222            let directive = DirectiveNode::cast(node).expect("directive kind must cast");
223            Ok((ItemKind::Directive, format_directive(&directive, source)?))
224        }
225        SyntaxKind::MetadataComment => {
226            let comment = MetadataNode::cast(node).expect("metadata kind must cast");
227            let (field, value) = comment
228                .field()
229                .ok_or_else(|| "invalid metadata field".to_owned())?;
230            let text = if value.is_empty() {
231                format!("[{field}]")
232            } else {
233                format!("[{field}] {value}")
234            };
235            Ok((ItemKind::Metadata, text))
236        }
237        SyntaxKind::NamespaceBlock => {
238            let namespace = NamespaceNode::cast(node).expect("namespace kind must cast");
239            if namespace.is_close() {
240                Ok((ItemKind::NamespaceClose, "}".to_owned()))
241            } else {
242                let name = namespace
243                    .name()
244                    .ok_or_else(|| "invalid namespace".to_owned())?;
245                if namespace.has_open_brace() {
246                    if namespace.is_group() {
247                        Ok((ItemKind::GroupOpen, format!("group {name} {{")))
248                    } else {
249                        Ok((ItemKind::NamespaceOpen, format!("[{name}] {{")))
250                    }
251                } else {
252                    Ok((ItemKind::LegacyNamespace, format!("[{name}]")))
253                }
254            }
255        }
256        SyntaxKind::TaskDecl => {
257            let task = TaskNode::cast(node).expect("task kind must cast");
258            Ok((ItemKind::Task, format_task(&task, source)?))
259        }
260        SyntaxKind::Error => Err("cannot format invalid syntax".to_owned()),
261        _ => Err("range does not contain a declaration".to_owned()),
262    }
263}
264
265fn is_formattable_node(kind: SyntaxKind) -> bool {
266    matches!(
267        kind,
268        SyntaxKind::Directive
269            | SyntaxKind::MetadataComment
270            | SyntaxKind::NamespaceBlock
271            | SyntaxKind::TaskDecl
272    )
273}
274
275fn ranges_touch(node: TextRange, requested: TextRange) -> bool {
276    if requested.is_empty() {
277        node.start() <= requested.start() && requested.start() < node.end()
278    } else {
279        node.start() < requested.end() && requested.start() < node.end()
280    }
281}
282
283fn needs_structural_blank(previous: Option<ItemKind>, current: ItemKind) -> bool {
284    let Some(previous) = previous else {
285        return false;
286    };
287    if current == ItemKind::NamespaceClose || previous == ItemKind::NamespaceOpen {
288        return false;
289    }
290    if previous == ItemKind::GroupOpen {
291        return true;
292    }
293    if previous == ItemKind::Metadata || previous == ItemKind::Comment {
294        return false;
295    }
296    if current == ItemKind::Metadata || current == ItemKind::Comment {
297        return !matches!(previous, ItemKind::Metadata | ItemKind::Comment);
298    }
299    if matches!(
300        previous,
301        ItemKind::LegacyNamespace | ItemKind::NamespaceClose
302    ) || matches!(
303        current,
304        ItemKind::LegacyNamespace | ItemKind::NamespaceOpen | ItemKind::GroupOpen
305    ) {
306        return true;
307    }
308    if previous == ItemKind::Directive && current == ItemKind::Directive {
309        return false;
310    }
311    previous == ItemKind::Task
312        || current == ItemKind::Task
313        || previous == ItemKind::Directive
314        || current == ItemKind::Directive
315}
316
317fn is_inside_braced_namespace(root: &SyntaxNode, offset: text_size::TextSize) -> bool {
318    let mut inside = false;
319    for node in root.children() {
320        if node.text_range().start() >= offset {
321            break;
322        }
323        let Some(namespace) = NamespaceNode::cast(node) else {
324            continue;
325        };
326        if namespace.is_close() {
327            inside = false;
328        } else {
329            inside = namespace.has_open_brace();
330        }
331    }
332    inside
333}
334
335fn indent_lines(text: &str) -> String {
336    text.lines()
337        .map(|line| {
338            if line.is_empty() {
339                String::new()
340            } else {
341                format!("{INDENT}{line}")
342            }
343        })
344        .collect::<Vec<_>>()
345        .join("\n")
346}
347
348fn include_leading_indent(source: &str, range: TextRange) -> TextRange {
349    let start = usize::from(range.start());
350    let line_start = source[..start].rfind('\n').map_or(0, |index| index + 1);
351    if source[line_start..start]
352        .chars()
353        .all(|character| matches!(character, ' ' | '\t'))
354    {
355        TextRange::new((line_start as u32).into(), range.end())
356    } else {
357        range
358    }
359}
360
361fn format_directive(directive: &DirectiveNode, source: &str) -> Result<String, String> {
362    let name = directive
363        .name()
364        .ok_or_else(|| "invalid directive".to_owned())?;
365    let raw_value = directive.raw_value().unwrap_or_default();
366    if directive.directive_kind() == Some(DirectiveKind::Var) {
367        let (variable, value) = raw_value
368            .split_once('=')
369            .ok_or_else(|| "invalid variable directive".to_owned())?;
370        return Ok(format!("!var {} = {}", variable.trim(), value.trim()));
371    }
372    if raw_value.is_empty() {
373        return Ok(format!("!{name}"));
374    }
375
376    // Slice CST-owned text to retain the original string spelling.
377    let raw = source_range(source, directive.range());
378    let value = raw
379        .trim()
380        .strip_prefix('!')
381        .and_then(|text| text.strip_prefix(name.as_str()))
382        .map(str::trim)
383        .unwrap_or(raw_value.as_str());
384    Ok(format!("!{name} {value}"))
385}
386
387fn format_task(task: &TaskNode, source: &str) -> Result<String, String> {
388    let header = task
389        .header()
390        .ok_or_else(|| "task has no header".to_owned())?;
391    let mut output = format_header(&header, source)?;
392    let body = source_range(
393        source,
394        TextRange::new(header.range().end(), task.range().end()),
395    );
396    let body = format_task_body(body);
397    if !body.is_empty() {
398        output.push('\n');
399        output.push_str(&body);
400    }
401    Ok(output)
402}
403
404fn format_header(header: &TaskHeaderNode, source: &str) -> Result<String, String> {
405    let name = header
406        .name()
407        .ok_or_else(|| "task header has no name".to_owned())?;
408    let parameters = header
409        .parameter_list()
410        .map(|list| {
411            list.parameters()
412                .map(|parameter| format_parameter(&parameter, source))
413                .collect::<Vec<_>>()
414        })
415        .unwrap_or_default();
416    let conditions = header
417        .conditions()
418        .map(|guard| format_guard(guard.text().as_str()))
419        .collect::<Vec<_>>();
420    let dependencies = header
421        .dependencies()
422        .map(|dependency| format_dependency(dependency.text().as_str()))
423        .collect::<Vec<_>>();
424    let shell = header
425        .shell()
426        .map(|shell| format_shell(shell.text().as_str()));
427
428    let params_inline = parameters.join(", ");
429    let prefix = format!("{name}({params_inline})");
430    let mut clauses =
431        Vec::with_capacity(conditions.len() + dependencies.len() + usize::from(shell.is_some()));
432    clauses.extend(conditions);
433    clauses.extend(dependencies);
434    if let Some(shell) = shell {
435        clauses.push(shell);
436    }
437
438    let mut inline = prefix.clone();
439    for clause in &clauses {
440        inline.push(' ');
441        inline.push_str(clause);
442    }
443    inline.push(':');
444    if clauses.len() < 3 {
445        return Ok(inline);
446    }
447
448    let mut output = prefix;
449    for clause in clauses {
450        output.push('\n');
451        output.push_str(INDENT);
452        output.push_str(&clause);
453    }
454    output.push_str("\n:");
455    Ok(output)
456}
457
458fn format_parameter(parameter: &ParameterNode, source: &str) -> String {
459    let raw = source_range(source, parameter.range()).trim();
460    let Some(equal) = find_unquoted(raw, '=') else {
461        return collapse_whitespace(raw);
462    };
463    let name = collapse_whitespace(raw[..equal].trim());
464    let value = raw[equal + 1..].trim();
465    format!("{name} = {value}")
466}
467
468fn format_guard(raw: &str) -> String {
469    let guard = raw.trim().trim_start_matches('?').trim();
470    format!("? {}", normalize_delimiters(guard))
471}
472
473fn format_dependency(raw: &str) -> String {
474    let dependency = raw.trim().trim_start_matches('&').trim();
475    if let Some(group) = dependency
476        .strip_prefix('(')
477        .and_then(|text| text.strip_suffix(')'))
478    {
479        let members = split_top_level(group, ',')
480            .into_iter()
481            .map(normalize_delimiters)
482            .filter(|member| !member.is_empty())
483            .collect::<Vec<_>>()
484            .join(", ");
485        format!("& ({members})")
486    } else {
487        format!("& {}", normalize_delimiters(dependency))
488    }
489}
490
491fn split_top_level(input: &str, separator: char) -> Vec<&str> {
492    let mut parts = Vec::new();
493    let mut start = 0usize;
494    let mut depth = 0usize;
495    let mut quoted = false;
496    let mut escaped = false;
497
498    for (index, character) in input.char_indices() {
499        if quoted {
500            if escaped {
501                escaped = false;
502            } else if character == '\\' {
503                escaped = true;
504            } else if character == '"' {
505                quoted = false;
506            }
507            continue;
508        }
509
510        match character {
511            '"' => quoted = true,
512            '(' => depth += 1,
513            ')' => depth = depth.saturating_sub(1),
514            current if current == separator && depth == 0 => {
515                parts.push(input[start..index].trim());
516                start = index + character.len_utf8();
517            }
518            _ => {}
519        }
520    }
521    parts.push(input[start..].trim());
522    parts
523}
524
525fn format_shell(raw: &str) -> String {
526    let compact = raw
527        .chars()
528        .filter(|character| !character.is_whitespace())
529        .collect::<String>();
530    if let Some(shell) = compact.strip_prefix("shell~=") {
531        format!("shell~={shell}")
532    } else if let Some(shell) = compact.strip_prefix("shell=") {
533        format!("shell={shell}")
534    } else {
535        compact
536    }
537}
538
539fn format_task_body(raw: &str) -> String {
540    let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
541    let mut lines = normalized.split('\n').peekable();
542    if lines.peek().is_some_and(|line| line.is_empty()) {
543        lines.next();
544    }
545
546    let mut output = Vec::new();
547    let mut pending_blank = false;
548    for line in lines {
549        let body = line.trim_start_matches([' ', '\t']);
550        if body.is_empty() {
551            pending_blank = !output.is_empty();
552            continue;
553        }
554        if pending_blank {
555            output.push(String::new());
556            pending_blank = false;
557        }
558
559        let formatted = if let Some(block) = body.strip_prefix('|') {
560            let content = block.strip_prefix([' ', '\t']).unwrap_or(block);
561            if content.is_empty() {
562                format!("{INDENT}|")
563            } else {
564                format!("{INDENT}| {content}")
565            }
566        } else {
567            format!("{INDENT}{body}")
568        };
569        output.push(formatted);
570    }
571    output.join("\n")
572}
573
574fn source_range(source: &str, range: TextRange) -> &str {
575    &source[usize::from(range.start())..usize::from(range.end())]
576}
577
578fn find_unquoted(input: &str, needle: char) -> Option<usize> {
579    let mut quoted = false;
580    let mut escaped = false;
581    for (index, character) in input.char_indices() {
582        if quoted {
583            if escaped {
584                escaped = false;
585            } else if character == '\\' {
586                escaped = true;
587            } else if character == '"' {
588                quoted = false;
589            }
590        } else if character == '"' {
591            quoted = true;
592        } else if character == needle {
593            return Some(index);
594        }
595    }
596    None
597}
598
599fn collapse_whitespace(input: &str) -> String {
600    input.split_whitespace().collect::<Vec<_>>().join(" ")
601}
602
603fn normalize_delimiters(input: &str) -> String {
604    let mut output = String::new();
605    let mut quoted = false;
606    let mut escaped = false;
607    let mut pending_space = false;
608    for character in input.chars() {
609        if quoted {
610            output.push(character);
611            if escaped {
612                escaped = false;
613            } else if character == '\\' {
614                escaped = true;
615            } else if character == '"' {
616                quoted = false;
617            }
618            continue;
619        }
620        if character == '"' {
621            if pending_space && !matches!(output.chars().last(), Some('(')) {
622                output.push(' ');
623            }
624            pending_space = false;
625            quoted = true;
626            output.push(character);
627        } else if character.is_whitespace() {
628            pending_space = true;
629        } else if matches!(character, '(' | ')') {
630            while output.ends_with(' ') {
631                output.pop();
632            }
633            output.push(character);
634            pending_space = false;
635        } else if character == ',' {
636            while output.ends_with(' ') {
637                output.pop();
638            }
639            output.push(',');
640            pending_space = true;
641        } else {
642            if pending_space && !output.is_empty() && !output.ends_with('(') {
643                output.push(' ');
644            }
645            pending_space = false;
646            output.push(character);
647        }
648    }
649    output.trim().to_owned()
650}