1use std::collections::HashMap;
4
5use crate::ast::{
6 Arg, Assignment, CaseBranch, CaseStmt, Command, Expr, ForLoop, IfStmt, Pipeline, Program,
7 Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop, Value,
8};
9use crate::tools::{ToolArgs, ToolRegistry};
10
11use super::issue::{IssueCode, ValidationIssue};
12use super::scope_tracker::ScopeTracker;
13
14pub struct Validator<'a> {
16 registry: &'a ToolRegistry,
18 user_tools: &'a HashMap<String, ToolDef>,
20 scope: ScopeTracker,
22 loop_depth: usize,
24 function_depth: usize,
26 issues: Vec<ValidationIssue>,
28}
29
30impl<'a> Validator<'a> {
31 pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
33 Self {
34 registry,
35 user_tools,
36 scope: ScopeTracker::new(),
37 loop_depth: 0,
38 function_depth: 0,
39 issues: Vec::new(),
40 }
41 }
42
43 pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
45 for stmt in &program.statements {
46 self.validate_stmt(stmt);
47 }
48 self.issues
49 }
50
51 fn validate_stmt(&mut self, stmt: &Stmt) {
53 match stmt {
54 Stmt::Assignment(assign) => self.validate_assignment(assign),
55 Stmt::Command(cmd) => self.validate_command(cmd),
56 Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
57 Stmt::If(if_stmt) => self.validate_if(if_stmt),
58 Stmt::For(for_loop) => self.validate_for(for_loop),
59 Stmt::While(while_loop) => self.validate_while(while_loop),
60 Stmt::Case(case_stmt) => self.validate_case(case_stmt),
61 Stmt::Break(levels) => self.validate_break(*levels),
62 Stmt::Continue(levels) => self.validate_continue(*levels),
63 Stmt::Return(expr) => self.validate_return(expr.as_deref()),
64 Stmt::Exit(expr) => {
65 if let Some(e) = expr {
66 self.validate_expr(e);
67 }
68 }
69 Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
70 Stmt::Test(test_expr) => self.validate_test(test_expr),
71 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
72 self.validate_stmt(left);
73 self.validate_stmt(right);
74 }
75 Stmt::Empty => {}
76 }
77 }
78
79 fn validate_assignment(&mut self, assign: &Assignment) {
81 self.validate_expr(&assign.value);
83 self.scope.bind(&assign.name);
85 }
86
87 fn validate_command(&mut self, cmd: &Command) {
89 if cmd.name == "source" || cmd.name == "." {
91 return;
92 }
93
94 if !is_static_command_name(&cmd.name) {
96 return;
97 }
98
99 let is_builtin = self.registry.contains(&cmd.name);
101 let is_user_tool = self.user_tools.contains_key(&cmd.name);
102 let is_special = is_special_command(&cmd.name);
103
104 if !is_builtin && !is_user_tool && !is_special {
105 self.issues.push(ValidationIssue::warning(
107 IssueCode::UndefinedCommand,
108 format!("command '{}' not found in builtin registry", cmd.name),
109 ).with_suggestion("this may be a script in PATH or external command"));
110 }
111
112 for arg in &cmd.args {
114 self.validate_arg(arg);
115 }
116
117 if let Some(tool) = self.registry.get(&cmd.name) {
119 let tool_args = build_tool_args_for_validation(&cmd.args);
120 let tool_issues = tool.validate(&tool_args);
121 self.issues.extend(tool_issues);
122 } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
123 self.validate_user_tool_args(user_tool, &cmd.args);
125 }
126
127 for redirect in &cmd.redirects {
129 self.validate_expr(&redirect.target);
130 }
131 }
132
133 fn validate_arg(&mut self, arg: &Arg) {
135 match arg {
136 Arg::Positional(expr) => self.validate_expr(expr),
137 Arg::Named { value, .. } => self.validate_expr(value),
138 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
139 }
140 }
141
142 fn validate_pipeline(&mut self, pipe: &Pipeline) {
144 let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
146 let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
147 if has_scatter && !has_gather {
148 self.issues.push(
149 ValidationIssue::error(
150 IssueCode::ScatterWithoutGather,
151 "scatter without gather — parallel results would be lost",
152 ).with_suggestion("add gather: ... | scatter | cmd | gather")
153 );
154 }
155
156 for cmd in &pipe.commands {
157 self.validate_command(cmd);
158 }
159 }
160
161 fn validate_if(&mut self, if_stmt: &IfStmt) {
163 self.validate_expr(&if_stmt.condition);
164
165 self.scope.push_frame();
166 for stmt in &if_stmt.then_branch {
167 self.validate_stmt(stmt);
168 }
169 self.scope.pop_frame();
170
171 if let Some(else_branch) = &if_stmt.else_branch {
172 self.scope.push_frame();
173 for stmt in else_branch {
174 self.validate_stmt(stmt);
175 }
176 self.scope.pop_frame();
177 }
178 }
179
180 fn validate_for(&mut self, for_loop: &ForLoop) {
182 for item in &for_loop.items {
184 self.validate_expr(item);
185
186 if self.is_bare_scalar_var(item) {
189 self.issues.push(
190 ValidationIssue::error(
191 IssueCode::ForLoopScalarVar,
192 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
193 )
194 .with_suggestion(concat!(
195 "use one of:\n",
196 " for i in $(split \"$VAR\") # split on whitespace\n",
197 " for i in $(split \"$VAR\" \":\") # split on delimiter\n",
198 " for i in $(seq 1 10) # iterate numbers\n",
199 " for i in $(glob \"*.rs\") # iterate files",
200 )),
201 );
202 }
203 }
204
205 self.loop_depth += 1;
206 self.scope.push_frame();
207
208 self.scope.bind(&for_loop.variable);
210
211 for stmt in &for_loop.body {
212 self.validate_stmt(stmt);
213 }
214
215 self.scope.pop_frame();
216 self.loop_depth -= 1;
217 }
218
219 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
225 match expr {
226 Expr::VarRef(_) => true,
228 Expr::VarWithDefault { .. } => true,
230 Expr::CommandSubst(_) => false,
232 Expr::Literal(_) => false,
234 Expr::Interpolated(_) => false,
236 _ => false,
238 }
239 }
240
241 fn validate_while(&mut self, while_loop: &WhileLoop) {
243 self.validate_expr(&while_loop.condition);
244
245 self.loop_depth += 1;
246 self.scope.push_frame();
247
248 for stmt in &while_loop.body {
249 self.validate_stmt(stmt);
250 }
251
252 self.scope.pop_frame();
253 self.loop_depth -= 1;
254 }
255
256 fn validate_case(&mut self, case_stmt: &CaseStmt) {
258 self.validate_expr(&case_stmt.expr);
259
260 for branch in &case_stmt.branches {
261 self.validate_case_branch(branch);
262 }
263 }
264
265 fn validate_case_branch(&mut self, branch: &CaseBranch) {
267 self.scope.push_frame();
268 for stmt in &branch.body {
269 self.validate_stmt(stmt);
270 }
271 self.scope.pop_frame();
272 }
273
274 fn validate_break(&mut self, levels: Option<usize>) {
276 if self.loop_depth == 0 {
277 self.issues.push(ValidationIssue::error(
278 IssueCode::BreakOutsideLoop,
279 "break used outside of a loop",
280 ));
281 } else if let Some(n) = levels
282 && n > self.loop_depth {
283 self.issues.push(ValidationIssue::warning(
284 IssueCode::BreakOutsideLoop,
285 format!(
286 "break {} exceeds loop nesting depth {}",
287 n, self.loop_depth
288 ),
289 ));
290 }
291 }
292
293 fn validate_continue(&mut self, levels: Option<usize>) {
295 if self.loop_depth == 0 {
296 self.issues.push(ValidationIssue::error(
297 IssueCode::BreakOutsideLoop,
298 "continue used outside of a loop",
299 ));
300 } else if let Some(n) = levels
301 && n > self.loop_depth {
302 self.issues.push(ValidationIssue::warning(
303 IssueCode::BreakOutsideLoop,
304 format!(
305 "continue {} exceeds loop nesting depth {}",
306 n, self.loop_depth
307 ),
308 ));
309 }
310 }
311
312 fn validate_return(&mut self, expr: Option<&Expr>) {
314 if let Some(e) = expr {
315 self.validate_expr(e);
316 }
317
318 if self.function_depth == 0 {
319 self.issues.push(ValidationIssue::error(
320 IssueCode::ReturnOutsideFunction,
321 "return used outside of a function",
322 ));
323 }
324 }
325
326 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
328 self.function_depth += 1;
329 self.scope.push_frame();
330
331 for param in &tool_def.params {
333 self.scope.bind(¶m.name);
334 if let Some(default) = ¶m.default {
336 self.validate_expr(default);
337 }
338 }
339
340 for stmt in &tool_def.body {
342 self.validate_stmt(stmt);
343 }
344
345 self.scope.pop_frame();
346 self.function_depth -= 1;
347 }
348
349 fn validate_test(&mut self, test: &TestExpr) {
351 match test {
352 TestExpr::FileTest { path, .. } => self.validate_expr(path),
353 TestExpr::StringTest { value, .. } => self.validate_expr(value),
354 TestExpr::Comparison { left, right, .. } => {
355 self.validate_expr(left);
356 self.validate_expr(right);
357 }
358 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
359 self.validate_test(left);
360 self.validate_test(right);
361 }
362 TestExpr::Not { expr } => self.validate_test(expr),
363 }
364 }
365
366 fn validate_expr(&mut self, expr: &Expr) {
368 match expr {
369 Expr::Literal(_) => {}
370 Expr::VarRef(path) => self.validate_var_ref(path),
371 Expr::Interpolated(parts) => {
372 for part in parts {
373 self.validate_string_part(part);
374 }
375 }
376 Expr::BinaryOp { left, right, .. } => {
377 self.validate_expr(left);
378 self.validate_expr(right);
379 }
380 Expr::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
381 Expr::Test(test) => self.validate_test(test),
382 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
383 Expr::VarLength(name) => self.check_var_defined(name),
384 Expr::VarWithDefault { name, .. } => {
385 let _ = name;
387 }
388 Expr::Arithmetic(_) => {
389 }
391 Expr::Command(cmd) => self.validate_command(cmd),
392 Expr::LastExitCode | Expr::CurrentPid => {}
393 Expr::GlobPattern(_) => {}
394 }
395 }
396
397 fn validate_var_ref(&mut self, path: &VarPath) {
399 if let Some(VarSegment::Field(name)) = path.segments.first() {
400 self.check_var_defined(name);
401 }
402 }
403
404 fn validate_string_part(&mut self, part: &StringPart) {
406 match part {
407 StringPart::Literal(_) => {}
408 StringPart::Var(path) => self.validate_var_ref(path),
409 StringPart::VarWithDefault { default, .. } => {
410 for p in default {
412 self.validate_string_part(p);
413 }
414 }
415 StringPart::VarLength(name) => self.check_var_defined(name),
416 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
417 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
419 StringPart::LastExitCode | StringPart::CurrentPid => {}
420 }
421 }
422
423 fn check_var_defined(&mut self, name: &str) {
425 if ScopeTracker::should_skip_undefined_check(name) {
427 return;
428 }
429
430 if !self.scope.is_bound(name) {
431 self.issues.push(ValidationIssue::warning(
432 IssueCode::PossiblyUndefinedVariable,
433 format!("variable '{}' may be undefined", name),
434 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
435 }
436 }
437
438 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
440 let positional_count = args
441 .iter()
442 .filter(|a| matches!(a, Arg::Positional(_)))
443 .count();
444
445 let required_count = tool_def
446 .params
447 .iter()
448 .filter(|p| p.default.is_none())
449 .count();
450
451 if positional_count < required_count {
452 self.issues.push(ValidationIssue::error(
453 IssueCode::MissingRequiredArg,
454 format!(
455 "'{}' requires {} arguments, got {}",
456 tool_def.name, required_count, positional_count
457 ),
458 ));
459 }
460 }
461}
462
463fn is_static_command_name(name: &str) -> bool {
465 !name.starts_with('$') && !name.contains("$(")
466}
467
468fn is_special_command(name: &str) -> bool {
470 matches!(
471 name,
472 "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
473 )
474}
475
476pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
481 let mut tool_args = ToolArgs::new();
482
483 for arg in args {
484 match arg {
485 Arg::Positional(expr) => {
486 tool_args.positional.push(expr_to_placeholder(expr));
487 }
488 Arg::Named { key, value } => {
489 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
490 }
491 Arg::ShortFlag(flag) => {
492 tool_args.flags.insert(flag.clone());
493 }
494 Arg::LongFlag(flag) => {
495 tool_args.flags.insert(flag.clone());
496 }
497 Arg::DoubleDash => {}
498 }
499 }
500
501 tool_args
502}
503
504fn expr_to_placeholder(expr: &Expr) -> Value {
509 match expr {
510 Expr::Literal(val) => val.clone(),
511 Expr::Interpolated(parts) if parts.len() == 1 => {
512 if let StringPart::Literal(s) = &parts[0] {
513 Value::String(s.clone())
514 } else {
515 Value::String("<dynamic>".to_string())
516 }
517 }
518 _ => Value::String("<dynamic>".to_string()),
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use crate::tools::{register_builtins, ToolRegistry};
527
528 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
529 let mut registry = ToolRegistry::new();
530 register_builtins(&mut registry);
531 let user_tools = HashMap::new();
532 (registry, user_tools)
533 }
534
535 #[test]
536 fn validates_undefined_command() {
537 let (registry, user_tools) = make_validator();
538 let validator = Validator::new(®istry, &user_tools);
539
540 let program = Program {
541 statements: vec![Stmt::Command(Command {
542 name: "nonexistent_command".to_string(),
543 args: vec![],
544 redirects: vec![],
545 })],
546 };
547
548 let issues = validator.validate(&program);
549 assert!(!issues.is_empty());
550 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
551 }
552
553 #[test]
554 fn validates_known_command() {
555 let (registry, user_tools) = make_validator();
556 let validator = Validator::new(®istry, &user_tools);
557
558 let program = Program {
559 statements: vec![Stmt::Command(Command {
560 name: "echo".to_string(),
561 args: vec![Arg::Positional(Expr::Literal(Value::String(
562 "hello".to_string(),
563 )))],
564 redirects: vec![],
565 })],
566 };
567
568 let issues = validator.validate(&program);
569 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
571 }
572
573 #[test]
574 fn validates_break_outside_loop() {
575 let (registry, user_tools) = make_validator();
576 let validator = Validator::new(®istry, &user_tools);
577
578 let program = Program {
579 statements: vec![Stmt::Break(None)],
580 };
581
582 let issues = validator.validate(&program);
583 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
584 }
585
586 #[test]
587 fn validates_break_inside_loop() {
588 let (registry, user_tools) = make_validator();
589 let validator = Validator::new(®istry, &user_tools);
590
591 let program = Program {
592 statements: vec![Stmt::For(ForLoop {
593 variable: "i".to_string(),
594 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
595 body: vec![Stmt::Break(None)],
596 })],
597 };
598
599 let issues = validator.validate(&program);
600 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
602 }
603
604 #[test]
605 fn validates_undefined_variable() {
606 let (registry, user_tools) = make_validator();
607 let validator = Validator::new(®istry, &user_tools);
608
609 let program = Program {
610 statements: vec![Stmt::Command(Command {
611 name: "echo".to_string(),
612 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
613 "UNDEFINED_VAR",
614 )))],
615 redirects: vec![],
616 })],
617 };
618
619 let issues = validator.validate(&program);
620 assert!(issues
621 .iter()
622 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
623 }
624
625 #[test]
626 fn validates_defined_variable() {
627 let (registry, user_tools) = make_validator();
628 let validator = Validator::new(®istry, &user_tools);
629
630 let program = Program {
631 statements: vec![
632 Stmt::Assignment(Assignment {
634 name: "MY_VAR".to_string(),
635 value: Expr::Literal(Value::String("value".to_string())),
636 local: false,
637 }),
638 Stmt::Command(Command {
640 name: "echo".to_string(),
641 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
642 redirects: vec![],
643 }),
644 ],
645 };
646
647 let issues = validator.validate(&program);
648 assert!(!issues
650 .iter()
651 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
652 && i.message.contains("MY_VAR")));
653 }
654
655 #[test]
656 fn skips_underscore_prefixed_vars() {
657 let (registry, user_tools) = make_validator();
658 let validator = Validator::new(®istry, &user_tools);
659
660 let program = Program {
661 statements: vec![Stmt::Command(Command {
662 name: "echo".to_string(),
663 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
664 redirects: vec![],
665 })],
666 };
667
668 let issues = validator.validate(&program);
669 assert!(!issues
671 .iter()
672 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
673 }
674
675 #[test]
676 fn builtin_vars_are_defined() {
677 let (registry, user_tools) = make_validator();
678 let validator = Validator::new(®istry, &user_tools);
679
680 let program = Program {
681 statements: vec![Stmt::Command(Command {
682 name: "echo".to_string(),
683 args: vec![
684 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
685 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
686 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
687 ],
688 redirects: vec![],
689 })],
690 };
691
692 let issues = validator.validate(&program);
693 assert!(!issues
695 .iter()
696 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
697 }
698
699 #[test]
700 fn validates_scatter_without_gather() {
701 let (registry, user_tools) = make_validator();
702 let validator = Validator::new(®istry, &user_tools);
703
704 let program = Program {
705 statements: vec![Stmt::Pipeline(Pipeline {
706 commands: vec![
707 Command { name: "seq".to_string(), args: vec![
708 Arg::Positional(Expr::Literal(Value::String("1".into()))),
709 Arg::Positional(Expr::Literal(Value::String("3".into()))),
710 ], redirects: vec![] },
711 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
712 Command { name: "echo".to_string(), args: vec![
713 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
714 ], redirects: vec![] },
715 ],
716 background: false,
717 })],
718 };
719
720 let issues = validator.validate(&program);
721 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
722 "should flag scatter without gather: {:?}", issues);
723 }
724
725 #[test]
726 fn allows_scatter_with_gather() {
727 let (registry, user_tools) = make_validator();
728 let validator = Validator::new(®istry, &user_tools);
729
730 let program = Program {
731 statements: vec![Stmt::Pipeline(Pipeline {
732 commands: vec![
733 Command { name: "seq".to_string(), args: vec![
734 Arg::Positional(Expr::Literal(Value::String("1".into()))),
735 Arg::Positional(Expr::Literal(Value::String("3".into()))),
736 ], redirects: vec![] },
737 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
738 Command { name: "echo".to_string(), args: vec![
739 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
740 ], redirects: vec![] },
741 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
742 ],
743 background: false,
744 })],
745 };
746
747 let issues = validator.validate(&program);
748 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
749 "scatter with gather should pass: {:?}", issues);
750 }
751}