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 !command_expects_pattern_or_text(&cmd.name) {
119 for arg in &cmd.args {
120 if let Arg::Positional(expr) = arg
121 && let Some(pattern) = self.extract_unquoted_glob_pattern(expr) {
122 self.issues.push(
123 ValidationIssue::error(
124 IssueCode::ShellGlobPattern,
125 format!(
126 "glob pattern '{}' won't expand (kaish has no implicit globbing)",
127 pattern
128 ),
129 )
130 .with_suggestion(
131 "use: glob \"pattern\" | xargs cmd OR for f in $(glob \"pattern\")",
132 ),
133 );
134 }
135 }
136 }
137
138 if let Some(tool) = self.registry.get(&cmd.name) {
140 let tool_args = build_tool_args_for_validation(&cmd.args);
141 let tool_issues = tool.validate(&tool_args);
142 self.issues.extend(tool_issues);
143 } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
144 self.validate_user_tool_args(user_tool, &cmd.args);
146 }
147
148 for redirect in &cmd.redirects {
150 self.validate_expr(&redirect.target);
151 }
152 }
153
154 fn validate_arg(&mut self, arg: &Arg) {
156 match arg {
157 Arg::Positional(expr) => self.validate_expr(expr),
158 Arg::Named { value, .. } => self.validate_expr(value),
159 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
160 }
161 }
162
163 fn validate_pipeline(&mut self, pipe: &Pipeline) {
165 let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
167 let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
168 if has_scatter && !has_gather {
169 self.issues.push(
170 ValidationIssue::error(
171 IssueCode::ScatterWithoutGather,
172 "scatter without gather — parallel results would be lost",
173 ).with_suggestion("add gather: ... | scatter | cmd | gather")
174 );
175 }
176
177 for cmd in &pipe.commands {
178 self.validate_command(cmd);
179 }
180 }
181
182 fn validate_if(&mut self, if_stmt: &IfStmt) {
184 self.validate_expr(&if_stmt.condition);
185
186 self.scope.push_frame();
187 for stmt in &if_stmt.then_branch {
188 self.validate_stmt(stmt);
189 }
190 self.scope.pop_frame();
191
192 if let Some(else_branch) = &if_stmt.else_branch {
193 self.scope.push_frame();
194 for stmt in else_branch {
195 self.validate_stmt(stmt);
196 }
197 self.scope.pop_frame();
198 }
199 }
200
201 fn validate_for(&mut self, for_loop: &ForLoop) {
203 for item in &for_loop.items {
205 self.validate_expr(item);
206
207 if self.is_bare_scalar_var(item) {
210 self.issues.push(
211 ValidationIssue::error(
212 IssueCode::ForLoopScalarVar,
213 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
214 )
215 .with_suggestion(concat!(
216 "use one of:\n",
217 " for i in $(split \"$VAR\") # split on whitespace\n",
218 " for i in $(split \"$VAR\" \":\") # split on delimiter\n",
219 " for i in $(seq 1 10) # iterate numbers\n",
220 " for i in $(glob \"*.rs\") # iterate files",
221 )),
222 );
223 }
224 }
225
226 self.loop_depth += 1;
227 self.scope.push_frame();
228
229 self.scope.bind(&for_loop.variable);
231
232 for stmt in &for_loop.body {
233 self.validate_stmt(stmt);
234 }
235
236 self.scope.pop_frame();
237 self.loop_depth -= 1;
238 }
239
240 fn extract_unquoted_glob_pattern(&self, expr: &Expr) -> Option<String> {
245 match expr {
246 Expr::Literal(Value::String(s)) if looks_like_shell_glob(s) => Some(s.clone()),
247 Expr::Interpolated(parts) if parts.len() == 1 => {
249 if let StringPart::Literal(s) = &parts[0]
250 && looks_like_shell_glob(s) {
251 return Some(s.clone());
252 }
253 None
254 }
255 _ => None,
256 }
257 }
258
259 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
263 match expr {
264 Expr::VarRef(_) => true,
266 Expr::VarWithDefault { .. } => true,
268 Expr::CommandSubst(_) => false,
270 Expr::Literal(_) => false,
272 Expr::Interpolated(_) => false,
274 _ => false,
276 }
277 }
278
279 fn validate_while(&mut self, while_loop: &WhileLoop) {
281 self.validate_expr(&while_loop.condition);
282
283 self.loop_depth += 1;
284 self.scope.push_frame();
285
286 for stmt in &while_loop.body {
287 self.validate_stmt(stmt);
288 }
289
290 self.scope.pop_frame();
291 self.loop_depth -= 1;
292 }
293
294 fn validate_case(&mut self, case_stmt: &CaseStmt) {
296 self.validate_expr(&case_stmt.expr);
297
298 for branch in &case_stmt.branches {
299 self.validate_case_branch(branch);
300 }
301 }
302
303 fn validate_case_branch(&mut self, branch: &CaseBranch) {
305 self.scope.push_frame();
306 for stmt in &branch.body {
307 self.validate_stmt(stmt);
308 }
309 self.scope.pop_frame();
310 }
311
312 fn validate_break(&mut self, levels: Option<usize>) {
314 if self.loop_depth == 0 {
315 self.issues.push(ValidationIssue::error(
316 IssueCode::BreakOutsideLoop,
317 "break used outside of a loop",
318 ));
319 } else if let Some(n) = levels
320 && n > self.loop_depth {
321 self.issues.push(ValidationIssue::warning(
322 IssueCode::BreakOutsideLoop,
323 format!(
324 "break {} exceeds loop nesting depth {}",
325 n, self.loop_depth
326 ),
327 ));
328 }
329 }
330
331 fn validate_continue(&mut self, levels: Option<usize>) {
333 if self.loop_depth == 0 {
334 self.issues.push(ValidationIssue::error(
335 IssueCode::BreakOutsideLoop,
336 "continue used outside of a loop",
337 ));
338 } else if let Some(n) = levels
339 && n > self.loop_depth {
340 self.issues.push(ValidationIssue::warning(
341 IssueCode::BreakOutsideLoop,
342 format!(
343 "continue {} exceeds loop nesting depth {}",
344 n, self.loop_depth
345 ),
346 ));
347 }
348 }
349
350 fn validate_return(&mut self, expr: Option<&Expr>) {
352 if let Some(e) = expr {
353 self.validate_expr(e);
354 }
355
356 if self.function_depth == 0 {
357 self.issues.push(ValidationIssue::error(
358 IssueCode::ReturnOutsideFunction,
359 "return used outside of a function",
360 ));
361 }
362 }
363
364 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
366 self.function_depth += 1;
367 self.scope.push_frame();
368
369 for param in &tool_def.params {
371 self.scope.bind(¶m.name);
372 if let Some(default) = ¶m.default {
374 self.validate_expr(default);
375 }
376 }
377
378 for stmt in &tool_def.body {
380 self.validate_stmt(stmt);
381 }
382
383 self.scope.pop_frame();
384 self.function_depth -= 1;
385 }
386
387 fn validate_test(&mut self, test: &TestExpr) {
389 match test {
390 TestExpr::FileTest { path, .. } => self.validate_expr(path),
391 TestExpr::StringTest { value, .. } => self.validate_expr(value),
392 TestExpr::Comparison { left, right, .. } => {
393 self.validate_expr(left);
394 self.validate_expr(right);
395 }
396 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
397 self.validate_test(left);
398 self.validate_test(right);
399 }
400 TestExpr::Not { expr } => self.validate_test(expr),
401 }
402 }
403
404 fn validate_expr(&mut self, expr: &Expr) {
406 match expr {
407 Expr::Literal(_) => {}
408 Expr::VarRef(path) => self.validate_var_ref(path),
409 Expr::Interpolated(parts) => {
410 for part in parts {
411 self.validate_string_part(part);
412 }
413 }
414 Expr::BinaryOp { left, right, .. } => {
415 self.validate_expr(left);
416 self.validate_expr(right);
417 }
418 Expr::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
419 Expr::Test(test) => self.validate_test(test),
420 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
421 Expr::VarLength(name) => self.check_var_defined(name),
422 Expr::VarWithDefault { name, .. } => {
423 let _ = name;
425 }
426 Expr::Arithmetic(_) => {
427 }
429 Expr::Command(cmd) => self.validate_command(cmd),
430 Expr::LastExitCode | Expr::CurrentPid => {}
431 }
432 }
433
434 fn validate_var_ref(&mut self, path: &VarPath) {
436 if let Some(VarSegment::Field(name)) = path.segments.first() {
437 self.check_var_defined(name);
438 }
439 }
440
441 fn validate_string_part(&mut self, part: &StringPart) {
443 match part {
444 StringPart::Literal(_) => {}
445 StringPart::Var(path) => self.validate_var_ref(path),
446 StringPart::VarWithDefault { default, .. } => {
447 for p in default {
449 self.validate_string_part(p);
450 }
451 }
452 StringPart::VarLength(name) => self.check_var_defined(name),
453 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
454 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
456 StringPart::LastExitCode | StringPart::CurrentPid => {}
457 }
458 }
459
460 fn check_var_defined(&mut self, name: &str) {
462 if ScopeTracker::should_skip_undefined_check(name) {
464 return;
465 }
466
467 if !self.scope.is_bound(name) {
468 self.issues.push(ValidationIssue::warning(
469 IssueCode::PossiblyUndefinedVariable,
470 format!("variable '{}' may be undefined", name),
471 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
472 }
473 }
474
475 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
477 let positional_count = args
478 .iter()
479 .filter(|a| matches!(a, Arg::Positional(_)))
480 .count();
481
482 let required_count = tool_def
483 .params
484 .iter()
485 .filter(|p| p.default.is_none())
486 .count();
487
488 if positional_count < required_count {
489 self.issues.push(ValidationIssue::error(
490 IssueCode::MissingRequiredArg,
491 format!(
492 "'{}' requires {} arguments, got {}",
493 tool_def.name, required_count, positional_count
494 ),
495 ));
496 }
497 }
498}
499
500fn is_static_command_name(name: &str) -> bool {
502 !name.starts_with('$') && !name.contains("$(")
503}
504
505fn looks_like_shell_glob(s: &str) -> bool {
510 if s.starts_with('^') || s.ends_with('$') {
512 return false;
513 }
514
515 let has_star = s.contains('*');
516 let has_question = s.contains('?') && !s.contains("??"); let has_bracket = s.contains('[') && s.contains(']');
518
519 let looks_like_path = s.contains('.') || s.contains('/');
521
522 (has_star || has_question || has_bracket) && looks_like_path
523}
524
525fn command_expects_pattern_or_text(cmd: &str) -> bool {
530 matches!(
531 cmd,
532 "grep" | "egrep" | "fgrep" | "sed" | "awk" | "find" | "glob" | "regex"
534 | "ls" | "cat" | "head" | "tail" | "wc"
535 | "echo" | "printf"
537 | "jq"
539 )
540}
541
542fn is_special_command(name: &str) -> bool {
544 matches!(
545 name,
546 "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
547 )
548}
549
550pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
555 let mut tool_args = ToolArgs::new();
556
557 for arg in args {
558 match arg {
559 Arg::Positional(expr) => {
560 tool_args.positional.push(expr_to_placeholder(expr));
561 }
562 Arg::Named { key, value } => {
563 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
564 }
565 Arg::ShortFlag(flag) => {
566 tool_args.flags.insert(flag.clone());
567 }
568 Arg::LongFlag(flag) => {
569 tool_args.flags.insert(flag.clone());
570 }
571 Arg::DoubleDash => {}
572 }
573 }
574
575 tool_args
576}
577
578fn expr_to_placeholder(expr: &Expr) -> Value {
583 match expr {
584 Expr::Literal(val) => val.clone(),
585 Expr::Interpolated(parts) if parts.len() == 1 => {
586 if let StringPart::Literal(s) = &parts[0] {
587 Value::String(s.clone())
588 } else {
589 Value::String("<dynamic>".to_string())
590 }
591 }
592 _ => Value::String("<dynamic>".to_string()),
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use crate::tools::{register_builtins, ToolRegistry};
601
602 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
603 let mut registry = ToolRegistry::new();
604 register_builtins(&mut registry);
605 let user_tools = HashMap::new();
606 (registry, user_tools)
607 }
608
609 #[test]
610 fn validates_undefined_command() {
611 let (registry, user_tools) = make_validator();
612 let validator = Validator::new(®istry, &user_tools);
613
614 let program = Program {
615 statements: vec![Stmt::Command(Command {
616 name: "nonexistent_command".to_string(),
617 args: vec![],
618 redirects: vec![],
619 })],
620 };
621
622 let issues = validator.validate(&program);
623 assert!(!issues.is_empty());
624 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
625 }
626
627 #[test]
628 fn validates_known_command() {
629 let (registry, user_tools) = make_validator();
630 let validator = Validator::new(®istry, &user_tools);
631
632 let program = Program {
633 statements: vec![Stmt::Command(Command {
634 name: "echo".to_string(),
635 args: vec![Arg::Positional(Expr::Literal(Value::String(
636 "hello".to_string(),
637 )))],
638 redirects: vec![],
639 })],
640 };
641
642 let issues = validator.validate(&program);
643 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
645 }
646
647 #[test]
648 fn validates_break_outside_loop() {
649 let (registry, user_tools) = make_validator();
650 let validator = Validator::new(®istry, &user_tools);
651
652 let program = Program {
653 statements: vec![Stmt::Break(None)],
654 };
655
656 let issues = validator.validate(&program);
657 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
658 }
659
660 #[test]
661 fn validates_break_inside_loop() {
662 let (registry, user_tools) = make_validator();
663 let validator = Validator::new(®istry, &user_tools);
664
665 let program = Program {
666 statements: vec![Stmt::For(ForLoop {
667 variable: "i".to_string(),
668 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
669 body: vec![Stmt::Break(None)],
670 })],
671 };
672
673 let issues = validator.validate(&program);
674 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
676 }
677
678 #[test]
679 fn validates_undefined_variable() {
680 let (registry, user_tools) = make_validator();
681 let validator = Validator::new(®istry, &user_tools);
682
683 let program = Program {
684 statements: vec![Stmt::Command(Command {
685 name: "echo".to_string(),
686 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
687 "UNDEFINED_VAR",
688 )))],
689 redirects: vec![],
690 })],
691 };
692
693 let issues = validator.validate(&program);
694 assert!(issues
695 .iter()
696 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
697 }
698
699 #[test]
700 fn validates_defined_variable() {
701 let (registry, user_tools) = make_validator();
702 let validator = Validator::new(®istry, &user_tools);
703
704 let program = Program {
705 statements: vec![
706 Stmt::Assignment(Assignment {
708 name: "MY_VAR".to_string(),
709 value: Expr::Literal(Value::String("value".to_string())),
710 local: false,
711 }),
712 Stmt::Command(Command {
714 name: "echo".to_string(),
715 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
716 redirects: vec![],
717 }),
718 ],
719 };
720
721 let issues = validator.validate(&program);
722 assert!(!issues
724 .iter()
725 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
726 && i.message.contains("MY_VAR")));
727 }
728
729 #[test]
730 fn skips_underscore_prefixed_vars() {
731 let (registry, user_tools) = make_validator();
732 let validator = Validator::new(®istry, &user_tools);
733
734 let program = Program {
735 statements: vec![Stmt::Command(Command {
736 name: "echo".to_string(),
737 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
738 redirects: vec![],
739 })],
740 };
741
742 let issues = validator.validate(&program);
743 assert!(!issues
745 .iter()
746 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
747 }
748
749 #[test]
750 fn builtin_vars_are_defined() {
751 let (registry, user_tools) = make_validator();
752 let validator = Validator::new(®istry, &user_tools);
753
754 let program = Program {
755 statements: vec![Stmt::Command(Command {
756 name: "echo".to_string(),
757 args: vec![
758 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
759 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
760 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
761 ],
762 redirects: vec![],
763 })],
764 };
765
766 let issues = validator.validate(&program);
767 assert!(!issues
769 .iter()
770 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
771 }
772
773 #[test]
774 fn looks_like_shell_glob_detects_star() {
775 assert!(looks_like_shell_glob("*.txt"));
776 assert!(looks_like_shell_glob("src/*.rs"));
777 assert!(looks_like_shell_glob("**/*.json"));
778 }
779
780 #[test]
781 fn looks_like_shell_glob_detects_question() {
782 assert!(looks_like_shell_glob("file?.log"));
783 assert!(looks_like_shell_glob("test?.txt"));
784 }
785
786 #[test]
787 fn looks_like_shell_glob_detects_brackets() {
788 assert!(looks_like_shell_glob("[abc].txt"));
789 assert!(looks_like_shell_glob("[a-z].log"));
790 assert!(looks_like_shell_glob("file[0-9].rs"));
791 }
792
793 #[test]
794 fn looks_like_shell_glob_rejects_non_patterns() {
795 assert!(!looks_like_shell_glob("readme.txt"));
797 assert!(!looks_like_shell_glob("src/main.rs"));
798 assert!(!looks_like_shell_glob("hello*world"));
800 assert!(!looks_like_shell_glob("^start"));
802 assert!(!looks_like_shell_glob("end$"));
803 }
804
805 #[test]
806 fn command_expects_pattern_or_text_returns_true() {
807 assert!(command_expects_pattern_or_text("grep"));
808 assert!(command_expects_pattern_or_text("echo"));
809 assert!(command_expects_pattern_or_text("find"));
810 assert!(command_expects_pattern_or_text("glob"));
811 }
812
813 #[test]
814 fn command_expects_pattern_or_text_returns_false() {
815 assert!(!command_expects_pattern_or_text("rm"));
816 assert!(!command_expects_pattern_or_text("cp"));
817 }
818
819 #[test]
820 fn validates_glob_pattern_in_ls() {
821 let (registry, user_tools) = make_validator();
822 let validator = Validator::new(®istry, &user_tools);
823
824 let program = Program {
825 statements: vec![Stmt::Command(Command {
826 name: "ls".to_string(),
827 args: vec![Arg::Positional(Expr::Literal(Value::String(
828 "*.txt".to_string(),
829 )))],
830 redirects: vec![],
831 })],
832 };
833
834 let issues = validator.validate(&program);
835 assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
836 }
837
838 #[test]
839 fn allows_glob_pattern_in_grep() {
840 let (registry, user_tools) = make_validator();
841 let validator = Validator::new(®istry, &user_tools);
842
843 let program = Program {
844 statements: vec![Stmt::Command(Command {
845 name: "grep".to_string(),
846 args: vec![Arg::Positional(Expr::Literal(Value::String(
847 "func.*test".to_string(),
848 )))],
849 redirects: vec![],
850 })],
851 };
852
853 let issues = validator.validate(&program);
854 assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
856 }
857
858 #[test]
859 fn allows_glob_pattern_in_echo() {
860 let (registry, user_tools) = make_validator();
861 let validator = Validator::new(®istry, &user_tools);
862
863 let program = Program {
864 statements: vec![Stmt::Command(Command {
865 name: "echo".to_string(),
866 args: vec![Arg::Positional(Expr::Literal(Value::String(
867 "*.txt".to_string(),
868 )))],
869 redirects: vec![],
870 })],
871 };
872
873 let issues = validator.validate(&program);
874 assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
876 }
877
878 #[test]
879 fn validates_scatter_without_gather() {
880 let (registry, user_tools) = make_validator();
881 let validator = Validator::new(®istry, &user_tools);
882
883 let program = Program {
884 statements: vec![Stmt::Pipeline(Pipeline {
885 commands: vec![
886 Command { name: "seq".to_string(), args: vec![
887 Arg::Positional(Expr::Literal(Value::String("1".into()))),
888 Arg::Positional(Expr::Literal(Value::String("3".into()))),
889 ], redirects: vec![] },
890 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
891 Command { name: "echo".to_string(), args: vec![
892 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
893 ], redirects: vec![] },
894 ],
895 background: false,
896 })],
897 };
898
899 let issues = validator.validate(&program);
900 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
901 "should flag scatter without gather: {:?}", issues);
902 }
903
904 #[test]
905 fn allows_scatter_with_gather() {
906 let (registry, user_tools) = make_validator();
907 let validator = Validator::new(®istry, &user_tools);
908
909 let program = Program {
910 statements: vec![Stmt::Pipeline(Pipeline {
911 commands: vec![
912 Command { name: "seq".to_string(), args: vec![
913 Arg::Positional(Expr::Literal(Value::String("1".into()))),
914 Arg::Positional(Expr::Literal(Value::String("3".into()))),
915 ], redirects: vec![] },
916 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
917 Command { name: "echo".to_string(), args: vec![
918 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
919 ], redirects: vec![] },
920 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
921 ],
922 background: false,
923 })],
924 };
925
926 let issues = validator.validate(&program);
927 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
928 "scatter with gather should pass: {:?}", issues);
929 }
930}