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