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