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 for cmd in &pipe.commands {
166 self.validate_command(cmd);
167 }
168 }
169
170 fn validate_if(&mut self, if_stmt: &IfStmt) {
172 self.validate_expr(&if_stmt.condition);
173
174 self.scope.push_frame();
175 for stmt in &if_stmt.then_branch {
176 self.validate_stmt(stmt);
177 }
178 self.scope.pop_frame();
179
180 if let Some(else_branch) = &if_stmt.else_branch {
181 self.scope.push_frame();
182 for stmt in else_branch {
183 self.validate_stmt(stmt);
184 }
185 self.scope.pop_frame();
186 }
187 }
188
189 fn validate_for(&mut self, for_loop: &ForLoop) {
191 for item in &for_loop.items {
193 self.validate_expr(item);
194
195 if self.is_bare_scalar_var(item) {
198 self.issues.push(
199 ValidationIssue::error(
200 IssueCode::ForLoopScalarVar,
201 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
202 )
203 .with_suggestion(concat!(
204 "use one of:\n",
205 " for i in $(split \"$VAR\") # split on whitespace\n",
206 " for i in $(split \"$VAR\" \":\") # split on delimiter\n",
207 " for i in $(seq 1 10) # iterate numbers\n",
208 " for i in $(glob \"*.rs\") # iterate files",
209 )),
210 );
211 }
212 }
213
214 self.loop_depth += 1;
215 self.scope.push_frame();
216
217 self.scope.bind(&for_loop.variable);
219
220 for stmt in &for_loop.body {
221 self.validate_stmt(stmt);
222 }
223
224 self.scope.pop_frame();
225 self.loop_depth -= 1;
226 }
227
228 fn extract_unquoted_glob_pattern(&self, expr: &Expr) -> Option<String> {
233 match expr {
234 Expr::Literal(Value::String(s)) if looks_like_shell_glob(s) => Some(s.clone()),
235 Expr::Interpolated(parts) if parts.len() == 1 => {
237 if let StringPart::Literal(s) = &parts[0]
238 && looks_like_shell_glob(s) {
239 return Some(s.clone());
240 }
241 None
242 }
243 _ => None,
244 }
245 }
246
247 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
251 match expr {
252 Expr::VarRef(_) => true,
254 Expr::VarWithDefault { .. } => true,
256 Expr::CommandSubst(_) => false,
258 Expr::Literal(_) => false,
260 Expr::Interpolated(_) => false,
262 _ => false,
264 }
265 }
266
267 fn validate_while(&mut self, while_loop: &WhileLoop) {
269 self.validate_expr(&while_loop.condition);
270
271 self.loop_depth += 1;
272 self.scope.push_frame();
273
274 for stmt in &while_loop.body {
275 self.validate_stmt(stmt);
276 }
277
278 self.scope.pop_frame();
279 self.loop_depth -= 1;
280 }
281
282 fn validate_case(&mut self, case_stmt: &CaseStmt) {
284 self.validate_expr(&case_stmt.expr);
285
286 for branch in &case_stmt.branches {
287 self.validate_case_branch(branch);
288 }
289 }
290
291 fn validate_case_branch(&mut self, branch: &CaseBranch) {
293 self.scope.push_frame();
294 for stmt in &branch.body {
295 self.validate_stmt(stmt);
296 }
297 self.scope.pop_frame();
298 }
299
300 fn validate_break(&mut self, levels: Option<usize>) {
302 if self.loop_depth == 0 {
303 self.issues.push(ValidationIssue::error(
304 IssueCode::BreakOutsideLoop,
305 "break used outside of a loop",
306 ));
307 } else if let Some(n) = levels
308 && n > self.loop_depth {
309 self.issues.push(ValidationIssue::warning(
310 IssueCode::BreakOutsideLoop,
311 format!(
312 "break {} exceeds loop nesting depth {}",
313 n, self.loop_depth
314 ),
315 ));
316 }
317 }
318
319 fn validate_continue(&mut self, levels: Option<usize>) {
321 if self.loop_depth == 0 {
322 self.issues.push(ValidationIssue::error(
323 IssueCode::BreakOutsideLoop,
324 "continue used outside of a loop",
325 ));
326 } else if let Some(n) = levels
327 && n > self.loop_depth {
328 self.issues.push(ValidationIssue::warning(
329 IssueCode::BreakOutsideLoop,
330 format!(
331 "continue {} exceeds loop nesting depth {}",
332 n, self.loop_depth
333 ),
334 ));
335 }
336 }
337
338 fn validate_return(&mut self, expr: Option<&Expr>) {
340 if let Some(e) = expr {
341 self.validate_expr(e);
342 }
343
344 if self.function_depth == 0 {
345 self.issues.push(ValidationIssue::error(
346 IssueCode::ReturnOutsideFunction,
347 "return used outside of a function",
348 ));
349 }
350 }
351
352 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
354 self.function_depth += 1;
355 self.scope.push_frame();
356
357 for param in &tool_def.params {
359 self.scope.bind(¶m.name);
360 if let Some(default) = ¶m.default {
362 self.validate_expr(default);
363 }
364 }
365
366 for stmt in &tool_def.body {
368 self.validate_stmt(stmt);
369 }
370
371 self.scope.pop_frame();
372 self.function_depth -= 1;
373 }
374
375 fn validate_test(&mut self, test: &TestExpr) {
377 match test {
378 TestExpr::FileTest { path, .. } => self.validate_expr(path),
379 TestExpr::StringTest { value, .. } => self.validate_expr(value),
380 TestExpr::Comparison { left, right, .. } => {
381 self.validate_expr(left);
382 self.validate_expr(right);
383 }
384 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
385 self.validate_test(left);
386 self.validate_test(right);
387 }
388 TestExpr::Not { expr } => self.validate_test(expr),
389 }
390 }
391
392 fn validate_expr(&mut self, expr: &Expr) {
394 match expr {
395 Expr::Literal(_) => {}
396 Expr::VarRef(path) => self.validate_var_ref(path),
397 Expr::Interpolated(parts) => {
398 for part in parts {
399 self.validate_string_part(part);
400 }
401 }
402 Expr::BinaryOp { left, right, .. } => {
403 self.validate_expr(left);
404 self.validate_expr(right);
405 }
406 Expr::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
407 Expr::Test(test) => self.validate_test(test),
408 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
409 Expr::VarLength(name) => self.check_var_defined(name),
410 Expr::VarWithDefault { name, .. } => {
411 let _ = name;
413 }
414 Expr::Arithmetic(_) => {
415 }
417 Expr::Command(cmd) => self.validate_command(cmd),
418 Expr::LastExitCode | Expr::CurrentPid => {}
419 }
420 }
421
422 fn validate_var_ref(&mut self, path: &VarPath) {
424 if let Some(VarSegment::Field(name)) = path.segments.first() {
425 self.check_var_defined(name);
426 }
427 }
428
429 fn validate_string_part(&mut self, part: &StringPart) {
431 match part {
432 StringPart::Literal(_) => {}
433 StringPart::Var(path) => self.validate_var_ref(path),
434 StringPart::VarWithDefault { default, .. } => {
435 for p in default {
437 self.validate_string_part(p);
438 }
439 }
440 StringPart::VarLength(name) => self.check_var_defined(name),
441 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
442 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
444 StringPart::LastExitCode | StringPart::CurrentPid => {}
445 }
446 }
447
448 fn check_var_defined(&mut self, name: &str) {
450 if ScopeTracker::should_skip_undefined_check(name) {
452 return;
453 }
454
455 if !self.scope.is_bound(name) {
456 self.issues.push(ValidationIssue::warning(
457 IssueCode::PossiblyUndefinedVariable,
458 format!("variable '{}' may be undefined", name),
459 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
460 }
461 }
462
463 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
465 let positional_count = args
466 .iter()
467 .filter(|a| matches!(a, Arg::Positional(_)))
468 .count();
469
470 let required_count = tool_def
471 .params
472 .iter()
473 .filter(|p| p.default.is_none())
474 .count();
475
476 if positional_count < required_count {
477 self.issues.push(ValidationIssue::error(
478 IssueCode::MissingRequiredArg,
479 format!(
480 "'{}' requires {} arguments, got {}",
481 tool_def.name, required_count, positional_count
482 ),
483 ));
484 }
485 }
486}
487
488fn is_static_command_name(name: &str) -> bool {
490 !name.starts_with('$') && !name.contains("$(")
491}
492
493fn looks_like_shell_glob(s: &str) -> bool {
498 if s.starts_with('^') || s.ends_with('$') {
500 return false;
501 }
502
503 let has_star = s.contains('*');
504 let has_question = s.contains('?') && !s.contains("??"); let has_bracket = s.contains('[') && s.contains(']');
506
507 let looks_like_path = s.contains('.') || s.contains('/');
509
510 (has_star || has_question || has_bracket) && looks_like_path
511}
512
513fn command_expects_pattern_or_text(cmd: &str) -> bool {
518 matches!(
519 cmd,
520 "grep" | "egrep" | "fgrep" | "sed" | "awk" | "find" | "glob" | "regex"
522 | "echo" | "printf"
524 | "jq"
526 )
527}
528
529fn is_special_command(name: &str) -> bool {
531 matches!(
532 name,
533 "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
534 )
535}
536
537pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
542 let mut tool_args = ToolArgs::new();
543
544 for arg in args {
545 match arg {
546 Arg::Positional(expr) => {
547 tool_args.positional.push(expr_to_placeholder(expr));
548 }
549 Arg::Named { key, value } => {
550 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
551 }
552 Arg::ShortFlag(flag) => {
553 tool_args.flags.insert(flag.clone());
554 }
555 Arg::LongFlag(flag) => {
556 tool_args.flags.insert(flag.clone());
557 }
558 Arg::DoubleDash => {}
559 }
560 }
561
562 tool_args
563}
564
565fn expr_to_placeholder(expr: &Expr) -> Value {
570 match expr {
571 Expr::Literal(val) => val.clone(),
572 Expr::Interpolated(parts) if parts.len() == 1 => {
573 if let StringPart::Literal(s) = &parts[0] {
574 Value::String(s.clone())
575 } else {
576 Value::String("<dynamic>".to_string())
577 }
578 }
579 _ => Value::String("<dynamic>".to_string()),
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use crate::tools::{register_builtins, ToolRegistry};
588
589 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
590 let mut registry = ToolRegistry::new();
591 register_builtins(&mut registry);
592 let user_tools = HashMap::new();
593 (registry, user_tools)
594 }
595
596 #[test]
597 fn validates_undefined_command() {
598 let (registry, user_tools) = make_validator();
599 let validator = Validator::new(®istry, &user_tools);
600
601 let program = Program {
602 statements: vec![Stmt::Command(Command {
603 name: "nonexistent_command".to_string(),
604 args: vec![],
605 redirects: vec![],
606 })],
607 };
608
609 let issues = validator.validate(&program);
610 assert!(!issues.is_empty());
611 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
612 }
613
614 #[test]
615 fn validates_known_command() {
616 let (registry, user_tools) = make_validator();
617 let validator = Validator::new(®istry, &user_tools);
618
619 let program = Program {
620 statements: vec![Stmt::Command(Command {
621 name: "echo".to_string(),
622 args: vec![Arg::Positional(Expr::Literal(Value::String(
623 "hello".to_string(),
624 )))],
625 redirects: vec![],
626 })],
627 };
628
629 let issues = validator.validate(&program);
630 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
632 }
633
634 #[test]
635 fn validates_break_outside_loop() {
636 let (registry, user_tools) = make_validator();
637 let validator = Validator::new(®istry, &user_tools);
638
639 let program = Program {
640 statements: vec![Stmt::Break(None)],
641 };
642
643 let issues = validator.validate(&program);
644 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
645 }
646
647 #[test]
648 fn validates_break_inside_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::For(ForLoop {
654 variable: "i".to_string(),
655 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
656 body: vec![Stmt::Break(None)],
657 })],
658 };
659
660 let issues = validator.validate(&program);
661 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
663 }
664
665 #[test]
666 fn validates_undefined_variable() {
667 let (registry, user_tools) = make_validator();
668 let validator = Validator::new(®istry, &user_tools);
669
670 let program = Program {
671 statements: vec![Stmt::Command(Command {
672 name: "echo".to_string(),
673 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
674 "UNDEFINED_VAR",
675 )))],
676 redirects: vec![],
677 })],
678 };
679
680 let issues = validator.validate(&program);
681 assert!(issues
682 .iter()
683 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
684 }
685
686 #[test]
687 fn validates_defined_variable() {
688 let (registry, user_tools) = make_validator();
689 let validator = Validator::new(®istry, &user_tools);
690
691 let program = Program {
692 statements: vec![
693 Stmt::Assignment(Assignment {
695 name: "MY_VAR".to_string(),
696 value: Expr::Literal(Value::String("value".to_string())),
697 local: false,
698 }),
699 Stmt::Command(Command {
701 name: "echo".to_string(),
702 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
703 redirects: vec![],
704 }),
705 ],
706 };
707
708 let issues = validator.validate(&program);
709 assert!(!issues
711 .iter()
712 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
713 && i.message.contains("MY_VAR")));
714 }
715
716 #[test]
717 fn skips_underscore_prefixed_vars() {
718 let (registry, user_tools) = make_validator();
719 let validator = Validator::new(®istry, &user_tools);
720
721 let program = Program {
722 statements: vec![Stmt::Command(Command {
723 name: "echo".to_string(),
724 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
725 redirects: vec![],
726 })],
727 };
728
729 let issues = validator.validate(&program);
730 assert!(!issues
732 .iter()
733 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
734 }
735
736 #[test]
737 fn builtin_vars_are_defined() {
738 let (registry, user_tools) = make_validator();
739 let validator = Validator::new(®istry, &user_tools);
740
741 let program = Program {
742 statements: vec![Stmt::Command(Command {
743 name: "echo".to_string(),
744 args: vec![
745 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
746 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
747 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
748 ],
749 redirects: vec![],
750 })],
751 };
752
753 let issues = validator.validate(&program);
754 assert!(!issues
756 .iter()
757 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
758 }
759
760 #[test]
761 fn looks_like_shell_glob_detects_star() {
762 assert!(looks_like_shell_glob("*.txt"));
763 assert!(looks_like_shell_glob("src/*.rs"));
764 assert!(looks_like_shell_glob("**/*.json"));
765 }
766
767 #[test]
768 fn looks_like_shell_glob_detects_question() {
769 assert!(looks_like_shell_glob("file?.log"));
770 assert!(looks_like_shell_glob("test?.txt"));
771 }
772
773 #[test]
774 fn looks_like_shell_glob_detects_brackets() {
775 assert!(looks_like_shell_glob("[abc].txt"));
776 assert!(looks_like_shell_glob("[a-z].log"));
777 assert!(looks_like_shell_glob("file[0-9].rs"));
778 }
779
780 #[test]
781 fn looks_like_shell_glob_rejects_non_patterns() {
782 assert!(!looks_like_shell_glob("readme.txt"));
784 assert!(!looks_like_shell_glob("src/main.rs"));
785 assert!(!looks_like_shell_glob("hello*world"));
787 assert!(!looks_like_shell_glob("^start"));
789 assert!(!looks_like_shell_glob("end$"));
790 }
791
792 #[test]
793 fn command_expects_pattern_or_text_returns_true() {
794 assert!(command_expects_pattern_or_text("grep"));
795 assert!(command_expects_pattern_or_text("echo"));
796 assert!(command_expects_pattern_or_text("find"));
797 assert!(command_expects_pattern_or_text("glob"));
798 }
799
800 #[test]
801 fn command_expects_pattern_or_text_returns_false() {
802 assert!(!command_expects_pattern_or_text("ls"));
803 assert!(!command_expects_pattern_or_text("cat"));
804 assert!(!command_expects_pattern_or_text("rm"));
805 assert!(!command_expects_pattern_or_text("cp"));
806 }
807
808 #[test]
809 fn validates_glob_pattern_in_ls() {
810 let (registry, user_tools) = make_validator();
811 let validator = Validator::new(®istry, &user_tools);
812
813 let program = Program {
814 statements: vec![Stmt::Command(Command {
815 name: "ls".to_string(),
816 args: vec![Arg::Positional(Expr::Literal(Value::String(
817 "*.txt".to_string(),
818 )))],
819 redirects: vec![],
820 })],
821 };
822
823 let issues = validator.validate(&program);
824 assert!(issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
825 }
826
827 #[test]
828 fn allows_glob_pattern_in_grep() {
829 let (registry, user_tools) = make_validator();
830 let validator = Validator::new(®istry, &user_tools);
831
832 let program = Program {
833 statements: vec![Stmt::Command(Command {
834 name: "grep".to_string(),
835 args: vec![Arg::Positional(Expr::Literal(Value::String(
836 "func.*test".to_string(),
837 )))],
838 redirects: vec![],
839 })],
840 };
841
842 let issues = validator.validate(&program);
843 assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
845 }
846
847 #[test]
848 fn allows_glob_pattern_in_echo() {
849 let (registry, user_tools) = make_validator();
850 let validator = Validator::new(®istry, &user_tools);
851
852 let program = Program {
853 statements: vec![Stmt::Command(Command {
854 name: "echo".to_string(),
855 args: vec![Arg::Positional(Expr::Literal(Value::String(
856 "*.txt".to_string(),
857 )))],
858 redirects: vec![],
859 })],
860 };
861
862 let issues = validator.validate(&program);
863 assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
865 }
866}