1use smol_str::SmolStr;
2use text_size::{TextRange, TextSize};
3
4use crate::{SyntaxKind, SyntaxNode};
5
6#[derive(Debug, Clone)]
14pub struct DocumentNode {
15 syntax: SyntaxNode,
16}
17
18#[derive(Debug, Clone)]
26pub struct DirectiveNode {
27 syntax: SyntaxNode,
28}
29
30#[derive(Debug, Clone)]
38pub struct DocCommentNode {
39 syntax: SyntaxNode,
40}
41
42#[derive(Debug, Clone)]
50pub struct NamespaceNode {
51 syntax: SyntaxNode,
52}
53
54#[derive(Debug, Clone)]
62pub struct TaskNode {
63 syntax: SyntaxNode,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum TaskStepNode {
69 Command(TaskCommandNode),
70 CommandBlock(TaskCommandBlockNode),
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct TaskCommandNode {
76 pub text: SmolStr,
77 pub range: TextRange,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct TaskCommandBlockNode {
83 pub source: SmolStr,
84 pub range: TextRange,
85 pub line_ranges: Vec<TextRange>,
86 pub marker_ranges: Vec<TextRange>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct TaskDependencyRef {
98 pub name: SmolStr,
99 pub range: TextRange,
100 pub stage: usize,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct TaskParamRef {
106 pub name: SmolStr,
107 pub range: TextRange,
108}
109
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct TaskHeaderInfo {
119 pub params: Option<SmolStr>,
120 pub param_refs: Vec<TaskParamRef>,
121 pub guard: Option<SmolStr>,
122 pub dependencies: Option<SmolStr>,
123 pub shell: Option<SmolStr>,
124 pub shell_fallback: bool,
125 pub dependency_refs: Vec<TaskDependencyRef>,
126}
127
128impl DocumentNode {
129 pub fn cast(syntax: SyntaxNode) -> Option<Self> {
137 (syntax.kind() == SyntaxKind::Document).then_some(Self { syntax })
138 }
139
140 pub fn syntax(&self) -> &SyntaxNode {
148 &self.syntax
149 }
150
151 pub fn range(&self) -> TextRange {
159 self.syntax.text_range()
160 }
161
162 pub fn directives(&self) -> impl Iterator<Item = DirectiveNode> + '_ {
170 self.syntax.children().filter_map(DirectiveNode::cast)
171 }
172
173 pub fn doc_comments(&self) -> impl Iterator<Item = DocCommentNode> + '_ {
181 self.syntax.children().filter_map(DocCommentNode::cast)
182 }
183
184 pub fn namespaces(&self) -> impl Iterator<Item = NamespaceNode> + '_ {
192 self.syntax.children().filter_map(NamespaceNode::cast)
193 }
194
195 pub fn tasks(&self) -> impl Iterator<Item = TaskNode> + '_ {
203 self.syntax.children().filter_map(TaskNode::cast)
204 }
205}
206
207impl DirectiveNode {
208 pub fn cast(syntax: SyntaxNode) -> Option<Self> {
216 (syntax.kind() == SyntaxKind::Directive).then_some(Self { syntax })
217 }
218
219 pub fn range(&self) -> TextRange {
227 self.syntax.text_range()
228 }
229
230 pub fn keyword_range(&self) -> Option<TextRange> {
238 let mut tokens = self
239 .syntax
240 .children_with_tokens()
241 .filter_map(|element| element.into_token())
242 .filter(|token| {
243 !matches!(
244 token.kind(),
245 SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
246 )
247 });
248 let bang = tokens.find(|token| token.kind() == SyntaxKind::Bang)?;
249 let keyword = tokens.next()?;
250 Some(TextRange::new(
251 bang.text_range().start(),
252 keyword.text_range().end(),
253 ))
254 }
255
256 pub fn name(&self) -> Option<SmolStr> {
264 non_trivia_token_texts(&self.syntax).nth(1)
265 }
266
267 pub fn value(&self) -> Option<SmolStr> {
275 let value = non_trivia_token_texts(&self.syntax)
276 .skip(2)
277 .collect::<Vec<_>>()
278 .join(" ");
279 (!value.is_empty()).then(|| SmolStr::new(value))
280 }
281
282 pub fn raw_value(&self) -> Option<SmolStr> {
284 let mut non_trivia = 0usize;
285 let mut value = String::new();
286
287 for token in self
288 .syntax
289 .children_with_tokens()
290 .filter_map(|element| element.into_token())
291 {
292 if token.kind() == SyntaxKind::Newline {
293 break;
294 }
295 if !matches!(
296 token.kind(),
297 SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Comment
298 ) {
299 non_trivia += 1;
300 }
301 if non_trivia >= 2 && !(non_trivia == 2 && token.kind() == SyntaxKind::Ident) {
302 value.push_str(token.text());
303 }
304 }
305
306 let value = value.trim();
307 (!value.is_empty()).then(|| SmolStr::new(value))
308 }
309}
310
311impl DocCommentNode {
312 pub fn cast(syntax: SyntaxNode) -> Option<Self> {
320 (syntax.kind() == SyntaxKind::DocComment).then_some(Self { syntax })
321 }
322
323 pub fn range(&self) -> TextRange {
331 self.syntax.text_range()
332 }
333
334 pub fn text(&self) -> Option<SmolStr> {
342 self.syntax
343 .text()
344 .to_string()
345 .trim()
346 .strip_prefix('#')
347 .map(str::trim)
348 .filter(|text| !text.is_empty())
349 .map(SmolStr::new)
350 }
351}
352
353impl NamespaceNode {
354 pub fn cast(syntax: SyntaxNode) -> Option<Self> {
362 (syntax.kind() == SyntaxKind::NamespaceBlock).then_some(Self { syntax })
363 }
364
365 pub fn range(&self) -> TextRange {
373 self.syntax.text_range()
374 }
375
376 pub fn name(&self) -> Option<SmolStr> {
384 self.syntax
385 .text()
386 .to_string()
387 .trim()
388 .strip_prefix('[')
389 .and_then(|text| text.strip_suffix(']'))
390 .map(str::trim)
391 .filter(|text| !text.is_empty())
392 .map(SmolStr::new)
393 }
394}
395
396impl TaskNode {
397 pub fn cast(syntax: SyntaxNode) -> Option<Self> {
405 (syntax.kind() == SyntaxKind::TaskDecl).then_some(Self { syntax })
406 }
407
408 pub fn range(&self) -> TextRange {
416 self.syntax.text_range()
417 }
418
419 pub fn name_range(&self) -> Option<TextRange> {
427 self.syntax
428 .children_with_tokens()
429 .filter_map(|element| element.into_token())
430 .find(|token| token.kind() == SyntaxKind::Ident)
431 .map(|token| token.text_range())
432 }
433
434 pub fn name(&self) -> Option<SmolStr> {
442 self.syntax
443 .children_with_tokens()
444 .filter_map(|element| element.into_token())
445 .find(|token| token.kind() == SyntaxKind::Ident)
446 .map(|token| SmolStr::new(token.text()))
447 }
448
449 pub fn header_text(&self) -> Option<SmolStr> {
457 let mut header = String::new();
458
459 for token in self
460 .syntax
461 .children_with_tokens()
462 .filter_map(|element| element.into_token())
463 {
464 if token.kind() == SyntaxKind::Colon {
465 break;
466 }
467 if token.kind() == SyntaxKind::Newline {
468 break;
469 }
470 header.push_str(token.text());
471 }
472
473 let header = header.trim();
474 (!header.is_empty()).then(|| SmolStr::new(header))
475 }
476
477 pub fn header_info(&self) -> TaskHeaderInfo {
485 parse_task_header(&self.syntax)
486 }
487
488 pub fn commands(&self) -> std::vec::IntoIter<SmolStr> {
496 self.steps()
497 .map(|step| match step {
498 TaskStepNode::Command(command) => command.text,
499 TaskStepNode::CommandBlock(block) => block.source,
500 })
501 .collect::<Vec<_>>()
502 .into_iter()
503 }
504
505 pub fn steps(&self) -> std::vec::IntoIter<TaskStepNode> {
507 task_body_steps(&self.syntax)
508 .collect::<Vec<_>>()
509 .into_iter()
510 }
511}
512
513#[derive(Debug, Clone, Copy)]
514struct BodyLine<'a> {
515 text: &'a str,
516 start: usize,
517 end_with_newline: usize,
518}
519
520fn task_body_steps(node: &SyntaxNode) -> impl Iterator<Item = TaskStepNode> + '_ {
521 let source = node.text().to_string();
522 let body_start = first_line_end(&source).unwrap_or(source.len());
523 let base = usize::from(node.text_range().start());
524 let lines = body_lines(&source, body_start).collect::<Vec<_>>();
525 let mut steps = Vec::new();
526 let mut index = 0usize;
527
528 while index < lines.len() {
529 let line = lines[index];
530 let trimmed = line.text.trim_start_matches([' ', '\t']);
531 if block_line_content(trimmed).is_none() {
532 if !trimmed.is_empty() && !trimmed.starts_with("//") {
533 let indent = line.text.len() - trimmed.len();
534 steps.push(TaskStepNode::Command(TaskCommandNode {
535 text: SmolStr::new(trimmed),
536 range: text_range(
537 base + line.start + indent,
538 base + line.start + line.text.len(),
539 ),
540 }));
541 }
542 index += 1;
543 continue;
544 }
545
546 let block_start = line.start;
547 let mut block_end = line.end_with_newline;
548 let mut block_source = String::new();
549 let mut line_ranges = Vec::new();
550 let mut marker_ranges = Vec::new();
551
552 while index < lines.len() {
553 let block_line = lines[index];
554 let trimmed = block_line.text.trim_start_matches([' ', '\t']);
555 let Some(content) = block_line_content(trimmed) else {
556 break;
557 };
558 let indent = block_line.text.len() - trimmed.len();
559 let marker_start = base + block_line.start + indent;
560 block_source.push_str(content);
561 block_source.push('\n');
562 line_ranges.push(text_range(
563 base + block_line.start,
564 base + block_line.start + block_line.text.len(),
565 ));
566 marker_ranges.push(text_range(marker_start, marker_start + 1));
567 block_end = block_line.end_with_newline;
568 index += 1;
569 }
570
571 steps.push(TaskStepNode::CommandBlock(TaskCommandBlockNode {
572 source: SmolStr::new(block_source),
573 range: text_range(base + block_start, base + block_end),
574 line_ranges,
575 marker_ranges,
576 }));
577 }
578
579 steps.into_iter()
580}
581
582fn first_line_end(source: &str) -> Option<usize> {
583 let (index, newline) = source
584 .char_indices()
585 .find(|(_, character)| matches!(character, '\n' | '\r'))?;
586 let newline_len = if newline == '\r' && source.as_bytes().get(index + 1) == Some(&b'\n') {
587 2
588 } else {
589 1
590 };
591 Some(index + newline_len)
592}
593
594fn body_lines(source: &str, start: usize) -> impl Iterator<Item = BodyLine<'_>> {
595 let mut cursor = start;
596 std::iter::from_fn(move || {
597 if cursor >= source.len() {
598 return None;
599 }
600 let line_start = cursor;
601 let rest = &source[cursor..];
602 let newline = rest
603 .char_indices()
604 .find(|(_, character)| matches!(character, '\n' | '\r'));
605 let (line_end, newline_len) = match newline {
606 Some((offset, '\r')) if rest.as_bytes().get(offset + 1) == Some(&b'\n') => {
607 (cursor + offset, 2)
608 }
609 Some((offset, _)) => (cursor + offset, 1),
610 None => (source.len(), 0),
611 };
612 cursor = line_end + newline_len;
613 Some(BodyLine {
614 text: &source[line_start..line_end],
615 start: line_start,
616 end_with_newline: cursor,
617 })
618 })
619}
620
621fn block_line_content(line: &str) -> Option<&str> {
622 let rest = line.strip_prefix('|')?;
623 match rest.as_bytes().first() {
624 None => Some(rest),
625 Some(b' ' | b'\t') => Some(&rest[1..]),
626 Some(_) => None,
627 }
628}
629
630fn text_range(start: usize, end: usize) -> TextRange {
631 TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635enum HeaderPhase {
636 BeforeTail,
637 Params { depth: usize },
638 Guard { depth: usize },
639 Dependencies,
640}
641
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643enum ShellExpectation {
644 None,
645 AllowEqOrName,
646 NeedName,
647}
648
649#[derive(Debug, Default)]
650struct PendingRef {
651 name: String,
652 start: Option<TextSize>,
653 end: Option<TextSize>,
654}
655
656impl PendingRef {
657 fn flush(&mut self, refs: &mut Vec<TaskDependencyRef>, stage: usize) {
658 if let (Some(start), Some(end)) = (self.start, self.end) {
659 let name = self.name.trim();
660 if !name.is_empty() {
661 refs.push(TaskDependencyRef {
662 name: SmolStr::new(name),
663 range: TextRange::new(start, end),
664 stage,
665 });
666 }
667 }
668 self.name.clear();
669 self.start = None;
670 self.end = None;
671 }
672
673 fn extend(&mut self, token: &crate::cst::SyntaxToken) {
674 self.start.get_or_insert(token.text_range().start());
675 self.end = Some(token.text_range().end());
676 self.name.push_str(token.text());
677 }
678}
679
680fn parse_task_header(node: &SyntaxNode) -> TaskHeaderInfo {
681 let mut info = TaskHeaderInfo::default();
682 let mut phase = HeaderPhase::BeforeTail;
683 let mut saw_name = false;
684 let mut stage = 0usize;
685 let mut group_depth = 0usize;
686 let mut pending = PendingRef::default();
687 let mut collector = String::new();
688 let mut dependencies_started = false;
689 let mut shell_expectation = ShellExpectation::None;
690 let mut expect_param_name = false;
691
692 for token in node
693 .children_with_tokens()
694 .filter_map(|element| element.into_token())
695 {
696 let kind = token.kind();
697 if matches!(
698 kind,
699 SyntaxKind::Colon | SyntaxKind::Newline | SyntaxKind::Eof
700 ) {
701 pending.flush(&mut info.dependency_refs, stage);
702 flush_header_collector(&mut info, &phase, &collector, dependencies_started);
703 break;
704 }
705
706 if !saw_name {
707 if kind == SyntaxKind::Ident {
708 saw_name = true;
709 }
710 continue;
711 }
712
713 if !matches!(shell_expectation, ShellExpectation::None) {
714 match (shell_expectation, kind) {
715 (_, SyntaxKind::Whitespace | SyntaxKind::Indent) => continue,
716 (ShellExpectation::AllowEqOrName, SyntaxKind::Eq) => {
717 shell_expectation = ShellExpectation::NeedName;
718 continue;
719 }
720 (_, SyntaxKind::Ident) => {
721 info.shell = Some(SmolStr::new(token.text()));
722 shell_expectation = ShellExpectation::None;
723 continue;
724 }
725 _ => {
726 shell_expectation = ShellExpectation::None;
727 }
728 }
729 }
730
731 match &mut phase {
732 HeaderPhase::BeforeTail => match kind {
733 SyntaxKind::LParen => {
734 collector.clear();
735 expect_param_name = true;
736 phase = HeaderPhase::Params { depth: 1 };
737 }
738 SyntaxKind::Question => {
739 collector.clear();
740 phase = HeaderPhase::Guard { depth: 0 };
741 }
742 SyntaxKind::Amp => {
743 collector.clear();
744 dependencies_started = true;
745 phase = HeaderPhase::Dependencies;
746 }
747 SyntaxKind::ShellFallbackKw => {
748 info.shell_fallback = true;
749 shell_expectation = ShellExpectation::NeedName;
750 }
751 SyntaxKind::ShellKw => shell_expectation = ShellExpectation::AllowEqOrName,
752 _ => {}
753 },
754 HeaderPhase::Params { depth } => {
755 if *depth == 1 && expect_param_name {
756 match kind {
757 SyntaxKind::Whitespace | SyntaxKind::Indent => {}
758 SyntaxKind::Ident | SyntaxKind::ShellKw => {
759 info.param_refs.push(TaskParamRef {
760 name: SmolStr::new(token.text()),
761 range: token.text_range(),
762 });
763 expect_param_name = false;
764 }
765 _ => expect_param_name = false,
766 }
767 }
768
769 match kind {
770 SyntaxKind::LParen => {
771 *depth += 1;
772 collector.push_str(token.text());
773 }
774 SyntaxKind::RParen => {
775 *depth -= 1;
776 if *depth == 0 {
777 let trimmed = collector.trim();
778 if !trimmed.is_empty() {
779 info.params = Some(SmolStr::new(trimmed));
780 }
781 collector.clear();
782 expect_param_name = false;
783 phase = HeaderPhase::BeforeTail;
784 } else {
785 collector.push_str(token.text());
786 }
787 }
788 SyntaxKind::Unknown if *depth == 1 && token.text() == "," => {
789 collector.push_str(token.text());
790 expect_param_name = true;
791 }
792 _ => collector.push_str(token.text()),
793 }
794 }
795 HeaderPhase::Guard { depth } => match kind {
796 SyntaxKind::LParen => {
797 *depth += 1;
798 collector.push_str(token.text());
799 }
800 SyntaxKind::RParen => {
801 if *depth > 0 {
802 *depth -= 1;
803 }
804 collector.push_str(token.text());
805 if *depth == 0 {
806 let trimmed = collector.trim();
807 if !trimmed.is_empty() {
808 info.guard = Some(SmolStr::new(trimmed));
809 }
810 collector.clear();
811 phase = HeaderPhase::BeforeTail;
812 }
813 }
814 SyntaxKind::Amp => {
815 let trimmed = collector.trim();
816 if !trimmed.is_empty() {
817 info.guard = Some(SmolStr::new(trimmed));
818 }
819 collector.clear();
820 dependencies_started = true;
821 phase = HeaderPhase::Dependencies;
822 }
823 SyntaxKind::ShellFallbackKw => {
824 let trimmed = collector.trim();
825 if !trimmed.is_empty() {
826 info.guard = Some(SmolStr::new(trimmed));
827 }
828 collector.clear();
829 info.shell_fallback = true;
830 shell_expectation = ShellExpectation::NeedName;
831 phase = HeaderPhase::BeforeTail;
832 }
833 SyntaxKind::ShellKw => {
834 let trimmed = collector.trim();
835 if !trimmed.is_empty() {
836 info.guard = Some(SmolStr::new(trimmed));
837 }
838 collector.clear();
839 shell_expectation = ShellExpectation::AllowEqOrName;
840 phase = HeaderPhase::BeforeTail;
841 }
842 _ => collector.push_str(token.text()),
843 },
844 HeaderPhase::Dependencies => match kind {
845 SyntaxKind::Amp if group_depth == 0 => {
846 pending.flush(&mut info.dependency_refs, stage);
847 if !info.dependency_refs.is_empty() {
848 stage += 1;
849 }
850 if !collector.trim().is_empty() {
851 if !info.dependencies.as_deref().unwrap_or_default().is_empty() {
852 collector.push(' ');
853 }
854 collector.push('&');
855 }
856 }
857 SyntaxKind::LParen => {
858 if group_depth > 0 {
859 pending.extend(&token);
860 }
861 group_depth += 1;
862 collector.push_str(token.text());
863 }
864 SyntaxKind::RParen => {
865 if group_depth > 1 {
866 pending.extend(&token);
867 } else {
868 pending.flush(&mut info.dependency_refs, stage);
869 }
870 group_depth = group_depth.saturating_sub(1);
871 collector.push_str(token.text());
872 }
873 SyntaxKind::ShellFallbackKw if group_depth == 0 => {
874 pending.flush(&mut info.dependency_refs, stage);
875 let trimmed = collector.trim();
876 if !trimmed.is_empty() {
877 info.dependencies = Some(SmolStr::new(trimmed));
878 }
879 collector.clear();
880 info.shell_fallback = true;
881 shell_expectation = ShellExpectation::NeedName;
882 phase = HeaderPhase::BeforeTail;
883 }
884 SyntaxKind::ShellKw if group_depth == 0 => {
885 pending.flush(&mut info.dependency_refs, stage);
886 let trimmed = collector.trim();
887 if !trimmed.is_empty() {
888 info.dependencies = Some(SmolStr::new(trimmed));
889 }
890 collector.clear();
891 shell_expectation = ShellExpectation::AllowEqOrName;
892 phase = HeaderPhase::BeforeTail;
893 }
894 SyntaxKind::Whitespace | SyntaxKind::Indent => {
895 collector.push_str(token.text());
896 }
897 SyntaxKind::Unknown if token.text() == "," && group_depth > 0 => {
898 pending.flush(&mut info.dependency_refs, stage);
899 collector.push_str(token.text());
900 }
901 _ => {
902 pending.extend(&token);
903 collector.push_str(token.text());
904 }
905 },
906 }
907 }
908
909 if info.dependencies.is_none() {
910 let trimmed = collector.trim();
911 if dependencies_started && !trimmed.is_empty() {
912 info.dependencies = Some(SmolStr::new(trimmed));
913 }
914 }
915
916 info
917}
918
919fn flush_header_collector(
920 info: &mut TaskHeaderInfo,
921 phase: &HeaderPhase,
922 collector: &str,
923 dependencies_started: bool,
924) {
925 let trimmed = collector.trim();
926 if trimmed.is_empty() {
927 return;
928 }
929
930 match phase {
931 HeaderPhase::Guard { .. } => info.guard = Some(SmolStr::new(trimmed)),
932 HeaderPhase::Dependencies if dependencies_started => {
933 info.dependencies = Some(SmolStr::new(trimmed))
934 }
935 _ => {}
936 }
937}
938
939fn non_trivia_token_texts(node: &SyntaxNode) -> impl Iterator<Item = SmolStr> + '_ {
940 node.children_with_tokens()
941 .filter_map(|element| element.into_token())
942 .filter(|token| {
943 !matches!(
944 token.kind(),
945 SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
946 )
947 })
948 .map(|token| SmolStr::new(token.text()))
949}