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 DocCommentNode {
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 GuardClauseNode {
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 stage: usize,
139}
140
141/// One parameter declaration parsed from a task header.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct TaskParamRef {
144    pub name: SmolStr,
145    pub range: TextRange,
146    pub default_value: Option<SmolStr>,
147    pub is_slice: bool,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct TaskGuardRef {
152    pub kind: GuardKind,
153    pub argument: SmolStr,
154    pub range: TextRange,
155}
156
157/// Structured task header data parsed from the CST token stream.
158///
159/// Args:
160/// None.
161///
162/// Returns:
163/// Parsed task header sections and dependency references.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct TaskHeaderInfo {
166    pub params: Option<SmolStr>,
167    pub param_refs: Vec<TaskParamRef>,
168    pub guard: Option<SmolStr>,
169    pub guards: Vec<TaskGuardRef>,
170    pub dependencies: Option<SmolStr>,
171    pub shell: Option<TaskShellRef>,
172    pub dependency_refs: Vec<TaskDependencyRef>,
173}
174
175impl DocumentNode {
176    /// Casts a raw rowan node into a typed document wrapper.
177    ///
178    /// Args:
179    /// syntax: Raw rowan syntax node.
180    ///
181    /// Returns:
182    /// Typed document wrapper when the kind matches `Document`.
183    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
184        (syntax.kind() == SyntaxKind::Document).then_some(Self { syntax })
185    }
186
187    /// Returns the raw rowan node.
188    ///
189    /// Args:
190    /// None.
191    ///
192    /// Returns:
193    /// Borrowed raw syntax node.
194    pub fn syntax(&self) -> &SyntaxNode {
195        &self.syntax
196    }
197
198    /// Returns the document text range.
199    ///
200    /// Args:
201    /// None.
202    ///
203    /// Returns:
204    /// Full document range in source text coordinates.
205    pub fn range(&self) -> TextRange {
206        self.syntax.text_range()
207    }
208
209    /// Iterates directive children.
210    ///
211    /// Args:
212    /// None.
213    ///
214    /// Returns:
215    /// Typed directive iterator.
216    pub fn directives(&self) -> impl Iterator<Item = DirectiveNode> + '_ {
217        self.syntax.children().filter_map(DirectiveNode::cast)
218    }
219
220    /// Iterates doc-comment children.
221    ///
222    /// Args:
223    /// None.
224    ///
225    /// Returns:
226    /// Typed doc-comment iterator.
227    pub fn doc_comments(&self) -> impl Iterator<Item = DocCommentNode> + '_ {
228        self.syntax.children().filter_map(DocCommentNode::cast)
229    }
230
231    /// Iterates namespace children.
232    ///
233    /// Args:
234    /// None.
235    ///
236    /// Returns:
237    /// Typed namespace iterator.
238    pub fn namespaces(&self) -> impl Iterator<Item = NamespaceNode> + '_ {
239        self.syntax.children().filter_map(NamespaceNode::cast)
240    }
241
242    /// Iterates task children.
243    ///
244    /// Args:
245    /// None.
246    ///
247    /// Returns:
248    /// Typed task iterator.
249    pub fn tasks(&self) -> impl Iterator<Item = TaskNode> + '_ {
250        self.syntax.children().filter_map(TaskNode::cast)
251    }
252}
253
254impl DirectiveNode {
255    /// Casts a raw rowan node into a typed directive wrapper.
256    ///
257    /// Args:
258    /// syntax: Raw rowan syntax node.
259    ///
260    /// Returns:
261    /// Typed directive wrapper when the kind matches `Directive`.
262    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
263        (syntax.kind() == SyntaxKind::Directive).then_some(Self { syntax })
264    }
265
266    /// Returns the directive text range.
267    ///
268    /// Args:
269    /// None.
270    ///
271    /// Returns:
272    /// Directive range in source text coordinates.
273    pub fn range(&self) -> TextRange {
274        self.syntax.text_range()
275    }
276
277    /// Returns the directive keyword range including the leading `!`.
278    ///
279    /// Args:
280    /// None.
281    ///
282    /// Returns:
283    /// Range covering a directive keyword such as `!shell` when present.
284    pub fn keyword_range(&self) -> Option<TextRange> {
285        let mut tokens = self
286            .syntax
287            .children_with_tokens()
288            .filter_map(|element| element.into_token())
289            .filter(|token| {
290                !matches!(
291                    token.kind(),
292                    SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
293                )
294            });
295        let bang = tokens.find(|token| token.kind() == SyntaxKind::Bang)?;
296        let keyword = tokens.next()?;
297        Some(TextRange::new(
298            bang.text_range().start(),
299            keyword.text_range().end(),
300        ))
301    }
302
303    /// Returns the directive name token text without the leading `!`.
304    ///
305    /// Args:
306    /// None.
307    ///
308    /// Returns:
309    /// Directive name when present.
310    pub fn name(&self) -> Option<SmolStr> {
311        non_trivia_token_texts(&self.syntax).nth(1)
312    }
313
314    /// Returns the typed directive kind.
315    pub fn directive_kind(&self) -> Option<DirectiveKind> {
316        self.name().map(|name| DirectiveKind::parse(&name))
317    }
318
319    /// Returns the directive value text after the directive name.
320    ///
321    /// Args:
322    /// None.
323    ///
324    /// Returns:
325    /// Joined directive value text when present.
326    pub fn value(&self) -> Option<SmolStr> {
327        let value = non_trivia_token_texts(&self.syntax)
328            .skip(2)
329            .collect::<Vec<_>>()
330            .join(" ");
331        (!value.is_empty()).then(|| SmolStr::new(value))
332    }
333
334    /// Returns the directive value with its original internal punctuation.
335    pub fn raw_value(&self) -> Option<SmolStr> {
336        let mut non_trivia = 0usize;
337        let mut value = String::new();
338
339        for token in self
340            .syntax
341            .children_with_tokens()
342            .filter_map(|element| element.into_token())
343        {
344            if token.kind() == SyntaxKind::Newline {
345                break;
346            }
347            if !matches!(
348                token.kind(),
349                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Comment
350            ) {
351                non_trivia += 1;
352            }
353            if non_trivia >= 2 && !(non_trivia == 2 && token.kind() == SyntaxKind::Ident) {
354                value.push_str(token.text());
355            }
356        }
357
358        let value = value.trim();
359        (!value.is_empty()).then(|| SmolStr::new(value))
360    }
361
362    /// Returns the first identifier range after the directive name.
363    pub fn argument_name_range(&self) -> Option<TextRange> {
364        self.syntax
365            .children_with_tokens()
366            .filter_map(|element| element.into_token())
367            .filter(|token| matches!(token.kind(), SyntaxKind::Ident | SyntaxKind::ShellKw))
368            .nth(1)
369            .map(|token| token.text_range())
370    }
371}
372
373impl DocCommentNode {
374    /// Casts a raw rowan node into a typed doc-comment wrapper.
375    ///
376    /// Args:
377    /// syntax: Raw rowan syntax node.
378    ///
379    /// Returns:
380    /// Typed doc-comment wrapper when the kind matches `DocComment`.
381    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
382        (syntax.kind() == SyntaxKind::DocComment).then_some(Self { syntax })
383    }
384
385    /// Returns the doc-comment text range.
386    ///
387    /// Args:
388    /// None.
389    ///
390    /// Returns:
391    /// Doc-comment range in source text coordinates.
392    pub fn range(&self) -> TextRange {
393        self.syntax.text_range()
394    }
395
396    /// Returns normalized doc-comment text without the leading `#`.
397    ///
398    /// Args:
399    /// None.
400    ///
401    /// Returns:
402    /// Trimmed doc-comment payload when present.
403    pub fn text(&self) -> Option<SmolStr> {
404        self.syntax
405            .text()
406            .to_string()
407            .trim()
408            .strip_prefix('#')
409            .map(str::trim)
410            .filter(|text| !text.is_empty())
411            .map(SmolStr::new)
412    }
413}
414
415impl NamespaceNode {
416    /// Casts a raw rowan node into a typed namespace wrapper.
417    ///
418    /// Args:
419    /// syntax: Raw rowan syntax node.
420    ///
421    /// Returns:
422    /// Typed namespace wrapper when the kind matches `NamespaceBlock`.
423    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
424        (syntax.kind() == SyntaxKind::NamespaceBlock).then_some(Self { syntax })
425    }
426
427    /// Returns the namespace text range.
428    ///
429    /// Args:
430    /// None.
431    ///
432    /// Returns:
433    /// Namespace range in source text coordinates.
434    pub fn range(&self) -> TextRange {
435        self.syntax.text_range()
436    }
437
438    /// Returns the namespace name without brackets.
439    ///
440    /// Args:
441    /// None.
442    ///
443    /// Returns:
444    /// Namespace name when present.
445    pub fn name(&self) -> Option<SmolStr> {
446        let source = self.syntax.text().to_string();
447        let label = source
448            .trim()
449            .strip_prefix('[')
450            .and_then(|text| text.split_once(']'))
451            .map(|(label, _)| label)
452            .map(str::trim)?;
453        (!label.is_empty()).then(|| SmolStr::new(label))
454    }
455
456    /// Returns the namespace name range inside the brackets.
457    ///
458    /// Args:
459    /// None.
460    ///
461    /// Returns:
462    /// Namespace name range when present.
463    pub fn name_range(&self) -> Option<TextRange> {
464        self.syntax
465            .children_with_tokens()
466            .filter_map(|element| element.into_token())
467            .find(|token| token.kind() == SyntaxKind::Ident)
468            .map(|token| token.text_range())
469    }
470
471    /// Returns whether this node closes the current namespace.
472    pub fn is_close(&self) -> bool {
473        self.syntax.text().to_string().trim() == "}"
474    }
475
476    /// Returns whether this namespace starts a braced scope.
477    pub fn has_open_brace(&self) -> bool {
478        self.syntax
479            .descendants_with_tokens()
480            .filter_map(|element| element.into_token())
481            .any(|token| token.kind() == SyntaxKind::LBrace)
482    }
483
484    /// Returns whether this label is empty.
485    pub fn is_empty(&self) -> bool {
486        if self.is_close() {
487            return false;
488        }
489        self.syntax
490            .text()
491            .to_string()
492            .trim()
493            .strip_prefix('[')
494            .and_then(|text| text.split_once(']'))
495            .map(|(label, _)| label)
496            .map(str::trim)
497            .filter(|text| !text.is_empty())
498            .is_none()
499    }
500}
501
502impl TaskNode {
503    /// Casts a raw rowan node into a typed task wrapper.
504    ///
505    /// Args:
506    /// syntax: Raw rowan syntax node.
507    ///
508    /// Returns:
509    /// Typed task wrapper when the kind matches `TaskDecl`.
510    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
511        (syntax.kind() == SyntaxKind::TaskDecl).then_some(Self { syntax })
512    }
513
514    /// Returns the task text range.
515    ///
516    /// Args:
517    /// None.
518    ///
519    /// Returns:
520    /// Task range in source text coordinates.
521    pub fn range(&self) -> TextRange {
522        self.syntax.text_range()
523    }
524
525    /// Returns the task name range from the header identifier.
526    ///
527    /// Args:
528    /// None.
529    ///
530    /// Returns:
531    /// Range covering the task name before the parameter list.
532    pub fn name_range(&self) -> Option<TextRange> {
533        self.header()?.name_range()
534    }
535
536    /// Returns the task name from the header identifier.
537    ///
538    /// Args:
539    /// None.
540    ///
541    /// Returns:
542    /// Task name when present.
543    pub fn name(&self) -> Option<SmolStr> {
544        self.header()?.name()
545    }
546
547    /// Returns the normalized task header text without the trailing `:`.
548    ///
549    /// Args:
550    /// None.
551    ///
552    /// Returns:
553    /// Header text when present.
554    pub fn header_text(&self) -> Option<SmolStr> {
555        let header = self.header()?.syntax.text().to_string();
556        let header = header.trim().trim_end_matches(':').trim_end();
557        (!header.is_empty()).then(|| SmolStr::new(header))
558    }
559
560    pub fn header(&self) -> Option<TaskHeaderNode> {
561        self.syntax.children().find_map(TaskHeaderNode::cast)
562    }
563
564    pub fn uses_multiline_header(&self) -> bool {
565        self.header()
566            .is_some_and(|header| header.syntax.text().to_string().contains(['\n', '\r']))
567    }
568
569    /// Returns the parsed task header sections and dependency references.
570    ///
571    /// Args:
572    /// None.
573    ///
574    /// Returns:
575    /// Structured header information parsed from one token stream pass.
576    pub fn header_info(&self) -> TaskHeaderInfo {
577        self.header()
578            .map_or_else(TaskHeaderInfo::default, |header| header.info())
579    }
580
581    /// Iterates normalized command lines from the task body.
582    ///
583    /// Args:
584    /// None.
585    ///
586    /// Returns:
587    /// Command lines in source order, without leading indentation.
588    pub fn commands(&self) -> std::vec::IntoIter<SmolStr> {
589        self.steps()
590            .map(|step| match step {
591                TaskStepNode::Command(command) => command.text,
592                TaskStepNode::CommandBlock(block) => block.source,
593            })
594            .collect::<Vec<_>>()
595            .into_iter()
596    }
597
598    /// Iterates executable task steps with source ranges.
599    pub fn steps(&self) -> std::vec::IntoIter<TaskStepNode> {
600        task_body_steps(&self.syntax)
601            .collect::<Vec<_>>()
602            .into_iter()
603    }
604}
605
606#[derive(Debug, Clone, Copy)]
607struct BodyLine<'a> {
608    text: &'a str,
609    start: usize,
610    end_with_newline: usize,
611}
612
613fn task_body_steps(node: &SyntaxNode) -> impl Iterator<Item = TaskStepNode> + '_ {
614    let source = node.text().to_string();
615    let body_start = node
616        .children()
617        .find(|child| child.kind() == SyntaxKind::TaskHeader)
618        .map(|header| usize::from(header.text_range().end() - node.text_range().start()))
619        .unwrap_or_else(|| first_line_end(&source).unwrap_or(source.len()));
620    let base = usize::from(node.text_range().start());
621    let lines = body_lines(&source, body_start).collect::<Vec<_>>();
622    let mut steps = Vec::new();
623    let mut index = 0usize;
624
625    while index < lines.len() {
626        let line = lines[index];
627        let trimmed = line.text.trim_start_matches([' ', '\t']);
628        if block_line_content(trimmed).is_none() {
629            if !trimmed.is_empty() && !trimmed.starts_with("//") {
630                let indent = line.text.len() - trimmed.len();
631                steps.push(TaskStepNode::Command(TaskCommandNode {
632                    text: SmolStr::new(trimmed),
633                    range: text_range(
634                        base + line.start + indent,
635                        base + line.start + line.text.len(),
636                    ),
637                }));
638            }
639            index += 1;
640            continue;
641        }
642
643        let block_start = line.start;
644        let mut block_end = line.end_with_newline;
645        let mut block_source = String::new();
646        let mut line_ranges = Vec::new();
647        let mut marker_ranges = Vec::new();
648
649        while index < lines.len() {
650            let block_line = lines[index];
651            let trimmed = block_line.text.trim_start_matches([' ', '\t']);
652            let Some(content) = block_line_content(trimmed) else {
653                break;
654            };
655            let indent = block_line.text.len() - trimmed.len();
656            let marker_start = base + block_line.start + indent;
657            block_source.push_str(content);
658            block_source.push('\n');
659            line_ranges.push(text_range(
660                base + block_line.start,
661                base + block_line.start + block_line.text.len(),
662            ));
663            marker_ranges.push(text_range(marker_start, marker_start + 1));
664            block_end = block_line.end_with_newline;
665            index += 1;
666        }
667
668        steps.push(TaskStepNode::CommandBlock(TaskCommandBlockNode {
669            source: SmolStr::new(block_source),
670            range: text_range(base + block_start, base + block_end),
671            line_ranges,
672            marker_ranges,
673        }));
674    }
675
676    steps.into_iter()
677}
678
679fn first_line_end(source: &str) -> Option<usize> {
680    let (index, newline) = source
681        .char_indices()
682        .find(|(_, character)| matches!(character, '\n' | '\r'))?;
683    let newline_len = if newline == '\r' && source.as_bytes().get(index + 1) == Some(&b'\n') {
684        2
685    } else {
686        1
687    };
688    Some(index + newline_len)
689}
690
691fn body_lines(source: &str, start: usize) -> impl Iterator<Item = BodyLine<'_>> {
692    let mut cursor = start;
693    std::iter::from_fn(move || {
694        if cursor >= source.len() {
695            return None;
696        }
697        let line_start = cursor;
698        let rest = &source[cursor..];
699        let newline = rest
700            .char_indices()
701            .find(|(_, character)| matches!(character, '\n' | '\r'));
702        let (line_end, newline_len) = match newline {
703            Some((offset, '\r')) if rest.as_bytes().get(offset + 1) == Some(&b'\n') => {
704                (cursor + offset, 2)
705            }
706            Some((offset, _)) => (cursor + offset, 1),
707            None => (source.len(), 0),
708        };
709        cursor = line_end + newline_len;
710        Some(BodyLine {
711            text: &source[line_start..line_end],
712            start: line_start,
713            end_with_newline: cursor,
714        })
715    })
716}
717
718fn block_line_content(line: &str) -> Option<&str> {
719    let rest = line.strip_prefix('|')?;
720    match rest.as_bytes().first() {
721        None => Some(rest),
722        Some(b' ' | b'\t') => Some(&rest[1..]),
723        Some(_) => None,
724    }
725}
726
727fn text_range(start: usize, end: usize) -> TextRange {
728    TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
729}
730
731#[derive(Debug, Default)]
732struct PendingRef {
733    name: String,
734    start: Option<TextSize>,
735    end: Option<TextSize>,
736}
737
738impl PendingRef {
739    fn flush(&mut self, refs: &mut Vec<TaskDependencyRef>, stage: usize) {
740        if let (Some(start), Some(end)) = (self.start, self.end) {
741            let name = self.name.trim();
742            if !name.is_empty() {
743                refs.push(TaskDependencyRef {
744                    name: SmolStr::new(name),
745                    range: TextRange::new(start, end),
746                    stage,
747                });
748            }
749        }
750        self.name.clear();
751        self.start = None;
752        self.end = None;
753    }
754
755    fn extend(&mut self, token: &crate::cst::SyntaxToken) {
756        self.start.get_or_insert(token.text_range().start());
757        self.end = Some(token.text_range().end());
758        self.name.push_str(token.text());
759    }
760}
761
762impl TaskHeaderNode {
763    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
764        (syntax.kind() == SyntaxKind::TaskHeader).then_some(Self { syntax })
765    }
766
767    pub fn range(&self) -> TextRange {
768        self.syntax.text_range()
769    }
770
771    pub fn name(&self) -> Option<SmolStr> {
772        self.name_node()?
773            .first_token()
774            .map(|token| SmolStr::new(token.text()))
775    }
776
777    pub fn name_range(&self) -> Option<TextRange> {
778        self.name_node()?
779            .first_token()
780            .map(|token| token.text_range())
781    }
782
783    pub fn parameter_list(&self) -> Option<ParameterListNode> {
784        self.syntax.children().find_map(ParameterListNode::cast)
785    }
786
787    pub fn guards(&self) -> impl Iterator<Item = GuardClauseNode> + '_ {
788        self.syntax.children().filter_map(GuardClauseNode::cast)
789    }
790
791    pub fn dependencies(&self) -> impl Iterator<Item = DependencyClauseNode> + '_ {
792        self.syntax
793            .children()
794            .filter_map(DependencyClauseNode::cast)
795    }
796
797    pub fn shell(&self) -> Option<ShellClauseNode> {
798        self.syntax.children().find_map(ShellClauseNode::cast)
799    }
800
801    pub fn terminator(&self) -> Option<HeaderTerminatorNode> {
802        self.syntax.children().find_map(HeaderTerminatorNode::cast)
803    }
804
805    pub fn info(&self) -> TaskHeaderInfo {
806        parse_task_header(self)
807    }
808
809    fn name_node(&self) -> Option<SyntaxNode> {
810        self.syntax
811            .children()
812            .find(|node| node.kind() == SyntaxKind::TaskName)
813    }
814}
815
816impl ParameterListNode {
817    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
818        (syntax.kind() == SyntaxKind::ParameterList).then_some(Self { syntax })
819    }
820
821    pub fn range(&self) -> TextRange {
822        self.syntax.text_range()
823    }
824
825    pub fn parameters(&self) -> impl Iterator<Item = ParameterNode> + '_ {
826        self.syntax.children().filter_map(ParameterNode::cast)
827    }
828}
829
830impl ParameterNode {
831    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
832        (syntax.kind() == SyntaxKind::Parameter).then_some(Self { syntax })
833    }
834
835    pub fn range(&self) -> TextRange {
836        self.syntax.text_range()
837    }
838
839    pub fn name(&self) -> Option<SmolStr> {
840        self.name_token().map(|token| SmolStr::new(token.text()))
841    }
842
843    pub fn name_range(&self) -> Option<TextRange> {
844        self.name_token().map(|token| token.text_range())
845    }
846
847    pub fn default_value(&self) -> Option<SmolStr> {
848        node_tokens(&self.syntax)
849            .find(|token| token.kind() == SyntaxKind::String)
850            .and_then(|token| {
851                token
852                    .text()
853                    .strip_prefix('"')?
854                    .strip_suffix('"')
855                    .map(SmolStr::new)
856            })
857    }
858
859    pub fn is_slice(&self) -> bool {
860        self.syntax
861            .text()
862            .to_string()
863            .split('=')
864            .next()
865            .is_some_and(|name| name.trim_end().ends_with(".."))
866    }
867
868    fn name_token(&self) -> Option<crate::cst::SyntaxToken> {
869        node_tokens(&self.syntax)
870            .find(|token| matches!(token.kind(), SyntaxKind::Ident | SyntaxKind::ShellKw))
871    }
872}
873
874macro_rules! clause_node {
875    ($type:ident, $kind:ident) => {
876        impl $type {
877            pub fn cast(syntax: SyntaxNode) -> Option<Self> {
878                (syntax.kind() == SyntaxKind::$kind).then_some(Self { syntax })
879            }
880
881            pub fn range(&self) -> TextRange {
882                self.syntax.text_range()
883            }
884
885            pub fn text(&self) -> SmolStr {
886                SmolStr::new(self.syntax.text().to_string().trim())
887            }
888        }
889    };
890}
891
892clause_node!(GuardClauseNode, GuardClause);
893clause_node!(DependencyClauseNode, DependencyClause);
894clause_node!(ShellClauseNode, ShellClause);
895clause_node!(HeaderTerminatorNode, HeaderTerminator);
896
897impl ShellClauseNode {
898    /// Returns the shell selection operator.
899    pub fn operator(&self) -> Option<ShellOperator> {
900        node_tokens(&self.syntax).find_map(|token| match token.kind() {
901            SyntaxKind::ShellKw => Some(ShellOperator::Required),
902            SyntaxKind::ShellFallbackKw => Some(ShellOperator::Fallback),
903            _ => None,
904        })
905    }
906
907    /// Returns the selected shell name.
908    pub fn shell_name(&self) -> Option<SmolStr> {
909        node_tokens(&self.syntax)
910            .find(|token| token.kind() == SyntaxKind::Ident)
911            .map(|token| SmolStr::new(token.text()))
912    }
913
914    /// Returns the clause range without surrounding whitespace.
915    pub fn content_range(&self) -> Option<TextRange> {
916        let mut tokens = node_tokens(&self.syntax).filter(|token| {
917            !matches!(
918                token.kind(),
919                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
920            )
921        });
922        let first = tokens.next()?;
923        let end = tokens.last().unwrap_or_else(|| first.clone());
924        Some(TextRange::new(
925            first.text_range().start(),
926            end.text_range().end(),
927        ))
928    }
929}
930
931fn parse_task_header(node: &TaskHeaderNode) -> TaskHeaderInfo {
932    let mut info = TaskHeaderInfo::default();
933
934    if let Some(parameters) = node.parameter_list() {
935        let refs = parameters
936            .parameters()
937            .filter_map(|parameter| {
938                Some(TaskParamRef {
939                    name: parameter.name()?,
940                    range: parameter.name_range()?,
941                    default_value: parameter.default_value(),
942                    is_slice: parameter.is_slice(),
943                })
944            })
945            .collect::<Vec<_>>();
946        if !refs.is_empty() {
947            info.params = Some(SmolStr::new(
948                refs.iter()
949                    .map(render_param_ref)
950                    .collect::<Vec<_>>()
951                    .join(", "),
952            ));
953        }
954        info.param_refs = refs;
955    }
956
957    info.guards = node.guards().filter_map(parse_guard_ref).collect();
958    info.guard = info
959        .guards
960        .first()
961        .map(|guard| SmolStr::new(format!("@{}(\"{}\")", guard.kind, guard.argument)));
962
963    let mut dependency_text = Vec::new();
964    for (stage, clause) in node.dependencies().enumerate() {
965        dependency_text.push(clause.text().trim_start_matches('&').trim().to_string());
966        parse_dependency_clause(&clause.syntax, stage, &mut info.dependency_refs);
967    }
968    if !dependency_text.is_empty() {
969        info.dependencies = Some(SmolStr::new(dependency_text.join(" & ")));
970    }
971
972    if let Some(shell) = node.shell() {
973        let tokens = node_tokens(&shell.syntax)
974            .filter(|token| {
975                !matches!(
976                    token.kind(),
977                    SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
978                )
979            })
980            .collect::<Vec<_>>();
981        let operator = tokens.first().and_then(|token| match token.kind() {
982            SyntaxKind::ShellKw => Some(ShellOperator::Required),
983            SyntaxKind::ShellFallbackKw => Some(ShellOperator::Fallback),
984            _ => None,
985        });
986        let kind = tokens
987            .iter()
988            .rev()
989            .find(|token| token.kind() == SyntaxKind::Ident)
990            .map(|token| ShellKind::parse(token.text()));
991        info.shell = operator.zip(kind).map(|(operator, kind)| TaskShellRef {
992            selection: ShellSelection { kind, operator },
993            range: shell.content_range().unwrap_or_else(|| shell.range()),
994        });
995    }
996
997    info
998}
999
1000fn render_param_ref(parameter: &TaskParamRef) -> String {
1001    let suffix = if parameter.is_slice { ".." } else { "" };
1002    match &parameter.default_value {
1003        Some(value) => format!("{}{suffix}=\"{value}\"", parameter.name),
1004        None => format!("{}{suffix}", parameter.name),
1005    }
1006}
1007
1008fn parse_guard_ref(clause: GuardClauseNode) -> Option<TaskGuardRef> {
1009    let tokens = node_tokens(&clause.syntax)
1010        .filter(|token| {
1011            !matches!(
1012                token.kind(),
1013                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1014            )
1015        })
1016        .collect::<Vec<_>>();
1017    let name = tokens
1018        .iter()
1019        .find(|token| token.kind() == SyntaxKind::Ident)?
1020        .text();
1021    let argument = tokens
1022        .iter()
1023        .find(|token| token.kind() == SyntaxKind::String)?
1024        .text()
1025        .strip_prefix('"')?
1026        .strip_suffix('"')?;
1027
1028    Some(TaskGuardRef {
1029        kind: GuardKind::parse(name),
1030        argument: SmolStr::new(argument),
1031        range: clause.range(),
1032    })
1033}
1034
1035fn parse_dependency_clause(node: &SyntaxNode, stage: usize, refs: &mut Vec<TaskDependencyRef>) {
1036    let mut pending = PendingRef::default();
1037    for token in node_tokens(node) {
1038        match token.kind() {
1039            SyntaxKind::Amp
1040            | SyntaxKind::LParen
1041            | SyntaxKind::Whitespace
1042            | SyntaxKind::Indent
1043            | SyntaxKind::Newline => {}
1044            SyntaxKind::RParen => pending.flush(refs, stage),
1045            SyntaxKind::Unknown if token.text() == "," => pending.flush(refs, stage),
1046            _ => pending.extend(&token),
1047        }
1048    }
1049    pending.flush(refs, stage);
1050}
1051
1052fn node_tokens(node: &SyntaxNode) -> impl Iterator<Item = crate::cst::SyntaxToken> + '_ {
1053    node.descendants_with_tokens()
1054        .filter_map(|element| element.into_token())
1055}
1056
1057fn non_trivia_token_texts(node: &SyntaxNode) -> impl Iterator<Item = SmolStr> + '_ {
1058    node.children_with_tokens()
1059        .filter_map(|element| element.into_token())
1060        .filter(|token| {
1061            !matches!(
1062                token.kind(),
1063                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
1064            )
1065        })
1066        .map(|token| SmolStr::new(token.text()))
1067}