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 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}] {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 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(¶meter, 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}| {content}")
544 }
545 } else {
546 format!("{INDENT}{body}")
547 };
548 output.push(formatted);
549 }
550 output.join("\n")
551}
552
553fn source_range(source: &str, range: TextRange) -> &str {
554 &source[usize::from(range.start())..usize::from(range.end())]
555}
556
557fn find_unquoted(input: &str, needle: char) -> Option<usize> {
558 let mut quoted = false;
559 let mut escaped = false;
560 for (index, character) in input.char_indices() {
561 if quoted {
562 if escaped {
563 escaped = false;
564 } else if character == '\\' {
565 escaped = true;
566 } else if character == '"' {
567 quoted = false;
568 }
569 } else if character == '"' {
570 quoted = true;
571 } else if character == needle {
572 return Some(index);
573 }
574 }
575 None
576}
577
578fn collapse_whitespace(input: &str) -> String {
579 input.split_whitespace().collect::<Vec<_>>().join(" ")
580}
581
582fn normalize_delimiters(input: &str) -> String {
583 let mut output = String::new();
584 let mut quoted = false;
585 let mut escaped = false;
586 let mut pending_space = false;
587 for character in input.chars() {
588 if quoted {
589 output.push(character);
590 if escaped {
591 escaped = false;
592 } else if character == '\\' {
593 escaped = true;
594 } else if character == '"' {
595 quoted = false;
596 }
597 continue;
598 }
599 if character == '"' {
600 if pending_space && !matches!(output.chars().last(), Some('(')) {
601 output.push(' ');
602 }
603 pending_space = false;
604 quoted = true;
605 output.push(character);
606 } else if character.is_whitespace() {
607 pending_space = true;
608 } else if matches!(character, '(' | ')') {
609 while output.ends_with(' ') {
610 output.pop();
611 }
612 output.push(character);
613 pending_space = false;
614 } else if character == ',' {
615 while output.ends_with(' ') {
616 output.pop();
617 }
618 output.push(',');
619 pending_space = true;
620 } else {
621 if pending_space && !output.is_empty() && !output.ends_with('(') {
622 output.push(' ');
623 }
624 pending_space = false;
625 output.push(character);
626 }
627 }
628 output.trim().to_owned()
629}