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