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 uses the 0.4 `group name {` syntax.
534    pub fn is_group(&self) -> bool {
535        self.syntax
536            .children_with_tokens()
537            .filter_map(|element| element.into_token())
538            .any(|token| token.kind() == SyntaxKind::GroupKw)
539    }
540
541    /// Returns whether this node closes the current namespace.
542    pub fn is_close(&self) -> bool {
543        self.syntax.text().to_string().trim() == "}"
544    }
545
546    /// Returns whether this namespace starts a braced scope.
547    pub fn has_open_brace(&self) -> bool {
548        self.syntax
549            .descendants_with_tokens()
550            .filter_map(|element| element.into_token())
551            .any(|token| token.kind() == SyntaxKind::LBrace)
552    }
553
554    /// Returns whether this label is empty.
555    pub fn is_empty(&self) -> bool {
556        if self.is_close() {
557            return false;
558        }
559        self.name().is_none()
560    }
561}
562
563impl TaskNode {
564    /// Casts a raw rowan node into a typed task wrapper.
565    ///
566    /// Args:
567    /// syntax: Raw rowan syntax node.
568    ///
569    /// Returns:
570    /// Typed task wrapper when the kind matches `TaskDecl`.
571    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
572        (syntax.kind() == SyntaxKind::TaskDecl).then_some(Self { syntax })
573    }
574
575    /// Returns the task text range.
576    ///
577    /// Args:
578    /// None.
579    ///
580    /// Returns:
581    /// Task range in source text coordinates.
582    pub fn range(&self) -> TextRange {
583        self.syntax.text_range()
584    }
585
586    /// Returns the task name range from the header identifier.
587    ///
588    /// Args:
589    /// None.
590    ///
591    /// Returns:
592    /// Range covering the task name before the parameter list.
593    pub fn name_range(&self) -> Option<TextRange> {
594        self.header()?.name_range()
595    }
596
597    /// Returns the task name from the header identifier.
598    ///
599    /// Args:
600    /// None.
601    ///
602    /// Returns:
603    /// Task name when present.
604    pub fn name(&self) -> Option<SmolStr> {
605        self.header()?.name()
606    }
607
608    /// Returns the normalized task header text without the trailing `:`.
609    ///
610    /// Args:
611    /// None.
612    ///
613    /// Returns:
614    /// Header text when present.
615    pub fn header_text(&self) -> Option<SmolStr> {
616        let header = self.header()?.syntax.text().to_string();
617        let header = header.trim().trim_end_matches(':').trim_end();
618        (!header.is_empty()).then(|| SmolStr::new(header))
619    }
620
621    pub fn header(&self) -> Option<TaskHeaderNode> {
622        self.syntax.children().find_map(TaskHeaderNode::cast)
623    }
624
625    pub fn uses_multiline_header(&self) -> bool {
626        self.header()
627            .is_some_and(|header| header.syntax.text().to_string().contains(['\n', '\r']))
628    }
629
630    /// Returns the parsed task header sections and dependency references.
631    ///
632    /// Args:
633    /// None.
634    ///
635    /// Returns:
636    /// Structured header information parsed from one token stream pass.
637    pub fn header_info(&self) -> TaskHeaderInfo {
638        self.header()
639            .map_or_else(TaskHeaderInfo::default, |header| header.info())
640    }
641
642    /// Iterates normalized command lines from the task body.
643    ///
644    /// Args:
645    /// None.
646    ///
647    /// Returns:
648    /// Command lines in source order, without leading indentation.
649    pub fn commands(&self) -> std::vec::IntoIter<SmolStr> {
650        self.steps()
651            .map(|step| match step {
652                TaskStepNode::Command(command) => command.text,
653                TaskStepNode::CommandBlock(block) => block.source,
654            })
655            .collect::<Vec<_>>()
656            .into_iter()
657    }
658
659    /// Iterates executable task steps with source ranges.
660    pub fn steps(&self) -> std::vec::IntoIter<TaskStepNode> {
661        task_body_steps(&self.syntax)
662            .collect::<Vec<_>>()
663            .into_iter()
664    }
665}
666
667#[derive(Debug, Clone, Copy)]
668struct BodyLine<'a> {
669    text: &'a str,
670    start: usize,
671    end_with_newline: usize,
672}
673
674fn task_body_steps(node: &SyntaxNode) -> impl Iterator<Item = TaskStepNode> + '_ {
675    let source = node.text().to_string();
676    let body_start = node
677        .children()
678        .find(|child| child.kind() == SyntaxKind::TaskHeader)
679        .map(|header| usize::from(header.text_range().end() - node.text_range().start()))
680        .unwrap_or_else(|| first_line_end(&source).unwrap_or(source.len()));
681    let base = usize::from(node.text_range().start());
682    let lines = body_lines(&source, body_start).collect::<Vec<_>>();
683    let mut steps = Vec::new();
684    let mut index = 0usize;
685
686    while index < lines.len() {
687        let line = lines[index];
688        let trimmed = line.text.trim_start_matches([' ', '\t']);
689        if block_line_content(trimmed).is_none() {
690            if !trimmed.is_empty() && !trimmed.starts_with("//") {
691                let indent = line.text.len() - trimmed.len();
692                steps.push(TaskStepNode::Command(TaskCommandNode {
693                    text: SmolStr::new(trimmed),
694                    range: text_range(
695                        base + line.start + indent,
696                        base + line.start + line.text.len(),
697                    ),
698                }));
699            }
700            index += 1;
701            continue;
702        }
703
704        let block_start = line.start;
705        let mut block_end = line.end_with_newline;
706        let mut block_source = String::new();
707        let mut line_ranges = Vec::new();
708        let mut marker_ranges = Vec::new();
709
710        while index < lines.len() {
711            let block_line = lines[index];
712            let trimmed = block_line.text.trim_start_matches([' ', '\t']);
713            let Some(content) = block_line_content(trimmed) else {
714                break;
715            };
716            let indent = block_line.text.len() - trimmed.len();
717            let marker_start = base + block_line.start + indent;
718            block_source.push_str(content);
719            block_source.push('\n');
720            line_ranges.push(text_range(
721                base + block_line.start,
722                base + block_line.start + block_line.text.len(),
723            ));
724            marker_ranges.push(text_range(marker_start, marker_start + 1));
725            block_end = block_line.end_with_newline;
726            index += 1;
727        }
728
729        steps.push(TaskStepNode::CommandBlock(TaskCommandBlockNode {
730            source: SmolStr::new(block_source),
731            range: text_range(base + block_start, base + block_end),
732            line_ranges,
733            marker_ranges,
734        }));
735    }
736
737    steps.into_iter()
738}
739
740fn first_line_end(source: &str) -> Option<usize> {
741    let (index, newline) = source
742        .char_indices()
743        .find(|(_, character)| matches!(character, '\n' | '\r'))?;
744    let newline_len = if newline == '\r' && source.as_bytes().get(index + 1) == Some(&b'\n') {
745        2
746    } else {
747        1
748    };
749    Some(index + newline_len)
750}
751
752fn body_lines(source: &str, start: usize) -> impl Iterator<Item = BodyLine<'_>> {
753    let mut cursor = start;
754    std::iter::from_fn(move || {
755        if cursor >= source.len() {
756            return None;
757        }
758        let line_start = cursor;
759        let rest = &source[cursor..];
760        let newline = rest
761            .char_indices()
762            .find(|(_, character)| matches!(character, '\n' | '\r'));
763        let (line_end, newline_len) = match newline {
764            Some((offset, '\r')) if rest.as_bytes().get(offset + 1) == Some(&b'\n') => {
765                (cursor + offset, 2)
766            }
767            Some((offset, _)) => (cursor + offset, 1),
768            None => (source.len(), 0),
769        };
770        cursor = line_end + newline_len;
771        Some(BodyLine {
772            text: &source[line_start..line_end],
773            start: line_start,
774            end_with_newline: cursor,
775        })
776    })
777}
778
779fn block_line_content(line: &str) -> Option<&str> {
780    let rest = line.strip_prefix('|')?;
781    match rest.as_bytes().first() {
782        None => Some(rest),
783        Some(b' ' | b'\t') => Some(&rest[1..]),
784        Some(_) => None,
785    }
786}
787
788fn text_range(start: usize, end: usize) -> TextRange {
789    TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
790}
791
792impl TaskHeaderNode {
793    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
794        (syntax.kind() == SyntaxKind::TaskHeader).then_some(Self { syntax })
795    }
796
797    pub fn range(&self) -> TextRange {
798        self.syntax.text_range()
799    }
800
801    pub fn name(&self) -> Option<SmolStr> {
802        self.name_node()?
803            .first_token()
804            .map(|token| SmolStr::new(token.text()))
805    }
806
807    pub fn name_range(&self) -> Option<TextRange> {
808        self.name_node()?
809            .first_token()
810            .map(|token| token.text_range())
811    }
812
813    pub fn parameter_list(&self) -> Option<ParameterListNode> {
814        self.syntax.children().find_map(ParameterListNode::cast)
815    }
816
817    pub fn conditions(&self) -> impl Iterator<Item = ConditionClauseNode> + '_ {
818        self.syntax.children().filter_map(ConditionClauseNode::cast)
819    }
820
821    pub fn dependencies(&self) -> impl Iterator<Item = DependencyClauseNode> + '_ {
822        self.syntax
823            .children()
824            .filter_map(DependencyClauseNode::cast)
825    }
826
827    pub fn shell(&self) -> Option<ShellClauseNode> {
828        self.syntax.children().find_map(ShellClauseNode::cast)
829    }
830
831    pub fn terminator(&self) -> Option<HeaderTerminatorNode> {
832        self.syntax.children().find_map(HeaderTerminatorNode::cast)
833    }
834
835    pub fn info(&self) -> TaskHeaderInfo {
836        parse_task_header(self)
837    }
838
839    fn name_node(&self) -> Option<SyntaxNode> {
840        self.syntax
841            .children()
842            .find(|node| node.kind() == SyntaxKind::TaskName)
843    }
844}
845
846impl ParameterListNode {
847    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
848        (syntax.kind() == SyntaxKind::ParameterList).then_some(Self { syntax })
849    }
850
851    pub fn range(&self) -> TextRange {
852        self.syntax.text_range()
853    }
854
855    pub fn parameters(&self) -> impl Iterator<Item = ParameterNode> + '_ {
856        self.syntax.children().filter_map(ParameterNode::cast)
857    }
858}
859
860impl ParameterNode {
861    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
862        (syntax.kind() == SyntaxKind::Parameter).then_some(Self { syntax })
863    }
864
865    pub fn range(&self) -> TextRange {
866        self.syntax.text_range()
867    }
868
869    pub fn name(&self) -> Option<SmolStr> {
870        self.name_token().map(|token| SmolStr::new(token.text()))
871    }
872
873    pub fn name_range(&self) -> Option<TextRange> {
874        self.name_token().map(|token| token.text_range())
875    }
876
877    pub fn default_value(&self) -> Option<SmolStr> {
878        node_tokens(&self.syntax)
879            .find(|token| token.kind() == SyntaxKind::String)
880            .and_then(|token| {
881                token
882                    .text()
883                    .strip_prefix('"')?
884                    .strip_suffix('"')
885                    .map(SmolStr::new)
886            })
887    }
888
889    pub fn is_slice(&self) -> bool {
890        self.syntax
891            .text()
892            .to_string()
893            .split('=')
894            .next()
895            .is_some_and(|name| name.trim_end().ends_with(".."))
896    }
897
898    fn name_token(&self) -> Option<crate::cst::SyntaxToken> {
899        node_tokens(&self.syntax)
900            .find(|token| matches!(token.kind(), SyntaxKind::Ident | SyntaxKind::ShellKw))
901    }
902}
903
904macro_rules! clause_node {
905    ($type:ident, $kind:ident) => {
906        impl $type {
907            pub fn cast(syntax: SyntaxNode) -> Option<Self> {
908                (syntax.kind() == SyntaxKind::$kind).then_some(Self { syntax })
909            }
910
911            pub fn range(&self) -> TextRange {
912                self.syntax.text_range()
913            }
914
915            pub fn text(&self) -> SmolStr {
916                SmolStr::new(self.syntax.text().to_string().trim())
917            }
918        }
919    };
920}
921
922clause_node!(ConditionClauseNode, ConditionClause);
923clause_node!(DependencyClauseNode, DependencyClause);
924clause_node!(ShellClauseNode, ShellClause);
925clause_node!(HeaderTerminatorNode, HeaderTerminator);
926
927impl ConditionClauseNode {
928    /// Returns the range of the leading conditional operator.
929    pub fn operator_range(&self) -> Option<TextRange> {
930        node_tokens(&self.syntax)
931            .find(|token| token.kind() == SyntaxKind::Question)
932            .map(|token| token.text_range())
933    }
934}
935
936impl DependencyClauseNode {
937    /// Returns the range of the leading dependency operator.
938    pub fn operator_range(&self) -> Option<TextRange> {
939        node_tokens(&self.syntax)
940            .find(|token| token.kind() == SyntaxKind::Amp)
941            .map(|token| token.text_range())
942    }
943
944    /// Returns the delimiter ranges for a parallel dependency group.
945    pub fn parallel_group_delimiter_ranges(&self) -> Vec<TextRange> {
946        let tokens = node_tokens(&self.syntax).collect::<Vec<_>>();
947        if !tokens
948            .iter()
949            .any(|token| token.kind() == SyntaxKind::LParen)
950            || !tokens
951                .iter()
952                .any(|token| token.kind() == SyntaxKind::RParen)
953        {
954            return Vec::new();
955        }
956
957        tokens
958            .into_iter()
959            .filter(|token| {
960                matches!(
961                    token.kind(),
962                    SyntaxKind::LParen | SyntaxKind::Comma | SyntaxKind::RParen
963                )
964            })
965            .map(|token| token.text_range())
966            .collect()
967    }
968}
969
970impl ShellClauseNode {
971    /// Returns the shell selection operator.
972    pub fn operator(&self) -> Option<ShellOperator> {
973        node_tokens(&self.syntax).find_map(|token| match token.kind() {
974            SyntaxKind::ShellKw => Some(ShellOperator::Required),
975            SyntaxKind::ShellFallbackKw => Some(ShellOperator::Fallback),
976            _ => None,
977        })
978    }
979
980    /// Returns the selected shell name.
981    pub fn shell_name(&self) -> Option<SmolStr> {
982        node_tokens(&self.syntax)
983            .find(|token| token.kind() == SyntaxKind::Ident)
984            .map(|token| SmolStr::new(token.text()))
985    }
986
987    /// Returns the clause range without surrounding whitespace.
988    pub fn content_range(&self) -> Option<TextRange> {
989        let mut tokens = node_tokens(&self.syntax).filter(|token| {
990            !matches!(
991                token.kind(),
992                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
993            )
994        });
995        let first = tokens.next()?;
996        let end = tokens.last().unwrap_or_else(|| first.clone());
997        Some(TextRange::new(
998            first.text_range().start(),
999            end.text_range().end(),
1000        ))
1001    }
1002}
1003
1004fn parse_task_header(node: &TaskHeaderNode) -> TaskHeaderInfo {
1005    let mut info = TaskHeaderInfo::default();
1006
1007    if let Some(parameters) = node.parameter_list() {
1008        let refs = parameters
1009            .parameters()
1010            .filter_map(|parameter| {
1011                Some(TaskParamRef {
1012                    name: parameter.name()?,
1013                    range: parameter.name_range()?,
1014                    default_value: parameter.default_value(),
1015                    is_slice: parameter.is_slice(),
1016                })
1017            })
1018            .collect::<Vec<_>>();
1019        if !refs.is_empty() {
1020            info.params = Some(SmolStr::new(
1021                refs.iter()
1022                    .map(render_param_ref)
1023                    .collect::<Vec<_>>()
1024                    .join(", "),
1025            ));
1026        }
1027        info.param_refs = refs;
1028    }
1029
1030    info.guards = node.conditions().filter_map(parse_guard_ref).collect();
1031    info.guard = info
1032        .guards
1033        .first()
1034        .map(|guard| SmolStr::new(format!("@{}(\"{}\")", guard.kind, guard.argument)));
1035
1036    let mut dependency_text = Vec::new();
1037    for (stage, clause) in node.dependencies().enumerate() {
1038        dependency_text.push(clause.text().trim_start_matches('&').trim().to_string());
1039        parse_dependency_clause(&clause.syntax, stage, &mut info.dependency_refs);
1040    }
1041    if !dependency_text.is_empty() {
1042        info.dependencies = Some(SmolStr::new(dependency_text.join(" & ")));
1043    }
1044
1045    if let Some(shell) = node.shell() {
1046        let tokens = node_tokens(&shell.syntax)
1047            .filter(|token| {
1048                !matches!(
1049                    token.kind(),
1050                    SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1051                )
1052            })
1053            .collect::<Vec<_>>();
1054        let operator = tokens.first().and_then(|token| match token.kind() {
1055            SyntaxKind::ShellKw => Some(ShellOperator::Required),
1056            SyntaxKind::ShellFallbackKw => Some(ShellOperator::Fallback),
1057            _ => None,
1058        });
1059        let kind = tokens
1060            .iter()
1061            .rev()
1062            .find(|token| token.kind() == SyntaxKind::Ident)
1063            .map(|token| ShellKind::parse(token.text()));
1064        info.shell = operator.zip(kind).map(|(operator, kind)| TaskShellRef {
1065            selection: ShellSelection { kind, operator },
1066            range: shell.content_range().unwrap_or_else(|| shell.range()),
1067        });
1068    }
1069
1070    info
1071}
1072
1073fn render_param_ref(parameter: &TaskParamRef) -> String {
1074    let suffix = if parameter.is_slice { ".." } else { "" };
1075    match &parameter.default_value {
1076        Some(value) => format!("{}{suffix}=\"{value}\"", parameter.name),
1077        None => format!("{}{suffix}", parameter.name),
1078    }
1079}
1080
1081fn parse_guard_ref(clause: ConditionClauseNode) -> Option<TaskGuardRef> {
1082    let tokens = node_tokens(&clause.syntax)
1083        .filter(|token| {
1084            !matches!(
1085                token.kind(),
1086                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1087            )
1088        })
1089        .collect::<Vec<_>>();
1090    let name_token = tokens
1091        .iter()
1092        .find(|token| token.kind() == SyntaxKind::Ident)?;
1093    let name = name_token.text();
1094    let name_start = tokens
1095        .iter()
1096        .find(|token| token.kind() == SyntaxKind::At)
1097        .map_or_else(
1098            || name_token.text_range().start(),
1099            |token| token.text_range().start(),
1100        );
1101    let argument = tokens
1102        .iter()
1103        .find(|token| token.kind() == SyntaxKind::String)?
1104        .text()
1105        .strip_prefix('"')?
1106        .strip_suffix('"')?;
1107
1108    Some(TaskGuardRef {
1109        kind: GuardKind::parse(name),
1110        argument: SmolStr::new(argument),
1111        range: clause.range(),
1112        name_range: TextRange::new(name_start, name_token.text_range().end()),
1113    })
1114}
1115
1116fn parse_dependency_clause(node: &SyntaxNode, stage: usize, refs: &mut Vec<TaskDependencyRef>) {
1117    let mut tokens = node_tokens(node)
1118        .filter(|token| {
1119            !matches!(
1120                token.kind(),
1121                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1122            )
1123        })
1124        .collect::<Vec<_>>();
1125    if tokens
1126        .first()
1127        .is_some_and(|token| token.kind() == SyntaxKind::Amp)
1128    {
1129        tokens.remove(0);
1130    }
1131    if tokens
1132        .first()
1133        .is_some_and(|token| token.kind() == SyntaxKind::LParen)
1134        && tokens
1135            .last()
1136            .is_some_and(|token| token.kind() == SyntaxKind::RParen)
1137    {
1138        tokens.remove(0);
1139        tokens.pop();
1140    }
1141
1142    let mut invocation_start = 0usize;
1143    let mut depth = 0usize;
1144    for index in 0..=tokens.len() {
1145        let at_separator =
1146            index == tokens.len() || (tokens[index].kind() == SyntaxKind::Comma && depth == 0);
1147        if at_separator {
1148            if let Some(reference) =
1149                parse_dependency_invocation(&tokens[invocation_start..index], stage)
1150            {
1151                refs.push(reference);
1152            }
1153            invocation_start = index + 1;
1154            continue;
1155        }
1156
1157        match tokens[index].kind() {
1158            SyntaxKind::LParen => depth += 1,
1159            SyntaxKind::RParen => depth = depth.saturating_sub(1),
1160            _ => {}
1161        }
1162    }
1163}
1164
1165fn parse_dependency_invocation(
1166    tokens: &[crate::cst::SyntaxToken],
1167    stage: usize,
1168) -> Option<TaskDependencyRef> {
1169    let first = tokens.first()?;
1170    let invocation_end = tokens.last()?.text_range().end();
1171    let argument_start = tokens
1172        .iter()
1173        .position(|token| token.kind() == SyntaxKind::LParen)
1174        .unwrap_or(tokens.len());
1175    let name_tokens = &tokens[..argument_start];
1176    let name_start = name_tokens.first()?.text_range().start();
1177    let name_end = name_tokens.last()?.text_range().end();
1178    let name = name_tokens
1179        .iter()
1180        .map(|token| token.text())
1181        .collect::<String>();
1182    let arguments = tokens
1183        .iter()
1184        .skip(argument_start.saturating_add(1))
1185        .filter(|token| token.kind() == SyntaxKind::String)
1186        .filter_map(|token| {
1187            let value = token.text().strip_prefix('"')?.strip_suffix('"')?;
1188            Some(TaskDependencyArgRef {
1189                value: SmolStr::new(value),
1190                range: token.text_range(),
1191            })
1192        })
1193        .collect();
1194
1195    Some(TaskDependencyRef {
1196        name: SmolStr::new(name),
1197        range: TextRange::new(name_start, name_end),
1198        arguments,
1199        invocation_range: TextRange::new(first.text_range().start(), invocation_end),
1200        stage,
1201    })
1202}
1203
1204fn node_tokens(node: &SyntaxNode) -> impl Iterator<Item = crate::cst::SyntaxToken> + '_ {
1205    node.descendants_with_tokens()
1206        .filter_map(|element| element.into_token())
1207}
1208
1209fn non_trivia_token_texts(node: &SyntaxNode) -> impl Iterator<Item = SmolStr> + '_ {
1210    node.children_with_tokens()
1211        .filter_map(|element| element.into_token())
1212        .filter(|token| {
1213            !matches!(
1214                token.kind(),
1215                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1216            )
1217        })
1218        .map(|token| SmolStr::new(token.text()))
1219}