1use std::collections::{HashMap, HashSet};
4
5use crate::ast::{
6 Arg, Assignment, CaseBranch, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
7 Program, SpannedPart, Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop,
8 Value,
9};
10use crate::kernel::{bind_glued_short_value, push_repeatable_value};
11use crate::scheduler::{is_bool_type, schema_param_lookup};
12use crate::validator::issue::Span;
13use crate::tools::{ToolArgs, ToolRegistry, ToolSchema};
14use kaish_types::CommandKind;
15
16use super::issue::{IssueCode, ValidationIssue};
17use super::scope_tracker::ScopeTracker;
18
19pub struct Validator<'a> {
21 registry: &'a ToolRegistry,
23 user_tools: &'a HashMap<String, ToolDef>,
25 scope: ScopeTracker,
27 loop_depth: usize,
29 function_depth: usize,
31 issues: Vec<ValidationIssue>,
33}
34
35impl<'a> Validator<'a> {
36 pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
38 Self {
39 registry,
40 user_tools,
41 scope: ScopeTracker::new(),
42 loop_depth: 0,
43 function_depth: 0,
44 issues: Vec::new(),
45 }
46 }
47
48 pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
50 for stmt in &program.statements {
51 self.validate_stmt(stmt);
52 }
53 self.issues
54 }
55
56 fn validate_stmt(&mut self, stmt: &Stmt) {
58 match stmt {
59 Stmt::Assignment(assign) => self.validate_assignment(assign),
60 Stmt::Command(cmd) => self.validate_command(cmd),
61 Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
62 Stmt::If(if_stmt) => self.validate_if(if_stmt),
63 Stmt::For(for_loop) => self.validate_for(for_loop),
64 Stmt::While(while_loop) => self.validate_while(while_loop),
65 Stmt::Case(case_stmt) => self.validate_case(case_stmt),
66 Stmt::Break(levels) => self.validate_break(*levels),
67 Stmt::Continue(levels) => self.validate_continue(*levels),
68 Stmt::Return(expr) => self.validate_return(expr.as_deref()),
69 Stmt::Exit(expr) => {
70 if let Some(e) = expr {
71 self.validate_expr(e);
72 }
73 }
74 Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
75 Stmt::Test(test_expr) => self.validate_test(test_expr),
76 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
77 self.validate_stmt(left);
78 self.validate_stmt(right);
79 }
80 Stmt::EnvScoped { assignments, body } => {
81 for assign in assignments {
84 self.validate_assignment(assign);
85 }
86 self.validate_stmt(body);
87 }
88 Stmt::Empty => {}
89 }
90 }
91
92 fn validate_assignment(&mut self, assign: &Assignment) {
102 self.validate_expr(&assign.value);
104
105 let name = assign.name();
106 if assign.path.segments.len() == 1 {
107 if let Some(dot) = name.find('.') {
108 let (root, rest) = (&name[..dot], &name[dot + 1..]);
109 self.issues.push(
110 ValidationIssue::error(
111 IssueCode::DottedAssignmentTarget,
112 format!(
113 "'{name}' is not a valid assignment target — kaish uses bracket \
114 access, not dots"
115 ),
116 )
117 .with_suggestion(format!("use `{root}[{rest}]=value`")),
118 );
119 }
120 self.scope.bind(name);
122 } else if !self.scope.is_bound(name) {
123 self.issues.push(
124 ValidationIssue::error(
125 IssueCode::LvalueUndefinedRoot,
126 format!(
127 "'{name}' is not defined — a subscripted assignment never creates the \
128 root variable"
129 ),
130 )
131 .with_suggestion(format!("create it first, e.g. `{name}={{}}` or `{name}=[]`")),
132 );
133 self.scope.bind(name);
136 }
137 }
138
139 fn validate_command(&mut self, cmd: &Command) {
141 if cmd.name == "source" || cmd.name == "." {
143 return;
144 }
145
146 if !is_static_command_name(&cmd.name) {
148 return;
149 }
150
151 let is_builtin = self.registry.contains(&cmd.name);
153 let is_user_tool = self.user_tools.contains_key(&cmd.name);
154 let is_special = is_special_command(&cmd.name);
155
156 if !is_builtin && !is_user_tool && !is_special {
157 self.issues.push(ValidationIssue::warning(
161 IssueCode::UndefinedCommand,
162 format!("command '{}' not found in builtin registry", cmd.name),
163 ).with_suggestion("this may be a script in PATH or external command"));
164 }
165
166 for arg in &cmd.args {
168 self.validate_arg(arg);
169 }
170
171 if let Some(tool) = self.registry.get(&cmd.name) {
176 let schema = tool.schema();
177 let tool_args = build_tool_args_for_validation(&cmd.args, Some(&schema));
178 let tool_issues = tool.validate(&tool_args);
179 self.issues.extend(tool_issues);
180 } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
181 self.validate_user_tool_args(user_tool, &cmd.args);
183 }
184
185 for redirect in &cmd.redirects {
187 self.validate_expr(&redirect.target);
188 }
189 }
190
191 fn validate_arg(&mut self, arg: &Arg) {
193 match arg {
194 Arg::Positional(expr) => self.validate_expr(expr),
195 Arg::Named { value, .. } => self.validate_expr(value),
196 Arg::WordAssign { value, .. } => self.validate_expr(value),
197 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
198 }
199 }
200
201 fn validate_pipeline(&mut self, pipe: &Pipeline) {
203 let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
205 let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
206 if has_scatter && !has_gather {
207 self.issues.push(
208 ValidationIssue::error(
209 IssueCode::ScatterWithoutGather,
210 "scatter without gather — parallel results would be lost",
211 ).with_suggestion("add gather: ... | scatter | cmd | gather")
212 );
213 }
214
215 for cmd in &pipe.commands {
216 self.validate_command(cmd);
217 }
218 }
219
220 fn validate_if(&mut self, if_stmt: &IfStmt) {
222 self.validate_expr(&if_stmt.condition);
223
224 self.scope.push_frame();
225 for stmt in &if_stmt.then_branch {
226 self.validate_stmt(stmt);
227 }
228 self.scope.pop_frame();
229
230 if let Some(else_branch) = &if_stmt.else_branch {
231 self.scope.push_frame();
232 for stmt in else_branch {
233 self.validate_stmt(stmt);
234 }
235 self.scope.pop_frame();
236 }
237 }
238
239 fn validate_for(&mut self, for_loop: &ForLoop) {
241 for item in &for_loop.items {
243 self.validate_expr(item);
244
245 if self.is_bare_scalar_var(item) {
248 self.issues.push(
249 ValidationIssue::error(
250 IssueCode::ForLoopScalarVar,
251 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
252 )
253 .with_suggestion(concat!(
254 "wrap it in $(...) — for a collection use keys/values:\n",
255 " for x in $(values $coll) # list elements / record values\n",
256 " for k in $(keys $coll) # list indices / record keys\n",
257 " for i in $(split \"$VAR\") # split a string on whitespace\n",
258 " for i in $(split \"$VAR\" \":\") # split a string on a delimiter\n",
259 " for i in $(seq 1 10) # iterate numbers\n",
260 " for i in $(glob \"*.rs\") # iterate files",
261 )),
262 );
263 }
264 }
265
266 self.loop_depth += 1;
267 self.scope.push_frame();
268
269 self.scope.bind(&for_loop.variable);
271
272 for stmt in &for_loop.body {
273 self.validate_stmt(stmt);
274 }
275
276 self.scope.pop_frame();
277 self.loop_depth -= 1;
278 }
279
280 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
286 match expr {
287 Expr::VarRef(_) => true,
289 Expr::VarWithDefault { .. } => true,
291 Expr::CommandSubst(_) => false,
293 Expr::Literal(_) => false,
295 Expr::Interpolated(_) => false,
297 _ => false,
299 }
300 }
301
302 fn validate_while(&mut self, while_loop: &WhileLoop) {
304 self.validate_expr(&while_loop.condition);
305
306 self.loop_depth += 1;
307 self.scope.push_frame();
308
309 for stmt in &while_loop.body {
310 self.validate_stmt(stmt);
311 }
312
313 self.scope.pop_frame();
314 self.loop_depth -= 1;
315 }
316
317 fn validate_case(&mut self, case_stmt: &CaseStmt) {
319 self.validate_expr(&case_stmt.expr);
320
321 for branch in &case_stmt.branches {
322 self.validate_case_branch(branch);
323 }
324 }
325
326 fn validate_case_branch(&mut self, branch: &CaseBranch) {
328 self.scope.push_frame();
329 for stmt in &branch.body {
330 self.validate_stmt(stmt);
331 }
332 self.scope.pop_frame();
333 }
334
335 fn validate_break(&mut self, levels: Option<usize>) {
337 if self.loop_depth == 0 {
338 self.issues.push(ValidationIssue::error(
339 IssueCode::BreakOutsideLoop,
340 "break used outside of a loop",
341 ));
342 } else if let Some(n) = levels
343 && n > self.loop_depth {
344 self.issues.push(ValidationIssue::warning(
345 IssueCode::BreakOutsideLoop,
346 format!(
347 "break {} exceeds loop nesting depth {}",
348 n, self.loop_depth
349 ),
350 ));
351 }
352 }
353
354 fn validate_continue(&mut self, levels: Option<usize>) {
356 if self.loop_depth == 0 {
357 self.issues.push(ValidationIssue::error(
358 IssueCode::BreakOutsideLoop,
359 "continue used outside of a loop",
360 ));
361 } else if let Some(n) = levels
362 && n > self.loop_depth {
363 self.issues.push(ValidationIssue::warning(
364 IssueCode::BreakOutsideLoop,
365 format!(
366 "continue {} exceeds loop nesting depth {}",
367 n, self.loop_depth
368 ),
369 ));
370 }
371 }
372
373 fn validate_return(&mut self, expr: Option<&Expr>) {
375 if let Some(e) = expr {
376 self.validate_expr(e);
377 }
378
379 if self.function_depth == 0 {
380 self.issues.push(ValidationIssue::error(
381 IssueCode::ReturnOutsideFunction,
382 "return used outside of a function",
383 ));
384 }
385 }
386
387 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
389 self.function_depth += 1;
390 self.scope.push_frame();
391
392 for param in &tool_def.params {
394 self.scope.bind(¶m.name);
395 if let Some(default) = ¶m.default {
397 self.validate_expr(default);
398 }
399 }
400
401 for stmt in &tool_def.body {
403 self.validate_stmt(stmt);
404 }
405
406 self.scope.pop_frame();
407 self.function_depth -= 1;
408 }
409
410 fn validate_test(&mut self, test: &TestExpr) {
412 match test {
413 TestExpr::FileTest { path, .. } => self.validate_expr(path),
414 TestExpr::StringTest { value, .. } => self.validate_expr(value),
415 TestExpr::Comparison { left, right, .. } => {
416 self.validate_expr(left);
417 self.validate_expr(right);
418 }
419 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
420 self.validate_test(left);
421 self.validate_test(right);
422 }
423 TestExpr::Not { expr } => self.validate_test(expr),
424 TestExpr::In { left, right } | TestExpr::NotIn { left, right } => {
425 self.validate_expr(left);
426 self.validate_expr(right);
427 }
428 }
429 }
430
431 fn validate_expr(&mut self, expr: &Expr) {
433 match expr {
434 Expr::Literal(_) => {}
435 Expr::VarRef(path) => self.validate_var_ref(path),
436 Expr::Interpolated(parts) => {
437 for part in parts {
438 self.validate_string_part(part);
439 }
440 }
441 Expr::HereDocBody { parts, .. } => {
442 for sp in parts {
443 self.validate_spanned_string_part(sp);
444 }
445 }
446 Expr::BinaryOp { left, right, .. } => {
447 self.validate_expr(left);
448 self.validate_expr(right);
449 }
450 Expr::CommandSubst(stmts) => {
451 for stmt in stmts {
452 self.validate_stmt(stmt);
453 }
454 }
455 Expr::Test(test) => self.validate_test(test),
456 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
457 Expr::VarLength(path) => {
458 if let Some(VarSegment::Field(root)) = path.segments.first() {
459 self.check_var_defined(root);
460 }
461 }
462 Expr::VarWithDefault { .. } => {
463 }
465 Expr::Arithmetic(_) => {
466 }
468 Expr::Command(cmd) => self.validate_command(cmd),
469 Expr::LastExitCode | Expr::CurrentPid => {}
470 Expr::GlobPattern(_) => {}
471 Expr::ListLiteral(elems) => {
472 for elem in elems {
473 match elem {
474 ListElem::Item(e) | ListElem::Spread(e) => self.validate_expr(e),
475 }
476 }
477 }
478 Expr::RecordLiteral(entries) => {
479 for entry in entries {
480 self.validate_expr(&entry.value);
481 }
482 }
483 }
484 }
485
486 fn validate_var_ref(&mut self, path: &VarPath) {
488 if let Some(VarSegment::Field(name)) = path.segments.first() {
489 if name == "?" && path.segments.len() > 1 {
492 self.issues.push(
493 ValidationIssue::error(
494 IssueCode::LastResultFieldAccess,
495 "${?.field} is removed; $? is the POSIX exit code",
496 )
497 .with_suggestion(
498 "use `kaish-last` to read the previous command's data or stdout",
499 ),
500 );
501 return;
502 }
503 self.check_var_defined(name);
504 }
505 }
506
507 fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
511 let issues_before = self.issues.len();
512 self.validate_string_part(&sp.part);
513 let span = Span::new(sp.offset, sp.offset + sp.len);
514 for issue in &mut self.issues[issues_before..] {
515 if issue.span.is_none() {
516 issue.span = Some(span);
517 }
518 }
519 }
520
521 fn validate_string_part(&mut self, part: &StringPart) {
523 match part {
524 StringPart::Literal(_) => {}
525 StringPart::Var(path) => self.validate_var_ref(path),
526 StringPart::VarWithDefault { default, .. } => {
527 for p in default {
529 self.validate_string_part(p);
530 }
531 }
532 StringPart::VarLength(path) => {
533 if let Some(VarSegment::Field(root)) = path.segments.first() {
534 self.check_var_defined(root);
535 }
536 }
537 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
538 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(stmts) => {
540 for stmt in stmts {
541 self.validate_stmt(stmt);
542 }
543 }
544 StringPart::LastExitCode | StringPart::CurrentPid => {}
545 }
546 }
547
548 fn check_var_defined(&mut self, name: &str) {
550 if ScopeTracker::should_skip_undefined_check(name) {
552 return;
553 }
554
555 if !self.scope.is_bound(name) {
556 self.issues.push(ValidationIssue::warning(
557 IssueCode::PossiblyUndefinedVariable,
558 format!("variable '{}' may be undefined", name),
559 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
560 }
561 }
562
563 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
571 let positional_count = args
572 .iter()
573 .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
574 .count();
575
576 let required_count = tool_def
577 .params
578 .iter()
579 .filter(|p| p.default.is_none())
580 .count();
581
582 if positional_count < required_count {
583 self.issues.push(ValidationIssue::error(
584 IssueCode::MissingRequiredArg,
585 format!(
586 "'{}' requires {} arguments, got {}",
587 tool_def.name, required_count, positional_count
588 ),
589 ));
590 }
591 }
592}
593
594pub(crate) fn is_static_command_name(name: &str) -> bool {
601 !name.starts_with('$') && !name.contains("$(") && !name.contains("${")
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
623pub(crate) enum SpecialForm {
624 True,
626 False,
628 Source,
630}
631
632impl SpecialForm {
633 pub(crate) fn from_name(name: &str) -> Option<Self> {
636 match name {
637 "true" => Some(Self::True),
638 "false" => Some(Self::False),
639 "source" | "." => Some(Self::Source),
640 _ => None,
641 }
642 }
643}
644
645pub(crate) fn is_runtime_special_form(name: &str) -> bool {
647 SpecialForm::from_name(name).is_some()
648}
649
650pub(crate) fn classify_command_name(
654 name: &str,
655 is_builtin: bool,
656 is_user_tool: bool,
657) -> CommandKind {
658 if !is_static_command_name(name) {
659 return CommandKind::Dynamic;
660 }
661 if is_runtime_special_form(name) {
662 return CommandKind::Special;
663 }
664 if is_user_tool {
667 return CommandKind::UserTool;
668 }
669 if is_builtin {
670 return CommandKind::Builtin;
671 }
672 CommandKind::External
673}
674
675fn is_special_command(name: &str) -> bool {
677 matches!(name, "true" | "false" | ":" | "readonly" | "local")
681}
682
683pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
688 let mut tool_args = ToolArgs::new();
689 let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
693 let mut consumed: HashSet<usize> = HashSet::new();
694 let mut past_double_dash = false;
695
696 for i in 0..args.len() {
697 match &args[i] {
698 Arg::DoubleDash => past_double_dash = true,
699 Arg::Positional(expr) => {
700 if !consumed.contains(&i) {
701 tool_args.positional.push(expr_to_placeholder(expr));
702 }
703 }
704 Arg::Named { key, value } => {
705 let v = expr_to_placeholder(value);
706 match param_lookup.get(key.as_str()) {
707 Some(&(canonical, _, _, true)) => {
709 let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
710 }
711 Some(&(canonical, ..)) => {
712 tool_args.named.insert(canonical.to_string(), v);
713 }
714 None => {
715 tool_args.named.insert(key.clone(), v);
716 }
717 }
718 }
719 Arg::WordAssign { key, value } => {
720 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
724 }
725 Arg::ShortFlag(name) => {
726 if past_double_dash {
727 tool_args.positional.push(Value::String(format!("-{name}")));
728 } else {
729 bind_short_flag_for_validation(
730 name,
731 ¶m_lookup,
732 args,
733 i,
734 &mut consumed,
735 &mut tool_args,
736 );
737 }
738 }
739 Arg::LongFlag(name) => {
740 if past_double_dash {
741 tool_args.positional.push(Value::String(format!("--{name}")));
742 } else {
743 match param_lookup.get(name.as_str()) {
744 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
745 bind_value_or_flag(
746 &mut tool_args, name, canonical, consumes, repeatable, args, i,
747 &mut consumed,
748 );
749 }
750 Some(&(canonical, ..)) => {
751 tool_args.flags.insert(canonical.to_string());
752 }
753 None => {
754 tool_args.flags.insert(name.clone());
755 }
756 }
757 }
758 }
759 }
760 }
761
762 tool_args
763}
764
765fn bind_short_flag_for_validation(
771 name: &str,
772 param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
773 args: &[Arg],
774 i: usize,
775 consumed: &mut HashSet<usize>,
776 tool_args: &mut ToolArgs,
777) {
778 if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
780 if is_bool_type(typ) {
781 tool_args.flags.insert(canonical.to_string());
782 } else {
783 bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
784 }
785 return;
786 }
787 if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
789 .get(&name[..1])
790 .filter(|(_, typ, ..)| !is_bool_type(typ))
791 {
792 let glued = name[1..].to_string();
793 if glued.is_empty() {
794 bind_value_or_flag(
795 tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
796 );
797 } else {
798 let _ =
799 bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
800 }
801 return;
802 }
803 let bytes = name.as_bytes();
806 let mut p = 0;
807 while p < bytes.len() {
808 let key = &name[p..p + 1];
809 match param_lookup.get(key) {
810 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
811 let glued = name[p + 1..].to_string();
812 if glued.is_empty() {
813 bind_value_or_flag(
814 tool_args, key, canonical, consumes, repeatable, args, i, consumed,
815 );
816 } else {
817 let _ = bind_glued_short_value(
818 tool_args, key, canonical, consumes, repeatable, glued,
819 );
820 }
821 return;
822 }
823 _ => {
824 tool_args.flags.insert(key.to_string());
825 p += 1;
826 }
827 }
828 }
829}
830
831#[allow(clippy::too_many_arguments)] fn bind_value_or_flag(
840 tool_args: &mut ToolArgs,
841 flag_name: &str,
842 canonical: &str,
843 consumes: usize,
844 repeatable: bool,
845 args: &[Arg],
846 i: usize,
847 consumed: &mut HashSet<usize>,
848) {
849 let want = consumes.max(1);
850 let allow_word_assign = consumes <= 1;
851 let mut collected: Vec<Value> = Vec::with_capacity(want);
852 for _ in 0..want {
853 let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
854 let idx = i + 1 + off;
855 if consumed.contains(&idx) {
856 return None;
857 }
858 match a {
859 Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
860 Arg::WordAssign { key, value } if allow_word_assign => {
861 let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
862 Some((idx, Value::String(format!("{key}={s}"))))
863 }
864 _ => None,
865 }
866 });
867 match found {
868 Some((idx, v)) => {
869 consumed.insert(idx);
870 collected.push(v);
871 }
872 None => break,
873 }
874 }
875
876 if collected.is_empty() {
877 tool_args.flags.insert(canonical.to_string());
878 return;
879 }
880 if consumes <= 1 {
881 if let Some(v) = collected.into_iter().next() {
882 if repeatable {
883 let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
884 } else {
885 tool_args.named.insert(canonical.to_string(), v);
886 }
887 }
888 return;
889 }
890 let occ: Vec<serde_json::Value> = collected
892 .iter()
893 .map(crate::interpreter::value_to_json)
894 .collect();
895 let entry = tool_args
896 .named
897 .entry(canonical.to_string())
898 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
899 if let Value::Json(serde_json::Value::Array(outer)) = entry {
900 outer.push(serde_json::Value::Array(occ));
901 }
902}
903
904fn expr_to_placeholder(expr: &Expr) -> Value {
909 match expr {
910 Expr::Literal(val) => val.clone(),
911 Expr::Interpolated(parts) if parts.len() == 1 => {
912 if let StringPart::Literal(s) = &parts[0] {
913 Value::String(s.clone())
914 } else {
915 Value::String("<dynamic>".to_string())
916 }
917 }
918 _ => Value::String("<dynamic>".to_string()),
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use crate::tools::{register_builtins, ToolRegistry};
927
928 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
929 let mut registry = ToolRegistry::new();
930 register_builtins(&mut registry);
931 let user_tools = HashMap::new();
932 (registry, user_tools)
933 }
934
935 #[test]
936 fn validates_undefined_command() {
937 let (registry, user_tools) = make_validator();
938 let validator = Validator::new(®istry, &user_tools);
939
940 let program = Program {
941 statements: vec![Stmt::Command(Command {
942 name: "nonexistent_command".to_string(),
943 args: vec![],
944 redirects: vec![],
945 })],
946 };
947
948 let issues = validator.validate(&program);
949 assert!(!issues.is_empty());
950 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
951 }
952
953 #[test]
957 fn test_command_is_a_known_builtin() {
958 let (registry, user_tools) = make_validator();
959 let validator = Validator::new(®istry, &user_tools);
960
961 let program = Program {
962 statements: vec![Stmt::Command(Command {
963 name: "test".to_string(),
964 args: vec![
965 Arg::Positional(Expr::Literal(Value::String("-n".to_string()))),
966 Arg::Positional(Expr::Literal(Value::String("hi".to_string()))),
967 ],
968 redirects: vec![],
969 })],
970 };
971
972 let issues = validator.validate(&program);
973 assert!(
974 !issues.iter().any(|i| i.code == IssueCode::UndefinedCommand),
975 "`test` is a builtin — no undefined-command warning: {issues:?}"
976 );
977 }
978
979 #[test]
980 fn validates_known_command() {
981 let (registry, user_tools) = make_validator();
982 let validator = Validator::new(®istry, &user_tools);
983
984 let program = Program {
985 statements: vec![Stmt::Command(Command {
986 name: "echo".to_string(),
987 args: vec![Arg::Positional(Expr::Literal(Value::String(
988 "hello".to_string(),
989 )))],
990 redirects: vec![],
991 })],
992 };
993
994 let issues = validator.validate(&program);
995 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
997 }
998
999 #[test]
1000 fn glued_value_flags_dont_false_error_at_validation() {
1001 let (registry, user_tools) = make_validator();
1007 let validator = Validator::new(®istry, &user_tools);
1008
1009 let program = Program {
1010 statements: vec![Stmt::Command(Command {
1011 name: "sed".to_string(),
1012 args: vec![
1013 Arg::ShortFlag("e1d".to_string()),
1014 Arg::ShortFlag("e2d".to_string()),
1015 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1016 ],
1017 redirects: vec![],
1018 })],
1019 };
1020
1021 let issues = validator.validate(&program);
1022 assert!(
1023 !issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
1024 "glued -e flags false-errored at validation: {:?}",
1025 issues.iter().map(|i| &i.message).collect::<Vec<_>>()
1026 );
1027 }
1028
1029 #[test]
1030 fn validates_break_outside_loop() {
1031 let (registry, user_tools) = make_validator();
1032 let validator = Validator::new(®istry, &user_tools);
1033
1034 let program = Program {
1035 statements: vec![Stmt::Break(None)],
1036 };
1037
1038 let issues = validator.validate(&program);
1039 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1040 }
1041
1042 #[test]
1043 fn validates_break_inside_loop() {
1044 let (registry, user_tools) = make_validator();
1045 let validator = Validator::new(®istry, &user_tools);
1046
1047 let program = Program {
1048 statements: vec![Stmt::For(ForLoop {
1049 variable: "i".to_string(),
1050 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
1051 body: vec![Stmt::Break(None)],
1052 })],
1053 };
1054
1055 let issues = validator.validate(&program);
1056 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1058 }
1059
1060 #[test]
1061 fn validates_undefined_variable() {
1062 let (registry, user_tools) = make_validator();
1063 let validator = Validator::new(®istry, &user_tools);
1064
1065 let program = Program {
1066 statements: vec![Stmt::Command(Command {
1067 name: "echo".to_string(),
1068 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
1069 "UNDEFINED_VAR",
1070 )))],
1071 redirects: vec![],
1072 })],
1073 };
1074
1075 let issues = validator.validate(&program);
1076 assert!(issues
1077 .iter()
1078 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1079 }
1080
1081 #[test]
1082 fn validates_defined_variable() {
1083 let (registry, user_tools) = make_validator();
1084 let validator = Validator::new(®istry, &user_tools);
1085
1086 let program = Program {
1087 statements: vec![
1088 Stmt::Assignment(Assignment {
1090 path: VarPath::simple("MY_VAR"),
1091 value: Expr::Literal(Value::String("value".to_string())),
1092 local: false,
1093 }),
1094 Stmt::Command(Command {
1096 name: "echo".to_string(),
1097 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
1098 redirects: vec![],
1099 }),
1100 ],
1101 };
1102
1103 let issues = validator.validate(&program);
1104 assert!(!issues
1106 .iter()
1107 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
1108 && i.message.contains("MY_VAR")));
1109 }
1110
1111 #[test]
1112 fn skips_underscore_prefixed_vars() {
1113 let (registry, user_tools) = make_validator();
1114 let validator = Validator::new(®istry, &user_tools);
1115
1116 let program = Program {
1117 statements: vec![Stmt::Command(Command {
1118 name: "echo".to_string(),
1119 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
1120 redirects: vec![],
1121 })],
1122 };
1123
1124 let issues = validator.validate(&program);
1125 assert!(!issues
1127 .iter()
1128 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1129 }
1130
1131 #[test]
1132 fn builtin_vars_are_defined() {
1133 let (registry, user_tools) = make_validator();
1134 let validator = Validator::new(®istry, &user_tools);
1135
1136 let program = Program {
1137 statements: vec![Stmt::Command(Command {
1138 name: "echo".to_string(),
1139 args: vec![
1140 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
1141 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
1142 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
1143 ],
1144 redirects: vec![],
1145 })],
1146 };
1147
1148 let issues = validator.validate(&program);
1149 assert!(!issues
1151 .iter()
1152 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1153 }
1154
1155 #[test]
1156 fn validates_scatter_without_gather() {
1157 let (registry, user_tools) = make_validator();
1158 let validator = Validator::new(®istry, &user_tools);
1159
1160 let program = Program {
1161 statements: vec![Stmt::Pipeline(Pipeline {
1162 commands: vec![
1163 Command { name: "seq".to_string(), args: vec![
1164 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1165 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1166 ], redirects: vec![] },
1167 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1168 Command { name: "echo".to_string(), args: vec![
1169 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1170 ], redirects: vec![] },
1171 ],
1172 background: false,
1173 })],
1174 };
1175
1176 let issues = validator.validate(&program);
1177 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1178 "should flag scatter without gather: {:?}", issues);
1179 }
1180
1181 #[test]
1182 fn allows_scatter_with_gather() {
1183 let (registry, user_tools) = make_validator();
1184 let validator = Validator::new(®istry, &user_tools);
1185
1186 let program = Program {
1187 statements: vec![Stmt::Pipeline(Pipeline {
1188 commands: vec![
1189 Command { name: "seq".to_string(), args: vec![
1190 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1191 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1192 ], redirects: vec![] },
1193 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1194 Command { name: "echo".to_string(), args: vec![
1195 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1196 ], redirects: vec![] },
1197 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
1198 ],
1199 background: false,
1200 })],
1201 };
1202
1203 let issues = validator.validate(&program);
1204 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1205 "scatter with gather should pass: {:?}", issues);
1206 }
1207
1208 fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
1209 let mut user_tools = HashMap::new();
1210 user_tools.insert(
1211 "mytool".to_string(),
1212 ToolDef {
1213 name: "mytool".to_string(),
1214 params: vec![crate::ast::ParamDef {
1215 name: "input".to_string(),
1216 param_type: None,
1217 default: None,
1218 }],
1219 body: vec![],
1220 },
1221 );
1222 user_tools
1223 }
1224
1225 #[test]
1229 fn user_tool_wordassign_counts_as_positional() {
1230 let mut registry = ToolRegistry::new();
1231 register_builtins(&mut registry);
1232 let user_tools = make_user_tool_with_required_positional();
1233 let validator = Validator::new(®istry, &user_tools);
1234
1235 let program = Program {
1236 statements: vec![Stmt::Command(Command {
1237 name: "mytool".to_string(),
1238 args: vec![Arg::WordAssign {
1239 key: "foo".to_string(),
1240 value: Expr::Literal(Value::String("bar".to_string())),
1241 }],
1242 redirects: vec![],
1243 })],
1244 };
1245
1246 let issues = validator.validate(&program);
1247 assert!(
1248 !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1249 "WordAssign should satisfy required positional; got {:?}",
1250 issues
1251 );
1252 }
1253
1254 #[test]
1257 fn user_tool_no_args_still_errors() {
1258 let mut registry = ToolRegistry::new();
1259 register_builtins(&mut registry);
1260 let user_tools = make_user_tool_with_required_positional();
1261 let validator = Validator::new(®istry, &user_tools);
1262
1263 let program = Program {
1264 statements: vec![Stmt::Command(Command {
1265 name: "mytool".to_string(),
1266 args: vec![],
1267 redirects: vec![],
1268 })],
1269 };
1270
1271 let issues = validator.validate(&program);
1272 assert!(
1273 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1274 "missing positional should still error; got {:?}",
1275 issues
1276 );
1277 }
1278}