Skip to main content

only_syntax/
ast_view.rs

1use smol_str::SmolStr;
2use text_size::{TextRange, TextSize};
3
4use crate::{SyntaxKind, SyntaxNode};
5
6/// Typed document CST wrapper.
7///
8/// Args:
9/// None.
10///
11/// Returns:
12/// Stable accessors for top-level syntax items and spans.
13#[derive(Debug, Clone)]
14pub struct DocumentNode {
15    syntax: SyntaxNode,
16}
17
18/// Typed directive CST wrapper.
19///
20/// Args:
21/// None.
22///
23/// Returns:
24/// Stable accessors for directive name, value and span.
25#[derive(Debug, Clone)]
26pub struct DirectiveNode {
27    syntax: SyntaxNode,
28}
29
30/// Typed doc-comment CST wrapper.
31///
32/// Args:
33/// None.
34///
35/// Returns:
36/// Stable accessors for doc-comment text and span.
37#[derive(Debug, Clone)]
38pub struct DocCommentNode {
39    syntax: SyntaxNode,
40}
41
42/// Typed namespace CST wrapper.
43///
44/// Args:
45/// None.
46///
47/// Returns:
48/// Stable accessors for namespace name and span.
49#[derive(Debug, Clone)]
50pub struct NamespaceNode {
51    syntax: SyntaxNode,
52}
53
54/// Typed task CST wrapper.
55///
56/// Args:
57/// None.
58///
59/// Returns:
60/// Stable accessors for task header, commands and span.
61#[derive(Debug, Clone)]
62pub struct TaskNode {
63    syntax: SyntaxNode,
64}
65
66/// One executable step read from a task body.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum TaskStepNode {
69    Command(TaskCommandNode),
70    CommandBlock(TaskCommandBlockNode),
71}
72
73/// One ordinary command line and its source range.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct TaskCommandNode {
76    pub text: SmolStr,
77    pub range: TextRange,
78}
79
80/// Consecutive block lines assembled into one shell input.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct TaskCommandBlockNode {
83    pub source: SmolStr,
84    pub range: TextRange,
85    pub line_ranges: Vec<TextRange>,
86    pub marker_ranges: Vec<TextRange>,
87}
88
89/// One dependency reference parsed from a task header.
90///
91/// Args:
92/// None.
93///
94/// Returns:
95/// Dependency text and the precise source range of that reference.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct TaskDependencyRef {
98    pub name: SmolStr,
99    pub range: TextRange,
100    pub stage: usize,
101}
102
103/// One parameter declaration parsed from a task header.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct TaskParamRef {
106    pub name: SmolStr,
107    pub range: TextRange,
108}
109
110/// Structured task header data parsed from the CST token stream.
111///
112/// Args:
113/// None.
114///
115/// Returns:
116/// Parsed task header sections and dependency references.
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct TaskHeaderInfo {
119    pub params: Option<SmolStr>,
120    pub param_refs: Vec<TaskParamRef>,
121    pub guard: Option<SmolStr>,
122    pub dependencies: Option<SmolStr>,
123    pub shell: Option<SmolStr>,
124    pub shell_fallback: bool,
125    pub dependency_refs: Vec<TaskDependencyRef>,
126}
127
128impl DocumentNode {
129    /// Casts a raw rowan node into a typed document wrapper.
130    ///
131    /// Args:
132    /// syntax: Raw rowan syntax node.
133    ///
134    /// Returns:
135    /// Typed document wrapper when the kind matches `Document`.
136    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
137        (syntax.kind() == SyntaxKind::Document).then_some(Self { syntax })
138    }
139
140    /// Returns the raw rowan node.
141    ///
142    /// Args:
143    /// None.
144    ///
145    /// Returns:
146    /// Borrowed raw syntax node.
147    pub fn syntax(&self) -> &SyntaxNode {
148        &self.syntax
149    }
150
151    /// Returns the document text range.
152    ///
153    /// Args:
154    /// None.
155    ///
156    /// Returns:
157    /// Full document range in source text coordinates.
158    pub fn range(&self) -> TextRange {
159        self.syntax.text_range()
160    }
161
162    /// Iterates directive children.
163    ///
164    /// Args:
165    /// None.
166    ///
167    /// Returns:
168    /// Typed directive iterator.
169    pub fn directives(&self) -> impl Iterator<Item = DirectiveNode> + '_ {
170        self.syntax.children().filter_map(DirectiveNode::cast)
171    }
172
173    /// Iterates doc-comment children.
174    ///
175    /// Args:
176    /// None.
177    ///
178    /// Returns:
179    /// Typed doc-comment iterator.
180    pub fn doc_comments(&self) -> impl Iterator<Item = DocCommentNode> + '_ {
181        self.syntax.children().filter_map(DocCommentNode::cast)
182    }
183
184    /// Iterates namespace children.
185    ///
186    /// Args:
187    /// None.
188    ///
189    /// Returns:
190    /// Typed namespace iterator.
191    pub fn namespaces(&self) -> impl Iterator<Item = NamespaceNode> + '_ {
192        self.syntax.children().filter_map(NamespaceNode::cast)
193    }
194
195    /// Iterates task children.
196    ///
197    /// Args:
198    /// None.
199    ///
200    /// Returns:
201    /// Typed task iterator.
202    pub fn tasks(&self) -> impl Iterator<Item = TaskNode> + '_ {
203        self.syntax.children().filter_map(TaskNode::cast)
204    }
205}
206
207impl DirectiveNode {
208    /// Casts a raw rowan node into a typed directive wrapper.
209    ///
210    /// Args:
211    /// syntax: Raw rowan syntax node.
212    ///
213    /// Returns:
214    /// Typed directive wrapper when the kind matches `Directive`.
215    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
216        (syntax.kind() == SyntaxKind::Directive).then_some(Self { syntax })
217    }
218
219    /// Returns the directive text range.
220    ///
221    /// Args:
222    /// None.
223    ///
224    /// Returns:
225    /// Directive range in source text coordinates.
226    pub fn range(&self) -> TextRange {
227        self.syntax.text_range()
228    }
229
230    /// Returns the directive keyword range including the leading `!`.
231    ///
232    /// Args:
233    /// None.
234    ///
235    /// Returns:
236    /// Range covering a directive keyword such as `!shell` when present.
237    pub fn keyword_range(&self) -> Option<TextRange> {
238        let mut tokens = self
239            .syntax
240            .children_with_tokens()
241            .filter_map(|element| element.into_token())
242            .filter(|token| {
243                !matches!(
244                    token.kind(),
245                    SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
246                )
247            });
248        let bang = tokens.find(|token| token.kind() == SyntaxKind::Bang)?;
249        let keyword = tokens.next()?;
250        Some(TextRange::new(
251            bang.text_range().start(),
252            keyword.text_range().end(),
253        ))
254    }
255
256    /// Returns the directive name token text without the leading `!`.
257    ///
258    /// Args:
259    /// None.
260    ///
261    /// Returns:
262    /// Directive name when present.
263    pub fn name(&self) -> Option<SmolStr> {
264        non_trivia_token_texts(&self.syntax).nth(1)
265    }
266
267    /// Returns the directive value text after the directive name.
268    ///
269    /// Args:
270    /// None.
271    ///
272    /// Returns:
273    /// Joined directive value text when present.
274    pub fn value(&self) -> Option<SmolStr> {
275        let value = non_trivia_token_texts(&self.syntax)
276            .skip(2)
277            .collect::<Vec<_>>()
278            .join(" ");
279        (!value.is_empty()).then(|| SmolStr::new(value))
280    }
281
282    /// Returns the directive value with its original internal punctuation.
283    pub fn raw_value(&self) -> Option<SmolStr> {
284        let mut non_trivia = 0usize;
285        let mut value = String::new();
286
287        for token in self
288            .syntax
289            .children_with_tokens()
290            .filter_map(|element| element.into_token())
291        {
292            if token.kind() == SyntaxKind::Newline {
293                break;
294            }
295            if !matches!(
296                token.kind(),
297                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Comment
298            ) {
299                non_trivia += 1;
300            }
301            if non_trivia >= 2 && !(non_trivia == 2 && token.kind() == SyntaxKind::Ident) {
302                value.push_str(token.text());
303            }
304        }
305
306        let value = value.trim();
307        (!value.is_empty()).then(|| SmolStr::new(value))
308    }
309}
310
311impl DocCommentNode {
312    /// Casts a raw rowan node into a typed doc-comment wrapper.
313    ///
314    /// Args:
315    /// syntax: Raw rowan syntax node.
316    ///
317    /// Returns:
318    /// Typed doc-comment wrapper when the kind matches `DocComment`.
319    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
320        (syntax.kind() == SyntaxKind::DocComment).then_some(Self { syntax })
321    }
322
323    /// Returns the doc-comment text range.
324    ///
325    /// Args:
326    /// None.
327    ///
328    /// Returns:
329    /// Doc-comment range in source text coordinates.
330    pub fn range(&self) -> TextRange {
331        self.syntax.text_range()
332    }
333
334    /// Returns normalized doc-comment text without the leading `#`.
335    ///
336    /// Args:
337    /// None.
338    ///
339    /// Returns:
340    /// Trimmed doc-comment payload when present.
341    pub fn text(&self) -> Option<SmolStr> {
342        self.syntax
343            .text()
344            .to_string()
345            .trim()
346            .strip_prefix('#')
347            .map(str::trim)
348            .filter(|text| !text.is_empty())
349            .map(SmolStr::new)
350    }
351}
352
353impl NamespaceNode {
354    /// Casts a raw rowan node into a typed namespace wrapper.
355    ///
356    /// Args:
357    /// syntax: Raw rowan syntax node.
358    ///
359    /// Returns:
360    /// Typed namespace wrapper when the kind matches `NamespaceBlock`.
361    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
362        (syntax.kind() == SyntaxKind::NamespaceBlock).then_some(Self { syntax })
363    }
364
365    /// Returns the namespace text range.
366    ///
367    /// Args:
368    /// None.
369    ///
370    /// Returns:
371    /// Namespace range in source text coordinates.
372    pub fn range(&self) -> TextRange {
373        self.syntax.text_range()
374    }
375
376    /// Returns the namespace name without brackets.
377    ///
378    /// Args:
379    /// None.
380    ///
381    /// Returns:
382    /// Namespace name when present.
383    pub fn name(&self) -> Option<SmolStr> {
384        self.syntax
385            .text()
386            .to_string()
387            .trim()
388            .strip_prefix('[')
389            .and_then(|text| text.strip_suffix(']'))
390            .map(str::trim)
391            .filter(|text| !text.is_empty())
392            .map(SmolStr::new)
393    }
394}
395
396impl TaskNode {
397    /// Casts a raw rowan node into a typed task wrapper.
398    ///
399    /// Args:
400    /// syntax: Raw rowan syntax node.
401    ///
402    /// Returns:
403    /// Typed task wrapper when the kind matches `TaskDecl`.
404    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
405        (syntax.kind() == SyntaxKind::TaskDecl).then_some(Self { syntax })
406    }
407
408    /// Returns the task text range.
409    ///
410    /// Args:
411    /// None.
412    ///
413    /// Returns:
414    /// Task range in source text coordinates.
415    pub fn range(&self) -> TextRange {
416        self.syntax.text_range()
417    }
418
419    /// Returns the task name range from the header identifier.
420    ///
421    /// Args:
422    /// None.
423    ///
424    /// Returns:
425    /// Range covering the task name before the parameter list.
426    pub fn name_range(&self) -> Option<TextRange> {
427        self.syntax
428            .children_with_tokens()
429            .filter_map(|element| element.into_token())
430            .find(|token| token.kind() == SyntaxKind::Ident)
431            .map(|token| token.text_range())
432    }
433
434    /// Returns the task name from the header identifier.
435    ///
436    /// Args:
437    /// None.
438    ///
439    /// Returns:
440    /// Task name when present.
441    pub fn name(&self) -> Option<SmolStr> {
442        self.syntax
443            .children_with_tokens()
444            .filter_map(|element| element.into_token())
445            .find(|token| token.kind() == SyntaxKind::Ident)
446            .map(|token| SmolStr::new(token.text()))
447    }
448
449    /// Returns the normalized task header text without the trailing `:`.
450    ///
451    /// Args:
452    /// None.
453    ///
454    /// Returns:
455    /// Header text when present.
456    pub fn header_text(&self) -> Option<SmolStr> {
457        let mut header = String::new();
458
459        for token in self
460            .syntax
461            .children_with_tokens()
462            .filter_map(|element| element.into_token())
463        {
464            if token.kind() == SyntaxKind::Colon {
465                break;
466            }
467            if token.kind() == SyntaxKind::Newline {
468                break;
469            }
470            header.push_str(token.text());
471        }
472
473        let header = header.trim();
474        (!header.is_empty()).then(|| SmolStr::new(header))
475    }
476
477    /// Returns the parsed task header sections and dependency references.
478    ///
479    /// Args:
480    /// None.
481    ///
482    /// Returns:
483    /// Structured header information parsed from one token stream pass.
484    pub fn header_info(&self) -> TaskHeaderInfo {
485        parse_task_header(&self.syntax)
486    }
487
488    /// Iterates normalized command lines from the task body.
489    ///
490    /// Args:
491    /// None.
492    ///
493    /// Returns:
494    /// Command lines in source order, without leading indentation.
495    pub fn commands(&self) -> std::vec::IntoIter<SmolStr> {
496        self.steps()
497            .map(|step| match step {
498                TaskStepNode::Command(command) => command.text,
499                TaskStepNode::CommandBlock(block) => block.source,
500            })
501            .collect::<Vec<_>>()
502            .into_iter()
503    }
504
505    /// Iterates executable task steps with source ranges.
506    pub fn steps(&self) -> std::vec::IntoIter<TaskStepNode> {
507        task_body_steps(&self.syntax)
508            .collect::<Vec<_>>()
509            .into_iter()
510    }
511}
512
513#[derive(Debug, Clone, Copy)]
514struct BodyLine<'a> {
515    text: &'a str,
516    start: usize,
517    end_with_newline: usize,
518}
519
520fn task_body_steps(node: &SyntaxNode) -> impl Iterator<Item = TaskStepNode> + '_ {
521    let source = node.text().to_string();
522    let body_start = first_line_end(&source).unwrap_or(source.len());
523    let base = usize::from(node.text_range().start());
524    let lines = body_lines(&source, body_start).collect::<Vec<_>>();
525    let mut steps = Vec::new();
526    let mut index = 0usize;
527
528    while index < lines.len() {
529        let line = lines[index];
530        let trimmed = line.text.trim_start_matches([' ', '\t']);
531        if block_line_content(trimmed).is_none() {
532            if !trimmed.is_empty() && !trimmed.starts_with("//") {
533                let indent = line.text.len() - trimmed.len();
534                steps.push(TaskStepNode::Command(TaskCommandNode {
535                    text: SmolStr::new(trimmed),
536                    range: text_range(
537                        base + line.start + indent,
538                        base + line.start + line.text.len(),
539                    ),
540                }));
541            }
542            index += 1;
543            continue;
544        }
545
546        let block_start = line.start;
547        let mut block_end = line.end_with_newline;
548        let mut block_source = String::new();
549        let mut line_ranges = Vec::new();
550        let mut marker_ranges = Vec::new();
551
552        while index < lines.len() {
553            let block_line = lines[index];
554            let trimmed = block_line.text.trim_start_matches([' ', '\t']);
555            let Some(content) = block_line_content(trimmed) else {
556                break;
557            };
558            let indent = block_line.text.len() - trimmed.len();
559            let marker_start = base + block_line.start + indent;
560            block_source.push_str(content);
561            block_source.push('\n');
562            line_ranges.push(text_range(
563                base + block_line.start,
564                base + block_line.start + block_line.text.len(),
565            ));
566            marker_ranges.push(text_range(marker_start, marker_start + 1));
567            block_end = block_line.end_with_newline;
568            index += 1;
569        }
570
571        steps.push(TaskStepNode::CommandBlock(TaskCommandBlockNode {
572            source: SmolStr::new(block_source),
573            range: text_range(base + block_start, base + block_end),
574            line_ranges,
575            marker_ranges,
576        }));
577    }
578
579    steps.into_iter()
580}
581
582fn first_line_end(source: &str) -> Option<usize> {
583    let (index, newline) = source
584        .char_indices()
585        .find(|(_, character)| matches!(character, '\n' | '\r'))?;
586    let newline_len = if newline == '\r' && source.as_bytes().get(index + 1) == Some(&b'\n') {
587        2
588    } else {
589        1
590    };
591    Some(index + newline_len)
592}
593
594fn body_lines(source: &str, start: usize) -> impl Iterator<Item = BodyLine<'_>> {
595    let mut cursor = start;
596    std::iter::from_fn(move || {
597        if cursor >= source.len() {
598            return None;
599        }
600        let line_start = cursor;
601        let rest = &source[cursor..];
602        let newline = rest
603            .char_indices()
604            .find(|(_, character)| matches!(character, '\n' | '\r'));
605        let (line_end, newline_len) = match newline {
606            Some((offset, '\r')) if rest.as_bytes().get(offset + 1) == Some(&b'\n') => {
607                (cursor + offset, 2)
608            }
609            Some((offset, _)) => (cursor + offset, 1),
610            None => (source.len(), 0),
611        };
612        cursor = line_end + newline_len;
613        Some(BodyLine {
614            text: &source[line_start..line_end],
615            start: line_start,
616            end_with_newline: cursor,
617        })
618    })
619}
620
621fn block_line_content(line: &str) -> Option<&str> {
622    let rest = line.strip_prefix('|')?;
623    match rest.as_bytes().first() {
624        None => Some(rest),
625        Some(b' ' | b'\t') => Some(&rest[1..]),
626        Some(_) => None,
627    }
628}
629
630fn text_range(start: usize, end: usize) -> TextRange {
631    TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635enum HeaderPhase {
636    BeforeTail,
637    Params { depth: usize },
638    Guard { depth: usize },
639    Dependencies,
640}
641
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643enum ShellExpectation {
644    None,
645    AllowEqOrName,
646    NeedName,
647}
648
649#[derive(Debug, Default)]
650struct PendingRef {
651    name: String,
652    start: Option<TextSize>,
653    end: Option<TextSize>,
654}
655
656impl PendingRef {
657    fn flush(&mut self, refs: &mut Vec<TaskDependencyRef>, stage: usize) {
658        if let (Some(start), Some(end)) = (self.start, self.end) {
659            let name = self.name.trim();
660            if !name.is_empty() {
661                refs.push(TaskDependencyRef {
662                    name: SmolStr::new(name),
663                    range: TextRange::new(start, end),
664                    stage,
665                });
666            }
667        }
668        self.name.clear();
669        self.start = None;
670        self.end = None;
671    }
672
673    fn extend(&mut self, token: &crate::cst::SyntaxToken) {
674        self.start.get_or_insert(token.text_range().start());
675        self.end = Some(token.text_range().end());
676        self.name.push_str(token.text());
677    }
678}
679
680fn parse_task_header(node: &SyntaxNode) -> TaskHeaderInfo {
681    let mut info = TaskHeaderInfo::default();
682    let mut phase = HeaderPhase::BeforeTail;
683    let mut saw_name = false;
684    let mut stage = 0usize;
685    let mut group_depth = 0usize;
686    let mut pending = PendingRef::default();
687    let mut collector = String::new();
688    let mut dependencies_started = false;
689    let mut shell_expectation = ShellExpectation::None;
690    let mut expect_param_name = false;
691
692    for token in node
693        .children_with_tokens()
694        .filter_map(|element| element.into_token())
695    {
696        let kind = token.kind();
697        if matches!(
698            kind,
699            SyntaxKind::Colon | SyntaxKind::Newline | SyntaxKind::Eof
700        ) {
701            pending.flush(&mut info.dependency_refs, stage);
702            flush_header_collector(&mut info, &phase, &collector, dependencies_started);
703            break;
704        }
705
706        if !saw_name {
707            if kind == SyntaxKind::Ident {
708                saw_name = true;
709            }
710            continue;
711        }
712
713        if !matches!(shell_expectation, ShellExpectation::None) {
714            match (shell_expectation, kind) {
715                (_, SyntaxKind::Whitespace | SyntaxKind::Indent) => continue,
716                (ShellExpectation::AllowEqOrName, SyntaxKind::Eq) => {
717                    shell_expectation = ShellExpectation::NeedName;
718                    continue;
719                }
720                (_, SyntaxKind::Ident) => {
721                    info.shell = Some(SmolStr::new(token.text()));
722                    shell_expectation = ShellExpectation::None;
723                    continue;
724                }
725                _ => {
726                    shell_expectation = ShellExpectation::None;
727                }
728            }
729        }
730
731        match &mut phase {
732            HeaderPhase::BeforeTail => match kind {
733                SyntaxKind::LParen => {
734                    collector.clear();
735                    expect_param_name = true;
736                    phase = HeaderPhase::Params { depth: 1 };
737                }
738                SyntaxKind::Question => {
739                    collector.clear();
740                    phase = HeaderPhase::Guard { depth: 0 };
741                }
742                SyntaxKind::Amp => {
743                    collector.clear();
744                    dependencies_started = true;
745                    phase = HeaderPhase::Dependencies;
746                }
747                SyntaxKind::ShellFallbackKw => {
748                    info.shell_fallback = true;
749                    shell_expectation = ShellExpectation::NeedName;
750                }
751                SyntaxKind::ShellKw => shell_expectation = ShellExpectation::AllowEqOrName,
752                _ => {}
753            },
754            HeaderPhase::Params { depth } => {
755                if *depth == 1 && expect_param_name {
756                    match kind {
757                        SyntaxKind::Whitespace | SyntaxKind::Indent => {}
758                        SyntaxKind::Ident | SyntaxKind::ShellKw => {
759                            info.param_refs.push(TaskParamRef {
760                                name: SmolStr::new(token.text()),
761                                range: token.text_range(),
762                            });
763                            expect_param_name = false;
764                        }
765                        _ => expect_param_name = false,
766                    }
767                }
768
769                match kind {
770                    SyntaxKind::LParen => {
771                        *depth += 1;
772                        collector.push_str(token.text());
773                    }
774                    SyntaxKind::RParen => {
775                        *depth -= 1;
776                        if *depth == 0 {
777                            let trimmed = collector.trim();
778                            if !trimmed.is_empty() {
779                                info.params = Some(SmolStr::new(trimmed));
780                            }
781                            collector.clear();
782                            expect_param_name = false;
783                            phase = HeaderPhase::BeforeTail;
784                        } else {
785                            collector.push_str(token.text());
786                        }
787                    }
788                    SyntaxKind::Unknown if *depth == 1 && token.text() == "," => {
789                        collector.push_str(token.text());
790                        expect_param_name = true;
791                    }
792                    _ => collector.push_str(token.text()),
793                }
794            }
795            HeaderPhase::Guard { depth } => match kind {
796                SyntaxKind::LParen => {
797                    *depth += 1;
798                    collector.push_str(token.text());
799                }
800                SyntaxKind::RParen => {
801                    if *depth > 0 {
802                        *depth -= 1;
803                    }
804                    collector.push_str(token.text());
805                    if *depth == 0 {
806                        let trimmed = collector.trim();
807                        if !trimmed.is_empty() {
808                            info.guard = Some(SmolStr::new(trimmed));
809                        }
810                        collector.clear();
811                        phase = HeaderPhase::BeforeTail;
812                    }
813                }
814                SyntaxKind::Amp => {
815                    let trimmed = collector.trim();
816                    if !trimmed.is_empty() {
817                        info.guard = Some(SmolStr::new(trimmed));
818                    }
819                    collector.clear();
820                    dependencies_started = true;
821                    phase = HeaderPhase::Dependencies;
822                }
823                SyntaxKind::ShellFallbackKw => {
824                    let trimmed = collector.trim();
825                    if !trimmed.is_empty() {
826                        info.guard = Some(SmolStr::new(trimmed));
827                    }
828                    collector.clear();
829                    info.shell_fallback = true;
830                    shell_expectation = ShellExpectation::NeedName;
831                    phase = HeaderPhase::BeforeTail;
832                }
833                SyntaxKind::ShellKw => {
834                    let trimmed = collector.trim();
835                    if !trimmed.is_empty() {
836                        info.guard = Some(SmolStr::new(trimmed));
837                    }
838                    collector.clear();
839                    shell_expectation = ShellExpectation::AllowEqOrName;
840                    phase = HeaderPhase::BeforeTail;
841                }
842                _ => collector.push_str(token.text()),
843            },
844            HeaderPhase::Dependencies => match kind {
845                SyntaxKind::Amp if group_depth == 0 => {
846                    pending.flush(&mut info.dependency_refs, stage);
847                    if !info.dependency_refs.is_empty() {
848                        stage += 1;
849                    }
850                    if !collector.trim().is_empty() {
851                        if !info.dependencies.as_deref().unwrap_or_default().is_empty() {
852                            collector.push(' ');
853                        }
854                        collector.push('&');
855                    }
856                }
857                SyntaxKind::LParen => {
858                    if group_depth > 0 {
859                        pending.extend(&token);
860                    }
861                    group_depth += 1;
862                    collector.push_str(token.text());
863                }
864                SyntaxKind::RParen => {
865                    if group_depth > 1 {
866                        pending.extend(&token);
867                    } else {
868                        pending.flush(&mut info.dependency_refs, stage);
869                    }
870                    group_depth = group_depth.saturating_sub(1);
871                    collector.push_str(token.text());
872                }
873                SyntaxKind::ShellFallbackKw if group_depth == 0 => {
874                    pending.flush(&mut info.dependency_refs, stage);
875                    let trimmed = collector.trim();
876                    if !trimmed.is_empty() {
877                        info.dependencies = Some(SmolStr::new(trimmed));
878                    }
879                    collector.clear();
880                    info.shell_fallback = true;
881                    shell_expectation = ShellExpectation::NeedName;
882                    phase = HeaderPhase::BeforeTail;
883                }
884                SyntaxKind::ShellKw if group_depth == 0 => {
885                    pending.flush(&mut info.dependency_refs, stage);
886                    let trimmed = collector.trim();
887                    if !trimmed.is_empty() {
888                        info.dependencies = Some(SmolStr::new(trimmed));
889                    }
890                    collector.clear();
891                    shell_expectation = ShellExpectation::AllowEqOrName;
892                    phase = HeaderPhase::BeforeTail;
893                }
894                SyntaxKind::Whitespace | SyntaxKind::Indent => {
895                    collector.push_str(token.text());
896                }
897                SyntaxKind::Unknown if token.text() == "," && group_depth > 0 => {
898                    pending.flush(&mut info.dependency_refs, stage);
899                    collector.push_str(token.text());
900                }
901                _ => {
902                    pending.extend(&token);
903                    collector.push_str(token.text());
904                }
905            },
906        }
907    }
908
909    if info.dependencies.is_none() {
910        let trimmed = collector.trim();
911        if dependencies_started && !trimmed.is_empty() {
912            info.dependencies = Some(SmolStr::new(trimmed));
913        }
914    }
915
916    info
917}
918
919fn flush_header_collector(
920    info: &mut TaskHeaderInfo,
921    phase: &HeaderPhase,
922    collector: &str,
923    dependencies_started: bool,
924) {
925    let trimmed = collector.trim();
926    if trimmed.is_empty() {
927        return;
928    }
929
930    match phase {
931        HeaderPhase::Guard { .. } => info.guard = Some(SmolStr::new(trimmed)),
932        HeaderPhase::Dependencies if dependencies_started => {
933            info.dependencies = Some(SmolStr::new(trimmed))
934        }
935        _ => {}
936    }
937}
938
939fn non_trivia_token_texts(node: &SyntaxNode) -> impl Iterator<Item = SmolStr> + '_ {
940    node.children_with_tokens()
941        .filter_map(|element| element.into_token())
942        .filter(|token| {
943            !matches!(
944                token.kind(),
945                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
946            )
947        })
948        .map(|token| SmolStr::new(token.text()))
949}