1use std::collections::{HashMap, HashSet};
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::kernel::{bind_glued_short_value, push_repeatable_value};
10use crate::scheduler::{is_bool_type, schema_param_lookup};
11use crate::validator::issue::Span;
12use crate::tools::{ToolArgs, ToolRegistry, ToolSchema};
13
14use super::issue::{IssueCode, ValidationIssue};
15use super::scope_tracker::ScopeTracker;
16
17pub struct Validator<'a> {
19 registry: &'a ToolRegistry,
21 user_tools: &'a HashMap<String, ToolDef>,
23 scope: ScopeTracker,
25 loop_depth: usize,
27 function_depth: usize,
29 issues: Vec<ValidationIssue>,
31}
32
33impl<'a> Validator<'a> {
34 pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
36 Self {
37 registry,
38 user_tools,
39 scope: ScopeTracker::new(),
40 loop_depth: 0,
41 function_depth: 0,
42 issues: Vec::new(),
43 }
44 }
45
46 pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
48 for stmt in &program.statements {
49 self.validate_stmt(stmt);
50 }
51 self.issues
52 }
53
54 fn validate_stmt(&mut self, stmt: &Stmt) {
56 match stmt {
57 Stmt::Assignment(assign) => self.validate_assignment(assign),
58 Stmt::Command(cmd) => self.validate_command(cmd),
59 Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
60 Stmt::If(if_stmt) => self.validate_if(if_stmt),
61 Stmt::For(for_loop) => self.validate_for(for_loop),
62 Stmt::While(while_loop) => self.validate_while(while_loop),
63 Stmt::Case(case_stmt) => self.validate_case(case_stmt),
64 Stmt::Break(levels) => self.validate_break(*levels),
65 Stmt::Continue(levels) => self.validate_continue(*levels),
66 Stmt::Return(expr) => self.validate_return(expr.as_deref()),
67 Stmt::Exit(expr) => {
68 if let Some(e) = expr {
69 self.validate_expr(e);
70 }
71 }
72 Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
73 Stmt::Test(test_expr) => self.validate_test(test_expr),
74 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
75 self.validate_stmt(left);
76 self.validate_stmt(right);
77 }
78 Stmt::EnvScoped { assignments, body } => {
79 for assign in assignments {
82 self.validate_assignment(assign);
83 }
84 self.validate_stmt(body);
85 }
86 Stmt::Empty => {}
87 }
88 }
89
90 fn validate_assignment(&mut self, assign: &Assignment) {
92 self.validate_expr(&assign.value);
94 self.scope.bind(&assign.name);
96 }
97
98 fn validate_command(&mut self, cmd: &Command) {
100 if cmd.name == "source" || cmd.name == "." {
102 return;
103 }
104
105 if !is_static_command_name(&cmd.name) {
107 return;
108 }
109
110 let is_builtin = self.registry.contains(&cmd.name);
112 let is_user_tool = self.user_tools.contains_key(&cmd.name);
113 let is_special = is_special_command(&cmd.name);
114
115 if !is_builtin && !is_user_tool && !is_special {
116 self.issues.push(ValidationIssue::warning(
118 IssueCode::UndefinedCommand,
119 format!("command '{}' not found in builtin registry", cmd.name),
120 ).with_suggestion("this may be a script in PATH or external command"));
121 }
122
123 for arg in &cmd.args {
125 self.validate_arg(arg);
126 }
127
128 if let Some(tool) = self.registry.get(&cmd.name) {
133 let schema = tool.schema();
134 let tool_args = build_tool_args_for_validation(&cmd.args, Some(&schema));
135 let tool_issues = tool.validate(&tool_args);
136 self.issues.extend(tool_issues);
137 } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
138 self.validate_user_tool_args(user_tool, &cmd.args);
140 }
141
142 for redirect in &cmd.redirects {
144 self.validate_expr(&redirect.target);
145 }
146 }
147
148 fn validate_arg(&mut self, arg: &Arg) {
150 match arg {
151 Arg::Positional(expr) => self.validate_expr(expr),
152 Arg::Named { value, .. } => self.validate_expr(value),
153 Arg::WordAssign { value, .. } => self.validate_expr(value),
154 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
155 }
156 }
157
158 fn validate_pipeline(&mut self, pipe: &Pipeline) {
160 let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
162 let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
163 if has_scatter && !has_gather {
164 self.issues.push(
165 ValidationIssue::error(
166 IssueCode::ScatterWithoutGather,
167 "scatter without gather — parallel results would be lost",
168 ).with_suggestion("add gather: ... | scatter | cmd | gather")
169 );
170 }
171
172 for cmd in &pipe.commands {
173 self.validate_command(cmd);
174 }
175 }
176
177 fn validate_if(&mut self, if_stmt: &IfStmt) {
179 self.validate_expr(&if_stmt.condition);
180
181 self.scope.push_frame();
182 for stmt in &if_stmt.then_branch {
183 self.validate_stmt(stmt);
184 }
185 self.scope.pop_frame();
186
187 if let Some(else_branch) = &if_stmt.else_branch {
188 self.scope.push_frame();
189 for stmt in else_branch {
190 self.validate_stmt(stmt);
191 }
192 self.scope.pop_frame();
193 }
194 }
195
196 fn validate_for(&mut self, for_loop: &ForLoop) {
198 for item in &for_loop.items {
200 self.validate_expr(item);
201
202 if self.is_bare_scalar_var(item) {
205 self.issues.push(
206 ValidationIssue::error(
207 IssueCode::ForLoopScalarVar,
208 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
209 )
210 .with_suggestion(concat!(
211 "use one of:\n",
212 " for i in $(split \"$VAR\") # split on whitespace\n",
213 " for i in $(split \"$VAR\" \":\") # split on delimiter\n",
214 " for i in $(seq 1 10) # iterate numbers\n",
215 " for i in $(glob \"*.rs\") # iterate files",
216 )),
217 );
218 }
219 }
220
221 self.loop_depth += 1;
222 self.scope.push_frame();
223
224 self.scope.bind(&for_loop.variable);
226
227 for stmt in &for_loop.body {
228 self.validate_stmt(stmt);
229 }
230
231 self.scope.pop_frame();
232 self.loop_depth -= 1;
233 }
234
235 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
241 match expr {
242 Expr::VarRef(_) => true,
244 Expr::VarWithDefault { .. } => true,
246 Expr::CommandSubst(_) => false,
248 Expr::Literal(_) => false,
250 Expr::Interpolated(_) => false,
252 _ => false,
254 }
255 }
256
257 fn validate_while(&mut self, while_loop: &WhileLoop) {
259 self.validate_expr(&while_loop.condition);
260
261 self.loop_depth += 1;
262 self.scope.push_frame();
263
264 for stmt in &while_loop.body {
265 self.validate_stmt(stmt);
266 }
267
268 self.scope.pop_frame();
269 self.loop_depth -= 1;
270 }
271
272 fn validate_case(&mut self, case_stmt: &CaseStmt) {
274 self.validate_expr(&case_stmt.expr);
275
276 for branch in &case_stmt.branches {
277 self.validate_case_branch(branch);
278 }
279 }
280
281 fn validate_case_branch(&mut self, branch: &CaseBranch) {
283 self.scope.push_frame();
284 for stmt in &branch.body {
285 self.validate_stmt(stmt);
286 }
287 self.scope.pop_frame();
288 }
289
290 fn validate_break(&mut self, levels: Option<usize>) {
292 if self.loop_depth == 0 {
293 self.issues.push(ValidationIssue::error(
294 IssueCode::BreakOutsideLoop,
295 "break used outside of a loop",
296 ));
297 } else if let Some(n) = levels
298 && n > self.loop_depth {
299 self.issues.push(ValidationIssue::warning(
300 IssueCode::BreakOutsideLoop,
301 format!(
302 "break {} exceeds loop nesting depth {}",
303 n, self.loop_depth
304 ),
305 ));
306 }
307 }
308
309 fn validate_continue(&mut self, levels: Option<usize>) {
311 if self.loop_depth == 0 {
312 self.issues.push(ValidationIssue::error(
313 IssueCode::BreakOutsideLoop,
314 "continue used outside of a loop",
315 ));
316 } else if let Some(n) = levels
317 && n > self.loop_depth {
318 self.issues.push(ValidationIssue::warning(
319 IssueCode::BreakOutsideLoop,
320 format!(
321 "continue {} exceeds loop nesting depth {}",
322 n, self.loop_depth
323 ),
324 ));
325 }
326 }
327
328 fn validate_return(&mut self, expr: Option<&Expr>) {
330 if let Some(e) = expr {
331 self.validate_expr(e);
332 }
333
334 if self.function_depth == 0 {
335 self.issues.push(ValidationIssue::error(
336 IssueCode::ReturnOutsideFunction,
337 "return used outside of a function",
338 ));
339 }
340 }
341
342 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
344 self.function_depth += 1;
345 self.scope.push_frame();
346
347 for param in &tool_def.params {
349 self.scope.bind(¶m.name);
350 if let Some(default) = ¶m.default {
352 self.validate_expr(default);
353 }
354 }
355
356 for stmt in &tool_def.body {
358 self.validate_stmt(stmt);
359 }
360
361 self.scope.pop_frame();
362 self.function_depth -= 1;
363 }
364
365 fn validate_test(&mut self, test: &TestExpr) {
367 match test {
368 TestExpr::FileTest { path, .. } => self.validate_expr(path),
369 TestExpr::StringTest { value, .. } => self.validate_expr(value),
370 TestExpr::Comparison { left, right, .. } => {
371 self.validate_expr(left);
372 self.validate_expr(right);
373 }
374 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
375 self.validate_test(left);
376 self.validate_test(right);
377 }
378 TestExpr::Not { expr } => self.validate_test(expr),
379 }
380 }
381
382 fn validate_expr(&mut self, expr: &Expr) {
384 match expr {
385 Expr::Literal(_) => {}
386 Expr::VarRef(path) => self.validate_var_ref(path),
387 Expr::Interpolated(parts) => {
388 for part in parts {
389 self.validate_string_part(part);
390 }
391 }
392 Expr::HereDocBody { parts, .. } => {
393 for sp in parts {
394 self.validate_spanned_string_part(sp);
395 }
396 }
397 Expr::BinaryOp { left, right, .. } => {
398 self.validate_expr(left);
399 self.validate_expr(right);
400 }
401 Expr::CommandSubst(stmts) => {
402 for stmt in stmts {
403 self.validate_stmt(stmt);
404 }
405 }
406 Expr::Test(test) => self.validate_test(test),
407 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
408 Expr::VarLength(name) => self.check_var_defined(name),
409 Expr::VarWithDefault { name, .. } => {
410 let _ = name;
412 }
413 Expr::Arithmetic(_) => {
414 }
416 Expr::Command(cmd) => self.validate_command(cmd),
417 Expr::LastExitCode | Expr::CurrentPid => {}
418 Expr::GlobPattern(_) => {}
419 }
420 }
421
422 fn validate_var_ref(&mut self, path: &VarPath) {
424 if let Some(VarSegment::Field(name)) = path.segments.first() {
425 if name == "?" && path.segments.len() > 1 {
428 self.issues.push(
429 ValidationIssue::error(
430 IssueCode::LastResultFieldAccess,
431 "${?.field} is removed; $? is the POSIX exit code",
432 )
433 .with_suggestion(
434 "use `kaish-last` to read the previous command's data or stdout",
435 ),
436 );
437 return;
438 }
439 self.check_var_defined(name);
440 }
441 }
442
443 fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
447 let issues_before = self.issues.len();
448 self.validate_string_part(&sp.part);
449 let span = Span::new(sp.offset, sp.offset + sp.len);
450 for issue in &mut self.issues[issues_before..] {
451 if issue.span.is_none() {
452 issue.span = Some(span);
453 }
454 }
455 }
456
457 fn validate_string_part(&mut self, part: &StringPart) {
459 match part {
460 StringPart::Literal(_) => {}
461 StringPart::Var(path) => self.validate_var_ref(path),
462 StringPart::VarWithDefault { default, .. } => {
463 for p in default {
465 self.validate_string_part(p);
466 }
467 }
468 StringPart::VarLength(name) => self.check_var_defined(name),
469 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
470 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(stmts) => {
472 for stmt in stmts {
473 self.validate_stmt(stmt);
474 }
475 }
476 StringPart::LastExitCode | StringPart::CurrentPid => {}
477 }
478 }
479
480 fn check_var_defined(&mut self, name: &str) {
482 if ScopeTracker::should_skip_undefined_check(name) {
484 return;
485 }
486
487 if !self.scope.is_bound(name) {
488 self.issues.push(ValidationIssue::warning(
489 IssueCode::PossiblyUndefinedVariable,
490 format!("variable '{}' may be undefined", name),
491 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
492 }
493 }
494
495 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
503 let positional_count = args
504 .iter()
505 .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
506 .count();
507
508 let required_count = tool_def
509 .params
510 .iter()
511 .filter(|p| p.default.is_none())
512 .count();
513
514 if positional_count < required_count {
515 self.issues.push(ValidationIssue::error(
516 IssueCode::MissingRequiredArg,
517 format!(
518 "'{}' requires {} arguments, got {}",
519 tool_def.name, required_count, positional_count
520 ),
521 ));
522 }
523 }
524}
525
526fn is_static_command_name(name: &str) -> bool {
528 !name.starts_with('$') && !name.contains("$(")
529}
530
531fn is_special_command(name: &str) -> bool {
533 matches!(
534 name,
535 "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
536 )
537}
538
539pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
544 let mut tool_args = ToolArgs::new();
545 let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
549 let mut consumed: HashSet<usize> = HashSet::new();
550 let mut past_double_dash = false;
551
552 for i in 0..args.len() {
553 match &args[i] {
554 Arg::DoubleDash => past_double_dash = true,
555 Arg::Positional(expr) => {
556 if !consumed.contains(&i) {
557 tool_args.positional.push(expr_to_placeholder(expr));
558 }
559 }
560 Arg::Named { key, value } => {
561 let v = expr_to_placeholder(value);
562 match param_lookup.get(key.as_str()) {
563 Some(&(canonical, _, _, true)) => {
565 let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
566 }
567 Some(&(canonical, ..)) => {
568 tool_args.named.insert(canonical.to_string(), v);
569 }
570 None => {
571 tool_args.named.insert(key.clone(), v);
572 }
573 }
574 }
575 Arg::WordAssign { key, value } => {
576 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
580 }
581 Arg::ShortFlag(name) => {
582 if past_double_dash {
583 tool_args.positional.push(Value::String(format!("-{name}")));
584 } else {
585 bind_short_flag_for_validation(
586 name,
587 ¶m_lookup,
588 args,
589 i,
590 &mut consumed,
591 &mut tool_args,
592 );
593 }
594 }
595 Arg::LongFlag(name) => {
596 if past_double_dash {
597 tool_args.positional.push(Value::String(format!("--{name}")));
598 } else {
599 match param_lookup.get(name.as_str()) {
600 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
601 bind_value_or_flag(
602 &mut tool_args, name, canonical, consumes, repeatable, args, i,
603 &mut consumed,
604 );
605 }
606 Some(&(canonical, ..)) => {
607 tool_args.flags.insert(canonical.to_string());
608 }
609 None => {
610 tool_args.flags.insert(name.clone());
611 }
612 }
613 }
614 }
615 }
616 }
617
618 tool_args
619}
620
621fn bind_short_flag_for_validation(
627 name: &str,
628 param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
629 args: &[Arg],
630 i: usize,
631 consumed: &mut HashSet<usize>,
632 tool_args: &mut ToolArgs,
633) {
634 if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
636 if is_bool_type(typ) {
637 tool_args.flags.insert(canonical.to_string());
638 } else {
639 bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
640 }
641 return;
642 }
643 if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
645 .get(&name[..1])
646 .filter(|(_, typ, ..)| !is_bool_type(typ))
647 {
648 let glued = name[1..].to_string();
649 if glued.is_empty() {
650 bind_value_or_flag(
651 tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
652 );
653 } else {
654 let _ =
655 bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
656 }
657 return;
658 }
659 let bytes = name.as_bytes();
662 let mut p = 0;
663 while p < bytes.len() {
664 let key = &name[p..p + 1];
665 match param_lookup.get(key) {
666 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
667 let glued = name[p + 1..].to_string();
668 if glued.is_empty() {
669 bind_value_or_flag(
670 tool_args, key, canonical, consumes, repeatable, args, i, consumed,
671 );
672 } else {
673 let _ = bind_glued_short_value(
674 tool_args, key, canonical, consumes, repeatable, glued,
675 );
676 }
677 return;
678 }
679 _ => {
680 tool_args.flags.insert(key.to_string());
681 p += 1;
682 }
683 }
684 }
685}
686
687#[allow(clippy::too_many_arguments)] fn bind_value_or_flag(
696 tool_args: &mut ToolArgs,
697 flag_name: &str,
698 canonical: &str,
699 consumes: usize,
700 repeatable: bool,
701 args: &[Arg],
702 i: usize,
703 consumed: &mut HashSet<usize>,
704) {
705 let want = consumes.max(1);
706 let allow_word_assign = consumes <= 1;
707 let mut collected: Vec<Value> = Vec::with_capacity(want);
708 for _ in 0..want {
709 let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
710 let idx = i + 1 + off;
711 if consumed.contains(&idx) {
712 return None;
713 }
714 match a {
715 Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
716 Arg::WordAssign { key, value } if allow_word_assign => {
717 let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
718 Some((idx, Value::String(format!("{key}={s}"))))
719 }
720 _ => None,
721 }
722 });
723 match found {
724 Some((idx, v)) => {
725 consumed.insert(idx);
726 collected.push(v);
727 }
728 None => break,
729 }
730 }
731
732 if collected.is_empty() {
733 tool_args.flags.insert(canonical.to_string());
734 return;
735 }
736 if consumes <= 1 {
737 if let Some(v) = collected.into_iter().next() {
738 if repeatable {
739 let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
740 } else {
741 tool_args.named.insert(canonical.to_string(), v);
742 }
743 }
744 return;
745 }
746 let occ: Vec<serde_json::Value> = collected
748 .iter()
749 .map(crate::interpreter::value_to_json)
750 .collect();
751 let entry = tool_args
752 .named
753 .entry(canonical.to_string())
754 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
755 if let Value::Json(serde_json::Value::Array(outer)) = entry {
756 outer.push(serde_json::Value::Array(occ));
757 }
758}
759
760fn expr_to_placeholder(expr: &Expr) -> Value {
765 match expr {
766 Expr::Literal(val) => val.clone(),
767 Expr::Interpolated(parts) if parts.len() == 1 => {
768 if let StringPart::Literal(s) = &parts[0] {
769 Value::String(s.clone())
770 } else {
771 Value::String("<dynamic>".to_string())
772 }
773 }
774 _ => Value::String("<dynamic>".to_string()),
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use crate::tools::{register_builtins, ToolRegistry};
783
784 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
785 let mut registry = ToolRegistry::new();
786 register_builtins(&mut registry);
787 let user_tools = HashMap::new();
788 (registry, user_tools)
789 }
790
791 #[test]
792 fn validates_undefined_command() {
793 let (registry, user_tools) = make_validator();
794 let validator = Validator::new(®istry, &user_tools);
795
796 let program = Program {
797 statements: vec![Stmt::Command(Command {
798 name: "nonexistent_command".to_string(),
799 args: vec![],
800 redirects: vec![],
801 })],
802 };
803
804 let issues = validator.validate(&program);
805 assert!(!issues.is_empty());
806 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
807 }
808
809 #[test]
810 fn validates_known_command() {
811 let (registry, user_tools) = make_validator();
812 let validator = Validator::new(®istry, &user_tools);
813
814 let program = Program {
815 statements: vec![Stmt::Command(Command {
816 name: "echo".to_string(),
817 args: vec![Arg::Positional(Expr::Literal(Value::String(
818 "hello".to_string(),
819 )))],
820 redirects: vec![],
821 })],
822 };
823
824 let issues = validator.validate(&program);
825 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
827 }
828
829 #[test]
830 fn glued_value_flags_dont_false_error_at_validation() {
831 let (registry, user_tools) = make_validator();
837 let validator = Validator::new(®istry, &user_tools);
838
839 let program = Program {
840 statements: vec![Stmt::Command(Command {
841 name: "sed".to_string(),
842 args: vec![
843 Arg::ShortFlag("e1d".to_string()),
844 Arg::ShortFlag("e2d".to_string()),
845 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
846 ],
847 redirects: vec![],
848 })],
849 };
850
851 let issues = validator.validate(&program);
852 assert!(
853 !issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
854 "glued -e flags false-errored at validation: {:?}",
855 issues.iter().map(|i| &i.message).collect::<Vec<_>>()
856 );
857 }
858
859 #[test]
860 fn validates_break_outside_loop() {
861 let (registry, user_tools) = make_validator();
862 let validator = Validator::new(®istry, &user_tools);
863
864 let program = Program {
865 statements: vec![Stmt::Break(None)],
866 };
867
868 let issues = validator.validate(&program);
869 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
870 }
871
872 #[test]
873 fn validates_break_inside_loop() {
874 let (registry, user_tools) = make_validator();
875 let validator = Validator::new(®istry, &user_tools);
876
877 let program = Program {
878 statements: vec![Stmt::For(ForLoop {
879 variable: "i".to_string(),
880 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
881 body: vec![Stmt::Break(None)],
882 })],
883 };
884
885 let issues = validator.validate(&program);
886 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
888 }
889
890 #[test]
891 fn validates_undefined_variable() {
892 let (registry, user_tools) = make_validator();
893 let validator = Validator::new(®istry, &user_tools);
894
895 let program = Program {
896 statements: vec![Stmt::Command(Command {
897 name: "echo".to_string(),
898 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
899 "UNDEFINED_VAR",
900 )))],
901 redirects: vec![],
902 })],
903 };
904
905 let issues = validator.validate(&program);
906 assert!(issues
907 .iter()
908 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
909 }
910
911 #[test]
912 fn validates_defined_variable() {
913 let (registry, user_tools) = make_validator();
914 let validator = Validator::new(®istry, &user_tools);
915
916 let program = Program {
917 statements: vec![
918 Stmt::Assignment(Assignment {
920 name: "MY_VAR".to_string(),
921 value: Expr::Literal(Value::String("value".to_string())),
922 local: false,
923 }),
924 Stmt::Command(Command {
926 name: "echo".to_string(),
927 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
928 redirects: vec![],
929 }),
930 ],
931 };
932
933 let issues = validator.validate(&program);
934 assert!(!issues
936 .iter()
937 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
938 && i.message.contains("MY_VAR")));
939 }
940
941 #[test]
942 fn skips_underscore_prefixed_vars() {
943 let (registry, user_tools) = make_validator();
944 let validator = Validator::new(®istry, &user_tools);
945
946 let program = Program {
947 statements: vec![Stmt::Command(Command {
948 name: "echo".to_string(),
949 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
950 redirects: vec![],
951 })],
952 };
953
954 let issues = validator.validate(&program);
955 assert!(!issues
957 .iter()
958 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
959 }
960
961 #[test]
962 fn builtin_vars_are_defined() {
963 let (registry, user_tools) = make_validator();
964 let validator = Validator::new(®istry, &user_tools);
965
966 let program = Program {
967 statements: vec![Stmt::Command(Command {
968 name: "echo".to_string(),
969 args: vec![
970 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
971 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
972 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
973 ],
974 redirects: vec![],
975 })],
976 };
977
978 let issues = validator.validate(&program);
979 assert!(!issues
981 .iter()
982 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
983 }
984
985 #[test]
986 fn validates_scatter_without_gather() {
987 let (registry, user_tools) = make_validator();
988 let validator = Validator::new(®istry, &user_tools);
989
990 let program = Program {
991 statements: vec![Stmt::Pipeline(Pipeline {
992 commands: vec![
993 Command { name: "seq".to_string(), args: vec![
994 Arg::Positional(Expr::Literal(Value::String("1".into()))),
995 Arg::Positional(Expr::Literal(Value::String("3".into()))),
996 ], redirects: vec![] },
997 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
998 Command { name: "echo".to_string(), args: vec![
999 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1000 ], redirects: vec![] },
1001 ],
1002 background: false,
1003 })],
1004 };
1005
1006 let issues = validator.validate(&program);
1007 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1008 "should flag scatter without gather: {:?}", issues);
1009 }
1010
1011 #[test]
1012 fn allows_scatter_with_gather() {
1013 let (registry, user_tools) = make_validator();
1014 let validator = Validator::new(®istry, &user_tools);
1015
1016 let program = Program {
1017 statements: vec![Stmt::Pipeline(Pipeline {
1018 commands: vec![
1019 Command { name: "seq".to_string(), args: vec![
1020 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1021 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1022 ], redirects: vec![] },
1023 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1024 Command { name: "echo".to_string(), args: vec![
1025 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1026 ], redirects: vec![] },
1027 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
1028 ],
1029 background: false,
1030 })],
1031 };
1032
1033 let issues = validator.validate(&program);
1034 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1035 "scatter with gather should pass: {:?}", issues);
1036 }
1037
1038 fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
1039 let mut user_tools = HashMap::new();
1040 user_tools.insert(
1041 "mytool".to_string(),
1042 ToolDef {
1043 name: "mytool".to_string(),
1044 params: vec![crate::ast::ParamDef {
1045 name: "input".to_string(),
1046 param_type: None,
1047 default: None,
1048 }],
1049 body: vec![],
1050 },
1051 );
1052 user_tools
1053 }
1054
1055 #[test]
1059 fn user_tool_wordassign_counts_as_positional() {
1060 let mut registry = ToolRegistry::new();
1061 register_builtins(&mut registry);
1062 let user_tools = make_user_tool_with_required_positional();
1063 let validator = Validator::new(®istry, &user_tools);
1064
1065 let program = Program {
1066 statements: vec![Stmt::Command(Command {
1067 name: "mytool".to_string(),
1068 args: vec![Arg::WordAssign {
1069 key: "foo".to_string(),
1070 value: Expr::Literal(Value::String("bar".to_string())),
1071 }],
1072 redirects: vec![],
1073 })],
1074 };
1075
1076 let issues = validator.validate(&program);
1077 assert!(
1078 !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1079 "WordAssign should satisfy required positional; got {:?}",
1080 issues
1081 );
1082 }
1083
1084 #[test]
1087 fn user_tool_no_args_still_errors() {
1088 let mut registry = ToolRegistry::new();
1089 register_builtins(&mut registry);
1090 let user_tools = make_user_tool_with_required_positional();
1091 let validator = Validator::new(®istry, &user_tools);
1092
1093 let program = Program {
1094 statements: vec![Stmt::Command(Command {
1095 name: "mytool".to_string(),
1096 args: vec![],
1097 redirects: vec![],
1098 })],
1099 };
1100
1101 let issues = validator.validate(&program);
1102 assert!(
1103 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1104 "missing positional should still error; got {:?}",
1105 issues
1106 );
1107 }
1108}