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