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 dependency reference parsed from a task header.
67///
68/// Args:
69/// None.
70///
71/// Returns:
72/// Dependency text and the precise source range of that reference.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct TaskDependencyRef {
75    pub name: SmolStr,
76    pub range: TextRange,
77    pub stage: usize,
78}
79
80/// Structured task header data parsed from the CST token stream.
81///
82/// Args:
83/// None.
84///
85/// Returns:
86/// Parsed task header sections and dependency references.
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
88pub struct TaskHeaderInfo {
89    pub params: Option<SmolStr>,
90    pub guard: Option<SmolStr>,
91    pub dependencies: Option<SmolStr>,
92    pub shell: Option<SmolStr>,
93    pub shell_fallback: bool,
94    pub dependency_refs: Vec<TaskDependencyRef>,
95}
96
97impl DocumentNode {
98    /// Casts a raw rowan node into a typed document wrapper.
99    ///
100    /// Args:
101    /// syntax: Raw rowan syntax node.
102    ///
103    /// Returns:
104    /// Typed document wrapper when the kind matches `Document`.
105    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
106        (syntax.kind() == SyntaxKind::Document).then_some(Self { syntax })
107    }
108
109    /// Returns the raw rowan node.
110    ///
111    /// Args:
112    /// None.
113    ///
114    /// Returns:
115    /// Borrowed raw syntax node.
116    pub fn syntax(&self) -> &SyntaxNode {
117        &self.syntax
118    }
119
120    /// Returns the document text range.
121    ///
122    /// Args:
123    /// None.
124    ///
125    /// Returns:
126    /// Full document range in source text coordinates.
127    pub fn range(&self) -> TextRange {
128        self.syntax.text_range()
129    }
130
131    /// Iterates directive children.
132    ///
133    /// Args:
134    /// None.
135    ///
136    /// Returns:
137    /// Typed directive iterator.
138    pub fn directives(&self) -> impl Iterator<Item = DirectiveNode> + '_ {
139        self.syntax.children().filter_map(DirectiveNode::cast)
140    }
141
142    /// Iterates doc-comment children.
143    ///
144    /// Args:
145    /// None.
146    ///
147    /// Returns:
148    /// Typed doc-comment iterator.
149    pub fn doc_comments(&self) -> impl Iterator<Item = DocCommentNode> + '_ {
150        self.syntax.children().filter_map(DocCommentNode::cast)
151    }
152
153    /// Iterates namespace children.
154    ///
155    /// Args:
156    /// None.
157    ///
158    /// Returns:
159    /// Typed namespace iterator.
160    pub fn namespaces(&self) -> impl Iterator<Item = NamespaceNode> + '_ {
161        self.syntax.children().filter_map(NamespaceNode::cast)
162    }
163
164    /// Iterates task children.
165    ///
166    /// Args:
167    /// None.
168    ///
169    /// Returns:
170    /// Typed task iterator.
171    pub fn tasks(&self) -> impl Iterator<Item = TaskNode> + '_ {
172        self.syntax.children().filter_map(TaskNode::cast)
173    }
174}
175
176impl DirectiveNode {
177    /// Casts a raw rowan node into a typed directive wrapper.
178    ///
179    /// Args:
180    /// syntax: Raw rowan syntax node.
181    ///
182    /// Returns:
183    /// Typed directive wrapper when the kind matches `Directive`.
184    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
185        (syntax.kind() == SyntaxKind::Directive).then_some(Self { syntax })
186    }
187
188    /// Returns the directive text range.
189    ///
190    /// Args:
191    /// None.
192    ///
193    /// Returns:
194    /// Directive range in source text coordinates.
195    pub fn range(&self) -> TextRange {
196        self.syntax.text_range()
197    }
198
199    /// Returns the directive keyword range including the leading `!`.
200    ///
201    /// Args:
202    /// None.
203    ///
204    /// Returns:
205    /// Range covering `!echo` or `!shell` when present.
206    pub fn keyword_range(&self) -> Option<TextRange> {
207        let mut tokens = self
208            .syntax
209            .children_with_tokens()
210            .filter_map(|element| element.into_token())
211            .filter(|token| {
212                !matches!(
213                    token.kind(),
214                    SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
215                )
216            });
217        let bang = tokens.find(|token| token.kind() == SyntaxKind::Bang)?;
218        let keyword = tokens.next()?;
219        Some(TextRange::new(
220            bang.text_range().start(),
221            keyword.text_range().end(),
222        ))
223    }
224
225    /// Returns the directive name token text without the leading `!`.
226    ///
227    /// Args:
228    /// None.
229    ///
230    /// Returns:
231    /// Directive name when present.
232    pub fn name(&self) -> Option<SmolStr> {
233        non_trivia_token_texts(&self.syntax).nth(1)
234    }
235
236    /// Returns the directive value text after the directive name.
237    ///
238    /// Args:
239    /// None.
240    ///
241    /// Returns:
242    /// Joined directive value text when present.
243    pub fn value(&self) -> Option<SmolStr> {
244        let value = non_trivia_token_texts(&self.syntax)
245            .skip(2)
246            .collect::<Vec<_>>()
247            .join(" ");
248        (!value.is_empty()).then(|| SmolStr::new(value))
249    }
250}
251
252impl DocCommentNode {
253    /// Casts a raw rowan node into a typed doc-comment wrapper.
254    ///
255    /// Args:
256    /// syntax: Raw rowan syntax node.
257    ///
258    /// Returns:
259    /// Typed doc-comment wrapper when the kind matches `DocComment`.
260    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
261        (syntax.kind() == SyntaxKind::DocComment).then_some(Self { syntax })
262    }
263
264    /// Returns the doc-comment text range.
265    ///
266    /// Args:
267    /// None.
268    ///
269    /// Returns:
270    /// Doc-comment range in source text coordinates.
271    pub fn range(&self) -> TextRange {
272        self.syntax.text_range()
273    }
274
275    /// Returns normalized doc-comment text without the leading `%`.
276    ///
277    /// Args:
278    /// None.
279    ///
280    /// Returns:
281    /// Trimmed doc-comment payload when present.
282    pub fn text(&self) -> Option<SmolStr> {
283        self.syntax
284            .text()
285            .to_string()
286            .trim()
287            .strip_prefix('%')
288            .map(str::trim)
289            .filter(|text| !text.is_empty())
290            .map(SmolStr::new)
291    }
292}
293
294impl NamespaceNode {
295    /// Casts a raw rowan node into a typed namespace wrapper.
296    ///
297    /// Args:
298    /// syntax: Raw rowan syntax node.
299    ///
300    /// Returns:
301    /// Typed namespace wrapper when the kind matches `NamespaceBlock`.
302    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
303        (syntax.kind() == SyntaxKind::NamespaceBlock).then_some(Self { syntax })
304    }
305
306    /// Returns the namespace text range.
307    ///
308    /// Args:
309    /// None.
310    ///
311    /// Returns:
312    /// Namespace range in source text coordinates.
313    pub fn range(&self) -> TextRange {
314        self.syntax.text_range()
315    }
316
317    /// Returns the namespace name without brackets.
318    ///
319    /// Args:
320    /// None.
321    ///
322    /// Returns:
323    /// Namespace name when present.
324    pub fn name(&self) -> Option<SmolStr> {
325        self.syntax
326            .text()
327            .to_string()
328            .trim()
329            .strip_prefix('[')
330            .and_then(|text| text.strip_suffix(']'))
331            .map(str::trim)
332            .filter(|text| !text.is_empty())
333            .map(SmolStr::new)
334    }
335}
336
337impl TaskNode {
338    /// Casts a raw rowan node into a typed task wrapper.
339    ///
340    /// Args:
341    /// syntax: Raw rowan syntax node.
342    ///
343    /// Returns:
344    /// Typed task wrapper when the kind matches `TaskDecl`.
345    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
346        (syntax.kind() == SyntaxKind::TaskDecl).then_some(Self { syntax })
347    }
348
349    /// Returns the task text range.
350    ///
351    /// Args:
352    /// None.
353    ///
354    /// Returns:
355    /// Task range in source text coordinates.
356    pub fn range(&self) -> TextRange {
357        self.syntax.text_range()
358    }
359
360    /// Returns the task name range from the header identifier.
361    ///
362    /// Args:
363    /// None.
364    ///
365    /// Returns:
366    /// Range covering the task name before the parameter list.
367    pub fn name_range(&self) -> Option<TextRange> {
368        self.syntax
369            .children_with_tokens()
370            .filter_map(|element| element.into_token())
371            .find(|token| token.kind() == SyntaxKind::Ident)
372            .map(|token| token.text_range())
373    }
374
375    /// Returns the task name from the header identifier.
376    ///
377    /// Args:
378    /// None.
379    ///
380    /// Returns:
381    /// Task name when present.
382    pub fn name(&self) -> Option<SmolStr> {
383        self.syntax
384            .children_with_tokens()
385            .filter_map(|element| element.into_token())
386            .find(|token| token.kind() == SyntaxKind::Ident)
387            .map(|token| SmolStr::new(token.text()))
388    }
389
390    /// Returns the normalized task header text without the trailing `:`.
391    ///
392    /// Args:
393    /// None.
394    ///
395    /// Returns:
396    /// Header text when present.
397    pub fn header_text(&self) -> Option<SmolStr> {
398        let mut header = String::new();
399
400        for token in self
401            .syntax
402            .children_with_tokens()
403            .filter_map(|element| element.into_token())
404        {
405            if token.kind() == SyntaxKind::Colon {
406                break;
407            }
408            if token.kind() == SyntaxKind::Newline {
409                break;
410            }
411            header.push_str(token.text());
412        }
413
414        let header = header.trim();
415        (!header.is_empty()).then(|| SmolStr::new(header))
416    }
417
418    /// Returns the parsed task header sections and dependency references.
419    ///
420    /// Args:
421    /// None.
422    ///
423    /// Returns:
424    /// Structured header information parsed from one token stream pass.
425    pub fn header_info(&self) -> TaskHeaderInfo {
426        parse_task_header(&self.syntax)
427    }
428
429    /// Iterates normalized command lines from the task body.
430    ///
431    /// Args:
432    /// None.
433    ///
434    /// Returns:
435    /// Command lines in source order, without leading indentation.
436    pub fn commands(&self) -> std::vec::IntoIter<SmolStr> {
437        self.syntax
438            .text()
439            .to_string()
440            .lines()
441            .skip(1)
442            .map(str::trim_start)
443            .filter(|line| !line.is_empty())
444            .map(SmolStr::new)
445            .collect::<Vec<_>>()
446            .into_iter()
447    }
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451enum HeaderPhase {
452    BeforeTail,
453    Params { depth: usize },
454    Guard { depth: usize },
455    Dependencies,
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459enum ShellExpectation {
460    None,
461    AllowEqOrName,
462    NeedName,
463}
464
465#[derive(Debug, Default)]
466struct PendingRef {
467    name: String,
468    start: Option<TextSize>,
469    end: Option<TextSize>,
470}
471
472impl PendingRef {
473    fn flush(&mut self, refs: &mut Vec<TaskDependencyRef>, stage: usize) {
474        if let (Some(start), Some(end)) = (self.start, self.end) {
475            let name = self.name.trim();
476            if !name.is_empty() {
477                refs.push(TaskDependencyRef {
478                    name: SmolStr::new(name),
479                    range: TextRange::new(start, end),
480                    stage,
481                });
482            }
483        }
484        self.name.clear();
485        self.start = None;
486        self.end = None;
487    }
488
489    fn extend(&mut self, token: &crate::cst::SyntaxToken) {
490        self.start.get_or_insert(token.text_range().start());
491        self.end = Some(token.text_range().end());
492        self.name.push_str(token.text());
493    }
494}
495
496fn parse_task_header(node: &SyntaxNode) -> TaskHeaderInfo {
497    let mut info = TaskHeaderInfo::default();
498    let mut phase = HeaderPhase::BeforeTail;
499    let mut saw_name = false;
500    let mut stage = 0usize;
501    let mut group_depth = 0usize;
502    let mut pending = PendingRef::default();
503    let mut collector = String::new();
504    let mut dependencies_started = false;
505    let mut shell_expectation = ShellExpectation::None;
506
507    for token in node
508        .children_with_tokens()
509        .filter_map(|element| element.into_token())
510    {
511        let kind = token.kind();
512        if matches!(
513            kind,
514            SyntaxKind::Colon | SyntaxKind::Newline | SyntaxKind::Eof
515        ) {
516            pending.flush(&mut info.dependency_refs, stage);
517            flush_header_collector(&mut info, &phase, &collector, dependencies_started);
518            break;
519        }
520
521        if !saw_name {
522            if kind == SyntaxKind::Ident {
523                saw_name = true;
524            }
525            continue;
526        }
527
528        if !matches!(shell_expectation, ShellExpectation::None) {
529            match (shell_expectation, kind) {
530                (_, SyntaxKind::Whitespace | SyntaxKind::Indent) => continue,
531                (ShellExpectation::AllowEqOrName, SyntaxKind::Eq) => {
532                    shell_expectation = ShellExpectation::NeedName;
533                    continue;
534                }
535                (_, SyntaxKind::Ident) => {
536                    info.shell = Some(SmolStr::new(token.text()));
537                    shell_expectation = ShellExpectation::None;
538                    continue;
539                }
540                _ => {
541                    shell_expectation = ShellExpectation::None;
542                }
543            }
544        }
545
546        match &mut phase {
547            HeaderPhase::BeforeTail => match kind {
548                SyntaxKind::LParen => {
549                    collector.clear();
550                    phase = HeaderPhase::Params { depth: 1 };
551                }
552                SyntaxKind::Question => {
553                    collector.clear();
554                    phase = HeaderPhase::Guard { depth: 0 };
555                }
556                SyntaxKind::Amp => {
557                    collector.clear();
558                    dependencies_started = true;
559                    phase = HeaderPhase::Dependencies;
560                }
561                SyntaxKind::ShellFallbackKw => {
562                    info.shell_fallback = true;
563                    shell_expectation = ShellExpectation::NeedName;
564                }
565                SyntaxKind::ShellKw => shell_expectation = ShellExpectation::AllowEqOrName,
566                _ => {}
567            },
568            HeaderPhase::Params { depth } => match kind {
569                SyntaxKind::LParen => {
570                    *depth += 1;
571                    collector.push_str(token.text());
572                }
573                SyntaxKind::RParen => {
574                    *depth -= 1;
575                    if *depth == 0 {
576                        let trimmed = collector.trim();
577                        if !trimmed.is_empty() {
578                            info.params = Some(SmolStr::new(trimmed));
579                        }
580                        collector.clear();
581                        phase = HeaderPhase::BeforeTail;
582                    } else {
583                        collector.push_str(token.text());
584                    }
585                }
586                _ => collector.push_str(token.text()),
587            },
588            HeaderPhase::Guard { depth } => match kind {
589                SyntaxKind::LParen => {
590                    *depth += 1;
591                    collector.push_str(token.text());
592                }
593                SyntaxKind::RParen => {
594                    if *depth > 0 {
595                        *depth -= 1;
596                    }
597                    collector.push_str(token.text());
598                    if *depth == 0 {
599                        let trimmed = collector.trim();
600                        if !trimmed.is_empty() {
601                            info.guard = Some(SmolStr::new(trimmed));
602                        }
603                        collector.clear();
604                        phase = HeaderPhase::BeforeTail;
605                    }
606                }
607                SyntaxKind::Amp => {
608                    let trimmed = collector.trim();
609                    if !trimmed.is_empty() {
610                        info.guard = Some(SmolStr::new(trimmed));
611                    }
612                    collector.clear();
613                    dependencies_started = true;
614                    phase = HeaderPhase::Dependencies;
615                }
616                SyntaxKind::ShellFallbackKw => {
617                    let trimmed = collector.trim();
618                    if !trimmed.is_empty() {
619                        info.guard = Some(SmolStr::new(trimmed));
620                    }
621                    collector.clear();
622                    info.shell_fallback = true;
623                    shell_expectation = ShellExpectation::NeedName;
624                    phase = HeaderPhase::BeforeTail;
625                }
626                SyntaxKind::ShellKw => {
627                    let trimmed = collector.trim();
628                    if !trimmed.is_empty() {
629                        info.guard = Some(SmolStr::new(trimmed));
630                    }
631                    collector.clear();
632                    shell_expectation = ShellExpectation::AllowEqOrName;
633                    phase = HeaderPhase::BeforeTail;
634                }
635                _ => collector.push_str(token.text()),
636            },
637            HeaderPhase::Dependencies => match kind {
638                SyntaxKind::Amp if group_depth == 0 => {
639                    pending.flush(&mut info.dependency_refs, stage);
640                    if !info.dependency_refs.is_empty() {
641                        stage += 1;
642                    }
643                    if !collector.trim().is_empty() {
644                        if !info.dependencies.as_deref().unwrap_or_default().is_empty() {
645                            collector.push(' ');
646                        }
647                        collector.push('&');
648                    }
649                }
650                SyntaxKind::LParen => {
651                    if group_depth > 0 {
652                        pending.extend(&token);
653                    }
654                    group_depth += 1;
655                    collector.push_str(token.text());
656                }
657                SyntaxKind::RParen => {
658                    if group_depth > 1 {
659                        pending.extend(&token);
660                    } else {
661                        pending.flush(&mut info.dependency_refs, stage);
662                    }
663                    group_depth = group_depth.saturating_sub(1);
664                    collector.push_str(token.text());
665                }
666                SyntaxKind::ShellFallbackKw if group_depth == 0 => {
667                    pending.flush(&mut info.dependency_refs, stage);
668                    let trimmed = collector.trim();
669                    if !trimmed.is_empty() {
670                        info.dependencies = Some(SmolStr::new(trimmed));
671                    }
672                    collector.clear();
673                    info.shell_fallback = true;
674                    shell_expectation = ShellExpectation::NeedName;
675                    phase = HeaderPhase::BeforeTail;
676                }
677                SyntaxKind::ShellKw if group_depth == 0 => {
678                    pending.flush(&mut info.dependency_refs, stage);
679                    let trimmed = collector.trim();
680                    if !trimmed.is_empty() {
681                        info.dependencies = Some(SmolStr::new(trimmed));
682                    }
683                    collector.clear();
684                    shell_expectation = ShellExpectation::AllowEqOrName;
685                    phase = HeaderPhase::BeforeTail;
686                }
687                SyntaxKind::Whitespace | SyntaxKind::Indent => {
688                    collector.push_str(token.text());
689                }
690                SyntaxKind::Unknown if token.text() == "," && group_depth > 0 => {
691                    pending.flush(&mut info.dependency_refs, stage);
692                    collector.push_str(token.text());
693                }
694                _ => {
695                    pending.extend(&token);
696                    collector.push_str(token.text());
697                }
698            },
699        }
700    }
701
702    if info.dependencies.is_none() {
703        let trimmed = collector.trim();
704        if dependencies_started && !trimmed.is_empty() {
705            info.dependencies = Some(SmolStr::new(trimmed));
706        }
707    }
708
709    info
710}
711
712fn flush_header_collector(
713    info: &mut TaskHeaderInfo,
714    phase: &HeaderPhase,
715    collector: &str,
716    dependencies_started: bool,
717) {
718    let trimmed = collector.trim();
719    if trimmed.is_empty() {
720        return;
721    }
722
723    match phase {
724        HeaderPhase::Guard { .. } => info.guard = Some(SmolStr::new(trimmed)),
725        HeaderPhase::Dependencies if dependencies_started => {
726            info.dependencies = Some(SmolStr::new(trimmed))
727        }
728        _ => {}
729    }
730}
731
732fn non_trivia_token_texts(node: &SyntaxNode) -> impl Iterator<Item = SmolStr> + '_ {
733    node.children_with_tokens()
734        .filter_map(|element| element.into_token())
735        .filter(|token| {
736            !matches!(
737                token.kind(),
738                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
739            )
740        })
741        .map(|token| SmolStr::new(token.text()))
742}