1use std::collections::{HashMap, HashSet};
4
5use crate::ast::{
6 Arg, Assignment, CaseBranch, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
7 PipelineStage, Program, SpannedPart, Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment,
8 WhileLoop,
9 Value,
10};
11use crate::kernel::{bind_glued_short_value, push_repeatable_value};
12use crate::scheduler::{is_bool_type, schema_param_lookup};
13use crate::validator::issue::Span;
14use crate::tools::{ToolArgs, ToolRegistry, ToolSchema};
15use kaish_types::CommandKind;
16
17use super::issue::{IssueCode, ValidationIssue};
18#[cfg(test)]
19use super::issue::Severity;
20use super::scope_tracker::ScopeTracker;
21
22pub struct Validator<'a> {
24 registry: &'a ToolRegistry,
26 user_tools: &'a HashMap<String, ToolDef>,
28 catalog: &'a [ToolSchema],
37 scope: ScopeTracker,
39 loop_depth: usize,
41 function_depth: usize,
43 issues: Vec<ValidationIssue>,
45}
46
47impl<'a> Validator<'a> {
48 pub fn new(
53 registry: &'a ToolRegistry,
54 user_tools: &'a HashMap<String, ToolDef>,
55 catalog: &'a [ToolSchema],
56 ) -> Self {
57 Self {
58 registry,
59 user_tools,
60 catalog,
61 scope: ScopeTracker::new(),
62 loop_depth: 0,
63 function_depth: 0,
64 issues: Vec::new(),
65 }
66 }
67
68 pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
70 for stmt in &program.statements {
71 self.validate_stmt(stmt);
72 }
73 self.issues
74 }
75
76 fn validate_stmt(&mut self, stmt: &Stmt) {
78 match stmt {
79 Stmt::Assignment(assign) => self.validate_assignment(assign),
80 Stmt::Command(cmd) => self.validate_command(cmd),
81 Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
82 Stmt::If(if_stmt) => self.validate_if(if_stmt),
83 Stmt::For(for_loop) => self.validate_for(for_loop),
84 Stmt::While(while_loop) => self.validate_while(while_loop),
85 Stmt::Case(case_stmt) => self.validate_case(case_stmt),
86 Stmt::Break(levels) => self.validate_break(*levels),
87 Stmt::Continue(levels) => self.validate_continue(*levels),
88 Stmt::Return(expr) => self.validate_return(expr.as_deref()),
89 Stmt::Exit(expr) => {
90 if let Some(e) = expr {
91 self.validate_expr(e);
92 }
93 }
94 Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
95 Stmt::Test(test_expr) => self.validate_test(test_expr),
96 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
97 self.validate_stmt(left);
98 self.validate_stmt(right);
99 }
100 Stmt::EnvScoped { assignments, body } => {
101 for assign in assignments {
104 self.validate_assignment(assign);
105 }
106 self.validate_stmt(body);
107 }
108 Stmt::Empty => {}
109 }
110 }
111
112 fn validate_assignment(&mut self, assign: &Assignment) {
122 self.validate_expr(&assign.value);
124
125 let name = assign.name();
126
127 if let Err(bad) = crate::name::validate(name) {
133 if !matches!(bad.ch, '.' | '#') {
137 self.issues.push(ValidationIssue::error(
138 IssueCode::InvisibleAssignmentTarget,
139 bad.to_string(),
140 ));
141 }
142 }
143
144 if let Some(mixed) = crate::name::mixed_script(name) {
148 self.issues.push(
149 ValidationIssue::warning(IssueCode::MixedScriptName, mixed.to_string())
150 .with_suggestion(mixed.suggestion()),
151 );
152 }
153
154 if assign.path.segments.len() == 1 {
155 if let Some(dot) = name.find('.') {
156 let (root, rest) = (&name[..dot], &name[dot + 1..]);
157 self.issues.push(
158 ValidationIssue::error(
159 IssueCode::DottedAssignmentTarget,
160 format!(
161 "'{name}' is not a valid assignment target — kaish uses bracket \
162 access, not dots"
163 ),
164 )
165 .with_suggestion(format!("use `{root}[{rest}]=value`")),
166 );
167 }
168 if name.contains('#') {
169 self.issues.push(
174 ValidationIssue::error(
175 IssueCode::UnreadableAssignmentTarget,
176 format!(
177 "'{name}' is not a valid assignment target — a variable name \
178 cannot contain `#`"
179 ),
180 )
181 .with_suggestion(format!(
182 "drop the `#`, e.g. `{}=value`",
183 name.replace('#', "_")
184 )),
185 );
186 }
187 self.scope.bind(name);
189 } else if !self.scope.is_bound(name) {
190 self.issues.push(
191 ValidationIssue::error(
192 IssueCode::LvalueUndefinedRoot,
193 format!(
194 "'{name}' is not defined — a subscripted assignment never creates the \
195 root variable"
196 ),
197 )
198 .with_suggestion(format!("create it first, e.g. `{name}={{}}` or `{name}=[]`")),
199 );
200 self.scope.bind(name);
203 }
204 }
205
206 fn validate_command(&mut self, cmd: &Command) {
208 if cmd.name == "source" || cmd.name == "." {
210 return;
211 }
212
213 if !is_static_command_name(&cmd.name) {
215 return;
216 }
217
218 let is_builtin = self.registry.contains(&cmd.name);
220 let is_user_tool = self.user_tools.contains_key(&cmd.name);
221 let is_special = is_special_command(&cmd.name);
222
223 if !is_builtin && !is_user_tool && !is_special {
224 self.issues.push(ValidationIssue::warning(
228 IssueCode::UndefinedCommand,
229 format!("command '{}' not found in builtin registry", cmd.name),
230 ).with_suggestion("this may be a script in PATH or external command"));
231 }
232
233 for arg in &cmd.args {
235 self.validate_arg(arg);
236 }
237
238 if let Some(tool) = self.registry.get(&cmd.name) {
243 let owned;
251 let schema: &ToolSchema =
252 match self.catalog.binary_search_by(|s| s.name.as_str().cmp(cmd.name.as_str())) {
253 Ok(i) => &self.catalog[i],
254 Err(_) => {
255 owned = tool.schema();
256 &owned
257 }
258 };
259 let tool_args = build_tool_args_for_validation(&cmd.args, Some(schema));
260 let tool_issues = tool.validate(&tool_args);
261 self.issues.extend(tool_issues);
262 } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
263 self.validate_user_tool_args(user_tool, &cmd.args);
265 }
266
267 for redirect in &cmd.redirects {
269 self.validate_expr(&redirect.target);
270 }
271 }
272
273 fn validate_arg(&mut self, arg: &Arg) {
275 match arg {
276 Arg::Positional(expr) => self.validate_expr(expr),
277 Arg::Named { value, .. } => self.validate_expr(value),
278 Arg::WordAssign { value, .. } => self.validate_expr(value),
279 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
280 }
281 }
282
283 fn validate_pipeline(&mut self, pipe: &Pipeline) {
285 let named = |name: &str| {
287 pipe.stages
288 .iter()
289 .filter_map(|s| s.as_command())
290 .any(|c| c.name == name)
291 };
292 let has_scatter = named("scatter");
293 let has_gather = named("gather");
294 if has_scatter && !has_gather {
295 self.issues.push(
296 ValidationIssue::error(
297 IssueCode::ScatterWithoutGather,
298 "scatter without gather — parallel results would be lost",
299 ).with_suggestion("add gather: ... | scatter | cmd | gather")
300 );
301 }
302
303 for stage in &pipe.stages {
304 match stage {
305 PipelineStage::Command(cmd) => self.validate_command(cmd),
306 PipelineStage::Compound(stmt) => self.validate_stmt(stmt),
307 }
308 }
309 }
310
311 fn validate_if(&mut self, if_stmt: &IfStmt) {
313 self.validate_expr(&if_stmt.condition);
314
315 self.scope.push_frame();
316 for stmt in &if_stmt.then_branch {
317 self.validate_stmt(stmt);
318 }
319 self.scope.pop_frame();
320
321 if let Some(else_branch) = &if_stmt.else_branch {
322 self.scope.push_frame();
323 for stmt in else_branch {
324 self.validate_stmt(stmt);
325 }
326 self.scope.pop_frame();
327 }
328 }
329
330 fn validate_for(&mut self, for_loop: &ForLoop) {
332 for item in &for_loop.items {
334 self.validate_expr(item);
335
336 if self.is_bare_scalar_var(item) {
339 self.issues.push(
340 ValidationIssue::error(
341 IssueCode::ForLoopScalarVar,
342 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
343 )
344 .with_suggestion(concat!(
345 "wrap it in $(...) — for a collection use keys/values:\n",
346 " for x in $(values $coll) # list elements / record values\n",
347 " for k in $(keys $coll) # list indices / record keys\n",
348 " for i in $(split \"$VAR\") # split a string on whitespace\n",
349 " for i in $(split \"$VAR\" \":\") # split a string on a delimiter\n",
350 " for i in $(seq 1 10) # iterate numbers\n",
351 " for i in $(glob \"*.rs\") # iterate files",
352 )),
353 );
354 }
355 }
356
357 self.loop_depth += 1;
358 self.scope.push_frame();
359
360 self.scope.bind(&for_loop.variable);
362
363 for stmt in &for_loop.body {
364 self.validate_stmt(stmt);
365 }
366
367 self.scope.pop_frame();
368 self.loop_depth -= 1;
369 }
370
371 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
377 match expr {
378 Expr::VarRef(_) => true,
380 Expr::VarWithDefault { .. } => true,
382 Expr::CommandSubst(_) => false,
384 Expr::Literal(_) => false,
386 Expr::Interpolated(_) => false,
388 _ => false,
390 }
391 }
392
393 fn validate_while(&mut self, while_loop: &WhileLoop) {
395 self.validate_expr(&while_loop.condition);
396
397 self.loop_depth += 1;
398 self.scope.push_frame();
399
400 for stmt in &while_loop.body {
401 self.validate_stmt(stmt);
402 }
403
404 self.scope.pop_frame();
405 self.loop_depth -= 1;
406 }
407
408 fn validate_case(&mut self, case_stmt: &CaseStmt) {
410 self.validate_expr(&case_stmt.expr);
411
412 for branch in &case_stmt.branches {
413 self.validate_case_branch(branch);
414 }
415 }
416
417 fn validate_case_branch(&mut self, branch: &CaseBranch) {
419 self.scope.push_frame();
420 for stmt in &branch.body {
421 self.validate_stmt(stmt);
422 }
423 self.scope.pop_frame();
424 }
425
426 fn validate_break(&mut self, levels: Option<usize>) {
428 if self.loop_depth == 0 {
429 self.issues.push(ValidationIssue::error(
430 IssueCode::BreakOutsideLoop,
431 "break used outside of a loop",
432 ));
433 } else if let Some(n) = levels
434 && n > self.loop_depth {
435 self.issues.push(ValidationIssue::warning(
436 IssueCode::BreakOutsideLoop,
437 format!(
438 "break {} exceeds loop nesting depth {}",
439 n, self.loop_depth
440 ),
441 ));
442 }
443 }
444
445 fn validate_continue(&mut self, levels: Option<usize>) {
447 if self.loop_depth == 0 {
448 self.issues.push(ValidationIssue::error(
449 IssueCode::BreakOutsideLoop,
450 "continue used outside of a loop",
451 ));
452 } else if let Some(n) = levels
453 && n > self.loop_depth {
454 self.issues.push(ValidationIssue::warning(
455 IssueCode::BreakOutsideLoop,
456 format!(
457 "continue {} exceeds loop nesting depth {}",
458 n, self.loop_depth
459 ),
460 ));
461 }
462 }
463
464 fn validate_return(&mut self, expr: Option<&Expr>) {
466 if let Some(e) = expr {
467 self.validate_expr(e);
468 }
469
470 if self.function_depth == 0 {
471 self.issues.push(ValidationIssue::error(
472 IssueCode::ReturnOutsideFunction,
473 "return used outside of a function",
474 ));
475 }
476 }
477
478 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
480 self.function_depth += 1;
481 self.scope.push_frame();
482
483 for param in &tool_def.params {
485 self.scope.bind(¶m.name);
486 if let Some(default) = ¶m.default {
488 self.validate_expr(default);
489 }
490 }
491
492 for stmt in &tool_def.body {
494 self.validate_stmt(stmt);
495 }
496
497 self.scope.pop_frame();
498 self.function_depth -= 1;
499 }
500
501 fn validate_test(&mut self, test: &TestExpr) {
503 match test {
504 TestExpr::FileTest { path, .. } => self.validate_expr(path),
505 TestExpr::StringTest { value, .. } => self.validate_expr(value),
506 TestExpr::Comparison { left, right, .. } => {
507 self.validate_expr(left);
508 self.validate_expr(right);
509 }
510 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
511 self.validate_test(left);
512 self.validate_test(right);
513 }
514 TestExpr::Not { expr } => self.validate_test(expr),
515 TestExpr::In { left, right } | TestExpr::NotIn { left, right } => {
516 self.validate_expr(left);
517 self.validate_expr(right);
518 }
519 }
520 }
521
522 fn validate_expr(&mut self, expr: &Expr) {
524 match expr {
525 Expr::Literal(_) => {}
526 Expr::VarRef(path) => self.validate_var_ref(path),
527 Expr::Interpolated(parts) => {
528 for part in parts {
529 self.validate_string_part(part);
530 }
531 }
532 Expr::HereDocBody { parts, .. } => {
533 for sp in parts {
534 self.validate_spanned_string_part(sp);
535 }
536 }
537 Expr::BinaryOp { left, right, .. } => {
538 self.validate_expr(left);
539 self.validate_expr(right);
540 }
541 Expr::CommandSubst(stmts) => {
542 for stmt in stmts {
543 self.validate_stmt(stmt);
544 }
545 }
546 Expr::Test(test) => self.validate_test(test),
547 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
548 Expr::VarLength(path) => {
549 if let Some(VarSegment::Field(root)) = path.segments.first() {
550 self.check_var_defined(root);
551 }
552 }
553 Expr::VarWithDefault { .. } => {
554 }
556 Expr::Arithmetic(_) => {
557 }
559 Expr::Command(cmd) => self.validate_command(cmd),
560 Expr::LastExitCode | Expr::CurrentPid => {}
561 Expr::GlobPattern(_) => {}
562 Expr::ListLiteral(elems) => {
563 for elem in elems {
564 match elem {
565 ListElem::Item(e) | ListElem::Spread(e) => self.validate_expr(e),
566 }
567 }
568 }
569 Expr::RecordLiteral(entries) => {
570 for entry in entries {
571 self.validate_expr(&entry.value);
572 }
573 }
574 }
575 }
576
577 fn validate_var_ref(&mut self, path: &VarPath) {
579 if let Some(VarSegment::Field(name)) = path.segments.first() {
580 if name == "?" && path.segments.len() > 1 {
583 self.issues.push(
584 ValidationIssue::error(
585 IssueCode::LastResultFieldAccess,
586 "${?.field} is removed; $? is the POSIX exit code",
587 )
588 .with_suggestion(
589 "use `kaish-last` to read the previous command's data or stdout",
590 ),
591 );
592 return;
593 }
594 self.check_var_defined(name);
595 }
596 }
597
598 fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
602 let issues_before = self.issues.len();
603 self.validate_string_part(&sp.part);
604 let span = Span::new(sp.offset, sp.offset + sp.len);
605 for issue in &mut self.issues[issues_before..] {
606 if issue.span.is_none() {
607 issue.span = Some(span);
608 }
609 }
610 }
611
612 fn validate_string_part(&mut self, part: &StringPart) {
614 match part {
615 StringPart::Literal(_) => {}
616 StringPart::Var(path) => self.validate_var_ref(path),
617 StringPart::VarWithDefault { default, .. } => {
618 for p in default {
620 self.validate_string_part(p);
621 }
622 }
623 StringPart::VarLength(path) => {
624 if let Some(VarSegment::Field(root)) = path.segments.first() {
625 self.check_var_defined(root);
626 }
627 }
628 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
629 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(stmts) => {
631 for stmt in stmts {
632 self.validate_stmt(stmt);
633 }
634 }
635 StringPart::LastExitCode | StringPart::CurrentPid => {}
636 }
637 }
638
639 fn check_var_defined(&mut self, name: &str) {
641 if ScopeTracker::should_skip_undefined_check(name) {
643 return;
644 }
645
646 if !self.scope.is_bound(name) {
647 self.issues.push(ValidationIssue::warning(
648 IssueCode::PossiblyUndefinedVariable,
649 format!("variable '{}' may be undefined", name),
650 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
651 }
652 }
653
654 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
662 let positional_count = args
663 .iter()
664 .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
665 .count();
666
667 let required_count = tool_def
668 .params
669 .iter()
670 .filter(|p| p.default.is_none())
671 .count();
672
673 if positional_count < required_count {
674 self.issues.push(ValidationIssue::error(
675 IssueCode::MissingRequiredArg,
676 format!(
677 "'{}' requires {} arguments, got {}",
678 tool_def.name, required_count, positional_count
679 ),
680 ));
681 }
682 }
683}
684
685pub(crate) fn is_static_command_name(name: &str) -> bool {
692 !name.starts_with('$') && !name.contains("$(") && !name.contains("${")
693}
694
695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714pub(crate) enum SpecialForm {
715 True,
717 False,
719 Source,
721}
722
723impl SpecialForm {
724 pub(crate) fn from_name(name: &str) -> Option<Self> {
727 match name {
728 "true" | ":" => Some(Self::True),
734 "false" => Some(Self::False),
735 "source" | "." => Some(Self::Source),
736 _ => None,
737 }
738 }
739}
740
741pub(crate) fn is_runtime_special_form(name: &str) -> bool {
743 SpecialForm::from_name(name).is_some()
744}
745
746pub(crate) fn classify_command_name(
750 name: &str,
751 is_builtin: bool,
752 is_user_tool: bool,
753) -> CommandKind {
754 if !is_static_command_name(name) {
755 return CommandKind::Dynamic;
756 }
757 if is_runtime_special_form(name) {
758 return CommandKind::Special;
759 }
760 if is_user_tool {
763 return CommandKind::UserTool;
764 }
765 if is_builtin {
766 return CommandKind::Builtin;
767 }
768 CommandKind::External
769}
770
771fn is_special_command(name: &str) -> bool {
773 matches!(name, "true" | "false" | "readonly" | "local")
778}
779
780pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
785 let mut tool_args = ToolArgs::new();
786 let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
790 let mut consumed: HashSet<usize> = HashSet::new();
791 let mut past_double_dash = false;
792
793 for i in 0..args.len() {
794 match &args[i] {
795 Arg::DoubleDash => past_double_dash = true,
796 Arg::Positional(expr) => {
797 if !consumed.contains(&i) {
798 tool_args.positional.push(expr_to_placeholder(expr));
799 }
800 }
801 Arg::Named { key, value } => {
802 let v = expr_to_placeholder(value);
803 match param_lookup.get(key.as_str()) {
804 Some(&(canonical, _, _, true)) => {
806 let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
807 }
808 Some(&(canonical, ..)) => {
809 tool_args.named.insert(canonical.to_string(), v);
810 }
811 None => {
812 tool_args.named.insert(key.clone(), v);
813 }
814 }
815 }
816 Arg::WordAssign { key, value } => {
817 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
821 }
822 Arg::ShortFlag(name) => {
823 if past_double_dash {
824 tool_args.positional.push(Value::String(format!("-{name}")));
825 } else {
826 bind_short_flag_for_validation(
827 name,
828 ¶m_lookup,
829 args,
830 i,
831 &mut consumed,
832 &mut tool_args,
833 );
834 }
835 }
836 Arg::LongFlag(name) => {
837 if past_double_dash {
838 tool_args.positional.push(Value::String(format!("--{name}")));
839 } else {
840 match param_lookup.get(name.as_str()) {
841 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
842 bind_value_or_flag(
843 &mut tool_args, name, canonical, consumes, repeatable, args, i,
844 &mut consumed,
845 );
846 }
847 Some(&(canonical, ..)) => {
848 tool_args.flags.insert(canonical.to_string());
849 }
850 None => {
851 tool_args.flags.insert(name.clone());
852 }
853 }
854 }
855 }
856 }
857 }
858
859 tool_args
860}
861
862fn bind_short_flag_for_validation(
868 name: &str,
869 param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
870 args: &[Arg],
871 i: usize,
872 consumed: &mut HashSet<usize>,
873 tool_args: &mut ToolArgs,
874) {
875 if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
877 if is_bool_type(typ) {
878 tool_args.flags.insert(canonical.to_string());
879 } else {
880 bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
881 }
882 return;
883 }
884 if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
886 .get(&name[..1])
887 .filter(|(_, typ, ..)| !is_bool_type(typ))
888 {
889 let glued = name[1..].to_string();
890 if glued.is_empty() {
891 bind_value_or_flag(
892 tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
893 );
894 } else {
895 let _ =
896 bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
897 }
898 return;
899 }
900 let bytes = name.as_bytes();
903 let mut p = 0;
904 while p < bytes.len() {
905 let key = &name[p..p + 1];
906 match param_lookup.get(key) {
907 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
908 let glued = name[p + 1..].to_string();
909 if glued.is_empty() {
910 bind_value_or_flag(
911 tool_args, key, canonical, consumes, repeatable, args, i, consumed,
912 );
913 } else {
914 let _ = bind_glued_short_value(
915 tool_args, key, canonical, consumes, repeatable, glued,
916 );
917 }
918 return;
919 }
920 _ => {
921 tool_args.flags.insert(key.to_string());
922 p += 1;
923 }
924 }
925 }
926}
927
928#[allow(clippy::too_many_arguments)] fn bind_value_or_flag(
937 tool_args: &mut ToolArgs,
938 flag_name: &str,
939 canonical: &str,
940 consumes: usize,
941 repeatable: bool,
942 args: &[Arg],
943 i: usize,
944 consumed: &mut HashSet<usize>,
945) {
946 let want = consumes.max(1);
947 let allow_word_assign = consumes <= 1;
948 let mut collected: Vec<Value> = Vec::with_capacity(want);
949 for _ in 0..want {
950 let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
951 let idx = i + 1 + off;
952 if consumed.contains(&idx) {
953 return None;
954 }
955 match a {
956 Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
957 Arg::WordAssign { key, value } if allow_word_assign => {
958 let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
959 Some((idx, Value::String(format!("{key}={s}"))))
960 }
961 _ => None,
962 }
963 });
964 match found {
965 Some((idx, v)) => {
966 consumed.insert(idx);
967 collected.push(v);
968 }
969 None => break,
970 }
971 }
972
973 if collected.is_empty() {
974 tool_args.flags.insert(canonical.to_string());
975 return;
976 }
977 if consumes <= 1 {
978 if let Some(v) = collected.into_iter().next() {
979 if repeatable {
980 let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
991 } else {
992 tool_args.named.insert(canonical.to_string(), v);
993 }
994 }
995 return;
996 }
997 let occ: Vec<serde_json::Value> = collected
1003 .iter()
1004 .map(crate::interpreter::value_to_json)
1005 .collect();
1006 let entry = tool_args
1007 .named
1008 .entry(canonical.to_string())
1009 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
1010 if let Value::Json(serde_json::Value::Array(outer)) = entry {
1011 outer.push(serde_json::Value::Array(occ));
1012 }
1013}
1014
1015fn expr_to_placeholder(expr: &Expr) -> Value {
1020 match expr {
1021 Expr::Literal(val) => val.clone(),
1022 Expr::Interpolated(parts) if parts.len() == 1 => {
1023 if let StringPart::Literal(s) = &parts[0] {
1024 Value::String(s.clone())
1025 } else {
1026 Value::String("<dynamic>".to_string())
1027 }
1028 }
1029 _ => Value::String("<dynamic>".to_string()),
1031 }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037 use crate::tools::{register_builtins, ToolRegistry};
1038
1039 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
1040 let mut registry = ToolRegistry::new();
1041 register_builtins(&mut registry);
1042 let user_tools = HashMap::new();
1043 (registry, user_tools)
1044 }
1045
1046 #[test]
1047 fn validates_undefined_command() {
1048 let (registry, user_tools) = make_validator();
1049 let validator = Validator::new(®istry, &user_tools, &[]);
1050
1051 let program = Program {
1052 statements: vec![Stmt::Command(Command {
1053 name: "nonexistent_command".to_string(),
1054 args: vec![],
1055 redirects: vec![],
1056 })],
1057 };
1058
1059 let issues = validator.validate(&program);
1060 assert!(!issues.is_empty());
1061 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
1062 }
1063
1064 #[test]
1068 fn test_command_is_a_known_builtin() {
1069 let (registry, user_tools) = make_validator();
1070 let validator = Validator::new(®istry, &user_tools, &[]);
1071
1072 let program = Program {
1073 statements: vec![Stmt::Command(Command {
1074 name: "test".to_string(),
1075 args: vec![
1076 Arg::Positional(Expr::Literal(Value::String("-n".to_string()))),
1077 Arg::Positional(Expr::Literal(Value::String("hi".to_string()))),
1078 ],
1079 redirects: vec![],
1080 })],
1081 };
1082
1083 let issues = validator.validate(&program);
1084 assert!(
1085 !issues.iter().any(|i| i.code == IssueCode::UndefinedCommand),
1086 "`test` is a builtin — no undefined-command warning: {issues:?}"
1087 );
1088 }
1089
1090 #[test]
1091 fn validates_known_command() {
1092 let (registry, user_tools) = make_validator();
1093 let validator = Validator::new(®istry, &user_tools, &[]);
1094
1095 let program = Program {
1096 statements: vec![Stmt::Command(Command {
1097 name: "echo".to_string(),
1098 args: vec![Arg::Positional(Expr::Literal(Value::String(
1099 "hello".to_string(),
1100 )))],
1101 redirects: vec![],
1102 })],
1103 };
1104
1105 let issues = validator.validate(&program);
1106 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
1108 }
1109
1110 #[test]
1136 fn catalog_hit_and_fallback_produce_identical_schema_driven_issues() {
1137 let (registry, user_tools) = make_validator();
1138 let catalog = registry.schemas();
1139 assert!(
1140 catalog.binary_search_by(|s| s.name.as_str().cmp("jq")).is_ok(),
1141 "fixture assumption: `jq` must be in the catalog for this to be a real hit"
1142 );
1143
1144 let program = Program {
1145 statements: vec![Stmt::Command(Command {
1146 name: "jq".to_string(),
1147 args: vec![],
1148 redirects: vec![],
1149 })],
1150 };
1151
1152 let fallback_issues =
1153 Validator::new(®istry, &user_tools, &[]).validate(&program);
1154 let catalog_issues =
1155 Validator::new(®istry, &user_tools, &catalog).validate(&program);
1156
1157 assert!(
1160 fallback_issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1161 "test input should trip MissingRequiredArg (`filter`) via the fallback path; \
1162 got {fallback_issues:?}"
1163 );
1164
1165 fn render(issues: &[ValidationIssue]) -> Vec<(Severity, IssueCode, &str, Option<&str>)> {
1166 issues
1167 .iter()
1168 .map(|i| (i.severity, i.code, i.message.as_str(), i.suggestion.as_deref()))
1169 .collect()
1170 }
1171 assert_eq!(
1172 render(&fallback_issues),
1173 render(&catalog_issues),
1174 "catalog-hit and tool.schema()-fallback validation must agree exactly \
1175 (same codes, same messages, same order); fallback={fallback_issues:?} \
1176 catalog={catalog_issues:?}"
1177 );
1178 }
1179
1180 #[test]
1181 fn glued_value_flags_dont_false_error_at_validation() {
1182 let (registry, user_tools) = make_validator();
1188 let validator = Validator::new(®istry, &user_tools, &[]);
1189
1190 let program = Program {
1191 statements: vec![Stmt::Command(Command {
1192 name: "sed".to_string(),
1193 args: vec![
1194 Arg::ShortFlag("e1d".to_string()),
1195 Arg::ShortFlag("e2d".to_string()),
1196 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1197 ],
1198 redirects: vec![],
1199 })],
1200 };
1201
1202 let issues = validator.validate(&program);
1203 assert!(
1204 !issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
1205 "glued -e flags false-errored at validation: {:?}",
1206 issues.iter().map(|i| &i.message).collect::<Vec<_>>()
1207 );
1208 }
1209
1210 #[test]
1211 fn validates_break_outside_loop() {
1212 let (registry, user_tools) = make_validator();
1213 let validator = Validator::new(®istry, &user_tools, &[]);
1214
1215 let program = Program {
1216 statements: vec![Stmt::Break(None)],
1217 };
1218
1219 let issues = validator.validate(&program);
1220 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1221 }
1222
1223 #[test]
1224 fn validates_break_inside_loop() {
1225 let (registry, user_tools) = make_validator();
1226 let validator = Validator::new(®istry, &user_tools, &[]);
1227
1228 let program = Program {
1229 statements: vec![Stmt::For(ForLoop {
1230 variable: "i".to_string(),
1231 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
1232 body: vec![Stmt::Break(None)],
1233 })],
1234 };
1235
1236 let issues = validator.validate(&program);
1237 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1239 }
1240
1241 #[test]
1242 fn validates_undefined_variable() {
1243 let (registry, user_tools) = make_validator();
1244 let validator = Validator::new(®istry, &user_tools, &[]);
1245
1246 let program = Program {
1247 statements: vec![Stmt::Command(Command {
1248 name: "echo".to_string(),
1249 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
1250 "UNDEFINED_VAR",
1251 )))],
1252 redirects: vec![],
1253 })],
1254 };
1255
1256 let issues = validator.validate(&program);
1257 assert!(issues
1258 .iter()
1259 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1260 }
1261
1262 #[test]
1263 fn validates_defined_variable() {
1264 let (registry, user_tools) = make_validator();
1265 let validator = Validator::new(®istry, &user_tools, &[]);
1266
1267 let program = Program {
1268 statements: vec![
1269 Stmt::Assignment(Assignment {
1271 path: VarPath::simple("MY_VAR"),
1272 value: Expr::Literal(Value::String("value".to_string())),
1273 local: false,
1274 }),
1275 Stmt::Command(Command {
1277 name: "echo".to_string(),
1278 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
1279 redirects: vec![],
1280 }),
1281 ],
1282 };
1283
1284 let issues = validator.validate(&program);
1285 assert!(!issues
1287 .iter()
1288 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
1289 && i.message.contains("MY_VAR")));
1290 }
1291
1292 #[test]
1293 fn skips_underscore_prefixed_vars() {
1294 let (registry, user_tools) = make_validator();
1295 let validator = Validator::new(®istry, &user_tools, &[]);
1296
1297 let program = Program {
1298 statements: vec![Stmt::Command(Command {
1299 name: "echo".to_string(),
1300 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
1301 redirects: vec![],
1302 })],
1303 };
1304
1305 let issues = validator.validate(&program);
1306 assert!(!issues
1308 .iter()
1309 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1310 }
1311
1312 #[test]
1313 fn builtin_vars_are_defined() {
1314 let (registry, user_tools) = make_validator();
1315 let validator = Validator::new(®istry, &user_tools, &[]);
1316
1317 let program = Program {
1318 statements: vec![Stmt::Command(Command {
1319 name: "echo".to_string(),
1320 args: vec![
1321 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
1322 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
1323 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
1324 ],
1325 redirects: vec![],
1326 })],
1327 };
1328
1329 let issues = validator.validate(&program);
1330 assert!(!issues
1332 .iter()
1333 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1334 }
1335
1336 #[test]
1337 fn validates_scatter_without_gather() {
1338 let (registry, user_tools) = make_validator();
1339 let validator = Validator::new(®istry, &user_tools, &[]);
1340
1341 let program = Program {
1342 statements: vec![Stmt::Pipeline(Pipeline {
1343 stages: vec![
1344 Command { name: "seq".to_string(), args: vec![
1345 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1346 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1347 ], redirects: vec![] },
1348 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1349 Command { name: "echo".to_string(), args: vec![
1350 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1351 ], redirects: vec![] },
1352 ]
1353 .into_iter()
1354 .map(PipelineStage::Command)
1355 .collect(),
1356 background: false,
1357 })],
1358 };
1359
1360 let issues = validator.validate(&program);
1361 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1362 "should flag scatter without gather: {:?}", issues);
1363 }
1364
1365 #[test]
1366 fn allows_scatter_with_gather() {
1367 let (registry, user_tools) = make_validator();
1368 let validator = Validator::new(®istry, &user_tools, &[]);
1369
1370 let program = Program {
1371 statements: vec![Stmt::Pipeline(Pipeline {
1372 stages: vec![
1373 Command { name: "seq".to_string(), args: vec![
1374 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1375 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1376 ], redirects: vec![] },
1377 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1378 Command { name: "echo".to_string(), args: vec![
1379 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1380 ], redirects: vec![] },
1381 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
1382 ]
1383 .into_iter()
1384 .map(PipelineStage::Command)
1385 .collect(),
1386 background: false,
1387 })],
1388 };
1389
1390 let issues = validator.validate(&program);
1391 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1392 "scatter with gather should pass: {:?}", issues);
1393 }
1394
1395 fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
1396 let mut user_tools = HashMap::new();
1397 user_tools.insert(
1398 "mytool".to_string(),
1399 ToolDef {
1400 name: "mytool".to_string(),
1401 params: vec![crate::ast::ParamDef {
1402 name: "input".to_string(),
1403 param_type: None,
1404 default: None,
1405 }],
1406 body: vec![],
1407 },
1408 );
1409 user_tools
1410 }
1411
1412 #[test]
1416 fn user_tool_wordassign_counts_as_positional() {
1417 let mut registry = ToolRegistry::new();
1418 register_builtins(&mut registry);
1419 let user_tools = make_user_tool_with_required_positional();
1420 let validator = Validator::new(®istry, &user_tools, &[]);
1421
1422 let program = Program {
1423 statements: vec![Stmt::Command(Command {
1424 name: "mytool".to_string(),
1425 args: vec![Arg::WordAssign {
1426 key: "foo".to_string(),
1427 value: Expr::Literal(Value::String("bar".to_string())),
1428 }],
1429 redirects: vec![],
1430 })],
1431 };
1432
1433 let issues = validator.validate(&program);
1434 assert!(
1435 !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1436 "WordAssign should satisfy required positional; got {:?}",
1437 issues
1438 );
1439 }
1440
1441 #[test]
1444 fn user_tool_no_args_still_errors() {
1445 let mut registry = ToolRegistry::new();
1446 register_builtins(&mut registry);
1447 let user_tools = make_user_tool_with_required_positional();
1448 let validator = Validator::new(®istry, &user_tools, &[]);
1449
1450 let program = Program {
1451 statements: vec![Stmt::Command(Command {
1452 name: "mytool".to_string(),
1453 args: vec![],
1454 redirects: vec![],
1455 })],
1456 };
1457
1458 let issues = validator.validate(&program);
1459 assert!(
1460 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1461 "missing positional should still error; got {:?}",
1462 issues
1463 );
1464 }
1465}