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