Skip to main content

only_syntax/
ast_view.rs

1use smol_str::SmolStr;
2use text_size::{TextRange, TextSize};
3
4use crate::{
5    DirectiveKind, GuardKind, ShellKind, ShellOperator, ShellSelection, SyntaxKind, SyntaxNode,
6    TaskShellRef,
7};
8
9/// Typed document CST wrapper.
10///
11/// Args:
12/// None.
13///
14/// Returns:
15/// Stable accessors for top-level syntax items and spans.
16#[derive(Debug, Clone)]
17pub struct DocumentNode {
18    syntax: SyntaxNode,
19}
20
21/// Typed directive CST wrapper.
22///
23/// Args:
24/// None.
25///
26/// Returns:
27/// Stable accessors for directive name, value and span.
28#[derive(Debug, Clone)]
29pub struct DirectiveNode {
30    syntax: SyntaxNode,
31}
32
33/// Typed doc-comment CST wrapper.
34///
35/// Args:
36/// None.
37///
38/// Returns:
39/// Stable accessors for doc-comment text and span.
40#[derive(Debug, Clone)]
41pub struct MetadataNode {
42    syntax: SyntaxNode,
43}
44
45/// Typed namespace CST wrapper.
46///
47/// Args:
48/// None.
49///
50/// Returns:
51/// Stable accessors for namespace name and span.
52#[derive(Debug, Clone)]
53pub struct NamespaceNode {
54    syntax: SyntaxNode,
55}
56
57/// Typed task CST wrapper.
58///
59/// Args:
60/// None.
61///
62/// Returns:
63/// Stable accessors for task header, commands and span.
64#[derive(Debug, Clone)]
65pub struct TaskNode {
66    syntax: SyntaxNode,
67}
68
69#[derive(Debug, Clone)]
70pub struct TaskHeaderNode {
71    syntax: SyntaxNode,
72}
73
74#[derive(Debug, Clone)]
75pub struct ParameterListNode {
76    syntax: SyntaxNode,
77}
78
79#[derive(Debug, Clone)]
80pub struct ParameterNode {
81    syntax: SyntaxNode,
82}
83
84#[derive(Debug, Clone)]
85pub struct ConditionClauseNode {
86    syntax: SyntaxNode,
87}
88
89#[derive(Debug, Clone)]
90pub struct DependencyClauseNode {
91    syntax: SyntaxNode,
92}
93
94#[derive(Debug, Clone)]
95pub struct ShellClauseNode {
96    syntax: SyntaxNode,
97}
98
99#[derive(Debug, Clone)]
100pub struct HeaderTerminatorNode {
101    syntax: SyntaxNode,
102}
103
104/// One executable step read from a task body.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum TaskStepNode {
107    Command(TaskCommandNode),
108    CommandBlock(TaskCommandBlockNode),
109}
110
111/// One ordinary command line and its source range.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct TaskCommandNode {
114    pub text: SmolStr,
115    pub range: TextRange,
116}
117
118/// Consecutive block lines assembled into one shell input.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct TaskCommandBlockNode {
121    pub source: SmolStr,
122    pub range: TextRange,
123    pub line_ranges: Vec<TextRange>,
124    pub marker_ranges: Vec<TextRange>,
125}
126
127/// One dependency reference parsed from a task header.
128///
129/// Args:
130/// None.
131///
132/// Returns:
133/// Dependency text and the precise source range of that reference.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct TaskDependencyRef {
136    pub name: SmolStr,
137    pub range: TextRange,
138    pub arguments: Vec<TaskDependencyArgRef>,
139    pub invocation_range: TextRange,
140    pub stage: usize,
141}
142
143/// One positional string argument in a dependency invocation.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct TaskDependencyArgRef {
146    pub value: SmolStr,
147    pub range: TextRange,
148}
149
150/// One parameter declaration parsed from a task header.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct TaskParamRef {
153    pub name: SmolStr,
154    pub range: TextRange,
155    pub default_value: Option<SmolStr>,
156    pub is_slice: bool,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct TaskGuardRef {
161    pub kind: GuardKind,
162    pub argument: SmolStr,
163    pub range: TextRange,
164    pub name_range: TextRange,
165}
166
167/// Structured task header data parsed from the CST token stream.
168///
169/// Args:
170/// None.
171///
172/// Returns:
173/// Parsed task header sections and dependency references.
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct TaskHeaderInfo {
176    pub params: Option<SmolStr>,
177    pub param_refs: Vec<TaskParamRef>,
178    pub guard: Option<SmolStr>,
179    pub guards: Vec<TaskGuardRef>,
180    pub dependencies: Option<SmolStr>,
181    pub shell: Option<TaskShellRef>,
182    pub dependency_refs: Vec<TaskDependencyRef>,
183}
184
185impl DocumentNode {
186    /// Casts a raw rowan node into a typed document wrapper.
187    ///
188    /// Args:
189    /// syntax: Raw rowan syntax node.
190    ///
191    /// Returns:
192    /// Typed document wrapper when the kind matches `Document`.
193    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
194        (syntax.kind() == SyntaxKind::Document).then_some(Self { syntax })
195    }
196
197    /// Returns the raw rowan node.
198    ///
199    /// Args:
200    /// None.
201    ///
202    /// Returns:
203    /// Borrowed raw syntax node.
204    pub fn syntax(&self) -> &SyntaxNode {
205        &self.syntax
206    }
207
208    /// Returns the document text range.
209    ///
210    /// Args:
211    /// None.
212    ///
213    /// Returns:
214    /// Full document range in source text coordinates.
215    pub fn range(&self) -> TextRange {
216        self.syntax.text_range()
217    }
218
219    /// Iterates directive children.
220    ///
221    /// Args:
222    /// None.
223    ///
224    /// Returns:
225    /// Typed directive iterator.
226    pub fn directives(&self) -> impl Iterator<Item = DirectiveNode> + '_ {
227        self.syntax.children().filter_map(DirectiveNode::cast)
228    }
229
230    /// Iterates doc-comment children.
231    ///
232    /// Args:
233    /// None.
234    ///
235    /// Returns:
236    /// Typed doc-comment iterator.
237    pub fn metadata(&self) -> impl Iterator<Item = MetadataNode> + '_ {
238        self.syntax.children().filter_map(MetadataNode::cast)
239    }
240
241    /// Iterates namespace children.
242    ///
243    /// Args:
244    /// None.
245    ///
246    /// Returns:
247    /// Typed namespace iterator.
248    pub fn namespaces(&self) -> impl Iterator<Item = NamespaceNode> + '_ {
249        self.syntax.children().filter_map(NamespaceNode::cast)
250    }
251
252    /// Iterates task children.
253    ///
254    /// Args:
255    /// None.
256    ///
257    /// Returns:
258    /// Typed task iterator.
259    pub fn tasks(&self) -> impl Iterator<Item = TaskNode> + '_ {
260        self.syntax.children().filter_map(TaskNode::cast)
261    }
262}
263
264impl DirectiveNode {
265    /// Casts a raw rowan node into a typed directive wrapper.
266    ///
267    /// Args:
268    /// syntax: Raw rowan syntax node.
269    ///
270    /// Returns:
271    /// Typed directive wrapper when the kind matches `Directive`.
272    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
273        (syntax.kind() == SyntaxKind::Directive).then_some(Self { syntax })
274    }
275
276    /// Returns the directive text range.
277    ///
278    /// Args:
279    /// None.
280    ///
281    /// Returns:
282    /// Directive range in source text coordinates.
283    pub fn range(&self) -> TextRange {
284        self.syntax.text_range()
285    }
286
287    /// Returns the directive keyword range including the leading `!`.
288    ///
289    /// Args:
290    /// None.
291    ///
292    /// Returns:
293    /// Range covering a directive keyword such as `!shell` when present.
294    pub fn keyword_range(&self) -> Option<TextRange> {
295        let mut tokens = self
296            .syntax
297            .children_with_tokens()
298            .filter_map(|element| element.into_token())
299            .filter(|token| {
300                !matches!(
301                    token.kind(),
302                    SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
303                )
304            });
305        let bang = tokens.find(|token| token.kind() == SyntaxKind::Bang)?;
306        let keyword = tokens.next()?;
307        Some(TextRange::new(
308            bang.text_range().start(),
309            keyword.text_range().end(),
310        ))
311    }
312
313    /// Returns the directive name token text without the leading `!`.
314    ///
315    /// Args:
316    /// None.
317    ///
318    /// Returns:
319    /// Directive name when present.
320    pub fn name(&self) -> Option<SmolStr> {
321        non_trivia_token_texts(&self.syntax).nth(1)
322    }
323
324    /// Returns the typed directive kind.
325    pub fn directive_kind(&self) -> Option<DirectiveKind> {
326        self.name().map(|name| DirectiveKind::parse(&name))
327    }
328
329    /// Returns the directive value text after the directive name.
330    ///
331    /// Args:
332    /// None.
333    ///
334    /// Returns:
335    /// Joined directive value text when present.
336    pub fn value(&self) -> Option<SmolStr> {
337        let value = non_trivia_token_texts(&self.syntax)
338            .skip(2)
339            .collect::<Vec<_>>()
340            .join(" ");
341        (!value.is_empty()).then(|| SmolStr::new(value))
342    }
343
344    /// Returns the directive value with its original internal punctuation.
345    pub fn raw_value(&self) -> Option<SmolStr> {
346        let mut non_trivia = 0usize;
347        let mut value = String::new();
348
349        for token in self
350            .syntax
351            .children_with_tokens()
352            .filter_map(|element| element.into_token())
353        {
354            if token.kind() == SyntaxKind::Newline {
355                break;
356            }
357            if !matches!(
358                token.kind(),
359                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Comment
360            ) {
361                non_trivia += 1;
362            }
363            if non_trivia >= 2 && !(non_trivia == 2 && token.kind() == SyntaxKind::Ident) {
364                value.push_str(token.text());
365            }
366        }
367
368        let value = value.trim();
369        (!value.is_empty()).then(|| SmolStr::new(value))
370    }
371
372    /// Returns the first identifier range after the directive name.
373    pub fn argument_name_range(&self) -> Option<TextRange> {
374        self.syntax
375            .children_with_tokens()
376            .filter_map(|element| element.into_token())
377            .filter(|token| matches!(token.kind(), SyntaxKind::Ident | SyntaxKind::ShellKw))
378            .nth(1)
379            .map(|token| token.text_range())
380    }
381}
382
383impl MetadataNode {
384    /// Casts a raw rowan node into a typed doc-comment wrapper.
385    ///
386    /// Args:
387    /// syntax: Raw rowan syntax node.
388    ///
389    /// Returns:
390    /// Typed wrapper for structured declaration metadata.
391    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
392        (syntax.kind() == SyntaxKind::MetadataComment).then_some(Self { syntax })
393    }
394
395    /// Returns the doc-comment text range.
396    ///
397    /// Args:
398    /// None.
399    ///
400    /// Returns:
401    /// Doc-comment range in source text coordinates.
402    pub fn range(&self) -> TextRange {
403        self.syntax.text_range()
404    }
405
406    /// Returns the comment payload without its marker or field header.
407    ///
408    /// Args:
409    /// None.
410    ///
411    /// Returns:
412    /// Trimmed doc-comment payload when present.
413    pub fn text(&self) -> Option<SmolStr> {
414        let text = self.syntax.text().to_string();
415        let text = text.trim();
416        let text = if self.syntax.kind() == SyntaxKind::MetadataComment {
417            let close = text.find(']')?;
418            text.get(close + 1..)?.trim()
419        } else {
420            text.strip_prefix('#')?.trim()
421        };
422        (!text.is_empty()).then(|| SmolStr::new(text))
423    }
424
425    /// Returns a structured metadata field when the comment starts with `[name]`.
426    pub fn field(&self) -> Option<(SmolStr, SmolStr)> {
427        if self.syntax.kind() != SyntaxKind::MetadataComment {
428            return None;
429        }
430        let text = self.syntax.text().to_string();
431        let text = text.trim().strip_prefix('[')?;
432        let close = text.find(']')?;
433        let name = &text[..close];
434        if name.is_empty()
435            || !name.chars().all(|character| {
436                character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
437            })
438        {
439            return None;
440        }
441
442        Some((SmolStr::new(name), SmolStr::new(text[close + 1..].trim())))
443    }
444
445    /// Returns the source range of a structured metadata field name.
446    pub fn field_range(&self) -> Option<TextRange> {
447        if self.syntax.kind() != SyntaxKind::MetadataComment {
448            return None;
449        }
450        let text = self.syntax.text().to_string();
451        let text = text.trim().strip_prefix('[')?;
452        let close = text.find(']')?;
453        let name = &text[..close];
454        if name.is_empty()
455            || !name.chars().all(|character| {
456                character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
457            })
458        {
459            return None;
460        }
461
462        let start = self.syntax.text_range().start() + TextSize::from(1);
463        Some(TextRange::new(
464            start,
465            start + TextSize::from(name.len() as u32),
466        ))
467    }
468
469    /// Returns the source range of the complete metadata tag.
470    pub fn tag_range(&self) -> Option<TextRange> {
471        let field = self.field_range()?;
472        let delimiter = TextSize::from(1);
473        Some(TextRange::new(
474            field.start() - delimiter,
475            field.end() + delimiter,
476        ))
477    }
478}
479
480impl NamespaceNode {
481    /// Casts a raw rowan node into a typed namespace wrapper.
482    ///
483    /// Args:
484    /// syntax: Raw rowan syntax node.
485    ///
486    /// Returns:
487    /// Typed namespace wrapper when the kind matches `NamespaceBlock`.
488    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
489        (syntax.kind() == SyntaxKind::NamespaceBlock).then_some(Self { syntax })
490    }
491
492    /// Returns the namespace text range.
493    ///
494    /// Args:
495    /// None.
496    ///
497    /// Returns:
498    /// Namespace range in source text coordinates.
499    pub fn range(&self) -> TextRange {
500        self.syntax.text_range()
501    }
502
503    /// Returns the namespace name without brackets.
504    ///
505    /// Args:
506    /// None.
507    ///
508    /// Returns:
509    /// Namespace name when present.
510    pub fn name(&self) -> Option<SmolStr> {
511        self.syntax
512            .children_with_tokens()
513            .filter_map(|element| element.into_token())
514            .find(|token| token.kind() == SyntaxKind::Ident)
515            .map(|token| SmolStr::new(token.text()))
516    }
517
518    /// Returns the namespace name range inside the brackets.
519    ///
520    /// Args:
521    /// None.
522    ///
523    /// Returns:
524    /// Namespace name range when present.
525    pub fn name_range(&self) -> Option<TextRange> {
526        self.syntax
527            .children_with_tokens()
528            .filter_map(|element| element.into_token())
529            .find(|token| token.kind() == SyntaxKind::Ident)
530            .map(|token| token.text_range())
531    }
532
533    /// Returns whether this node closes the current namespace.
534    pub fn is_close(&self) -> bool {
535        self.syntax.text().to_string().trim() == "}"
536    }
537
538    /// Returns whether this namespace starts a braced scope.
539    pub fn has_open_brace(&self) -> bool {
540        self.syntax
541            .descendants_with_tokens()
542            .filter_map(|element| element.into_token())
543            .any(|token| token.kind() == SyntaxKind::LBrace)
544    }
545
546    /// Returns whether this label is empty.
547    pub fn is_empty(&self) -> bool {
548        if self.is_close() {
549            return false;
550        }
551        self.name().is_none()
552    }
553}
554
555impl TaskNode {
556    /// Casts a raw rowan node into a typed task wrapper.
557    ///
558    /// Args:
559    /// syntax: Raw rowan syntax node.
560    ///
561    /// Returns:
562    /// Typed task wrapper when the kind matches `TaskDecl`.
563    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
564        (syntax.kind() == SyntaxKind::TaskDecl).then_some(Self { syntax })
565    }
566
567    /// Returns the task text range.
568    ///
569    /// Args:
570    /// None.
571    ///
572    /// Returns:
573    /// Task range in source text coordinates.
574    pub fn range(&self) -> TextRange {
575        self.syntax.text_range()
576    }
577
578    /// Returns the task name range from the header identifier.
579    ///
580    /// Args:
581    /// None.
582    ///
583    /// Returns:
584    /// Range covering the task name before the parameter list.
585    pub fn name_range(&self) -> Option<TextRange> {
586        self.header()?.name_range()
587    }
588
589    /// Returns the task name from the header identifier.
590    ///
591    /// Args:
592    /// None.
593    ///
594    /// Returns:
595    /// Task name when present.
596    pub fn name(&self) -> Option<SmolStr> {
597        self.header()?.name()
598    }
599
600    /// Returns the normalized task header text without the trailing `:`.
601    ///
602    /// Args:
603    /// None.
604    ///
605    /// Returns:
606    /// Header text when present.
607    pub fn header_text(&self) -> Option<SmolStr> {
608        let header = self.header()?.syntax.text().to_string();
609        let header = header.trim().trim_end_matches(':').trim_end();
610        (!header.is_empty()).then(|| SmolStr::new(header))
611    }
612
613    pub fn header(&self) -> Option<TaskHeaderNode> {
614        self.syntax.children().find_map(TaskHeaderNode::cast)
615    }
616
617    pub fn uses_multiline_header(&self) -> bool {
618        self.header()
619            .is_some_and(|header| header.syntax.text().to_string().contains(['\n', '\r']))
620    }
621
622    /// Returns the parsed task header sections and dependency references.
623    ///
624    /// Args:
625    /// None.
626    ///
627    /// Returns:
628    /// Structured header information parsed from one token stream pass.
629    pub fn header_info(&self) -> TaskHeaderInfo {
630        self.header()
631            .map_or_else(TaskHeaderInfo::default, |header| header.info())
632    }
633
634    /// Iterates normalized command lines from the task body.
635    ///
636    /// Args:
637    /// None.
638    ///
639    /// Returns:
640    /// Command lines in source order, without leading indentation.
641    pub fn commands(&self) -> std::vec::IntoIter<SmolStr> {
642        self.steps()
643            .map(|step| match step {
644                TaskStepNode::Command(command) => command.text,
645                TaskStepNode::CommandBlock(block) => block.source,
646            })
647            .collect::<Vec<_>>()
648            .into_iter()
649    }
650
651    /// Iterates executable task steps with source ranges.
652    pub fn steps(&self) -> std::vec::IntoIter<TaskStepNode> {
653        task_body_steps(&self.syntax)
654            .collect::<Vec<_>>()
655            .into_iter()
656    }
657}
658
659#[derive(Debug, Clone, Copy)]
660struct BodyLine<'a> {
661    text: &'a str,
662    start: usize,
663    end_with_newline: usize,
664}
665
666fn task_body_steps(node: &SyntaxNode) -> impl Iterator<Item = TaskStepNode> + '_ {
667    let source = node.text().to_string();
668    let body_start = node
669        .children()
670        .find(|child| child.kind() == SyntaxKind::TaskHeader)
671        .map(|header| usize::from(header.text_range().end() - node.text_range().start()))
672        .unwrap_or_else(|| first_line_end(&source).unwrap_or(source.len()));
673    let base = usize::from(node.text_range().start());
674    let lines = body_lines(&source, body_start).collect::<Vec<_>>();
675    let mut steps = Vec::new();
676    let mut index = 0usize;
677
678    while index < lines.len() {
679        let line = lines[index];
680        let trimmed = line.text.trim_start_matches([' ', '\t']);
681        if block_line_content(trimmed).is_none() {
682            if !trimmed.is_empty() && !trimmed.starts_with("//") {
683                let indent = line.text.len() - trimmed.len();
684                steps.push(TaskStepNode::Command(TaskCommandNode {
685                    text: SmolStr::new(trimmed),
686                    range: text_range(
687                        base + line.start + indent,
688                        base + line.start + line.text.len(),
689                    ),
690                }));
691            }
692            index += 1;
693            continue;
694        }
695
696        let block_start = line.start;
697        let mut block_end = line.end_with_newline;
698        let mut block_source = String::new();
699        let mut line_ranges = Vec::new();
700        let mut marker_ranges = Vec::new();
701
702        while index < lines.len() {
703            let block_line = lines[index];
704            let trimmed = block_line.text.trim_start_matches([' ', '\t']);
705            let Some(content) = block_line_content(trimmed) else {
706                break;
707            };
708            let indent = block_line.text.len() - trimmed.len();
709            let marker_start = base + block_line.start + indent;
710            block_source.push_str(content);
711            block_source.push('\n');
712            line_ranges.push(text_range(
713                base + block_line.start,
714                base + block_line.start + block_line.text.len(),
715            ));
716            marker_ranges.push(text_range(marker_start, marker_start + 1));
717            block_end = block_line.end_with_newline;
718            index += 1;
719        }
720
721        steps.push(TaskStepNode::CommandBlock(TaskCommandBlockNode {
722            source: SmolStr::new(block_source),
723            range: text_range(base + block_start, base + block_end),
724            line_ranges,
725            marker_ranges,
726        }));
727    }
728
729    steps.into_iter()
730}
731
732fn first_line_end(source: &str) -> Option<usize> {
733    let (index, newline) = source
734        .char_indices()
735        .find(|(_, character)| matches!(character, '\n' | '\r'))?;
736    let newline_len = if newline == '\r' && source.as_bytes().get(index + 1) == Some(&b'\n') {
737        2
738    } else {
739        1
740    };
741    Some(index + newline_len)
742}
743
744fn body_lines(source: &str, start: usize) -> impl Iterator<Item = BodyLine<'_>> {
745    let mut cursor = start;
746    std::iter::from_fn(move || {
747        if cursor >= source.len() {
748            return None;
749        }
750        let line_start = cursor;
751        let rest = &source[cursor..];
752        let newline = rest
753            .char_indices()
754            .find(|(_, character)| matches!(character, '\n' | '\r'));
755        let (line_end, newline_len) = match newline {
756            Some((offset, '\r')) if rest.as_bytes().get(offset + 1) == Some(&b'\n') => {
757                (cursor + offset, 2)
758            }
759            Some((offset, _)) => (cursor + offset, 1),
760            None => (source.len(), 0),
761        };
762        cursor = line_end + newline_len;
763        Some(BodyLine {
764            text: &source[line_start..line_end],
765            start: line_start,
766            end_with_newline: cursor,
767        })
768    })
769}
770
771fn block_line_content(line: &str) -> Option<&str> {
772    let rest = line.strip_prefix('|')?;
773    match rest.as_bytes().first() {
774        None => Some(rest),
775        Some(b' ' | b'\t') => Some(&rest[1..]),
776        Some(_) => None,
777    }
778}
779
780fn text_range(start: usize, end: usize) -> TextRange {
781    TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
782}
783
784impl TaskHeaderNode {
785    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
786        (syntax.kind() == SyntaxKind::TaskHeader).then_some(Self { syntax })
787    }
788
789    pub fn range(&self) -> TextRange {
790        self.syntax.text_range()
791    }
792
793    pub fn name(&self) -> Option<SmolStr> {
794        self.name_node()?
795            .first_token()
796            .map(|token| SmolStr::new(token.text()))
797    }
798
799    pub fn name_range(&self) -> Option<TextRange> {
800        self.name_node()?
801            .first_token()
802            .map(|token| token.text_range())
803    }
804
805    pub fn parameter_list(&self) -> Option<ParameterListNode> {
806        self.syntax.children().find_map(ParameterListNode::cast)
807    }
808
809    pub fn conditions(&self) -> impl Iterator<Item = ConditionClauseNode> + '_ {
810        self.syntax.children().filter_map(ConditionClauseNode::cast)
811    }
812
813    pub fn dependencies(&self) -> impl Iterator<Item = DependencyClauseNode> + '_ {
814        self.syntax
815            .children()
816            .filter_map(DependencyClauseNode::cast)
817    }
818
819    pub fn shell(&self) -> Option<ShellClauseNode> {
820        self.syntax.children().find_map(ShellClauseNode::cast)
821    }
822
823    pub fn terminator(&self) -> Option<HeaderTerminatorNode> {
824        self.syntax.children().find_map(HeaderTerminatorNode::cast)
825    }
826
827    pub fn info(&self) -> TaskHeaderInfo {
828        parse_task_header(self)
829    }
830
831    fn name_node(&self) -> Option<SyntaxNode> {
832        self.syntax
833            .children()
834            .find(|node| node.kind() == SyntaxKind::TaskName)
835    }
836}
837
838impl ParameterListNode {
839    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
840        (syntax.kind() == SyntaxKind::ParameterList).then_some(Self { syntax })
841    }
842
843    pub fn range(&self) -> TextRange {
844        self.syntax.text_range()
845    }
846
847    /// Returns ranges for the parameter list parentheses.
848    pub fn delimiter_ranges(&self) -> Vec<TextRange> {
849        self.syntax
850            .children_with_tokens()
851            .filter_map(|element| element.into_token())
852            .filter(|token| matches!(token.kind(), SyntaxKind::LParen | SyntaxKind::RParen))
853            .map(|token| token.text_range())
854            .collect()
855    }
856
857    pub fn parameters(&self) -> impl Iterator<Item = ParameterNode> + '_ {
858        self.syntax.children().filter_map(ParameterNode::cast)
859    }
860}
861
862impl ParameterNode {
863    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
864        (syntax.kind() == SyntaxKind::Parameter).then_some(Self { syntax })
865    }
866
867    pub fn range(&self) -> TextRange {
868        self.syntax.text_range()
869    }
870
871    pub fn name(&self) -> Option<SmolStr> {
872        self.name_token().map(|token| SmolStr::new(token.text()))
873    }
874
875    pub fn name_range(&self) -> Option<TextRange> {
876        self.name_token().map(|token| token.text_range())
877    }
878
879    pub fn default_value(&self) -> Option<SmolStr> {
880        node_tokens(&self.syntax)
881            .find(|token| token.kind() == SyntaxKind::String)
882            .and_then(|token| {
883                token
884                    .text()
885                    .strip_prefix('"')?
886                    .strip_suffix('"')
887                    .map(SmolStr::new)
888            })
889    }
890
891    pub fn is_slice(&self) -> bool {
892        self.syntax
893            .text()
894            .to_string()
895            .split('=')
896            .next()
897            .is_some_and(|name| name.trim_end().ends_with(".."))
898    }
899
900    fn name_token(&self) -> Option<crate::cst::SyntaxToken> {
901        node_tokens(&self.syntax)
902            .find(|token| matches!(token.kind(), SyntaxKind::Ident | SyntaxKind::ShellKw))
903    }
904}
905
906macro_rules! clause_node {
907    ($type:ident, $kind:ident) => {
908        impl $type {
909            pub fn cast(syntax: SyntaxNode) -> Option<Self> {
910                (syntax.kind() == SyntaxKind::$kind).then_some(Self { syntax })
911            }
912
913            pub fn range(&self) -> TextRange {
914                self.syntax.text_range()
915            }
916
917            pub fn text(&self) -> SmolStr {
918                SmolStr::new(self.syntax.text().to_string().trim())
919            }
920        }
921    };
922}
923
924clause_node!(ConditionClauseNode, ConditionClause);
925clause_node!(DependencyClauseNode, DependencyClause);
926clause_node!(ShellClauseNode, ShellClause);
927clause_node!(HeaderTerminatorNode, HeaderTerminator);
928
929impl ConditionClauseNode {
930    /// Returns the range of the leading conditional operator.
931    pub fn operator_range(&self) -> Option<TextRange> {
932        node_tokens(&self.syntax)
933            .find(|token| token.kind() == SyntaxKind::Question)
934            .map(|token| token.text_range())
935    }
936}
937
938impl DependencyClauseNode {
939    /// Returns the range of the leading dependency operator.
940    pub fn operator_range(&self) -> Option<TextRange> {
941        node_tokens(&self.syntax)
942            .find(|token| token.kind() == SyntaxKind::Amp)
943            .map(|token| token.text_range())
944    }
945
946    /// Returns the delimiter ranges for a parallel dependency group.
947    pub fn parallel_group_delimiter_ranges(&self) -> Vec<TextRange> {
948        let tokens = node_tokens(&self.syntax).collect::<Vec<_>>();
949        if !tokens
950            .iter()
951            .any(|token| token.kind() == SyntaxKind::LParen)
952            || !tokens
953                .iter()
954                .any(|token| token.kind() == SyntaxKind::RParen)
955        {
956            return Vec::new();
957        }
958
959        tokens
960            .into_iter()
961            .filter(|token| {
962                matches!(
963                    token.kind(),
964                    SyntaxKind::LParen | SyntaxKind::Comma | SyntaxKind::RParen
965                )
966            })
967            .map(|token| token.text_range())
968            .collect()
969    }
970}
971
972impl ShellClauseNode {
973    /// Returns the shell selection operator.
974    pub fn operator(&self) -> Option<ShellOperator> {
975        node_tokens(&self.syntax).find_map(|token| match token.kind() {
976            SyntaxKind::ShellKw => Some(ShellOperator::Required),
977            SyntaxKind::ShellFallbackKw => Some(ShellOperator::Fallback),
978            _ => None,
979        })
980    }
981
982    /// Returns the selected shell name.
983    pub fn shell_name(&self) -> Option<SmolStr> {
984        node_tokens(&self.syntax)
985            .find(|token| token.kind() == SyntaxKind::Ident)
986            .map(|token| SmolStr::new(token.text()))
987    }
988
989    /// Returns the clause range without surrounding whitespace.
990    pub fn content_range(&self) -> Option<TextRange> {
991        let mut tokens = node_tokens(&self.syntax).filter(|token| {
992            !matches!(
993                token.kind(),
994                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
995            )
996        });
997        let first = tokens.next()?;
998        let end = tokens.last().unwrap_or_else(|| first.clone());
999        Some(TextRange::new(
1000            first.text_range().start(),
1001            end.text_range().end(),
1002        ))
1003    }
1004}
1005
1006fn parse_task_header(node: &TaskHeaderNode) -> TaskHeaderInfo {
1007    let mut info = TaskHeaderInfo::default();
1008
1009    if let Some(parameters) = node.parameter_list() {
1010        let refs = parameters
1011            .parameters()
1012            .filter_map(|parameter| {
1013                Some(TaskParamRef {
1014                    name: parameter.name()?,
1015                    range: parameter.name_range()?,
1016                    default_value: parameter.default_value(),
1017                    is_slice: parameter.is_slice(),
1018                })
1019            })
1020            .collect::<Vec<_>>();
1021        if !refs.is_empty() {
1022            info.params = Some(SmolStr::new(
1023                refs.iter()
1024                    .map(render_param_ref)
1025                    .collect::<Vec<_>>()
1026                    .join(", "),
1027            ));
1028        }
1029        info.param_refs = refs;
1030    }
1031
1032    info.guards = node.conditions().filter_map(parse_guard_ref).collect();
1033    info.guard = info
1034        .guards
1035        .first()
1036        .map(|guard| SmolStr::new(format!("@{}(\"{}\")", guard.kind, guard.argument)));
1037
1038    let mut dependency_text = Vec::new();
1039    for (stage, clause) in node.dependencies().enumerate() {
1040        dependency_text.push(clause.text().trim_start_matches('&').trim().to_string());
1041        parse_dependency_clause(&clause.syntax, stage, &mut info.dependency_refs);
1042    }
1043    if !dependency_text.is_empty() {
1044        info.dependencies = Some(SmolStr::new(dependency_text.join(" & ")));
1045    }
1046
1047    if let Some(shell) = node.shell() {
1048        let tokens = node_tokens(&shell.syntax)
1049            .filter(|token| {
1050                !matches!(
1051                    token.kind(),
1052                    SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1053                )
1054            })
1055            .collect::<Vec<_>>();
1056        let operator = tokens.first().and_then(|token| match token.kind() {
1057            SyntaxKind::ShellKw => Some(ShellOperator::Required),
1058            SyntaxKind::ShellFallbackKw => Some(ShellOperator::Fallback),
1059            _ => None,
1060        });
1061        let kind = tokens
1062            .iter()
1063            .rev()
1064            .find(|token| token.kind() == SyntaxKind::Ident)
1065            .map(|token| ShellKind::parse(token.text()));
1066        info.shell = operator.zip(kind).map(|(operator, kind)| TaskShellRef {
1067            selection: ShellSelection { kind, operator },
1068            range: shell.content_range().unwrap_or_else(|| shell.range()),
1069        });
1070    }
1071
1072    info
1073}
1074
1075fn render_param_ref(parameter: &TaskParamRef) -> String {
1076    let suffix = if parameter.is_slice { ".." } else { "" };
1077    match &parameter.default_value {
1078        Some(value) => format!("{}{suffix}=\"{value}\"", parameter.name),
1079        None => format!("{}{suffix}", parameter.name),
1080    }
1081}
1082
1083fn parse_guard_ref(clause: ConditionClauseNode) -> Option<TaskGuardRef> {
1084    let tokens = node_tokens(&clause.syntax)
1085        .filter(|token| {
1086            !matches!(
1087                token.kind(),
1088                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1089            )
1090        })
1091        .collect::<Vec<_>>();
1092    let name_token = tokens
1093        .iter()
1094        .find(|token| token.kind() == SyntaxKind::Ident)?;
1095    let name = name_token.text();
1096    let name_start = tokens
1097        .iter()
1098        .find(|token| token.kind() == SyntaxKind::At)
1099        .map_or_else(
1100            || name_token.text_range().start(),
1101            |token| token.text_range().start(),
1102        );
1103    let argument = tokens
1104        .iter()
1105        .find(|token| token.kind() == SyntaxKind::String)?
1106        .text()
1107        .strip_prefix('"')?
1108        .strip_suffix('"')?;
1109
1110    Some(TaskGuardRef {
1111        kind: GuardKind::parse(name),
1112        argument: SmolStr::new(argument),
1113        range: clause.range(),
1114        name_range: TextRange::new(name_start, name_token.text_range().end()),
1115    })
1116}
1117
1118fn parse_dependency_clause(node: &SyntaxNode, stage: usize, refs: &mut Vec<TaskDependencyRef>) {
1119    let mut tokens = node_tokens(node)
1120        .filter(|token| {
1121            !matches!(
1122                token.kind(),
1123                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1124            )
1125        })
1126        .collect::<Vec<_>>();
1127    if tokens
1128        .first()
1129        .is_some_and(|token| token.kind() == SyntaxKind::Amp)
1130    {
1131        tokens.remove(0);
1132    }
1133    if tokens
1134        .first()
1135        .is_some_and(|token| token.kind() == SyntaxKind::LParen)
1136        && tokens
1137            .last()
1138            .is_some_and(|token| token.kind() == SyntaxKind::RParen)
1139    {
1140        tokens.remove(0);
1141        tokens.pop();
1142    }
1143
1144    let mut invocation_start = 0usize;
1145    let mut depth = 0usize;
1146    for index in 0..=tokens.len() {
1147        let at_separator =
1148            index == tokens.len() || (tokens[index].kind() == SyntaxKind::Comma && depth == 0);
1149        if at_separator {
1150            if let Some(reference) =
1151                parse_dependency_invocation(&tokens[invocation_start..index], stage)
1152            {
1153                refs.push(reference);
1154            }
1155            invocation_start = index + 1;
1156            continue;
1157        }
1158
1159        match tokens[index].kind() {
1160            SyntaxKind::LParen => depth += 1,
1161            SyntaxKind::RParen => depth = depth.saturating_sub(1),
1162            _ => {}
1163        }
1164    }
1165}
1166
1167fn parse_dependency_invocation(
1168    tokens: &[crate::cst::SyntaxToken],
1169    stage: usize,
1170) -> Option<TaskDependencyRef> {
1171    let first = tokens.first()?;
1172    let invocation_end = tokens.last()?.text_range().end();
1173    let argument_start = tokens
1174        .iter()
1175        .position(|token| token.kind() == SyntaxKind::LParen)
1176        .unwrap_or(tokens.len());
1177    let name_tokens = &tokens[..argument_start];
1178    let name_start = name_tokens.first()?.text_range().start();
1179    let name_end = name_tokens.last()?.text_range().end();
1180    let name = name_tokens
1181        .iter()
1182        .map(|token| token.text())
1183        .collect::<String>();
1184    let arguments = tokens
1185        .iter()
1186        .skip(argument_start.saturating_add(1))
1187        .filter(|token| token.kind() == SyntaxKind::String)
1188        .filter_map(|token| {
1189            let value = token.text().strip_prefix('"')?.strip_suffix('"')?;
1190            Some(TaskDependencyArgRef {
1191                value: SmolStr::new(value),
1192                range: token.text_range(),
1193            })
1194        })
1195        .collect();
1196
1197    Some(TaskDependencyRef {
1198        name: SmolStr::new(name),
1199        range: TextRange::new(name_start, name_end),
1200        arguments,
1201        invocation_range: TextRange::new(first.text_range().start(), invocation_end),
1202        stage,
1203    })
1204}
1205
1206fn node_tokens(node: &SyntaxNode) -> impl Iterator<Item = crate::cst::SyntaxToken> + '_ {
1207    node.descendants_with_tokens()
1208        .filter_map(|element| element.into_token())
1209}
1210
1211fn non_trivia_token_texts(node: &SyntaxNode) -> impl Iterator<Item = SmolStr> + '_ {
1212    node.children_with_tokens()
1213        .filter_map(|element| element.into_token())
1214        .filter(|token| {
1215            !matches!(
1216                token.kind(),
1217                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1218            )
1219        })
1220        .map(|token| SmolStr::new(token.text()))
1221}