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 if cmd.name == "test" {
123 self.issues.push(ValidationIssue::warning(
124 IssueCode::PosixTestCommand,
125 "'test' is not a kaish command".to_string(),
126 ).with_suggestion(
127 "use [[ … ]] for conditionals — it is validated before running, \
128 whereas `test` resolves to an external command that bypasses the VFS",
129 ));
130 } else {
131 self.issues.push(ValidationIssue::warning(
133 IssueCode::UndefinedCommand,
134 format!("command '{}' not found in builtin registry", cmd.name),
135 ).with_suggestion("this may be a script in PATH or external command"));
136 }
137 }
138
139 for arg in &cmd.args {
141 self.validate_arg(arg);
142 }
143
144 if let Some(tool) = self.registry.get(&cmd.name) {
149 let schema = tool.schema();
150 let tool_args = build_tool_args_for_validation(&cmd.args, Some(&schema));
151 let tool_issues = tool.validate(&tool_args);
152 self.issues.extend(tool_issues);
153 } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
154 self.validate_user_tool_args(user_tool, &cmd.args);
156 }
157
158 for redirect in &cmd.redirects {
160 self.validate_expr(&redirect.target);
161 }
162 }
163
164 fn validate_arg(&mut self, arg: &Arg) {
166 match arg {
167 Arg::Positional(expr) => self.validate_expr(expr),
168 Arg::Named { value, .. } => self.validate_expr(value),
169 Arg::WordAssign { value, .. } => self.validate_expr(value),
170 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
171 }
172 }
173
174 fn validate_pipeline(&mut self, pipe: &Pipeline) {
176 let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
178 let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
179 if has_scatter && !has_gather {
180 self.issues.push(
181 ValidationIssue::error(
182 IssueCode::ScatterWithoutGather,
183 "scatter without gather — parallel results would be lost",
184 ).with_suggestion("add gather: ... | scatter | cmd | gather")
185 );
186 }
187
188 for cmd in &pipe.commands {
189 self.validate_command(cmd);
190 }
191 }
192
193 fn validate_if(&mut self, if_stmt: &IfStmt) {
195 self.validate_expr(&if_stmt.condition);
196
197 self.scope.push_frame();
198 for stmt in &if_stmt.then_branch {
199 self.validate_stmt(stmt);
200 }
201 self.scope.pop_frame();
202
203 if let Some(else_branch) = &if_stmt.else_branch {
204 self.scope.push_frame();
205 for stmt in else_branch {
206 self.validate_stmt(stmt);
207 }
208 self.scope.pop_frame();
209 }
210 }
211
212 fn validate_for(&mut self, for_loop: &ForLoop) {
214 for item in &for_loop.items {
216 self.validate_expr(item);
217
218 if self.is_bare_scalar_var(item) {
221 self.issues.push(
222 ValidationIssue::error(
223 IssueCode::ForLoopScalarVar,
224 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
225 )
226 .with_suggestion(concat!(
227 "use one of:\n",
228 " for i in $(split \"$VAR\") # split on whitespace\n",
229 " for i in $(split \"$VAR\" \":\") # split on delimiter\n",
230 " for i in $(seq 1 10) # iterate numbers\n",
231 " for i in $(glob \"*.rs\") # iterate files",
232 )),
233 );
234 }
235 }
236
237 self.loop_depth += 1;
238 self.scope.push_frame();
239
240 self.scope.bind(&for_loop.variable);
242
243 for stmt in &for_loop.body {
244 self.validate_stmt(stmt);
245 }
246
247 self.scope.pop_frame();
248 self.loop_depth -= 1;
249 }
250
251 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
257 match expr {
258 Expr::VarRef(_) => true,
260 Expr::VarWithDefault { .. } => true,
262 Expr::CommandSubst(_) => false,
264 Expr::Literal(_) => false,
266 Expr::Interpolated(_) => false,
268 _ => false,
270 }
271 }
272
273 fn validate_while(&mut self, while_loop: &WhileLoop) {
275 self.validate_expr(&while_loop.condition);
276
277 self.loop_depth += 1;
278 self.scope.push_frame();
279
280 for stmt in &while_loop.body {
281 self.validate_stmt(stmt);
282 }
283
284 self.scope.pop_frame();
285 self.loop_depth -= 1;
286 }
287
288 fn validate_case(&mut self, case_stmt: &CaseStmt) {
290 self.validate_expr(&case_stmt.expr);
291
292 for branch in &case_stmt.branches {
293 self.validate_case_branch(branch);
294 }
295 }
296
297 fn validate_case_branch(&mut self, branch: &CaseBranch) {
299 self.scope.push_frame();
300 for stmt in &branch.body {
301 self.validate_stmt(stmt);
302 }
303 self.scope.pop_frame();
304 }
305
306 fn validate_break(&mut self, levels: Option<usize>) {
308 if self.loop_depth == 0 {
309 self.issues.push(ValidationIssue::error(
310 IssueCode::BreakOutsideLoop,
311 "break used outside of a loop",
312 ));
313 } else if let Some(n) = levels
314 && n > self.loop_depth {
315 self.issues.push(ValidationIssue::warning(
316 IssueCode::BreakOutsideLoop,
317 format!(
318 "break {} exceeds loop nesting depth {}",
319 n, self.loop_depth
320 ),
321 ));
322 }
323 }
324
325 fn validate_continue(&mut self, levels: Option<usize>) {
327 if self.loop_depth == 0 {
328 self.issues.push(ValidationIssue::error(
329 IssueCode::BreakOutsideLoop,
330 "continue used outside of a loop",
331 ));
332 } else if let Some(n) = levels
333 && n > self.loop_depth {
334 self.issues.push(ValidationIssue::warning(
335 IssueCode::BreakOutsideLoop,
336 format!(
337 "continue {} exceeds loop nesting depth {}",
338 n, self.loop_depth
339 ),
340 ));
341 }
342 }
343
344 fn validate_return(&mut self, expr: Option<&Expr>) {
346 if let Some(e) = expr {
347 self.validate_expr(e);
348 }
349
350 if self.function_depth == 0 {
351 self.issues.push(ValidationIssue::error(
352 IssueCode::ReturnOutsideFunction,
353 "return used outside of a function",
354 ));
355 }
356 }
357
358 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
360 self.function_depth += 1;
361 self.scope.push_frame();
362
363 for param in &tool_def.params {
365 self.scope.bind(¶m.name);
366 if let Some(default) = ¶m.default {
368 self.validate_expr(default);
369 }
370 }
371
372 for stmt in &tool_def.body {
374 self.validate_stmt(stmt);
375 }
376
377 self.scope.pop_frame();
378 self.function_depth -= 1;
379 }
380
381 fn validate_test(&mut self, test: &TestExpr) {
383 match test {
384 TestExpr::FileTest { path, .. } => self.validate_expr(path),
385 TestExpr::StringTest { value, .. } => self.validate_expr(value),
386 TestExpr::Comparison { left, right, .. } => {
387 self.validate_expr(left);
388 self.validate_expr(right);
389 }
390 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
391 self.validate_test(left);
392 self.validate_test(right);
393 }
394 TestExpr::Not { expr } => self.validate_test(expr),
395 }
396 }
397
398 fn validate_expr(&mut self, expr: &Expr) {
400 match expr {
401 Expr::Literal(_) => {}
402 Expr::VarRef(path) => self.validate_var_ref(path),
403 Expr::Interpolated(parts) => {
404 for part in parts {
405 self.validate_string_part(part);
406 }
407 }
408 Expr::HereDocBody { parts, .. } => {
409 for sp in parts {
410 self.validate_spanned_string_part(sp);
411 }
412 }
413 Expr::BinaryOp { left, right, .. } => {
414 self.validate_expr(left);
415 self.validate_expr(right);
416 }
417 Expr::CommandSubst(stmts) => {
418 for stmt in stmts {
419 self.validate_stmt(stmt);
420 }
421 }
422 Expr::Test(test) => self.validate_test(test),
423 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
424 Expr::VarLength(name) => self.check_var_defined(name),
425 Expr::VarWithDefault { name, .. } => {
426 let _ = name;
428 }
429 Expr::Arithmetic(_) => {
430 }
432 Expr::Command(cmd) => self.validate_command(cmd),
433 Expr::LastExitCode | Expr::CurrentPid => {}
434 Expr::GlobPattern(_) => {}
435 }
436 }
437
438 fn validate_var_ref(&mut self, path: &VarPath) {
440 if let Some(VarSegment::Field(name)) = path.segments.first() {
441 if name == "?" && path.segments.len() > 1 {
444 self.issues.push(
445 ValidationIssue::error(
446 IssueCode::LastResultFieldAccess,
447 "${?.field} is removed; $? is the POSIX exit code",
448 )
449 .with_suggestion(
450 "use `kaish-last` to read the previous command's data or stdout",
451 ),
452 );
453 return;
454 }
455 self.check_var_defined(name);
456 }
457 }
458
459 fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
463 let issues_before = self.issues.len();
464 self.validate_string_part(&sp.part);
465 let span = Span::new(sp.offset, sp.offset + sp.len);
466 for issue in &mut self.issues[issues_before..] {
467 if issue.span.is_none() {
468 issue.span = Some(span);
469 }
470 }
471 }
472
473 fn validate_string_part(&mut self, part: &StringPart) {
475 match part {
476 StringPart::Literal(_) => {}
477 StringPart::Var(path) => self.validate_var_ref(path),
478 StringPart::VarWithDefault { default, .. } => {
479 for p in default {
481 self.validate_string_part(p);
482 }
483 }
484 StringPart::VarLength(name) => self.check_var_defined(name),
485 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
486 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(stmts) => {
488 for stmt in stmts {
489 self.validate_stmt(stmt);
490 }
491 }
492 StringPart::LastExitCode | StringPart::CurrentPid => {}
493 }
494 }
495
496 fn check_var_defined(&mut self, name: &str) {
498 if ScopeTracker::should_skip_undefined_check(name) {
500 return;
501 }
502
503 if !self.scope.is_bound(name) {
504 self.issues.push(ValidationIssue::warning(
505 IssueCode::PossiblyUndefinedVariable,
506 format!("variable '{}' may be undefined", name),
507 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
508 }
509 }
510
511 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
519 let positional_count = args
520 .iter()
521 .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
522 .count();
523
524 let required_count = tool_def
525 .params
526 .iter()
527 .filter(|p| p.default.is_none())
528 .count();
529
530 if positional_count < required_count {
531 self.issues.push(ValidationIssue::error(
532 IssueCode::MissingRequiredArg,
533 format!(
534 "'{}' requires {} arguments, got {}",
535 tool_def.name, required_count, positional_count
536 ),
537 ));
538 }
539 }
540}
541
542fn is_static_command_name(name: &str) -> bool {
544 !name.starts_with('$') && !name.contains("$(")
545}
546
547fn is_special_command(name: &str) -> bool {
549 matches!(name, "true" | "false" | ":" | "readonly" | "local")
553}
554
555pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
560 let mut tool_args = ToolArgs::new();
561 let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
565 let mut consumed: HashSet<usize> = HashSet::new();
566 let mut past_double_dash = false;
567
568 for i in 0..args.len() {
569 match &args[i] {
570 Arg::DoubleDash => past_double_dash = true,
571 Arg::Positional(expr) => {
572 if !consumed.contains(&i) {
573 tool_args.positional.push(expr_to_placeholder(expr));
574 }
575 }
576 Arg::Named { key, value } => {
577 let v = expr_to_placeholder(value);
578 match param_lookup.get(key.as_str()) {
579 Some(&(canonical, _, _, true)) => {
581 let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
582 }
583 Some(&(canonical, ..)) => {
584 tool_args.named.insert(canonical.to_string(), v);
585 }
586 None => {
587 tool_args.named.insert(key.clone(), v);
588 }
589 }
590 }
591 Arg::WordAssign { key, value } => {
592 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
596 }
597 Arg::ShortFlag(name) => {
598 if past_double_dash {
599 tool_args.positional.push(Value::String(format!("-{name}")));
600 } else {
601 bind_short_flag_for_validation(
602 name,
603 ¶m_lookup,
604 args,
605 i,
606 &mut consumed,
607 &mut tool_args,
608 );
609 }
610 }
611 Arg::LongFlag(name) => {
612 if past_double_dash {
613 tool_args.positional.push(Value::String(format!("--{name}")));
614 } else {
615 match param_lookup.get(name.as_str()) {
616 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
617 bind_value_or_flag(
618 &mut tool_args, name, canonical, consumes, repeatable, args, i,
619 &mut consumed,
620 );
621 }
622 Some(&(canonical, ..)) => {
623 tool_args.flags.insert(canonical.to_string());
624 }
625 None => {
626 tool_args.flags.insert(name.clone());
627 }
628 }
629 }
630 }
631 }
632 }
633
634 tool_args
635}
636
637fn bind_short_flag_for_validation(
643 name: &str,
644 param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
645 args: &[Arg],
646 i: usize,
647 consumed: &mut HashSet<usize>,
648 tool_args: &mut ToolArgs,
649) {
650 if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
652 if is_bool_type(typ) {
653 tool_args.flags.insert(canonical.to_string());
654 } else {
655 bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
656 }
657 return;
658 }
659 if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
661 .get(&name[..1])
662 .filter(|(_, typ, ..)| !is_bool_type(typ))
663 {
664 let glued = name[1..].to_string();
665 if glued.is_empty() {
666 bind_value_or_flag(
667 tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
668 );
669 } else {
670 let _ =
671 bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
672 }
673 return;
674 }
675 let bytes = name.as_bytes();
678 let mut p = 0;
679 while p < bytes.len() {
680 let key = &name[p..p + 1];
681 match param_lookup.get(key) {
682 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
683 let glued = name[p + 1..].to_string();
684 if glued.is_empty() {
685 bind_value_or_flag(
686 tool_args, key, canonical, consumes, repeatable, args, i, consumed,
687 );
688 } else {
689 let _ = bind_glued_short_value(
690 tool_args, key, canonical, consumes, repeatable, glued,
691 );
692 }
693 return;
694 }
695 _ => {
696 tool_args.flags.insert(key.to_string());
697 p += 1;
698 }
699 }
700 }
701}
702
703#[allow(clippy::too_many_arguments)] fn bind_value_or_flag(
712 tool_args: &mut ToolArgs,
713 flag_name: &str,
714 canonical: &str,
715 consumes: usize,
716 repeatable: bool,
717 args: &[Arg],
718 i: usize,
719 consumed: &mut HashSet<usize>,
720) {
721 let want = consumes.max(1);
722 let allow_word_assign = consumes <= 1;
723 let mut collected: Vec<Value> = Vec::with_capacity(want);
724 for _ in 0..want {
725 let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
726 let idx = i + 1 + off;
727 if consumed.contains(&idx) {
728 return None;
729 }
730 match a {
731 Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
732 Arg::WordAssign { key, value } if allow_word_assign => {
733 let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
734 Some((idx, Value::String(format!("{key}={s}"))))
735 }
736 _ => None,
737 }
738 });
739 match found {
740 Some((idx, v)) => {
741 consumed.insert(idx);
742 collected.push(v);
743 }
744 None => break,
745 }
746 }
747
748 if collected.is_empty() {
749 tool_args.flags.insert(canonical.to_string());
750 return;
751 }
752 if consumes <= 1 {
753 if let Some(v) = collected.into_iter().next() {
754 if repeatable {
755 let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
756 } else {
757 tool_args.named.insert(canonical.to_string(), v);
758 }
759 }
760 return;
761 }
762 let occ: Vec<serde_json::Value> = collected
764 .iter()
765 .map(crate::interpreter::value_to_json)
766 .collect();
767 let entry = tool_args
768 .named
769 .entry(canonical.to_string())
770 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
771 if let Value::Json(serde_json::Value::Array(outer)) = entry {
772 outer.push(serde_json::Value::Array(occ));
773 }
774}
775
776fn expr_to_placeholder(expr: &Expr) -> Value {
781 match expr {
782 Expr::Literal(val) => val.clone(),
783 Expr::Interpolated(parts) if parts.len() == 1 => {
784 if let StringPart::Literal(s) = &parts[0] {
785 Value::String(s.clone())
786 } else {
787 Value::String("<dynamic>".to_string())
788 }
789 }
790 _ => Value::String("<dynamic>".to_string()),
792 }
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798 use crate::tools::{register_builtins, ToolRegistry};
799
800 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
801 let mut registry = ToolRegistry::new();
802 register_builtins(&mut registry);
803 let user_tools = HashMap::new();
804 (registry, user_tools)
805 }
806
807 #[test]
808 fn validates_undefined_command() {
809 let (registry, user_tools) = make_validator();
810 let validator = Validator::new(®istry, &user_tools);
811
812 let program = Program {
813 statements: vec![Stmt::Command(Command {
814 name: "nonexistent_command".to_string(),
815 args: vec![],
816 redirects: vec![],
817 })],
818 };
819
820 let issues = validator.validate(&program);
821 assert!(!issues.is_empty());
822 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
823 }
824
825 #[test]
826 fn test_command_steers_to_double_bracket() {
827 let (registry, user_tools) = make_validator();
828 let validator = Validator::new(®istry, &user_tools);
829
830 let program = Program {
831 statements: vec![Stmt::Command(Command {
832 name: "test".to_string(),
833 args: vec![
834 Arg::Positional(Expr::Literal(Value::String("-n".to_string()))),
835 Arg::Positional(Expr::Literal(Value::String("hi".to_string()))),
836 ],
837 redirects: vec![],
838 })],
839 };
840
841 let issues = validator.validate(&program);
842 let issue = issues
843 .iter()
844 .find(|i| i.code == IssueCode::PosixTestCommand)
845 .expect("`test` should emit a PosixTestCommand advisory");
846 assert_eq!(issue.severity, crate::validator::Severity::Warning);
847 assert!(issue.code.surfaces_to_agent(), "the advisory must surface to the agent");
848 assert!(issue.suggestion.as_deref().unwrap_or_default().contains("[["));
849 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
851 }
852
853 #[test]
854 fn path_qualified_test_is_honored_not_steered() {
855 let (registry, user_tools) = make_validator();
860
861 for name in ["./test", "/opt/custom/test"] {
862 let validator = Validator::new(®istry, &user_tools);
863 let program = Program {
864 statements: vec![Stmt::Command(Command {
865 name: name.to_string(),
866 args: vec![],
867 redirects: vec![],
868 })],
869 };
870 let issues = validator.validate(&program);
871 assert!(
872 !issues.iter().any(|i| i.code == IssueCode::PosixTestCommand),
873 "path-qualified '{name}' must not get the test advisory"
874 );
875 }
876 }
877
878 #[test]
879 fn validates_known_command() {
880 let (registry, user_tools) = make_validator();
881 let validator = Validator::new(®istry, &user_tools);
882
883 let program = Program {
884 statements: vec![Stmt::Command(Command {
885 name: "echo".to_string(),
886 args: vec![Arg::Positional(Expr::Literal(Value::String(
887 "hello".to_string(),
888 )))],
889 redirects: vec![],
890 })],
891 };
892
893 let issues = validator.validate(&program);
894 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
896 }
897
898 #[test]
899 fn glued_value_flags_dont_false_error_at_validation() {
900 let (registry, user_tools) = make_validator();
906 let validator = Validator::new(®istry, &user_tools);
907
908 let program = Program {
909 statements: vec![Stmt::Command(Command {
910 name: "sed".to_string(),
911 args: vec![
912 Arg::ShortFlag("e1d".to_string()),
913 Arg::ShortFlag("e2d".to_string()),
914 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
915 ],
916 redirects: vec![],
917 })],
918 };
919
920 let issues = validator.validate(&program);
921 assert!(
922 !issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
923 "glued -e flags false-errored at validation: {:?}",
924 issues.iter().map(|i| &i.message).collect::<Vec<_>>()
925 );
926 }
927
928 #[test]
929 fn validates_break_outside_loop() {
930 let (registry, user_tools) = make_validator();
931 let validator = Validator::new(®istry, &user_tools);
932
933 let program = Program {
934 statements: vec![Stmt::Break(None)],
935 };
936
937 let issues = validator.validate(&program);
938 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
939 }
940
941 #[test]
942 fn validates_break_inside_loop() {
943 let (registry, user_tools) = make_validator();
944 let validator = Validator::new(®istry, &user_tools);
945
946 let program = Program {
947 statements: vec![Stmt::For(ForLoop {
948 variable: "i".to_string(),
949 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
950 body: vec![Stmt::Break(None)],
951 })],
952 };
953
954 let issues = validator.validate(&program);
955 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
957 }
958
959 #[test]
960 fn validates_undefined_variable() {
961 let (registry, user_tools) = make_validator();
962 let validator = Validator::new(®istry, &user_tools);
963
964 let program = Program {
965 statements: vec![Stmt::Command(Command {
966 name: "echo".to_string(),
967 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
968 "UNDEFINED_VAR",
969 )))],
970 redirects: vec![],
971 })],
972 };
973
974 let issues = validator.validate(&program);
975 assert!(issues
976 .iter()
977 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
978 }
979
980 #[test]
981 fn validates_defined_variable() {
982 let (registry, user_tools) = make_validator();
983 let validator = Validator::new(®istry, &user_tools);
984
985 let program = Program {
986 statements: vec![
987 Stmt::Assignment(Assignment {
989 name: "MY_VAR".to_string(),
990 value: Expr::Literal(Value::String("value".to_string())),
991 local: false,
992 }),
993 Stmt::Command(Command {
995 name: "echo".to_string(),
996 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
997 redirects: vec![],
998 }),
999 ],
1000 };
1001
1002 let issues = validator.validate(&program);
1003 assert!(!issues
1005 .iter()
1006 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
1007 && i.message.contains("MY_VAR")));
1008 }
1009
1010 #[test]
1011 fn skips_underscore_prefixed_vars() {
1012 let (registry, user_tools) = make_validator();
1013 let validator = Validator::new(®istry, &user_tools);
1014
1015 let program = Program {
1016 statements: vec![Stmt::Command(Command {
1017 name: "echo".to_string(),
1018 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
1019 redirects: vec![],
1020 })],
1021 };
1022
1023 let issues = validator.validate(&program);
1024 assert!(!issues
1026 .iter()
1027 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1028 }
1029
1030 #[test]
1031 fn builtin_vars_are_defined() {
1032 let (registry, user_tools) = make_validator();
1033 let validator = Validator::new(®istry, &user_tools);
1034
1035 let program = Program {
1036 statements: vec![Stmt::Command(Command {
1037 name: "echo".to_string(),
1038 args: vec![
1039 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
1040 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
1041 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
1042 ],
1043 redirects: vec![],
1044 })],
1045 };
1046
1047 let issues = validator.validate(&program);
1048 assert!(!issues
1050 .iter()
1051 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1052 }
1053
1054 #[test]
1055 fn validates_scatter_without_gather() {
1056 let (registry, user_tools) = make_validator();
1057 let validator = Validator::new(®istry, &user_tools);
1058
1059 let program = Program {
1060 statements: vec![Stmt::Pipeline(Pipeline {
1061 commands: vec![
1062 Command { name: "seq".to_string(), args: vec![
1063 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1064 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1065 ], redirects: vec![] },
1066 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1067 Command { name: "echo".to_string(), args: vec![
1068 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1069 ], redirects: vec![] },
1070 ],
1071 background: false,
1072 })],
1073 };
1074
1075 let issues = validator.validate(&program);
1076 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1077 "should flag scatter without gather: {:?}", issues);
1078 }
1079
1080 #[test]
1081 fn allows_scatter_with_gather() {
1082 let (registry, user_tools) = make_validator();
1083 let validator = Validator::new(®istry, &user_tools);
1084
1085 let program = Program {
1086 statements: vec![Stmt::Pipeline(Pipeline {
1087 commands: vec![
1088 Command { name: "seq".to_string(), args: vec![
1089 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1090 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1091 ], redirects: vec![] },
1092 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1093 Command { name: "echo".to_string(), args: vec![
1094 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1095 ], redirects: vec![] },
1096 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
1097 ],
1098 background: false,
1099 })],
1100 };
1101
1102 let issues = validator.validate(&program);
1103 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1104 "scatter with gather should pass: {:?}", issues);
1105 }
1106
1107 fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
1108 let mut user_tools = HashMap::new();
1109 user_tools.insert(
1110 "mytool".to_string(),
1111 ToolDef {
1112 name: "mytool".to_string(),
1113 params: vec![crate::ast::ParamDef {
1114 name: "input".to_string(),
1115 param_type: None,
1116 default: None,
1117 }],
1118 body: vec![],
1119 },
1120 );
1121 user_tools
1122 }
1123
1124 #[test]
1128 fn user_tool_wordassign_counts_as_positional() {
1129 let mut registry = ToolRegistry::new();
1130 register_builtins(&mut registry);
1131 let user_tools = make_user_tool_with_required_positional();
1132 let validator = Validator::new(®istry, &user_tools);
1133
1134 let program = Program {
1135 statements: vec![Stmt::Command(Command {
1136 name: "mytool".to_string(),
1137 args: vec![Arg::WordAssign {
1138 key: "foo".to_string(),
1139 value: Expr::Literal(Value::String("bar".to_string())),
1140 }],
1141 redirects: vec![],
1142 })],
1143 };
1144
1145 let issues = validator.validate(&program);
1146 assert!(
1147 !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1148 "WordAssign should satisfy required positional; got {:?}",
1149 issues
1150 );
1151 }
1152
1153 #[test]
1156 fn user_tool_no_args_still_errors() {
1157 let mut registry = ToolRegistry::new();
1158 register_builtins(&mut registry);
1159 let user_tools = make_user_tool_with_required_positional();
1160 let validator = Validator::new(®istry, &user_tools);
1161
1162 let program = Program {
1163 statements: vec![Stmt::Command(Command {
1164 name: "mytool".to_string(),
1165 args: vec![],
1166 redirects: vec![],
1167 })],
1168 };
1169
1170 let issues = validator.validate(&program);
1171 assert!(
1172 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1173 "missing positional should still error; got {:?}",
1174 issues
1175 );
1176 }
1177}