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 | "echo" | "printf"
536 | "jq"
538 )
539}
540
541fn is_special_command(name: &str) -> bool {
543 matches!(
544 name,
545 "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
546 )
547}
548
549pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
554 let mut tool_args = ToolArgs::new();
555
556 for arg in args {
557 match arg {
558 Arg::Positional(expr) => {
559 tool_args.positional.push(expr_to_placeholder(expr));
560 }
561 Arg::Named { key, value } => {
562 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
563 }
564 Arg::ShortFlag(flag) => {
565 tool_args.flags.insert(flag.clone());
566 }
567 Arg::LongFlag(flag) => {
568 tool_args.flags.insert(flag.clone());
569 }
570 Arg::DoubleDash => {}
571 }
572 }
573
574 tool_args
575}
576
577fn expr_to_placeholder(expr: &Expr) -> Value {
582 match expr {
583 Expr::Literal(val) => val.clone(),
584 Expr::Interpolated(parts) if parts.len() == 1 => {
585 if let StringPart::Literal(s) = &parts[0] {
586 Value::String(s.clone())
587 } else {
588 Value::String("<dynamic>".to_string())
589 }
590 }
591 _ => Value::String("<dynamic>".to_string()),
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use crate::tools::{register_builtins, ToolRegistry};
600
601 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
602 let mut registry = ToolRegistry::new();
603 register_builtins(&mut registry);
604 let user_tools = HashMap::new();
605 (registry, user_tools)
606 }
607
608 #[test]
609 fn validates_undefined_command() {
610 let (registry, user_tools) = make_validator();
611 let validator = Validator::new(®istry, &user_tools);
612
613 let program = Program {
614 statements: vec![Stmt::Command(Command {
615 name: "nonexistent_command".to_string(),
616 args: vec![],
617 redirects: vec![],
618 })],
619 };
620
621 let issues = validator.validate(&program);
622 assert!(!issues.is_empty());
623 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
624 }
625
626 #[test]
627 fn validates_known_command() {
628 let (registry, user_tools) = make_validator();
629 let validator = Validator::new(®istry, &user_tools);
630
631 let program = Program {
632 statements: vec![Stmt::Command(Command {
633 name: "echo".to_string(),
634 args: vec![Arg::Positional(Expr::Literal(Value::String(
635 "hello".to_string(),
636 )))],
637 redirects: vec![],
638 })],
639 };
640
641 let issues = validator.validate(&program);
642 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
644 }
645
646 #[test]
647 fn validates_break_outside_loop() {
648 let (registry, user_tools) = make_validator();
649 let validator = Validator::new(®istry, &user_tools);
650
651 let program = Program {
652 statements: vec![Stmt::Break(None)],
653 };
654
655 let issues = validator.validate(&program);
656 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
657 }
658
659 #[test]
660 fn validates_break_inside_loop() {
661 let (registry, user_tools) = make_validator();
662 let validator = Validator::new(®istry, &user_tools);
663
664 let program = Program {
665 statements: vec![Stmt::For(ForLoop {
666 variable: "i".to_string(),
667 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
668 body: vec![Stmt::Break(None)],
669 })],
670 };
671
672 let issues = validator.validate(&program);
673 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
675 }
676
677 #[test]
678 fn validates_undefined_variable() {
679 let (registry, user_tools) = make_validator();
680 let validator = Validator::new(®istry, &user_tools);
681
682 let program = Program {
683 statements: vec![Stmt::Command(Command {
684 name: "echo".to_string(),
685 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
686 "UNDEFINED_VAR",
687 )))],
688 redirects: vec![],
689 })],
690 };
691
692 let issues = validator.validate(&program);
693 assert!(issues
694 .iter()
695 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
696 }
697
698 #[test]
699 fn validates_defined_variable() {
700 let (registry, user_tools) = make_validator();
701 let validator = Validator::new(®istry, &user_tools);
702
703 let program = Program {
704 statements: vec![
705 Stmt::Assignment(Assignment {
707 name: "MY_VAR".to_string(),
708 value: Expr::Literal(Value::String("value".to_string())),
709 local: false,
710 }),
711 Stmt::Command(Command {
713 name: "echo".to_string(),
714 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
715 redirects: vec![],
716 }),
717 ],
718 };
719
720 let issues = validator.validate(&program);
721 assert!(!issues
723 .iter()
724 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
725 && i.message.contains("MY_VAR")));
726 }
727
728 #[test]
729 fn skips_underscore_prefixed_vars() {
730 let (registry, user_tools) = make_validator();
731 let validator = Validator::new(®istry, &user_tools);
732
733 let program = Program {
734 statements: vec![Stmt::Command(Command {
735 name: "echo".to_string(),
736 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
737 redirects: vec![],
738 })],
739 };
740
741 let issues = validator.validate(&program);
742 assert!(!issues
744 .iter()
745 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
746 }
747
748 #[test]
749 fn builtin_vars_are_defined() {
750 let (registry, user_tools) = make_validator();
751 let validator = Validator::new(®istry, &user_tools);
752
753 let program = Program {
754 statements: vec![Stmt::Command(Command {
755 name: "echo".to_string(),
756 args: vec![
757 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
758 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
759 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
760 ],
761 redirects: vec![],
762 })],
763 };
764
765 let issues = validator.validate(&program);
766 assert!(!issues
768 .iter()
769 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
770 }
771
772 #[test]
773 fn looks_like_shell_glob_detects_star() {
774 assert!(looks_like_shell_glob("*.txt"));
775 assert!(looks_like_shell_glob("src/*.rs"));
776 assert!(looks_like_shell_glob("**/*.json"));
777 }
778
779 #[test]
780 fn looks_like_shell_glob_detects_question() {
781 assert!(looks_like_shell_glob("file?.log"));
782 assert!(looks_like_shell_glob("test?.txt"));
783 }
784
785 #[test]
786 fn looks_like_shell_glob_detects_brackets() {
787 assert!(looks_like_shell_glob("[abc].txt"));
788 assert!(looks_like_shell_glob("[a-z].log"));
789 assert!(looks_like_shell_glob("file[0-9].rs"));
790 }
791
792 #[test]
793 fn looks_like_shell_glob_rejects_non_patterns() {
794 assert!(!looks_like_shell_glob("readme.txt"));
796 assert!(!looks_like_shell_glob("src/main.rs"));
797 assert!(!looks_like_shell_glob("hello*world"));
799 assert!(!looks_like_shell_glob("^start"));
801 assert!(!looks_like_shell_glob("end$"));
802 }
803
804 #[test]
805 fn command_expects_pattern_or_text_returns_true() {
806 assert!(command_expects_pattern_or_text("grep"));
807 assert!(command_expects_pattern_or_text("echo"));
808 assert!(command_expects_pattern_or_text("find"));
809 assert!(command_expects_pattern_or_text("glob"));
810 }
811
812 #[test]
813 fn command_expects_pattern_or_text_returns_false() {
814 assert!(!command_expects_pattern_or_text("ls"));
815 assert!(!command_expects_pattern_or_text("cat"));
816 assert!(!command_expects_pattern_or_text("rm"));
817 assert!(!command_expects_pattern_or_text("cp"));
818 }
819
820 #[test]
821 fn validates_glob_pattern_in_ls() {
822 let (registry, user_tools) = make_validator();
823 let validator = Validator::new(®istry, &user_tools);
824
825 let program = Program {
826 statements: vec![Stmt::Command(Command {
827 name: "ls".to_string(),
828 args: vec![Arg::Positional(Expr::Literal(Value::String(
829 "*.txt".to_string(),
830 )))],
831 redirects: vec![],
832 })],
833 };
834
835 let issues = validator.validate(&program);
836 assert!(issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
837 }
838
839 #[test]
840 fn allows_glob_pattern_in_grep() {
841 let (registry, user_tools) = make_validator();
842 let validator = Validator::new(®istry, &user_tools);
843
844 let program = Program {
845 statements: vec![Stmt::Command(Command {
846 name: "grep".to_string(),
847 args: vec![Arg::Positional(Expr::Literal(Value::String(
848 "func.*test".to_string(),
849 )))],
850 redirects: vec![],
851 })],
852 };
853
854 let issues = validator.validate(&program);
855 assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
857 }
858
859 #[test]
860 fn allows_glob_pattern_in_echo() {
861 let (registry, user_tools) = make_validator();
862 let validator = Validator::new(®istry, &user_tools);
863
864 let program = Program {
865 statements: vec![Stmt::Command(Command {
866 name: "echo".to_string(),
867 args: vec![Arg::Positional(Expr::Literal(Value::String(
868 "*.txt".to_string(),
869 )))],
870 redirects: vec![],
871 })],
872 };
873
874 let issues = validator.validate(&program);
875 assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
877 }
878
879 #[test]
880 fn validates_scatter_without_gather() {
881 let (registry, user_tools) = make_validator();
882 let validator = Validator::new(®istry, &user_tools);
883
884 let program = Program {
885 statements: vec![Stmt::Pipeline(Pipeline {
886 commands: vec![
887 Command { name: "seq".to_string(), args: vec![
888 Arg::Positional(Expr::Literal(Value::String("1".into()))),
889 Arg::Positional(Expr::Literal(Value::String("3".into()))),
890 ], redirects: vec![] },
891 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
892 Command { name: "echo".to_string(), args: vec![
893 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
894 ], redirects: vec![] },
895 ],
896 background: false,
897 })],
898 };
899
900 let issues = validator.validate(&program);
901 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
902 "should flag scatter without gather: {:?}", issues);
903 }
904
905 #[test]
906 fn allows_scatter_with_gather() {
907 let (registry, user_tools) = make_validator();
908 let validator = Validator::new(®istry, &user_tools);
909
910 let program = Program {
911 statements: vec![Stmt::Pipeline(Pipeline {
912 commands: vec![
913 Command { name: "seq".to_string(), args: vec![
914 Arg::Positional(Expr::Literal(Value::String("1".into()))),
915 Arg::Positional(Expr::Literal(Value::String("3".into()))),
916 ], redirects: vec![] },
917 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
918 Command { name: "echo".to_string(), args: vec![
919 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
920 ], redirects: vec![] },
921 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
922 ],
923 background: false,
924 })],
925 };
926
927 let issues = validator.validate(&program);
928 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
929 "scatter with gather should pass: {:?}", issues);
930 }
931}