use std::collections::HashSet;
use only_diagnostic::{Diagnostic, DiagnosticCode, DiagnosticPhase, DiagnosticSeverity};
use text_size::TextRange;
use crate::{
DirectiveAst, DirectiveKind, DocumentAst, GuardKind, ShellKind, ShellOperator, ShellSelection,
SymbolIndex, TaskAst,
};
pub(crate) fn validate_document(document: &DocumentAst, symbols: &SymbolIndex) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let task_names: HashSet<_> = symbols.tasks.iter().map(|task| task.name.clone()).collect();
let global_task_names: HashSet<_> = document
.tasks
.iter()
.filter(|task| task.namespace.is_none())
.map(|task| task.name.clone())
.collect();
let supports_command_blocks =
document
.directives
.iter()
.find_map(|directive| match directive {
DirectiveAst::Version { major, minor, .. } => Some(*major > 0 || *minor >= 2),
DirectiveAst::Shell { .. } => None,
DirectiveAst::Variable { .. } => None,
})
== Some(true);
let supports_engineering = document.directives.iter().any(|directive| {
matches!(directive, DirectiveAst::Version { major, minor, .. } if *major > 0 || *minor >= 3)
});
let globals = document
.directives
.iter()
.filter_map(|directive| match directive {
DirectiveAst::Variable { name, .. } => Some(name.clone()),
_ => None,
})
.collect::<HashSet<_>>();
for directive in &document.directives {
if let DirectiveAst::Shell { shell, range } = directive {
validate_shell(
&ShellSelection::required(shell.clone()),
*range,
&mut diagnostics,
);
}
}
if !supports_engineering && (!globals.is_empty() || document.uses_braced_namespaces) {
let range = document
.directives
.iter()
.find_map(|directive| match directive {
DirectiveAst::Variable { range, .. } => Some(*range),
_ => None,
})
.or_else(|| document.namespaces.first().map(|namespace| namespace.range))
.unwrap_or_default();
diagnostics.push(error(
"semantic.engineering-version",
"this syntax needs `!version 0.3` or newer".to_string(),
range,
));
}
for namespace in &document.namespaces {
if supports_engineering && !namespace.is_braced {
diagnostics.push(error(
"namespace.missing-open-brace",
format!(
"use '[{}] {{' and end the namespace with '}}'",
namespace.name
),
namespace.range,
));
} else if supports_engineering && namespace.close_range.is_none() {
diagnostics.push(error(
"namespace.missing-close",
format!("namespace '{}' must end with '}}'", namespace.name),
namespace.range,
));
}
if global_task_names.contains(&namespace.name) {
diagnostics.push(error(
"semantic.namespace-conflict",
format!(
"task and namespace cannot both be named '{}'",
namespace.name
),
namespace.range,
));
}
}
for task in &document.tasks {
validate_task(
task,
&task_names,
&globals,
supports_command_blocks,
supports_engineering,
&mut diagnostics,
);
}
report_duplicate_directives(document, &mut diagnostics);
report_duplicate_variables(document, &mut diagnostics);
report_duplicate_tasks(document, &mut diagnostics);
diagnostics
}
fn validate_task(
task: &TaskAst,
task_names: &HashSet<smol_str::SmolStr>,
globals: &HashSet<smol_str::SmolStr>,
supports_command_blocks: bool,
supports_engineering: bool,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut params = HashSet::new();
for (index, param) in task.params.iter().enumerate() {
if !params.insert(param.name.clone()) {
diagnostics.push(error(
"semantic.duplicate-parameter",
format!(
"parameter '{}' is used more than once in task '{}'",
param.name,
task.qualified_name()
),
task.range,
));
}
if param.is_slice && index + 1 != task.params.len() {
diagnostics.push(error(
"semantic.slice-parameter-position",
format!(
"slice parameter '{}..' must be last in task '{}'",
param.name,
task.qualified_name()
),
task.range,
));
}
if param.is_slice && param.default_value.is_some() {
diagnostics.push(error(
"semantic.slice-parameter-default",
format!("slice parameter '{}..' cannot have a default", param.name),
task.range,
));
}
}
let uses_shell_fallback = task
.shell
.as_ref()
.is_some_and(|shell| shell.selection.operator == ShellOperator::Fallback);
if !supports_engineering
&& (task.guards.len() > 1 || task.uses_multiline_header || uses_shell_fallback)
{
diagnostics.push(error(
"semantic.engineering-version",
"this syntax needs `!version 0.3` or newer".to_string(),
task.range,
));
}
if let Some(shell) = &task.shell {
validate_shell(&shell.selection, shell.range, diagnostics);
}
let mut guards = HashSet::new();
for guard in &task.guards {
if !guard.kind.is_supported() {
diagnostics.push(error(
"semantic.unknown-guard",
format!(
"guard '@{}' is not supported; use {}",
guard.kind,
supported_guards()
),
guard.range,
));
}
let key = format!("{}:{}", guard.kind, guard.argument);
if !guards.insert(key) {
diagnostics.push(error(
"semantic.duplicate-guard",
format!(
"guard '@{}(\"{}\")' is repeated",
guard.kind, guard.argument
),
guard.range,
));
}
}
for dependency in &task.dependencies {
if !task_names.contains(&dependency.name) {
diagnostics.push(error(
"semantic.undefined-dependency",
format!(
"task '{}' depends on missing task '{}'",
task.qualified_name(),
dependency.name
),
dependency.range,
));
}
}
for step in &task.steps {
if let crate::TaskStepAst::CommandBlock(block) = step
&& !supports_command_blocks
{
diagnostics.push(error(
"semantic.command-block-version",
"command blocks need `!version 0.2` or newer".to_string(),
block.range,
));
}
let interpolations = match step {
crate::TaskStepAst::Command(command) => &command.interpolations,
crate::TaskStepAst::CommandBlock(block) => &block.interpolations,
};
for interpolation in interpolations {
if !params.contains(&interpolation.name) && !globals.contains(&interpolation.name) {
diagnostics.push(error(
"semantic.undefined-variable",
format!("variable '{}' is not defined", interpolation.name),
interpolation.range,
));
}
}
}
}
fn validate_shell(shell: &ShellSelection, range: TextRange, diagnostics: &mut Vec<Diagnostic>) {
if !shell.kind.is_supported() {
diagnostics.push(error(
"semantic.unknown-shell",
format!(
"shell '{}' is not supported; use {}",
shell.kind,
supported_shells()
),
range,
));
} else if shell.operator == ShellOperator::Fallback && shell.kind.fallback().is_none() {
diagnostics.push(error(
"semantic.invalid-shell-fallback",
format!(
"shell '{}' has no fallback; use `shell={}`",
shell.kind, shell.kind
),
range,
));
}
}
fn supported_shells() -> String {
format_choices(
ShellKind::SUPPORTED
.iter()
.map(|shell| format!("'{}'", shell.as_str()))
.collect(),
)
}
fn supported_guards() -> String {
format_choices(
GuardKind::SUPPORTED
.iter()
.map(|guard| format!("'@{}'", guard.as_str()))
.collect(),
)
}
fn format_choices(mut choices: Vec<String>) -> String {
let Some(last) = choices.pop() else {
return String::new();
};
if choices.is_empty() {
return last;
}
format!("{}, or {last}", choices.join(", "))
}
fn error(code: &str, message: String, range: TextRange) -> Diagnostic {
Diagnostic::new(
DiagnosticSeverity::Error,
DiagnosticCode::new(code),
message,
DiagnosticPhase::Semantic,
range,
)
}
fn report_duplicate_tasks(document: &DocumentAst, diagnostics: &mut Vec<Diagnostic>) {
let mut seen = std::collections::HashMap::<String, &TaskAst>::new();
let mut seen_guards = std::collections::HashMap::<(String, String), &TaskAst>::new();
for task in &document.tasks {
if !task.guards.is_empty() {
let guard_key = (task.qualified_name().to_string(), render_guards(task));
if seen_guards.insert(guard_key, task).is_some() {
diagnostics.push(error(
"semantic.ambiguous-guard",
format!(
"task '{}' has the same guard more than once",
task.qualified_name()
),
task.range,
));
}
}
let key = task_signature_key(task);
if seen.insert(key, task).is_some() {
diagnostics.push(error(
"semantic.duplicate-task",
format!("task '{}' is defined more than once", task.qualified_name()),
task.range,
));
}
}
}
fn report_duplicate_directives(document: &DocumentAst, diagnostics: &mut Vec<Diagnostic>) {
let mut seen = std::collections::HashMap::<DirectiveKind, TextRange>::new();
for directive in &document.directives {
let (name, range) = match directive {
DirectiveAst::Version { .. } => continue,
DirectiveAst::Shell { range, .. } => (DirectiveKind::Shell, *range),
DirectiveAst::Variable { .. } => continue,
};
if let Some(previous_range) = seen.insert(name.clone(), range) {
diagnostics.push(error(
"semantic.duplicate-directive",
format!("`!{name}` is used more than once"),
previous_range.cover(range),
));
}
}
}
fn report_duplicate_variables(document: &DocumentAst, diagnostics: &mut Vec<Diagnostic>) {
let mut seen = std::collections::HashMap::<&str, TextRange>::new();
for directive in &document.directives {
let DirectiveAst::Variable { name, range, .. } = directive else {
continue;
};
if let Some(previous) = seen.insert(name.as_str(), *range) {
diagnostics.push(error(
"variable.duplicate",
format!("variable '{name}' is used more than once"),
previous.cover(*range),
));
}
}
}
fn task_signature_key(task: &TaskAst) -> String {
let parameter_names = task
.params
.iter()
.map(
|parameter| match (parameter.is_slice, ¶meter.default_value) {
(true, Some(default)) => format!("{}..={default}", parameter.name),
(true, None) => format!("{}..", parameter.name),
(false, Some(default)) => format!("{}={default}", parameter.name),
(false, None) => parameter.name.to_string(),
},
)
.collect::<Vec<_>>()
.join(",");
let guard = render_guards(task);
format!("{}|{}|{}", task.qualified_name(), parameter_names, guard)
}
fn render_guards(task: &TaskAst) -> String {
task.guards
.iter()
.map(|guard| format!("{}:{}", guard.kind, guard.argument))
.collect::<Vec<_>>()
.join("?")
}