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
11pub 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
28pub 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 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(¶meter, 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}