1use std::collections::HashMap;
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::validator::issue::Span;
10use crate::tools::{ToolArgs, ToolRegistry};
11
12use super::issue::{IssueCode, ValidationIssue};
13use super::scope_tracker::ScopeTracker;
14
15pub struct Validator<'a> {
17 registry: &'a ToolRegistry,
19 user_tools: &'a HashMap<String, ToolDef>,
21 scope: ScopeTracker,
23 loop_depth: usize,
25 function_depth: usize,
27 issues: Vec<ValidationIssue>,
29}
30
31impl<'a> Validator<'a> {
32 pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
34 Self {
35 registry,
36 user_tools,
37 scope: ScopeTracker::new(),
38 loop_depth: 0,
39 function_depth: 0,
40 issues: Vec::new(),
41 }
42 }
43
44 pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
46 for stmt in &program.statements {
47 self.validate_stmt(stmt);
48 }
49 self.issues
50 }
51
52 fn validate_stmt(&mut self, stmt: &Stmt) {
54 match stmt {
55 Stmt::Assignment(assign) => self.validate_assignment(assign),
56 Stmt::Command(cmd) => self.validate_command(cmd),
57 Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
58 Stmt::If(if_stmt) => self.validate_if(if_stmt),
59 Stmt::For(for_loop) => self.validate_for(for_loop),
60 Stmt::While(while_loop) => self.validate_while(while_loop),
61 Stmt::Case(case_stmt) => self.validate_case(case_stmt),
62 Stmt::Break(levels) => self.validate_break(*levels),
63 Stmt::Continue(levels) => self.validate_continue(*levels),
64 Stmt::Return(expr) => self.validate_return(expr.as_deref()),
65 Stmt::Exit(expr) => {
66 if let Some(e) = expr {
67 self.validate_expr(e);
68 }
69 }
70 Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
71 Stmt::Test(test_expr) => self.validate_test(test_expr),
72 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
73 self.validate_stmt(left);
74 self.validate_stmt(right);
75 }
76 Stmt::Empty => {}
77 }
78 }
79
80 fn validate_assignment(&mut self, assign: &Assignment) {
82 self.validate_expr(&assign.value);
84 self.scope.bind(&assign.name);
86 }
87
88 fn validate_command(&mut self, cmd: &Command) {
90 if cmd.name == "source" || cmd.name == "." {
92 return;
93 }
94
95 if !is_static_command_name(&cmd.name) {
97 return;
98 }
99
100 let is_builtin = self.registry.contains(&cmd.name);
102 let is_user_tool = self.user_tools.contains_key(&cmd.name);
103 let is_special = is_special_command(&cmd.name);
104
105 if !is_builtin && !is_user_tool && !is_special {
106 self.issues.push(ValidationIssue::warning(
108 IssueCode::UndefinedCommand,
109 format!("command '{}' not found in builtin registry", cmd.name),
110 ).with_suggestion("this may be a script in PATH or external command"));
111 }
112
113 for arg in &cmd.args {
115 self.validate_arg(arg);
116 }
117
118 if let Some(tool) = self.registry.get(&cmd.name) {
120 let tool_args = build_tool_args_for_validation(&cmd.args);
121 let tool_issues = tool.validate(&tool_args);
122 self.issues.extend(tool_issues);
123 } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
124 self.validate_user_tool_args(user_tool, &cmd.args);
126 }
127
128 for redirect in &cmd.redirects {
130 self.validate_expr(&redirect.target);
131 }
132 }
133
134 fn validate_arg(&mut self, arg: &Arg) {
136 match arg {
137 Arg::Positional(expr) => self.validate_expr(expr),
138 Arg::Named { value, .. } => self.validate_expr(value),
139 Arg::WordAssign { value, .. } => self.validate_expr(value),
140 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
141 }
142 }
143
144 fn validate_pipeline(&mut self, pipe: &Pipeline) {
146 let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
148 let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
149 if has_scatter && !has_gather {
150 self.issues.push(
151 ValidationIssue::error(
152 IssueCode::ScatterWithoutGather,
153 "scatter without gather — parallel results would be lost",
154 ).with_suggestion("add gather: ... | scatter | cmd | gather")
155 );
156 }
157
158 for cmd in &pipe.commands {
159 self.validate_command(cmd);
160 }
161 }
162
163 fn validate_if(&mut self, if_stmt: &IfStmt) {
165 self.validate_expr(&if_stmt.condition);
166
167 self.scope.push_frame();
168 for stmt in &if_stmt.then_branch {
169 self.validate_stmt(stmt);
170 }
171 self.scope.pop_frame();
172
173 if let Some(else_branch) = &if_stmt.else_branch {
174 self.scope.push_frame();
175 for stmt in else_branch {
176 self.validate_stmt(stmt);
177 }
178 self.scope.pop_frame();
179 }
180 }
181
182 fn validate_for(&mut self, for_loop: &ForLoop) {
184 for item in &for_loop.items {
186 self.validate_expr(item);
187
188 if self.is_bare_scalar_var(item) {
191 self.issues.push(
192 ValidationIssue::error(
193 IssueCode::ForLoopScalarVar,
194 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
195 )
196 .with_suggestion(concat!(
197 "use one of:\n",
198 " for i in $(split \"$VAR\") # split on whitespace\n",
199 " for i in $(split \"$VAR\" \":\") # split on delimiter\n",
200 " for i in $(seq 1 10) # iterate numbers\n",
201 " for i in $(glob \"*.rs\") # iterate files",
202 )),
203 );
204 }
205 }
206
207 self.loop_depth += 1;
208 self.scope.push_frame();
209
210 self.scope.bind(&for_loop.variable);
212
213 for stmt in &for_loop.body {
214 self.validate_stmt(stmt);
215 }
216
217 self.scope.pop_frame();
218 self.loop_depth -= 1;
219 }
220
221 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
227 match expr {
228 Expr::VarRef(_) => true,
230 Expr::VarWithDefault { .. } => true,
232 Expr::CommandSubst(_) => false,
234 Expr::Literal(_) => false,
236 Expr::Interpolated(_) => false,
238 _ => false,
240 }
241 }
242
243 fn validate_while(&mut self, while_loop: &WhileLoop) {
245 self.validate_expr(&while_loop.condition);
246
247 self.loop_depth += 1;
248 self.scope.push_frame();
249
250 for stmt in &while_loop.body {
251 self.validate_stmt(stmt);
252 }
253
254 self.scope.pop_frame();
255 self.loop_depth -= 1;
256 }
257
258 fn validate_case(&mut self, case_stmt: &CaseStmt) {
260 self.validate_expr(&case_stmt.expr);
261
262 for branch in &case_stmt.branches {
263 self.validate_case_branch(branch);
264 }
265 }
266
267 fn validate_case_branch(&mut self, branch: &CaseBranch) {
269 self.scope.push_frame();
270 for stmt in &branch.body {
271 self.validate_stmt(stmt);
272 }
273 self.scope.pop_frame();
274 }
275
276 fn validate_break(&mut self, levels: Option<usize>) {
278 if self.loop_depth == 0 {
279 self.issues.push(ValidationIssue::error(
280 IssueCode::BreakOutsideLoop,
281 "break used outside of a loop",
282 ));
283 } else if let Some(n) = levels
284 && n > self.loop_depth {
285 self.issues.push(ValidationIssue::warning(
286 IssueCode::BreakOutsideLoop,
287 format!(
288 "break {} exceeds loop nesting depth {}",
289 n, self.loop_depth
290 ),
291 ));
292 }
293 }
294
295 fn validate_continue(&mut self, levels: Option<usize>) {
297 if self.loop_depth == 0 {
298 self.issues.push(ValidationIssue::error(
299 IssueCode::BreakOutsideLoop,
300 "continue used outside of a loop",
301 ));
302 } else if let Some(n) = levels
303 && n > self.loop_depth {
304 self.issues.push(ValidationIssue::warning(
305 IssueCode::BreakOutsideLoop,
306 format!(
307 "continue {} exceeds loop nesting depth {}",
308 n, self.loop_depth
309 ),
310 ));
311 }
312 }
313
314 fn validate_return(&mut self, expr: Option<&Expr>) {
316 if let Some(e) = expr {
317 self.validate_expr(e);
318 }
319
320 if self.function_depth == 0 {
321 self.issues.push(ValidationIssue::error(
322 IssueCode::ReturnOutsideFunction,
323 "return used outside of a function",
324 ));
325 }
326 }
327
328 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
330 self.function_depth += 1;
331 self.scope.push_frame();
332
333 for param in &tool_def.params {
335 self.scope.bind(¶m.name);
336 if let Some(default) = ¶m.default {
338 self.validate_expr(default);
339 }
340 }
341
342 for stmt in &tool_def.body {
344 self.validate_stmt(stmt);
345 }
346
347 self.scope.pop_frame();
348 self.function_depth -= 1;
349 }
350
351 fn validate_test(&mut self, test: &TestExpr) {
353 match test {
354 TestExpr::FileTest { path, .. } => self.validate_expr(path),
355 TestExpr::StringTest { value, .. } => self.validate_expr(value),
356 TestExpr::Comparison { left, right, .. } => {
357 self.validate_expr(left);
358 self.validate_expr(right);
359 }
360 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
361 self.validate_test(left);
362 self.validate_test(right);
363 }
364 TestExpr::Not { expr } => self.validate_test(expr),
365 }
366 }
367
368 fn validate_expr(&mut self, expr: &Expr) {
370 match expr {
371 Expr::Literal(_) => {}
372 Expr::VarRef(path) => self.validate_var_ref(path),
373 Expr::Interpolated(parts) => {
374 for part in parts {
375 self.validate_string_part(part);
376 }
377 }
378 Expr::HereDocBody { parts, .. } => {
379 for sp in parts {
380 self.validate_spanned_string_part(sp);
381 }
382 }
383 Expr::BinaryOp { left, right, .. } => {
384 self.validate_expr(left);
385 self.validate_expr(right);
386 }
387 Expr::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
388 Expr::Test(test) => self.validate_test(test),
389 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
390 Expr::VarLength(name) => self.check_var_defined(name),
391 Expr::VarWithDefault { name, .. } => {
392 let _ = name;
394 }
395 Expr::Arithmetic(_) => {
396 }
398 Expr::Command(cmd) => self.validate_command(cmd),
399 Expr::LastExitCode | Expr::CurrentPid => {}
400 Expr::GlobPattern(_) => {}
401 }
402 }
403
404 fn validate_var_ref(&mut self, path: &VarPath) {
406 if let Some(VarSegment::Field(name)) = path.segments.first() {
407 if name == "?" && path.segments.len() > 1 {
410 self.issues.push(
411 ValidationIssue::error(
412 IssueCode::LastResultFieldAccess,
413 "${?.field} is removed; $? is the POSIX exit code",
414 )
415 .with_suggestion(
416 "use `kaish-last` to read the previous command's data or stdout",
417 ),
418 );
419 return;
420 }
421 self.check_var_defined(name);
422 }
423 }
424
425 fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
429 let issues_before = self.issues.len();
430 self.validate_string_part(&sp.part);
431 let span = Span::new(sp.offset, sp.offset + sp.len);
432 for issue in &mut self.issues[issues_before..] {
433 if issue.span.is_none() {
434 issue.span = Some(span);
435 }
436 }
437 }
438
439 fn validate_string_part(&mut self, part: &StringPart) {
441 match part {
442 StringPart::Literal(_) => {}
443 StringPart::Var(path) => self.validate_var_ref(path),
444 StringPart::VarWithDefault { default, .. } => {
445 for p in default {
447 self.validate_string_part(p);
448 }
449 }
450 StringPart::VarLength(name) => self.check_var_defined(name),
451 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
452 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
454 StringPart::LastExitCode | StringPart::CurrentPid => {}
455 }
456 }
457
458 fn check_var_defined(&mut self, name: &str) {
460 if ScopeTracker::should_skip_undefined_check(name) {
462 return;
463 }
464
465 if !self.scope.is_bound(name) {
466 self.issues.push(ValidationIssue::warning(
467 IssueCode::PossiblyUndefinedVariable,
468 format!("variable '{}' may be undefined", name),
469 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
470 }
471 }
472
473 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
481 let positional_count = args
482 .iter()
483 .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
484 .count();
485
486 let required_count = tool_def
487 .params
488 .iter()
489 .filter(|p| p.default.is_none())
490 .count();
491
492 if positional_count < required_count {
493 self.issues.push(ValidationIssue::error(
494 IssueCode::MissingRequiredArg,
495 format!(
496 "'{}' requires {} arguments, got {}",
497 tool_def.name, required_count, positional_count
498 ),
499 ));
500 }
501 }
502}
503
504fn is_static_command_name(name: &str) -> bool {
506 !name.starts_with('$') && !name.contains("$(")
507}
508
509fn is_special_command(name: &str) -> bool {
511 matches!(
512 name,
513 "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
514 )
515}
516
517pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
522 let mut tool_args = ToolArgs::new();
523
524 for arg in args {
525 match arg {
526 Arg::Positional(expr) => {
527 tool_args.positional.push(expr_to_placeholder(expr));
528 }
529 Arg::Named { key, value } => {
530 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
531 }
532 Arg::WordAssign { key, value } => {
533 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
537 }
538 Arg::ShortFlag(flag) => {
539 tool_args.flags.insert(flag.clone());
540 }
541 Arg::LongFlag(flag) => {
542 tool_args.flags.insert(flag.clone());
543 }
544 Arg::DoubleDash => {}
545 }
546 }
547
548 tool_args
549}
550
551fn expr_to_placeholder(expr: &Expr) -> Value {
556 match expr {
557 Expr::Literal(val) => val.clone(),
558 Expr::Interpolated(parts) if parts.len() == 1 => {
559 if let StringPart::Literal(s) = &parts[0] {
560 Value::String(s.clone())
561 } else {
562 Value::String("<dynamic>".to_string())
563 }
564 }
565 _ => Value::String("<dynamic>".to_string()),
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use crate::tools::{register_builtins, ToolRegistry};
574
575 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
576 let mut registry = ToolRegistry::new();
577 register_builtins(&mut registry);
578 let user_tools = HashMap::new();
579 (registry, user_tools)
580 }
581
582 #[test]
583 fn validates_undefined_command() {
584 let (registry, user_tools) = make_validator();
585 let validator = Validator::new(®istry, &user_tools);
586
587 let program = Program {
588 statements: vec![Stmt::Command(Command {
589 name: "nonexistent_command".to_string(),
590 args: vec![],
591 redirects: vec![],
592 })],
593 };
594
595 let issues = validator.validate(&program);
596 assert!(!issues.is_empty());
597 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
598 }
599
600 #[test]
601 fn validates_known_command() {
602 let (registry, user_tools) = make_validator();
603 let validator = Validator::new(®istry, &user_tools);
604
605 let program = Program {
606 statements: vec![Stmt::Command(Command {
607 name: "echo".to_string(),
608 args: vec![Arg::Positional(Expr::Literal(Value::String(
609 "hello".to_string(),
610 )))],
611 redirects: vec![],
612 })],
613 };
614
615 let issues = validator.validate(&program);
616 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
618 }
619
620 #[test]
621 fn validates_break_outside_loop() {
622 let (registry, user_tools) = make_validator();
623 let validator = Validator::new(®istry, &user_tools);
624
625 let program = Program {
626 statements: vec![Stmt::Break(None)],
627 };
628
629 let issues = validator.validate(&program);
630 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
631 }
632
633 #[test]
634 fn validates_break_inside_loop() {
635 let (registry, user_tools) = make_validator();
636 let validator = Validator::new(®istry, &user_tools);
637
638 let program = Program {
639 statements: vec![Stmt::For(ForLoop {
640 variable: "i".to_string(),
641 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
642 body: vec![Stmt::Break(None)],
643 })],
644 };
645
646 let issues = validator.validate(&program);
647 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
649 }
650
651 #[test]
652 fn validates_undefined_variable() {
653 let (registry, user_tools) = make_validator();
654 let validator = Validator::new(®istry, &user_tools);
655
656 let program = Program {
657 statements: vec![Stmt::Command(Command {
658 name: "echo".to_string(),
659 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
660 "UNDEFINED_VAR",
661 )))],
662 redirects: vec![],
663 })],
664 };
665
666 let issues = validator.validate(&program);
667 assert!(issues
668 .iter()
669 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
670 }
671
672 #[test]
673 fn validates_defined_variable() {
674 let (registry, user_tools) = make_validator();
675 let validator = Validator::new(®istry, &user_tools);
676
677 let program = Program {
678 statements: vec![
679 Stmt::Assignment(Assignment {
681 name: "MY_VAR".to_string(),
682 value: Expr::Literal(Value::String("value".to_string())),
683 local: false,
684 }),
685 Stmt::Command(Command {
687 name: "echo".to_string(),
688 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
689 redirects: vec![],
690 }),
691 ],
692 };
693
694 let issues = validator.validate(&program);
695 assert!(!issues
697 .iter()
698 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
699 && i.message.contains("MY_VAR")));
700 }
701
702 #[test]
703 fn skips_underscore_prefixed_vars() {
704 let (registry, user_tools) = make_validator();
705 let validator = Validator::new(®istry, &user_tools);
706
707 let program = Program {
708 statements: vec![Stmt::Command(Command {
709 name: "echo".to_string(),
710 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
711 redirects: vec![],
712 })],
713 };
714
715 let issues = validator.validate(&program);
716 assert!(!issues
718 .iter()
719 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
720 }
721
722 #[test]
723 fn builtin_vars_are_defined() {
724 let (registry, user_tools) = make_validator();
725 let validator = Validator::new(®istry, &user_tools);
726
727 let program = Program {
728 statements: vec![Stmt::Command(Command {
729 name: "echo".to_string(),
730 args: vec![
731 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
732 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
733 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
734 ],
735 redirects: vec![],
736 })],
737 };
738
739 let issues = validator.validate(&program);
740 assert!(!issues
742 .iter()
743 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
744 }
745
746 #[test]
747 fn validates_scatter_without_gather() {
748 let (registry, user_tools) = make_validator();
749 let validator = Validator::new(®istry, &user_tools);
750
751 let program = Program {
752 statements: vec![Stmt::Pipeline(Pipeline {
753 commands: vec![
754 Command { name: "seq".to_string(), args: vec![
755 Arg::Positional(Expr::Literal(Value::String("1".into()))),
756 Arg::Positional(Expr::Literal(Value::String("3".into()))),
757 ], redirects: vec![] },
758 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
759 Command { name: "echo".to_string(), args: vec![
760 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
761 ], redirects: vec![] },
762 ],
763 background: false,
764 })],
765 };
766
767 let issues = validator.validate(&program);
768 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
769 "should flag scatter without gather: {:?}", issues);
770 }
771
772 #[test]
773 fn allows_scatter_with_gather() {
774 let (registry, user_tools) = make_validator();
775 let validator = Validator::new(®istry, &user_tools);
776
777 let program = Program {
778 statements: vec![Stmt::Pipeline(Pipeline {
779 commands: vec![
780 Command { name: "seq".to_string(), args: vec![
781 Arg::Positional(Expr::Literal(Value::String("1".into()))),
782 Arg::Positional(Expr::Literal(Value::String("3".into()))),
783 ], redirects: vec![] },
784 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
785 Command { name: "echo".to_string(), args: vec![
786 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
787 ], redirects: vec![] },
788 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
789 ],
790 background: false,
791 })],
792 };
793
794 let issues = validator.validate(&program);
795 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
796 "scatter with gather should pass: {:?}", issues);
797 }
798
799 fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
800 let mut user_tools = HashMap::new();
801 user_tools.insert(
802 "mytool".to_string(),
803 ToolDef {
804 name: "mytool".to_string(),
805 params: vec![crate::ast::ParamDef {
806 name: "input".to_string(),
807 param_type: None,
808 default: None,
809 }],
810 body: vec![],
811 },
812 );
813 user_tools
814 }
815
816 #[test]
820 fn user_tool_wordassign_counts_as_positional() {
821 let mut registry = ToolRegistry::new();
822 register_builtins(&mut registry);
823 let user_tools = make_user_tool_with_required_positional();
824 let validator = Validator::new(®istry, &user_tools);
825
826 let program = Program {
827 statements: vec![Stmt::Command(Command {
828 name: "mytool".to_string(),
829 args: vec![Arg::WordAssign {
830 key: "foo".to_string(),
831 value: Expr::Literal(Value::String("bar".to_string())),
832 }],
833 redirects: vec![],
834 })],
835 };
836
837 let issues = validator.validate(&program);
838 assert!(
839 !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
840 "WordAssign should satisfy required positional; got {:?}",
841 issues
842 );
843 }
844
845 #[test]
848 fn user_tool_no_args_still_errors() {
849 let mut registry = ToolRegistry::new();
850 register_builtins(&mut registry);
851 let user_tools = make_user_tool_with_required_positional();
852 let validator = Validator::new(®istry, &user_tools);
853
854 let program = Program {
855 statements: vec![Stmt::Command(Command {
856 name: "mytool".to_string(),
857 args: vec![],
858 redirects: vec![],
859 })],
860 };
861
862 let issues = validator.validate(&program);
863 assert!(
864 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
865 "missing positional should still error; got {:?}",
866 issues
867 );
868 }
869}