use std::collections::HashMap;
use crate::ast::{
Arg, Assignment, CaseBranch, CaseStmt, Command, Expr, ForLoop, IfStmt, Pipeline, Program,
SpannedPart, Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop, Value,
};
use crate::validator::issue::Span;
use crate::tools::{ToolArgs, ToolRegistry};
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::Empty => {}
}
}
fn validate_assignment(&mut self, assign: &Assignment) {
self.validate_expr(&assign.value);
self.scope.bind(&assign.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 tool_args = build_tool_args_for_validation(&cmd.args);
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!(
"use one of:\n",
" for i in $(split \"$VAR\") # split on whitespace\n",
" for i in $(split \"$VAR\" \":\") # split on 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),
}
}
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(pipeline) => self.validate_pipeline(pipeline),
Expr::Test(test) => self.validate_test(test),
Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
Expr::VarLength(name) => self.check_var_defined(name),
Expr::VarWithDefault { name, .. } => {
let _ = name;
}
Expr::Arithmetic(_) => {
}
Expr::Command(cmd) => self.validate_command(cmd),
Expr::LastExitCode | Expr::CurrentPid => {}
Expr::GlobPattern(_) => {}
}
}
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(name) => self.check_var_defined(name),
StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
StringPart::Arithmetic(_) => {} StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
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
),
));
}
}
}
fn is_static_command_name(name: &str) -> bool {
!name.starts_with('$') && !name.contains("$(")
}
fn is_special_command(name: &str) -> bool {
matches!(
name,
"true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
)
}
pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
let mut tool_args = ToolArgs::new();
for arg in args {
match arg {
Arg::Positional(expr) => {
tool_args.positional.push(expr_to_placeholder(expr));
}
Arg::Named { key, value } => {
tool_args.named.insert(key.clone(), expr_to_placeholder(value));
}
Arg::WordAssign { key, value } => {
tool_args.named.insert(key.clone(), expr_to_placeholder(value));
}
Arg::ShortFlag(flag) => {
tool_args.flags.insert(flag.clone());
}
Arg::LongFlag(flag) => {
tool_args.flags.insert(flag.clone());
}
Arg::DoubleDash => {}
}
}
tool_args
}
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 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 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 {
name: "MY_VAR".to_string(),
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
);
}
}