use smol_str::SmolStr;
use text_size::{TextRange, TextSize};
use crate::{
DirectiveKind, GuardKind, ShellKind, ShellOperator, ShellSelection, SyntaxKind, SyntaxNode,
TaskShellRef,
};
#[derive(Debug, Clone)]
pub struct DocumentNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct DirectiveNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct DocCommentNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct NamespaceNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct TaskNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct TaskHeaderNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct ParameterListNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct ParameterNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct GuardClauseNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct DependencyClauseNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct ShellClauseNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone)]
pub struct HeaderTerminatorNode {
syntax: SyntaxNode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskStepNode {
Command(TaskCommandNode),
CommandBlock(TaskCommandBlockNode),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskCommandNode {
pub text: SmolStr,
pub range: TextRange,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskCommandBlockNode {
pub source: SmolStr,
pub range: TextRange,
pub line_ranges: Vec<TextRange>,
pub marker_ranges: Vec<TextRange>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskDependencyRef {
pub name: SmolStr,
pub range: TextRange,
pub stage: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskParamRef {
pub name: SmolStr,
pub range: TextRange,
pub default_value: Option<SmolStr>,
pub is_slice: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskGuardRef {
pub kind: GuardKind,
pub argument: SmolStr,
pub range: TextRange,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TaskHeaderInfo {
pub params: Option<SmolStr>,
pub param_refs: Vec<TaskParamRef>,
pub guard: Option<SmolStr>,
pub guards: Vec<TaskGuardRef>,
pub dependencies: Option<SmolStr>,
pub shell: Option<TaskShellRef>,
pub dependency_refs: Vec<TaskDependencyRef>,
}
impl DocumentNode {
pub fn cast(syntax: SyntaxNode) -> Option<Self> {
(syntax.kind() == SyntaxKind::Document).then_some(Self { syntax })
}
pub fn syntax(&self) -> &SyntaxNode {
&self.syntax
}
pub fn range(&self) -> TextRange {
self.syntax.text_range()
}
pub fn directives(&self) -> impl Iterator<Item = DirectiveNode> + '_ {
self.syntax.children().filter_map(DirectiveNode::cast)
}
pub fn doc_comments(&self) -> impl Iterator<Item = DocCommentNode> + '_ {
self.syntax.children().filter_map(DocCommentNode::cast)
}
pub fn namespaces(&self) -> impl Iterator<Item = NamespaceNode> + '_ {
self.syntax.children().filter_map(NamespaceNode::cast)
}
pub fn tasks(&self) -> impl Iterator<Item = TaskNode> + '_ {
self.syntax.children().filter_map(TaskNode::cast)
}
}
impl DirectiveNode {
pub fn cast(syntax: SyntaxNode) -> Option<Self> {
(syntax.kind() == SyntaxKind::Directive).then_some(Self { syntax })
}
pub fn range(&self) -> TextRange {
self.syntax.text_range()
}
pub fn keyword_range(&self) -> Option<TextRange> {
let mut tokens = self
.syntax
.children_with_tokens()
.filter_map(|element| element.into_token())
.filter(|token| {
!matches!(
token.kind(),
SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
)
});
let bang = tokens.find(|token| token.kind() == SyntaxKind::Bang)?;
let keyword = tokens.next()?;
Some(TextRange::new(
bang.text_range().start(),
keyword.text_range().end(),
))
}
pub fn name(&self) -> Option<SmolStr> {
non_trivia_token_texts(&self.syntax).nth(1)
}
pub fn directive_kind(&self) -> Option<DirectiveKind> {
self.name().map(|name| DirectiveKind::parse(&name))
}
pub fn value(&self) -> Option<SmolStr> {
let value = non_trivia_token_texts(&self.syntax)
.skip(2)
.collect::<Vec<_>>()
.join(" ");
(!value.is_empty()).then(|| SmolStr::new(value))
}
pub fn raw_value(&self) -> Option<SmolStr> {
let mut non_trivia = 0usize;
let mut value = String::new();
for token in self
.syntax
.children_with_tokens()
.filter_map(|element| element.into_token())
{
if token.kind() == SyntaxKind::Newline {
break;
}
if !matches!(
token.kind(),
SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Comment
) {
non_trivia += 1;
}
if non_trivia >= 2 && !(non_trivia == 2 && token.kind() == SyntaxKind::Ident) {
value.push_str(token.text());
}
}
let value = value.trim();
(!value.is_empty()).then(|| SmolStr::new(value))
}
pub fn argument_name_range(&self) -> Option<TextRange> {
self.syntax
.children_with_tokens()
.filter_map(|element| element.into_token())
.filter(|token| matches!(token.kind(), SyntaxKind::Ident | SyntaxKind::ShellKw))
.nth(1)
.map(|token| token.text_range())
}
}
impl DocCommentNode {
pub fn cast(syntax: SyntaxNode) -> Option<Self> {
(syntax.kind() == SyntaxKind::DocComment).then_some(Self { syntax })
}
pub fn range(&self) -> TextRange {
self.syntax.text_range()
}
pub fn text(&self) -> Option<SmolStr> {
self.syntax
.text()
.to_string()
.trim()
.strip_prefix('#')
.map(str::trim)
.filter(|text| !text.is_empty())
.map(SmolStr::new)
}
}
impl NamespaceNode {
pub fn cast(syntax: SyntaxNode) -> Option<Self> {
(syntax.kind() == SyntaxKind::NamespaceBlock).then_some(Self { syntax })
}
pub fn range(&self) -> TextRange {
self.syntax.text_range()
}
pub fn name(&self) -> Option<SmolStr> {
let source = self.syntax.text().to_string();
let label = source
.trim()
.strip_prefix('[')
.and_then(|text| text.split_once(']'))
.map(|(label, _)| label)
.map(str::trim)?;
(!label.is_empty()).then(|| SmolStr::new(label))
}
pub fn name_range(&self) -> Option<TextRange> {
self.syntax
.children_with_tokens()
.filter_map(|element| element.into_token())
.find(|token| token.kind() == SyntaxKind::Ident)
.map(|token| token.text_range())
}
pub fn is_close(&self) -> bool {
self.syntax.text().to_string().trim() == "}"
}
pub fn has_open_brace(&self) -> bool {
self.syntax
.descendants_with_tokens()
.filter_map(|element| element.into_token())
.any(|token| token.kind() == SyntaxKind::LBrace)
}
pub fn is_empty(&self) -> bool {
if self.is_close() {
return false;
}
self.syntax
.text()
.to_string()
.trim()
.strip_prefix('[')
.and_then(|text| text.split_once(']'))
.map(|(label, _)| label)
.map(str::trim)
.filter(|text| !text.is_empty())
.is_none()
}
}
impl TaskNode {
pub fn cast(syntax: SyntaxNode) -> Option<Self> {
(syntax.kind() == SyntaxKind::TaskDecl).then_some(Self { syntax })
}
pub fn range(&self) -> TextRange {
self.syntax.text_range()
}
pub fn name_range(&self) -> Option<TextRange> {
self.header()?.name_range()
}
pub fn name(&self) -> Option<SmolStr> {
self.header()?.name()
}
pub fn header_text(&self) -> Option<SmolStr> {
let header = self.header()?.syntax.text().to_string();
let header = header.trim().trim_end_matches(':').trim_end();
(!header.is_empty()).then(|| SmolStr::new(header))
}
pub fn header(&self) -> Option<TaskHeaderNode> {
self.syntax.children().find_map(TaskHeaderNode::cast)
}
pub fn uses_multiline_header(&self) -> bool {
self.header()
.is_some_and(|header| header.syntax.text().to_string().contains(['\n', '\r']))
}
pub fn header_info(&self) -> TaskHeaderInfo {
self.header()
.map_or_else(TaskHeaderInfo::default, |header| header.info())
}
pub fn commands(&self) -> std::vec::IntoIter<SmolStr> {
self.steps()
.map(|step| match step {
TaskStepNode::Command(command) => command.text,
TaskStepNode::CommandBlock(block) => block.source,
})
.collect::<Vec<_>>()
.into_iter()
}
pub fn steps(&self) -> std::vec::IntoIter<TaskStepNode> {
task_body_steps(&self.syntax)
.collect::<Vec<_>>()
.into_iter()
}
}
#[derive(Debug, Clone, Copy)]
struct BodyLine<'a> {
text: &'a str,
start: usize,
end_with_newline: usize,
}
fn task_body_steps(node: &SyntaxNode) -> impl Iterator<Item = TaskStepNode> + '_ {
let source = node.text().to_string();
let body_start = node
.children()
.find(|child| child.kind() == SyntaxKind::TaskHeader)
.map(|header| usize::from(header.text_range().end() - node.text_range().start()))
.unwrap_or_else(|| first_line_end(&source).unwrap_or(source.len()));
let base = usize::from(node.text_range().start());
let lines = body_lines(&source, body_start).collect::<Vec<_>>();
let mut steps = Vec::new();
let mut index = 0usize;
while index < lines.len() {
let line = lines[index];
let trimmed = line.text.trim_start_matches([' ', '\t']);
if block_line_content(trimmed).is_none() {
if !trimmed.is_empty() && !trimmed.starts_with("//") {
let indent = line.text.len() - trimmed.len();
steps.push(TaskStepNode::Command(TaskCommandNode {
text: SmolStr::new(trimmed),
range: text_range(
base + line.start + indent,
base + line.start + line.text.len(),
),
}));
}
index += 1;
continue;
}
let block_start = line.start;
let mut block_end = line.end_with_newline;
let mut block_source = String::new();
let mut line_ranges = Vec::new();
let mut marker_ranges = Vec::new();
while index < lines.len() {
let block_line = lines[index];
let trimmed = block_line.text.trim_start_matches([' ', '\t']);
let Some(content) = block_line_content(trimmed) else {
break;
};
let indent = block_line.text.len() - trimmed.len();
let marker_start = base + block_line.start + indent;
block_source.push_str(content);
block_source.push('\n');
line_ranges.push(text_range(
base + block_line.start,
base + block_line.start + block_line.text.len(),
));
marker_ranges.push(text_range(marker_start, marker_start + 1));
block_end = block_line.end_with_newline;
index += 1;
}
steps.push(TaskStepNode::CommandBlock(TaskCommandBlockNode {
source: SmolStr::new(block_source),
range: text_range(base + block_start, base + block_end),
line_ranges,
marker_ranges,
}));
}
steps.into_iter()
}
fn first_line_end(source: &str) -> Option<usize> {
let (index, newline) = source
.char_indices()
.find(|(_, character)| matches!(character, '\n' | '\r'))?;
let newline_len = if newline == '\r' && source.as_bytes().get(index + 1) == Some(&b'\n') {
2
} else {
1
};
Some(index + newline_len)
}
fn body_lines(source: &str, start: usize) -> impl Iterator<Item = BodyLine<'_>> {
let mut cursor = start;
std::iter::from_fn(move || {
if cursor >= source.len() {
return None;
}
let line_start = cursor;
let rest = &source[cursor..];
let newline = rest
.char_indices()
.find(|(_, character)| matches!(character, '\n' | '\r'));
let (line_end, newline_len) = match newline {
Some((offset, '\r')) if rest.as_bytes().get(offset + 1) == Some(&b'\n') => {
(cursor + offset, 2)
}
Some((offset, _)) => (cursor + offset, 1),
None => (source.len(), 0),
};
cursor = line_end + newline_len;
Some(BodyLine {
text: &source[line_start..line_end],
start: line_start,
end_with_newline: cursor,
})
})
}
fn block_line_content(line: &str) -> Option<&str> {
let rest = line.strip_prefix('|')?;
match rest.as_bytes().first() {
None => Some(rest),
Some(b' ' | b'\t') => Some(&rest[1..]),
Some(_) => None,
}
}
fn text_range(start: usize, end: usize) -> TextRange {
TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
}
#[derive(Debug, Default)]
struct PendingRef {
name: String,
start: Option<TextSize>,
end: Option<TextSize>,
}
impl PendingRef {
fn flush(&mut self, refs: &mut Vec<TaskDependencyRef>, stage: usize) {
if let (Some(start), Some(end)) = (self.start, self.end) {
let name = self.name.trim();
if !name.is_empty() {
refs.push(TaskDependencyRef {
name: SmolStr::new(name),
range: TextRange::new(start, end),
stage,
});
}
}
self.name.clear();
self.start = None;
self.end = None;
}
fn extend(&mut self, token: &crate::cst::SyntaxToken) {
self.start.get_or_insert(token.text_range().start());
self.end = Some(token.text_range().end());
self.name.push_str(token.text());
}
}
impl TaskHeaderNode {
pub fn cast(syntax: SyntaxNode) -> Option<Self> {
(syntax.kind() == SyntaxKind::TaskHeader).then_some(Self { syntax })
}
pub fn range(&self) -> TextRange {
self.syntax.text_range()
}
pub fn name(&self) -> Option<SmolStr> {
self.name_node()?
.first_token()
.map(|token| SmolStr::new(token.text()))
}
pub fn name_range(&self) -> Option<TextRange> {
self.name_node()?
.first_token()
.map(|token| token.text_range())
}
pub fn parameter_list(&self) -> Option<ParameterListNode> {
self.syntax.children().find_map(ParameterListNode::cast)
}
pub fn guards(&self) -> impl Iterator<Item = GuardClauseNode> + '_ {
self.syntax.children().filter_map(GuardClauseNode::cast)
}
pub fn dependencies(&self) -> impl Iterator<Item = DependencyClauseNode> + '_ {
self.syntax
.children()
.filter_map(DependencyClauseNode::cast)
}
pub fn shell(&self) -> Option<ShellClauseNode> {
self.syntax.children().find_map(ShellClauseNode::cast)
}
pub fn terminator(&self) -> Option<HeaderTerminatorNode> {
self.syntax.children().find_map(HeaderTerminatorNode::cast)
}
pub fn info(&self) -> TaskHeaderInfo {
parse_task_header(self)
}
fn name_node(&self) -> Option<SyntaxNode> {
self.syntax
.children()
.find(|node| node.kind() == SyntaxKind::TaskName)
}
}
impl ParameterListNode {
pub fn cast(syntax: SyntaxNode) -> Option<Self> {
(syntax.kind() == SyntaxKind::ParameterList).then_some(Self { syntax })
}
pub fn range(&self) -> TextRange {
self.syntax.text_range()
}
pub fn parameters(&self) -> impl Iterator<Item = ParameterNode> + '_ {
self.syntax.children().filter_map(ParameterNode::cast)
}
}
impl ParameterNode {
pub fn cast(syntax: SyntaxNode) -> Option<Self> {
(syntax.kind() == SyntaxKind::Parameter).then_some(Self { syntax })
}
pub fn range(&self) -> TextRange {
self.syntax.text_range()
}
pub fn name(&self) -> Option<SmolStr> {
self.name_token().map(|token| SmolStr::new(token.text()))
}
pub fn name_range(&self) -> Option<TextRange> {
self.name_token().map(|token| token.text_range())
}
pub fn default_value(&self) -> Option<SmolStr> {
node_tokens(&self.syntax)
.find(|token| token.kind() == SyntaxKind::String)
.and_then(|token| {
token
.text()
.strip_prefix('"')?
.strip_suffix('"')
.map(SmolStr::new)
})
}
pub fn is_slice(&self) -> bool {
self.syntax
.text()
.to_string()
.split('=')
.next()
.is_some_and(|name| name.trim_end().ends_with(".."))
}
fn name_token(&self) -> Option<crate::cst::SyntaxToken> {
node_tokens(&self.syntax)
.find(|token| matches!(token.kind(), SyntaxKind::Ident | SyntaxKind::ShellKw))
}
}
macro_rules! clause_node {
($type:ident, $kind:ident) => {
impl $type {
pub fn cast(syntax: SyntaxNode) -> Option<Self> {
(syntax.kind() == SyntaxKind::$kind).then_some(Self { syntax })
}
pub fn range(&self) -> TextRange {
self.syntax.text_range()
}
pub fn text(&self) -> SmolStr {
SmolStr::new(self.syntax.text().to_string().trim())
}
}
};
}
clause_node!(GuardClauseNode, GuardClause);
clause_node!(DependencyClauseNode, DependencyClause);
clause_node!(ShellClauseNode, ShellClause);
clause_node!(HeaderTerminatorNode, HeaderTerminator);
impl ShellClauseNode {
pub fn operator(&self) -> Option<ShellOperator> {
node_tokens(&self.syntax).find_map(|token| match token.kind() {
SyntaxKind::ShellKw => Some(ShellOperator::Required),
SyntaxKind::ShellFallbackKw => Some(ShellOperator::Fallback),
_ => None,
})
}
pub fn shell_name(&self) -> Option<SmolStr> {
node_tokens(&self.syntax)
.find(|token| token.kind() == SyntaxKind::Ident)
.map(|token| SmolStr::new(token.text()))
}
pub fn content_range(&self) -> Option<TextRange> {
let mut tokens = node_tokens(&self.syntax).filter(|token| {
!matches!(
token.kind(),
SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
)
});
let first = tokens.next()?;
let end = tokens.last().unwrap_or_else(|| first.clone());
Some(TextRange::new(
first.text_range().start(),
end.text_range().end(),
))
}
}
fn parse_task_header(node: &TaskHeaderNode) -> TaskHeaderInfo {
let mut info = TaskHeaderInfo::default();
if let Some(parameters) = node.parameter_list() {
let refs = parameters
.parameters()
.filter_map(|parameter| {
Some(TaskParamRef {
name: parameter.name()?,
range: parameter.name_range()?,
default_value: parameter.default_value(),
is_slice: parameter.is_slice(),
})
})
.collect::<Vec<_>>();
if !refs.is_empty() {
info.params = Some(SmolStr::new(
refs.iter()
.map(render_param_ref)
.collect::<Vec<_>>()
.join(", "),
));
}
info.param_refs = refs;
}
info.guards = node.guards().filter_map(parse_guard_ref).collect();
info.guard = info
.guards
.first()
.map(|guard| SmolStr::new(format!("@{}(\"{}\")", guard.kind, guard.argument)));
let mut dependency_text = Vec::new();
for (stage, clause) in node.dependencies().enumerate() {
dependency_text.push(clause.text().trim_start_matches('&').trim().to_string());
parse_dependency_clause(&clause.syntax, stage, &mut info.dependency_refs);
}
if !dependency_text.is_empty() {
info.dependencies = Some(SmolStr::new(dependency_text.join(" & ")));
}
if let Some(shell) = node.shell() {
let tokens = node_tokens(&shell.syntax)
.filter(|token| {
!matches!(
token.kind(),
SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
)
})
.collect::<Vec<_>>();
let operator = tokens.first().and_then(|token| match token.kind() {
SyntaxKind::ShellKw => Some(ShellOperator::Required),
SyntaxKind::ShellFallbackKw => Some(ShellOperator::Fallback),
_ => None,
});
let kind = tokens
.iter()
.rev()
.find(|token| token.kind() == SyntaxKind::Ident)
.map(|token| ShellKind::parse(token.text()));
info.shell = operator.zip(kind).map(|(operator, kind)| TaskShellRef {
selection: ShellSelection { kind, operator },
range: shell.content_range().unwrap_or_else(|| shell.range()),
});
}
info
}
fn render_param_ref(parameter: &TaskParamRef) -> String {
let suffix = if parameter.is_slice { ".." } else { "" };
match ¶meter.default_value {
Some(value) => format!("{}{suffix}=\"{value}\"", parameter.name),
None => format!("{}{suffix}", parameter.name),
}
}
fn parse_guard_ref(clause: GuardClauseNode) -> Option<TaskGuardRef> {
let tokens = node_tokens(&clause.syntax)
.filter(|token| {
!matches!(
token.kind(),
SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
)
})
.collect::<Vec<_>>();
let name = tokens
.iter()
.find(|token| token.kind() == SyntaxKind::Ident)?
.text();
let argument = tokens
.iter()
.find(|token| token.kind() == SyntaxKind::String)?
.text()
.strip_prefix('"')?
.strip_suffix('"')?;
Some(TaskGuardRef {
kind: GuardKind::parse(name),
argument: SmolStr::new(argument),
range: clause.range(),
})
}
fn parse_dependency_clause(node: &SyntaxNode, stage: usize, refs: &mut Vec<TaskDependencyRef>) {
let mut pending = PendingRef::default();
for token in node_tokens(node) {
match token.kind() {
SyntaxKind::Amp
| SyntaxKind::LParen
| SyntaxKind::Whitespace
| SyntaxKind::Indent
| SyntaxKind::Newline => {}
SyntaxKind::RParen => pending.flush(refs, stage),
SyntaxKind::Unknown if token.text() == "," => pending.flush(refs, stage),
_ => pending.extend(&token),
}
}
pending.flush(refs, stage);
}
fn node_tokens(node: &SyntaxNode) -> impl Iterator<Item = crate::cst::SyntaxToken> + '_ {
node.descendants_with_tokens()
.filter_map(|element| element.into_token())
}
fn non_trivia_token_texts(node: &SyntaxNode) -> impl Iterator<Item = SmolStr> + '_ {
node.children_with_tokens()
.filter_map(|element| element.into_token())
.filter(|token| {
!matches!(
token.kind(),
SyntaxKind::Whitespace | SyntaxKind::Indent | SyntaxKind::Newline
)
})
.map(|token| SmolStr::new(token.text()))
}