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, Default)]
459struct PendingRef {
460    name: String,
461    start: Option<TextSize>,
462    end: Option<TextSize>,
463}
464
465impl PendingRef {
466    fn flush(&mut self, refs: &mut Vec<TaskDependencyRef>, stage: usize) {
467        if let (Some(start), Some(end)) = (self.start, self.end) {
468            let name = self.name.trim();
469            if !name.is_empty() {
470                refs.push(TaskDependencyRef {
471                    name: SmolStr::new(name),
472                    range: TextRange::new(start, end),
473                    stage,
474                });
475            }
476        }
477        self.name.clear();
478        self.start = None;
479        self.end = None;
480    }
481
482    fn extend(&mut self, token: &crate::cst::SyntaxToken) {
483        self.start.get_or_insert(token.text_range().start());
484        self.end = Some(token.text_range().end());
485        self.name.push_str(token.text());
486    }
487}
488
489fn parse_task_header(node: &SyntaxNode) -> TaskHeaderInfo {
490    let mut info = TaskHeaderInfo::default();
491    let mut phase = HeaderPhase::BeforeTail;
492    let mut saw_name = false;
493    let mut stage = 0usize;
494    let mut group_depth = 0usize;
495    let mut pending = PendingRef::default();
496    let mut collector = String::new();
497    let mut dependencies_started = false;
498    let mut shell_expecting_ident = false;
499
500    for token in node
501        .children_with_tokens()
502        .filter_map(|element| element.into_token())
503    {
504        let kind = token.kind();
505        if matches!(
506            kind,
507            SyntaxKind::Colon | SyntaxKind::Newline | SyntaxKind::Eof
508        ) {
509            pending.flush(&mut info.dependency_refs, stage);
510            flush_header_collector(&mut info, &phase, &collector, dependencies_started);
511            break;
512        }
513
514        if !saw_name {
515            if kind == SyntaxKind::Ident {
516                saw_name = true;
517            }
518            continue;
519        }
520
521        if shell_expecting_ident {
522            if kind == SyntaxKind::Ident {
523                info.shell = Some(SmolStr::new(token.text()));
524            }
525            shell_expecting_ident = false;
526            continue;
527        }
528
529        match &mut phase {
530            HeaderPhase::BeforeTail => match kind {
531                SyntaxKind::LParen => {
532                    collector.clear();
533                    phase = HeaderPhase::Params { depth: 1 };
534                }
535                SyntaxKind::Question => {
536                    collector.clear();
537                    phase = HeaderPhase::Guard { depth: 0 };
538                }
539                SyntaxKind::Amp => {
540                    collector.clear();
541                    dependencies_started = true;
542                    phase = HeaderPhase::Dependencies;
543                }
544                SyntaxKind::ShellFallbackKw => {
545                    info.shell_fallback = true;
546                    shell_expecting_ident = true;
547                }
548                SyntaxKind::ShellKw => shell_expecting_ident = true,
549                _ => {}
550            },
551            HeaderPhase::Params { depth } => match kind {
552                SyntaxKind::LParen => {
553                    *depth += 1;
554                    collector.push_str(token.text());
555                }
556                SyntaxKind::RParen => {
557                    *depth -= 1;
558                    if *depth == 0 {
559                        let trimmed = collector.trim();
560                        if !trimmed.is_empty() {
561                            info.params = Some(SmolStr::new(trimmed));
562                        }
563                        collector.clear();
564                        phase = HeaderPhase::BeforeTail;
565                    } else {
566                        collector.push_str(token.text());
567                    }
568                }
569                _ => collector.push_str(token.text()),
570            },
571            HeaderPhase::Guard { depth } => match kind {
572                SyntaxKind::LParen => {
573                    *depth += 1;
574                    collector.push_str(token.text());
575                }
576                SyntaxKind::RParen => {
577                    if *depth > 0 {
578                        *depth -= 1;
579                    }
580                    collector.push_str(token.text());
581                    if *depth == 0 {
582                        let trimmed = collector.trim();
583                        if !trimmed.is_empty() {
584                            info.guard = Some(SmolStr::new(trimmed));
585                        }
586                        collector.clear();
587                        phase = HeaderPhase::BeforeTail;
588                    }
589                }
590                SyntaxKind::Amp => {
591                    let trimmed = collector.trim();
592                    if !trimmed.is_empty() {
593                        info.guard = Some(SmolStr::new(trimmed));
594                    }
595                    collector.clear();
596                    dependencies_started = true;
597                    phase = HeaderPhase::Dependencies;
598                }
599                SyntaxKind::ShellFallbackKw => {
600                    let trimmed = collector.trim();
601                    if !trimmed.is_empty() {
602                        info.guard = Some(SmolStr::new(trimmed));
603                    }
604                    collector.clear();
605                    info.shell_fallback = true;
606                    shell_expecting_ident = true;
607                    phase = HeaderPhase::BeforeTail;
608                }
609                SyntaxKind::ShellKw => {
610                    let trimmed = collector.trim();
611                    if !trimmed.is_empty() {
612                        info.guard = Some(SmolStr::new(trimmed));
613                    }
614                    collector.clear();
615                    shell_expecting_ident = true;
616                    phase = HeaderPhase::BeforeTail;
617                }
618                _ => collector.push_str(token.text()),
619            },
620            HeaderPhase::Dependencies => match kind {
621                SyntaxKind::Amp if group_depth == 0 => {
622                    pending.flush(&mut info.dependency_refs, stage);
623                    if !info.dependency_refs.is_empty() {
624                        stage += 1;
625                    }
626                    if !collector.trim().is_empty() {
627                        if !info.dependencies.as_deref().unwrap_or_default().is_empty() {
628                            collector.push(' ');
629                        }
630                        collector.push('&');
631                    }
632                }
633                SyntaxKind::LParen => {
634                    if group_depth > 0 {
635                        pending.extend(&token);
636                    }
637                    group_depth += 1;
638                    collector.push_str(token.text());
639                }
640                SyntaxKind::RParen => {
641                    if group_depth > 1 {
642                        pending.extend(&token);
643                    } else {
644                        pending.flush(&mut info.dependency_refs, stage);
645                    }
646                    group_depth = group_depth.saturating_sub(1);
647                    collector.push_str(token.text());
648                }
649                SyntaxKind::ShellFallbackKw if group_depth == 0 => {
650                    pending.flush(&mut info.dependency_refs, stage);
651                    let trimmed = collector.trim();
652                    if !trimmed.is_empty() {
653                        info.dependencies = Some(SmolStr::new(trimmed));
654                    }
655                    collector.clear();
656                    info.shell_fallback = true;
657                    shell_expecting_ident = true;
658                    phase = HeaderPhase::BeforeTail;
659                }
660                SyntaxKind::ShellKw if group_depth == 0 => {
661                    pending.flush(&mut info.dependency_refs, stage);
662                    let trimmed = collector.trim();
663                    if !trimmed.is_empty() {
664                        info.dependencies = Some(SmolStr::new(trimmed));
665                    }
666                    collector.clear();
667                    shell_expecting_ident = true;
668                    phase = HeaderPhase::BeforeTail;
669                }
670                SyntaxKind::Whitespace | SyntaxKind::Indent => {
671                    collector.push_str(token.text());
672                }
673                SyntaxKind::Unknown if token.text() == "," && group_depth > 0 => {
674                    pending.flush(&mut info.dependency_refs, stage);
675                    collector.push_str(token.text());
676                }
677                _ => {
678                    pending.extend(&token);
679                    collector.push_str(token.text());
680                }
681            },
682        }
683    }
684
685    if info.dependencies.is_none() {
686        let trimmed = collector.trim();
687        if dependencies_started && !trimmed.is_empty() {
688            info.dependencies = Some(SmolStr::new(trimmed));
689        }
690    }
691
692    info
693}
694
695fn flush_header_collector(
696    info: &mut TaskHeaderInfo,
697    phase: &HeaderPhase,
698    collector: &str,
699    dependencies_started: bool,
700) {
701    let trimmed = collector.trim();
702    if trimmed.is_empty() {
703        return;
704    }
705
706    match phase {
707        HeaderPhase::Guard { .. } => info.guard = Some(SmolStr::new(trimmed)),
708        HeaderPhase::Dependencies if dependencies_started => {
709            info.dependencies = Some(SmolStr::new(trimmed))
710        }
711        _ => {}
712    }
713}
714
715fn non_trivia_token_texts(node: &SyntaxNode) -> impl Iterator<Item = SmolStr> + '_ {
716    node.children_with_tokens()
717        .filter_map(|element| element.into_token())
718        .filter(|token| {
719            !matches!(
720                token.kind(),
721                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
722            )
723        })
724        .map(|token| SmolStr::new(token.text()))
725}