use std::collections::{HashMap, HashSet};
use crate::ast::{
Arg, Assignment, CaseBranch, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
Program, SpannedPart, Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop,
Value,
};
use crate::kernel::{bind_glued_short_value, push_repeatable_value};
use crate::scheduler::{is_bool_type, schema_param_lookup};
use crate::validator::issue::Span;
use crate::tools::{ToolArgs, ToolRegistry, ToolSchema};
use kaish_types::CommandKind;
use super::issue::{IssueCode, ValidationIssue};
use super::scope_tracker::ScopeTracker;
pub struct Validator<'a> {
registry: &'a ToolRegistry,
user_tools: &'a HashMap<String, ToolDef>,
scope: ScopeTracker,
loop_depth: usize,
function_depth: usize,
issues: Vec<ValidationIssue>,
}
impl<'a> Validator<'a> {
pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
Self {
registry,
user_tools,
scope: ScopeTracker::new(),
loop_depth: 0,
function_depth: 0,
issues: Vec::new(),
}
}
pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
for stmt in &program.statements {
self.validate_stmt(stmt);
}
self.issues
}
fn validate_stmt(&mut self, stmt: &Stmt) {
match stmt {
Stmt::Assignment(assign) => self.validate_assignment(assign),
Stmt::Command(cmd) => self.validate_command(cmd),
Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
Stmt::If(if_stmt) => self.validate_if(if_stmt),
Stmt::For(for_loop) => self.validate_for(for_loop),
Stmt::While(while_loop) => self.validate_while(while_loop),
Stmt::Case(case_stmt) => self.validate_case(case_stmt),
Stmt::Break(levels) => self.validate_break(*levels),
Stmt::Continue(levels) => self.validate_continue(*levels),
Stmt::Return(expr) => self.validate_return(expr.as_deref()),
Stmt::Exit(expr) => {
if let Some(e) = expr {
self.validate_expr(e);
}
}
Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
Stmt::Test(test_expr) => self.validate_test(test_expr),
Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
self.validate_stmt(left);
self.validate_stmt(right);
}
Stmt::EnvScoped { assignments, body } => {
for assign in assignments {
self.validate_assignment(assign);
}
self.validate_stmt(body);
}
Stmt::Empty => {}
}
}
fn validate_assignment(&mut self, assign: &Assignment) {
self.validate_expr(&assign.value);
let name = assign.name();
if assign.path.segments.len() == 1 {
if let Some(dot) = name.find('.') {
let (root, rest) = (&name[..dot], &name[dot + 1..]);
self.issues.push(
ValidationIssue::error(
IssueCode::DottedAssignmentTarget,
format!(
"'{name}' is not a valid assignment target — kaish uses bracket \
access, not dots"
),
)
.with_suggestion(format!("use `{root}[{rest}]=value`")),
);
}
self.scope.bind(name);
} else if !self.scope.is_bound(name) {
self.issues.push(
ValidationIssue::error(
IssueCode::LvalueUndefinedRoot,
format!(
"'{name}' is not defined — a subscripted assignment never creates the \
root variable"
),
)
.with_suggestion(format!("create it first, e.g. `{name}={{}}` or `{name}=[]`")),
);
self.scope.bind(name);
}
}
fn validate_command(&mut self, cmd: &Command) {
if cmd.name == "source" || cmd.name == "." {
return;
}
if !is_static_command_name(&cmd.name) {
return;
}
let is_builtin = self.registry.contains(&cmd.name);
let is_user_tool = self.user_tools.contains_key(&cmd.name);
let is_special = is_special_command(&cmd.name);
if !is_builtin && !is_user_tool && !is_special {
self.issues.push(ValidationIssue::warning(
IssueCode::UndefinedCommand,
format!("command '{}' not found in builtin registry", cmd.name),
).with_suggestion("this may be a script in PATH or external command"));
}
for arg in &cmd.args {
self.validate_arg(arg);
}
if let Some(tool) = self.registry.get(&cmd.name) {
let schema = tool.schema();
let tool_args = build_tool_args_for_validation(&cmd.args, Some(&schema));
let tool_issues = tool.validate(&tool_args);
self.issues.extend(tool_issues);
} else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
self.validate_user_tool_args(user_tool, &cmd.args);
}
for redirect in &cmd.redirects {
self.validate_expr(&redirect.target);
}
}
fn validate_arg(&mut self, arg: &Arg) {
match arg {
Arg::Positional(expr) => self.validate_expr(expr),
Arg::Named { value, .. } => self.validate_expr(value),
Arg::WordAssign { value, .. } => self.validate_expr(value),
Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
}
}
fn validate_pipeline(&mut self, pipe: &Pipeline) {
let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
if has_scatter && !has_gather {
self.issues.push(
ValidationIssue::error(
IssueCode::ScatterWithoutGather,
"scatter without gather — parallel results would be lost",
).with_suggestion("add gather: ... | scatter | cmd | gather")
);
}
for cmd in &pipe.commands {
self.validate_command(cmd);
}
}
fn validate_if(&mut self, if_stmt: &IfStmt) {
self.validate_expr(&if_stmt.condition);
self.scope.push_frame();
for stmt in &if_stmt.then_branch {
self.validate_stmt(stmt);
}
self.scope.pop_frame();
if let Some(else_branch) = &if_stmt.else_branch {
self.scope.push_frame();
for stmt in else_branch {
self.validate_stmt(stmt);
}
self.scope.pop_frame();
}
}
fn validate_for(&mut self, for_loop: &ForLoop) {
for item in &for_loop.items {
self.validate_expr(item);
if self.is_bare_scalar_var(item) {
self.issues.push(
ValidationIssue::error(
IssueCode::ForLoopScalarVar,
"bare variable in for loop iterates once (kaish has no implicit word splitting)",
)
.with_suggestion(concat!(
"wrap it in $(...) — for a collection use keys/values:\n",
" for x in $(values $coll) # list elements / record values\n",
" for k in $(keys $coll) # list indices / record keys\n",
" for i in $(split \"$VAR\") # split a string on whitespace\n",
" for i in $(split \"$VAR\" \":\") # split a string on a delimiter\n",
" for i in $(seq 1 10) # iterate numbers\n",
" for i in $(glob \"*.rs\") # iterate files",
)),
);
}
}
self.loop_depth += 1;
self.scope.push_frame();
self.scope.bind(&for_loop.variable);
for stmt in &for_loop.body {
self.validate_stmt(stmt);
}
self.scope.pop_frame();
self.loop_depth -= 1;
}
fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
match expr {
Expr::VarRef(_) => true,
Expr::VarWithDefault { .. } => true,
Expr::CommandSubst(_) => false,
Expr::Literal(_) => false,
Expr::Interpolated(_) => false,
_ => false,
}
}
fn validate_while(&mut self, while_loop: &WhileLoop) {
self.validate_expr(&while_loop.condition);
self.loop_depth += 1;
self.scope.push_frame();
for stmt in &while_loop.body {
self.validate_stmt(stmt);
}
self.scope.pop_frame();
self.loop_depth -= 1;
}
fn validate_case(&mut self, case_stmt: &CaseStmt) {
self.validate_expr(&case_stmt.expr);
for branch in &case_stmt.branches {
self.validate_case_branch(branch);
}
}
fn validate_case_branch(&mut self, branch: &CaseBranch) {
self.scope.push_frame();
for stmt in &branch.body {
self.validate_stmt(stmt);
}
self.scope.pop_frame();
}
fn validate_break(&mut self, levels: Option<usize>) {
if self.loop_depth == 0 {
self.issues.push(ValidationIssue::error(
IssueCode::BreakOutsideLoop,
"break used outside of a loop",
));
} else if let Some(n) = levels
&& n > self.loop_depth {
self.issues.push(ValidationIssue::warning(
IssueCode::BreakOutsideLoop,
format!(
"break {} exceeds loop nesting depth {}",
n, self.loop_depth
),
));
}
}
fn validate_continue(&mut self, levels: Option<usize>) {
if self.loop_depth == 0 {
self.issues.push(ValidationIssue::error(
IssueCode::BreakOutsideLoop,
"continue used outside of a loop",
));
} else if let Some(n) = levels
&& n > self.loop_depth {
self.issues.push(ValidationIssue::warning(
IssueCode::BreakOutsideLoop,
format!(
"continue {} exceeds loop nesting depth {}",
n, self.loop_depth
),
));
}
}
fn validate_return(&mut self, expr: Option<&Expr>) {
if let Some(e) = expr {
self.validate_expr(e);
}
if self.function_depth == 0 {
self.issues.push(ValidationIssue::error(
IssueCode::ReturnOutsideFunction,
"return used outside of a function",
));
}
}
fn validate_tool_def(&mut self, tool_def: &ToolDef) {
self.function_depth += 1;
self.scope.push_frame();
for param in &tool_def.params {
self.scope.bind(¶m.name);
if let Some(default) = ¶m.default {
self.validate_expr(default);
}
}
for stmt in &tool_def.body {
self.validate_stmt(stmt);
}
self.scope.pop_frame();
self.function_depth -= 1;
}
fn validate_test(&mut self, test: &TestExpr) {
match test {
TestExpr::FileTest { path, .. } => self.validate_expr(path),
TestExpr::StringTest { value, .. } => self.validate_expr(value),
TestExpr::Comparison { left, right, .. } => {
self.validate_expr(left);
self.validate_expr(right);
}
TestExpr::And { left, right } | TestExpr::Or { left, right } => {
self.validate_test(left);
self.validate_test(right);
}
TestExpr::Not { expr } => self.validate_test(expr),
TestExpr::In { left, right } | TestExpr::NotIn { left, right } => {
self.validate_expr(left);
self.validate_expr(right);
}
}
}
fn validate_expr(&mut self, expr: &Expr) {
match expr {
Expr::Literal(_) => {}
Expr::VarRef(path) => self.validate_var_ref(path),
Expr::Interpolated(parts) => {
for part in parts {
self.validate_string_part(part);
}
}
Expr::HereDocBody { parts, .. } => {
for sp in parts {
self.validate_spanned_string_part(sp);
}
}
Expr::BinaryOp { left, right, .. } => {
self.validate_expr(left);
self.validate_expr(right);
}
Expr::CommandSubst(stmts) => {
for stmt in stmts {
self.validate_stmt(stmt);
}
}
Expr::Test(test) => self.validate_test(test),
Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
Expr::VarLength(path) => {
if let Some(VarSegment::Field(root)) = path.segments.first() {
self.check_var_defined(root);
}
}
Expr::VarWithDefault { .. } => {
}
Expr::Arithmetic(_) => {
}
Expr::Command(cmd) => self.validate_command(cmd),
Expr::LastExitCode | Expr::CurrentPid => {}
Expr::GlobPattern(_) => {}
Expr::ListLiteral(elems) => {
for elem in elems {
match elem {
ListElem::Item(e) | ListElem::Spread(e) => self.validate_expr(e),
}
}
}
Expr::RecordLiteral(entries) => {
for entry in entries {
self.validate_expr(&entry.value);
}
}
}
}
fn validate_var_ref(&mut self, path: &VarPath) {
if let Some(VarSegment::Field(name)) = path.segments.first() {
if name == "?" && path.segments.len() > 1 {
self.issues.push(
ValidationIssue::error(
IssueCode::LastResultFieldAccess,
"${?.field} is removed; $? is the POSIX exit code",
)
.with_suggestion(
"use `kaish-last` to read the previous command's data or stdout",
),
);
return;
}
self.check_var_defined(name);
}
}
fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
let issues_before = self.issues.len();
self.validate_string_part(&sp.part);
let span = Span::new(sp.offset, sp.offset + sp.len);
for issue in &mut self.issues[issues_before..] {
if issue.span.is_none() {
issue.span = Some(span);
}
}
}
fn validate_string_part(&mut self, part: &StringPart) {
match part {
StringPart::Literal(_) => {}
StringPart::Var(path) => self.validate_var_ref(path),
StringPart::VarWithDefault { default, .. } => {
for p in default {
self.validate_string_part(p);
}
}
StringPart::VarLength(path) => {
if let Some(VarSegment::Field(root)) = path.segments.first() {
self.check_var_defined(root);
}
}
StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
StringPart::Arithmetic(_) => {} StringPart::CommandSubst(stmts) => {
for stmt in stmts {
self.validate_stmt(stmt);
}
}
StringPart::LastExitCode | StringPart::CurrentPid => {}
}
}
fn check_var_defined(&mut self, name: &str) {
if ScopeTracker::should_skip_undefined_check(name) {
return;
}
if !self.scope.is_bound(name) {
self.issues.push(ValidationIssue::warning(
IssueCode::PossiblyUndefinedVariable,
format!("variable '{}' may be undefined", name),
).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
}
}
fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
let positional_count = args
.iter()
.filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
.count();
let required_count = tool_def
.params
.iter()
.filter(|p| p.default.is_none())
.count();
if positional_count < required_count {
self.issues.push(ValidationIssue::error(
IssueCode::MissingRequiredArg,
format!(
"'{}' requires {} arguments, got {}",
tool_def.name, required_count, positional_count
),
));
}
}
}
pub(crate) fn is_static_command_name(name: &str) -> bool {
!name.starts_with('$') && !name.contains("$(") && !name.contains("${")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SpecialForm {
True,
False,
Source,
}
impl SpecialForm {
pub(crate) fn from_name(name: &str) -> Option<Self> {
match name {
"true" => Some(Self::True),
"false" => Some(Self::False),
"source" | "." => Some(Self::Source),
_ => None,
}
}
}
pub(crate) fn is_runtime_special_form(name: &str) -> bool {
SpecialForm::from_name(name).is_some()
}
pub(crate) fn classify_command_name(
name: &str,
is_builtin: bool,
is_user_tool: bool,
) -> CommandKind {
if !is_static_command_name(name) {
return CommandKind::Dynamic;
}
if is_runtime_special_form(name) {
return CommandKind::Special;
}
if is_user_tool {
return CommandKind::UserTool;
}
if is_builtin {
return CommandKind::Builtin;
}
CommandKind::External
}
fn is_special_command(name: &str) -> bool {
matches!(name, "true" | "false" | ":" | "readonly" | "local")
}
pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
let mut tool_args = ToolArgs::new();
let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
let mut consumed: HashSet<usize> = HashSet::new();
let mut past_double_dash = false;
for i in 0..args.len() {
match &args[i] {
Arg::DoubleDash => past_double_dash = true,
Arg::Positional(expr) => {
if !consumed.contains(&i) {
tool_args.positional.push(expr_to_placeholder(expr));
}
}
Arg::Named { key, value } => {
let v = expr_to_placeholder(value);
match param_lookup.get(key.as_str()) {
Some(&(canonical, _, _, true)) => {
let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
}
Some(&(canonical, ..)) => {
tool_args.named.insert(canonical.to_string(), v);
}
None => {
tool_args.named.insert(key.clone(), v);
}
}
}
Arg::WordAssign { key, value } => {
tool_args.named.insert(key.clone(), expr_to_placeholder(value));
}
Arg::ShortFlag(name) => {
if past_double_dash {
tool_args.positional.push(Value::String(format!("-{name}")));
} else {
bind_short_flag_for_validation(
name,
¶m_lookup,
args,
i,
&mut consumed,
&mut tool_args,
);
}
}
Arg::LongFlag(name) => {
if past_double_dash {
tool_args.positional.push(Value::String(format!("--{name}")));
} else {
match param_lookup.get(name.as_str()) {
Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
bind_value_or_flag(
&mut tool_args, name, canonical, consumes, repeatable, args, i,
&mut consumed,
);
}
Some(&(canonical, ..)) => {
tool_args.flags.insert(canonical.to_string());
}
None => {
tool_args.flags.insert(name.clone());
}
}
}
}
}
}
tool_args
}
fn bind_short_flag_for_validation(
name: &str,
param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
args: &[Arg],
i: usize,
consumed: &mut HashSet<usize>,
tool_args: &mut ToolArgs,
) {
if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
if is_bool_type(typ) {
tool_args.flags.insert(canonical.to_string());
} else {
bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
}
return;
}
if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
.get(&name[..1])
.filter(|(_, typ, ..)| !is_bool_type(typ))
{
let glued = name[1..].to_string();
if glued.is_empty() {
bind_value_or_flag(
tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
);
} else {
let _ =
bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
}
return;
}
let bytes = name.as_bytes();
let mut p = 0;
while p < bytes.len() {
let key = &name[p..p + 1];
match param_lookup.get(key) {
Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
let glued = name[p + 1..].to_string();
if glued.is_empty() {
bind_value_or_flag(
tool_args, key, canonical, consumes, repeatable, args, i, consumed,
);
} else {
let _ = bind_glued_short_value(
tool_args, key, canonical, consumes, repeatable, glued,
);
}
return;
}
_ => {
tool_args.flags.insert(key.to_string());
p += 1;
}
}
}
}
#[allow(clippy::too_many_arguments)] fn bind_value_or_flag(
tool_args: &mut ToolArgs,
flag_name: &str,
canonical: &str,
consumes: usize,
repeatable: bool,
args: &[Arg],
i: usize,
consumed: &mut HashSet<usize>,
) {
let want = consumes.max(1);
let allow_word_assign = consumes <= 1;
let mut collected: Vec<Value> = Vec::with_capacity(want);
for _ in 0..want {
let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
let idx = i + 1 + off;
if consumed.contains(&idx) {
return None;
}
match a {
Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
Arg::WordAssign { key, value } if allow_word_assign => {
let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
Some((idx, Value::String(format!("{key}={s}"))))
}
_ => None,
}
});
match found {
Some((idx, v)) => {
consumed.insert(idx);
collected.push(v);
}
None => break,
}
}
if collected.is_empty() {
tool_args.flags.insert(canonical.to_string());
return;
}
if consumes <= 1 {
if let Some(v) = collected.into_iter().next() {
if repeatable {
let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
} else {
tool_args.named.insert(canonical.to_string(), v);
}
}
return;
}
let occ: Vec<serde_json::Value> = collected
.iter()
.map(crate::interpreter::value_to_json)
.collect();
let entry = tool_args
.named
.entry(canonical.to_string())
.or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
if let Value::Json(serde_json::Value::Array(outer)) = entry {
outer.push(serde_json::Value::Array(occ));
}
}
fn expr_to_placeholder(expr: &Expr) -> Value {
match expr {
Expr::Literal(val) => val.clone(),
Expr::Interpolated(parts) if parts.len() == 1 => {
if let StringPart::Literal(s) = &parts[0] {
Value::String(s.clone())
} else {
Value::String("<dynamic>".to_string())
}
}
_ => Value::String("<dynamic>".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::{register_builtins, ToolRegistry};
fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
let mut registry = ToolRegistry::new();
register_builtins(&mut registry);
let user_tools = HashMap::new();
(registry, user_tools)
}
#[test]
fn validates_undefined_command() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Command(Command {
name: "nonexistent_command".to_string(),
args: vec![],
redirects: vec![],
})],
};
let issues = validator.validate(&program);
assert!(!issues.is_empty());
assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
}
#[test]
fn test_command_is_a_known_builtin() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Command(Command {
name: "test".to_string(),
args: vec![
Arg::Positional(Expr::Literal(Value::String("-n".to_string()))),
Arg::Positional(Expr::Literal(Value::String("hi".to_string()))),
],
redirects: vec![],
})],
};
let issues = validator.validate(&program);
assert!(
!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand),
"`test` is a builtin — no undefined-command warning: {issues:?}"
);
}
#[test]
fn validates_known_command() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Command(Command {
name: "echo".to_string(),
args: vec![Arg::Positional(Expr::Literal(Value::String(
"hello".to_string(),
)))],
redirects: vec![],
})],
};
let issues = validator.validate(&program);
assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
}
#[test]
fn glued_value_flags_dont_false_error_at_validation() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Command(Command {
name: "sed".to_string(),
args: vec![
Arg::ShortFlag("e1d".to_string()),
Arg::ShortFlag("e2d".to_string()),
Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
],
redirects: vec![],
})],
};
let issues = validator.validate(&program);
assert!(
!issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
"glued -e flags false-errored at validation: {:?}",
issues.iter().map(|i| &i.message).collect::<Vec<_>>()
);
}
#[test]
fn validates_break_outside_loop() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Break(None)],
};
let issues = validator.validate(&program);
assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
}
#[test]
fn validates_break_inside_loop() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::For(ForLoop {
variable: "i".to_string(),
items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
body: vec![Stmt::Break(None)],
})],
};
let issues = validator.validate(&program);
assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
}
#[test]
fn validates_undefined_variable() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Command(Command {
name: "echo".to_string(),
args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
"UNDEFINED_VAR",
)))],
redirects: vec![],
})],
};
let issues = validator.validate(&program);
assert!(issues
.iter()
.any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
}
#[test]
fn validates_defined_variable() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![
Stmt::Assignment(Assignment {
path: VarPath::simple("MY_VAR"),
value: Expr::Literal(Value::String("value".to_string())),
local: false,
}),
Stmt::Command(Command {
name: "echo".to_string(),
args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
redirects: vec![],
}),
],
};
let issues = validator.validate(&program);
assert!(!issues
.iter()
.any(|i| i.code == IssueCode::PossiblyUndefinedVariable
&& i.message.contains("MY_VAR")));
}
#[test]
fn skips_underscore_prefixed_vars() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Command(Command {
name: "echo".to_string(),
args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
redirects: vec![],
})],
};
let issues = validator.validate(&program);
assert!(!issues
.iter()
.any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
}
#[test]
fn builtin_vars_are_defined() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Command(Command {
name: "echo".to_string(),
args: vec![
Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
],
redirects: vec![],
})],
};
let issues = validator.validate(&program);
assert!(!issues
.iter()
.any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
}
#[test]
fn validates_scatter_without_gather() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Pipeline(Pipeline {
commands: vec![
Command { name: "seq".to_string(), args: vec![
Arg::Positional(Expr::Literal(Value::String("1".into()))),
Arg::Positional(Expr::Literal(Value::String("3".into()))),
], redirects: vec![] },
Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
Command { name: "echo".to_string(), args: vec![
Arg::Positional(Expr::Literal(Value::String("hi".into()))),
], redirects: vec![] },
],
background: false,
})],
};
let issues = validator.validate(&program);
assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
"should flag scatter without gather: {:?}", issues);
}
#[test]
fn allows_scatter_with_gather() {
let (registry, user_tools) = make_validator();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Pipeline(Pipeline {
commands: vec![
Command { name: "seq".to_string(), args: vec![
Arg::Positional(Expr::Literal(Value::String("1".into()))),
Arg::Positional(Expr::Literal(Value::String("3".into()))),
], redirects: vec![] },
Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
Command { name: "echo".to_string(), args: vec![
Arg::Positional(Expr::Literal(Value::String("hi".into()))),
], redirects: vec![] },
Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
],
background: false,
})],
};
let issues = validator.validate(&program);
assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
"scatter with gather should pass: {:?}", issues);
}
fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
let mut user_tools = HashMap::new();
user_tools.insert(
"mytool".to_string(),
ToolDef {
name: "mytool".to_string(),
params: vec![crate::ast::ParamDef {
name: "input".to_string(),
param_type: None,
default: None,
}],
body: vec![],
},
);
user_tools
}
#[test]
fn user_tool_wordassign_counts_as_positional() {
let mut registry = ToolRegistry::new();
register_builtins(&mut registry);
let user_tools = make_user_tool_with_required_positional();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Command(Command {
name: "mytool".to_string(),
args: vec![Arg::WordAssign {
key: "foo".to_string(),
value: Expr::Literal(Value::String("bar".to_string())),
}],
redirects: vec![],
})],
};
let issues = validator.validate(&program);
assert!(
!issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
"WordAssign should satisfy required positional; got {:?}",
issues
);
}
#[test]
fn user_tool_no_args_still_errors() {
let mut registry = ToolRegistry::new();
register_builtins(&mut registry);
let user_tools = make_user_tool_with_required_positional();
let validator = Validator::new(®istry, &user_tools);
let program = Program {
statements: vec![Stmt::Command(Command {
name: "mytool".to_string(),
args: vec![],
redirects: vec![],
})],
};
let issues = validator.validate(&program);
assert!(
issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
"missing positional should still error; got {:?}",
issues
);
}
}