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    /// Returns the directive value with its original internal punctuation.
252    pub fn raw_value(&self) -> Option<SmolStr> {
253        let mut non_trivia = 0usize;
254        let mut value = String::new();
255
256        for token in self
257            .syntax
258            .children_with_tokens()
259            .filter_map(|element| element.into_token())
260        {
261            if token.kind() == SyntaxKind::Newline {
262                break;
263            }
264            if !matches!(
265                token.kind(),
266                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Comment
267            ) {
268                non_trivia += 1;
269            }
270            if non_trivia >= 2 && !(non_trivia == 2 && token.kind() == SyntaxKind::Ident) {
271                value.push_str(token.text());
272            }
273        }
274
275        let value = value.trim();
276        (!value.is_empty()).then(|| SmolStr::new(value))
277    }
278}
279
280impl DocCommentNode {
281    /// Casts a raw rowan node into a typed doc-comment wrapper.
282    ///
283    /// Args:
284    /// syntax: Raw rowan syntax node.
285    ///
286    /// Returns:
287    /// Typed doc-comment wrapper when the kind matches `DocComment`.
288    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
289        (syntax.kind() == SyntaxKind::DocComment).then_some(Self { syntax })
290    }
291
292    /// Returns the doc-comment text range.
293    ///
294    /// Args:
295    /// None.
296    ///
297    /// Returns:
298    /// Doc-comment range in source text coordinates.
299    pub fn range(&self) -> TextRange {
300        self.syntax.text_range()
301    }
302
303    /// Returns normalized doc-comment text without the leading `#`.
304    ///
305    /// Args:
306    /// None.
307    ///
308    /// Returns:
309    /// Trimmed doc-comment payload when present.
310    pub fn text(&self) -> Option<SmolStr> {
311        self.syntax
312            .text()
313            .to_string()
314            .trim()
315            .strip_prefix('#')
316            .map(str::trim)
317            .filter(|text| !text.is_empty())
318            .map(SmolStr::new)
319    }
320}
321
322impl NamespaceNode {
323    /// Casts a raw rowan node into a typed namespace wrapper.
324    ///
325    /// Args:
326    /// syntax: Raw rowan syntax node.
327    ///
328    /// Returns:
329    /// Typed namespace wrapper when the kind matches `NamespaceBlock`.
330    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
331        (syntax.kind() == SyntaxKind::NamespaceBlock).then_some(Self { syntax })
332    }
333
334    /// Returns the namespace text range.
335    ///
336    /// Args:
337    /// None.
338    ///
339    /// Returns:
340    /// Namespace range in source text coordinates.
341    pub fn range(&self) -> TextRange {
342        self.syntax.text_range()
343    }
344
345    /// Returns the namespace name without brackets.
346    ///
347    /// Args:
348    /// None.
349    ///
350    /// Returns:
351    /// Namespace name when present.
352    pub fn name(&self) -> Option<SmolStr> {
353        self.syntax
354            .text()
355            .to_string()
356            .trim()
357            .strip_prefix('[')
358            .and_then(|text| text.strip_suffix(']'))
359            .map(str::trim)
360            .filter(|text| !text.is_empty())
361            .map(SmolStr::new)
362    }
363}
364
365impl TaskNode {
366    /// Casts a raw rowan node into a typed task wrapper.
367    ///
368    /// Args:
369    /// syntax: Raw rowan syntax node.
370    ///
371    /// Returns:
372    /// Typed task wrapper when the kind matches `TaskDecl`.
373    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
374        (syntax.kind() == SyntaxKind::TaskDecl).then_some(Self { syntax })
375    }
376
377    /// Returns the task text range.
378    ///
379    /// Args:
380    /// None.
381    ///
382    /// Returns:
383    /// Task range in source text coordinates.
384    pub fn range(&self) -> TextRange {
385        self.syntax.text_range()
386    }
387
388    /// Returns the task name range from the header identifier.
389    ///
390    /// Args:
391    /// None.
392    ///
393    /// Returns:
394    /// Range covering the task name before the parameter list.
395    pub fn name_range(&self) -> Option<TextRange> {
396        self.syntax
397            .children_with_tokens()
398            .filter_map(|element| element.into_token())
399            .find(|token| token.kind() == SyntaxKind::Ident)
400            .map(|token| token.text_range())
401    }
402
403    /// Returns the task name from the header identifier.
404    ///
405    /// Args:
406    /// None.
407    ///
408    /// Returns:
409    /// Task name when present.
410    pub fn name(&self) -> Option<SmolStr> {
411        self.syntax
412            .children_with_tokens()
413            .filter_map(|element| element.into_token())
414            .find(|token| token.kind() == SyntaxKind::Ident)
415            .map(|token| SmolStr::new(token.text()))
416    }
417
418    /// Returns the normalized task header text without the trailing `:`.
419    ///
420    /// Args:
421    /// None.
422    ///
423    /// Returns:
424    /// Header text when present.
425    pub fn header_text(&self) -> Option<SmolStr> {
426        let mut header = String::new();
427
428        for token in self
429            .syntax
430            .children_with_tokens()
431            .filter_map(|element| element.into_token())
432        {
433            if token.kind() == SyntaxKind::Colon {
434                break;
435            }
436            if token.kind() == SyntaxKind::Newline {
437                break;
438            }
439            header.push_str(token.text());
440        }
441
442        let header = header.trim();
443        (!header.is_empty()).then(|| SmolStr::new(header))
444    }
445
446    /// Returns the parsed task header sections and dependency references.
447    ///
448    /// Args:
449    /// None.
450    ///
451    /// Returns:
452    /// Structured header information parsed from one token stream pass.
453    pub fn header_info(&self) -> TaskHeaderInfo {
454        parse_task_header(&self.syntax)
455    }
456
457    /// Iterates normalized command lines from the task body.
458    ///
459    /// Args:
460    /// None.
461    ///
462    /// Returns:
463    /// Command lines in source order, without leading indentation.
464    pub fn commands(&self) -> std::vec::IntoIter<SmolStr> {
465        self.syntax
466            .text()
467            .to_string()
468            .lines()
469            .skip(1)
470            .map(str::trim_start)
471            .filter(|line| !line.is_empty())
472            .filter(|line| !line.starts_with("//"))
473            .map(SmolStr::new)
474            .collect::<Vec<_>>()
475            .into_iter()
476    }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480enum HeaderPhase {
481    BeforeTail,
482    Params { depth: usize },
483    Guard { depth: usize },
484    Dependencies,
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488enum ShellExpectation {
489    None,
490    AllowEqOrName,
491    NeedName,
492}
493
494#[derive(Debug, Default)]
495struct PendingRef {
496    name: String,
497    start: Option<TextSize>,
498    end: Option<TextSize>,
499}
500
501impl PendingRef {
502    fn flush(&mut self, refs: &mut Vec<TaskDependencyRef>, stage: usize) {
503        if let (Some(start), Some(end)) = (self.start, self.end) {
504            let name = self.name.trim();
505            if !name.is_empty() {
506                refs.push(TaskDependencyRef {
507                    name: SmolStr::new(name),
508                    range: TextRange::new(start, end),
509                    stage,
510                });
511            }
512        }
513        self.name.clear();
514        self.start = None;
515        self.end = None;
516    }
517
518    fn extend(&mut self, token: &crate::cst::SyntaxToken) {
519        self.start.get_or_insert(token.text_range().start());
520        self.end = Some(token.text_range().end());
521        self.name.push_str(token.text());
522    }
523}
524
525fn parse_task_header(node: &SyntaxNode) -> TaskHeaderInfo {
526    let mut info = TaskHeaderInfo::default();
527    let mut phase = HeaderPhase::BeforeTail;
528    let mut saw_name = false;
529    let mut stage = 0usize;
530    let mut group_depth = 0usize;
531    let mut pending = PendingRef::default();
532    let mut collector = String::new();
533    let mut dependencies_started = false;
534    let mut shell_expectation = ShellExpectation::None;
535
536    for token in node
537        .children_with_tokens()
538        .filter_map(|element| element.into_token())
539    {
540        let kind = token.kind();
541        if matches!(
542            kind,
543            SyntaxKind::Colon | SyntaxKind::Newline | SyntaxKind::Eof
544        ) {
545            pending.flush(&mut info.dependency_refs, stage);
546            flush_header_collector(&mut info, &phase, &collector, dependencies_started);
547            break;
548        }
549
550        if !saw_name {
551            if kind == SyntaxKind::Ident {
552                saw_name = true;
553            }
554            continue;
555        }
556
557        if !matches!(shell_expectation, ShellExpectation::None) {
558            match (shell_expectation, kind) {
559                (_, SyntaxKind::Whitespace | SyntaxKind::Indent) => continue,
560                (ShellExpectation::AllowEqOrName, SyntaxKind::Eq) => {
561                    shell_expectation = ShellExpectation::NeedName;
562                    continue;
563                }
564                (_, SyntaxKind::Ident) => {
565                    info.shell = Some(SmolStr::new(token.text()));
566                    shell_expectation = ShellExpectation::None;
567                    continue;
568                }
569                _ => {
570                    shell_expectation = ShellExpectation::None;
571                }
572            }
573        }
574
575        match &mut phase {
576            HeaderPhase::BeforeTail => match kind {
577                SyntaxKind::LParen => {
578                    collector.clear();
579                    phase = HeaderPhase::Params { depth: 1 };
580                }
581                SyntaxKind::Question => {
582                    collector.clear();
583                    phase = HeaderPhase::Guard { depth: 0 };
584                }
585                SyntaxKind::Amp => {
586                    collector.clear();
587                    dependencies_started = true;
588                    phase = HeaderPhase::Dependencies;
589                }
590                SyntaxKind::ShellFallbackKw => {
591                    info.shell_fallback = true;
592                    shell_expectation = ShellExpectation::NeedName;
593                }
594                SyntaxKind::ShellKw => shell_expectation = ShellExpectation::AllowEqOrName,
595                _ => {}
596            },
597            HeaderPhase::Params { depth } => match kind {
598                SyntaxKind::LParen => {
599                    *depth += 1;
600                    collector.push_str(token.text());
601                }
602                SyntaxKind::RParen => {
603                    *depth -= 1;
604                    if *depth == 0 {
605                        let trimmed = collector.trim();
606                        if !trimmed.is_empty() {
607                            info.params = Some(SmolStr::new(trimmed));
608                        }
609                        collector.clear();
610                        phase = HeaderPhase::BeforeTail;
611                    } else {
612                        collector.push_str(token.text());
613                    }
614                }
615                _ => collector.push_str(token.text()),
616            },
617            HeaderPhase::Guard { depth } => match kind {
618                SyntaxKind::LParen => {
619                    *depth += 1;
620                    collector.push_str(token.text());
621                }
622                SyntaxKind::RParen => {
623                    if *depth > 0 {
624                        *depth -= 1;
625                    }
626                    collector.push_str(token.text());
627                    if *depth == 0 {
628                        let trimmed = collector.trim();
629                        if !trimmed.is_empty() {
630                            info.guard = Some(SmolStr::new(trimmed));
631                        }
632                        collector.clear();
633                        phase = HeaderPhase::BeforeTail;
634                    }
635                }
636                SyntaxKind::Amp => {
637                    let trimmed = collector.trim();
638                    if !trimmed.is_empty() {
639                        info.guard = Some(SmolStr::new(trimmed));
640                    }
641                    collector.clear();
642                    dependencies_started = true;
643                    phase = HeaderPhase::Dependencies;
644                }
645                SyntaxKind::ShellFallbackKw => {
646                    let trimmed = collector.trim();
647                    if !trimmed.is_empty() {
648                        info.guard = Some(SmolStr::new(trimmed));
649                    }
650                    collector.clear();
651                    info.shell_fallback = true;
652                    shell_expectation = ShellExpectation::NeedName;
653                    phase = HeaderPhase::BeforeTail;
654                }
655                SyntaxKind::ShellKw => {
656                    let trimmed = collector.trim();
657                    if !trimmed.is_empty() {
658                        info.guard = Some(SmolStr::new(trimmed));
659                    }
660                    collector.clear();
661                    shell_expectation = ShellExpectation::AllowEqOrName;
662                    phase = HeaderPhase::BeforeTail;
663                }
664                _ => collector.push_str(token.text()),
665            },
666            HeaderPhase::Dependencies => match kind {
667                SyntaxKind::Amp if group_depth == 0 => {
668                    pending.flush(&mut info.dependency_refs, stage);
669                    if !info.dependency_refs.is_empty() {
670                        stage += 1;
671                    }
672                    if !collector.trim().is_empty() {
673                        if !info.dependencies.as_deref().unwrap_or_default().is_empty() {
674                            collector.push(' ');
675                        }
676                        collector.push('&');
677                    }
678                }
679                SyntaxKind::LParen => {
680                    if group_depth > 0 {
681                        pending.extend(&token);
682                    }
683                    group_depth += 1;
684                    collector.push_str(token.text());
685                }
686                SyntaxKind::RParen => {
687                    if group_depth > 1 {
688                        pending.extend(&token);
689                    } else {
690                        pending.flush(&mut info.dependency_refs, stage);
691                    }
692                    group_depth = group_depth.saturating_sub(1);
693                    collector.push_str(token.text());
694                }
695                SyntaxKind::ShellFallbackKw if group_depth == 0 => {
696                    pending.flush(&mut info.dependency_refs, stage);
697                    let trimmed = collector.trim();
698                    if !trimmed.is_empty() {
699                        info.dependencies = Some(SmolStr::new(trimmed));
700                    }
701                    collector.clear();
702                    info.shell_fallback = true;
703                    shell_expectation = ShellExpectation::NeedName;
704                    phase = HeaderPhase::BeforeTail;
705                }
706                SyntaxKind::ShellKw if group_depth == 0 => {
707                    pending.flush(&mut info.dependency_refs, stage);
708                    let trimmed = collector.trim();
709                    if !trimmed.is_empty() {
710                        info.dependencies = Some(SmolStr::new(trimmed));
711                    }
712                    collector.clear();
713                    shell_expectation = ShellExpectation::AllowEqOrName;
714                    phase = HeaderPhase::BeforeTail;
715                }
716                SyntaxKind::Whitespace | SyntaxKind::Indent => {
717                    collector.push_str(token.text());
718                }
719                SyntaxKind::Unknown if token.text() == "," && group_depth > 0 => {
720                    pending.flush(&mut info.dependency_refs, stage);
721                    collector.push_str(token.text());
722                }
723                _ => {
724                    pending.extend(&token);
725                    collector.push_str(token.text());
726                }
727            },
728        }
729    }
730
731    if info.dependencies.is_none() {
732        let trimmed = collector.trim();
733        if dependencies_started && !trimmed.is_empty() {
734            info.dependencies = Some(SmolStr::new(trimmed));
735        }
736    }
737
738    info
739}
740
741fn flush_header_collector(
742    info: &mut TaskHeaderInfo,
743    phase: &HeaderPhase,
744    collector: &str,
745    dependencies_started: bool,
746) {
747    let trimmed = collector.trim();
748    if trimmed.is_empty() {
749        return;
750    }
751
752    match phase {
753        HeaderPhase::Guard { .. } => info.guard = Some(SmolStr::new(trimmed)),
754        HeaderPhase::Dependencies if dependencies_started => {
755            info.dependencies = Some(SmolStr::new(trimmed))
756        }
757        _ => {}
758    }
759}
760
761fn non_trivia_token_texts(node: &SyntaxNode) -> impl Iterator<Item = SmolStr> + '_ {
762    node.children_with_tokens()
763        .filter_map(|element| element.into_token())
764        .filter(|token| {
765            !matches!(
766                token.kind(),
767                SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
768            )
769        })
770        .map(|token| SmolStr::new(token.text()))
771}