Skip to main content

kaish_kernel/validator/
walker.rs

1//! AST walker for pre-execution validation.
2
3use 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
15/// AST validator that checks for issues before execution.
16pub struct Validator<'a> {
17    /// Reference to the tool registry.
18    registry: &'a ToolRegistry,
19    /// User-defined tools.
20    user_tools: &'a HashMap<String, ToolDef>,
21    /// Variable scope tracker.
22    scope: ScopeTracker,
23    /// Current loop nesting depth.
24    loop_depth: usize,
25    /// Current function nesting depth.
26    function_depth: usize,
27    /// Collected validation issues.
28    issues: Vec<ValidationIssue>,
29}
30
31impl<'a> Validator<'a> {
32    /// Create a new validator.
33    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    /// Validate a program and return all issues found.
45    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    /// Validate a single statement.
53    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    /// Validate an assignment statement.
81    fn validate_assignment(&mut self, assign: &Assignment) {
82        // Validate the value expression
83        self.validate_expr(&assign.value);
84        // Bind the variable name in scope
85        self.scope.bind(&assign.name);
86    }
87
88    /// Validate a command invocation.
89    fn validate_command(&mut self, cmd: &Command) {
90        // Skip source/. commands - they're dynamic
91        if cmd.name == "source" || cmd.name == "." {
92            return;
93        }
94
95        // Skip dynamic command names (variable expansions)
96        if !is_static_command_name(&cmd.name) {
97            return;
98        }
99
100        // Check if command exists
101        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            // Warning only - command might be a script in PATH or external tool
107            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        // Validate arguments expressions
114        for arg in &cmd.args {
115            self.validate_arg(arg);
116        }
117
118        // If we have a schema, validate args against it
119        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            // Validate against user-defined tool parameters
125            self.validate_user_tool_args(user_tool, &cmd.args);
126        }
127
128        // Validate redirects
129        for redirect in &cmd.redirects {
130            self.validate_expr(&redirect.target);
131        }
132    }
133
134    /// Validate a command argument.
135    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::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
140        }
141    }
142
143    /// Validate a pipeline.
144    fn validate_pipeline(&mut self, pipe: &Pipeline) {
145        // Check for scatter without gather
146        let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
147        let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
148        if has_scatter && !has_gather {
149            self.issues.push(
150                ValidationIssue::error(
151                    IssueCode::ScatterWithoutGather,
152                    "scatter without gather — parallel results would be lost",
153                ).with_suggestion("add gather: ... | scatter | cmd | gather")
154            );
155        }
156
157        for cmd in &pipe.commands {
158            self.validate_command(cmd);
159        }
160    }
161
162    /// Validate an if statement.
163    fn validate_if(&mut self, if_stmt: &IfStmt) {
164        self.validate_expr(&if_stmt.condition);
165
166        self.scope.push_frame();
167        for stmt in &if_stmt.then_branch {
168            self.validate_stmt(stmt);
169        }
170        self.scope.pop_frame();
171
172        if let Some(else_branch) = &if_stmt.else_branch {
173            self.scope.push_frame();
174            for stmt in else_branch {
175                self.validate_stmt(stmt);
176            }
177            self.scope.pop_frame();
178        }
179    }
180
181    /// Validate a for loop.
182    fn validate_for(&mut self, for_loop: &ForLoop) {
183        // Validate item expressions and check for bare scalar variables
184        for item in &for_loop.items {
185            self.validate_expr(item);
186
187            // Detect `for i in $VAR` pattern - always a mistake in kaish
188            // since we don't do implicit word splitting
189            if self.is_bare_scalar_var(item) {
190                self.issues.push(
191                    ValidationIssue::error(
192                        IssueCode::ForLoopScalarVar,
193                        "bare variable in for loop iterates once (kaish has no implicit word splitting)",
194                    )
195                    .with_suggestion(concat!(
196                        "use one of:\n",
197                        "    for i in $(split \"$VAR\")      # split on whitespace\n",
198                        "    for i in $(split \"$VAR\" \":\")  # split on delimiter\n",
199                        "    for i in $(seq 1 10)          # iterate numbers\n",
200                        "    for i in $(glob \"*.rs\")       # iterate files",
201                    )),
202                );
203            }
204        }
205
206        self.loop_depth += 1;
207        self.scope.push_frame();
208
209        // Bind loop variable
210        self.scope.bind(&for_loop.variable);
211
212        for stmt in &for_loop.body {
213            self.validate_stmt(stmt);
214        }
215
216        self.scope.pop_frame();
217        self.loop_depth -= 1;
218    }
219
220    /// Extract glob pattern from unquoted literal expressions.
221    ///
222    /// Check if an expression is a bare scalar variable reference.
223    ///
224    /// Returns true for `$VAR` or `${VAR}` but not for `$(cmd)` or `"$VAR"`.
225    fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
226        match expr {
227            // Direct variable reference like $VAR or ${VAR}
228            Expr::VarRef(_) => true,
229            // Variable with default like ${VAR:-default} - also problematic
230            Expr::VarWithDefault { .. } => true,
231            // NOT a problem: command substitution like $(cmd) - returns structured data
232            Expr::CommandSubst(_) => false,
233            // NOT a problem: literals are fine
234            Expr::Literal(_) => false,
235            // NOT a problem: interpolated strings are a single value
236            Expr::Interpolated(_) => false,
237            // Everything else: not a bare scalar var
238            _ => false,
239        }
240    }
241
242    /// Validate a while loop.
243    fn validate_while(&mut self, while_loop: &WhileLoop) {
244        self.validate_expr(&while_loop.condition);
245
246        self.loop_depth += 1;
247        self.scope.push_frame();
248
249        for stmt in &while_loop.body {
250            self.validate_stmt(stmt);
251        }
252
253        self.scope.pop_frame();
254        self.loop_depth -= 1;
255    }
256
257    /// Validate a case statement.
258    fn validate_case(&mut self, case_stmt: &CaseStmt) {
259        self.validate_expr(&case_stmt.expr);
260
261        for branch in &case_stmt.branches {
262            self.validate_case_branch(branch);
263        }
264    }
265
266    /// Validate a case branch.
267    fn validate_case_branch(&mut self, branch: &CaseBranch) {
268        self.scope.push_frame();
269        for stmt in &branch.body {
270            self.validate_stmt(stmt);
271        }
272        self.scope.pop_frame();
273    }
274
275    /// Validate a break statement.
276    fn validate_break(&mut self, levels: Option<usize>) {
277        if self.loop_depth == 0 {
278            self.issues.push(ValidationIssue::error(
279                IssueCode::BreakOutsideLoop,
280                "break used outside of a loop",
281            ));
282        } else if let Some(n) = levels
283            && n > self.loop_depth {
284                self.issues.push(ValidationIssue::warning(
285                    IssueCode::BreakOutsideLoop,
286                    format!(
287                        "break {} exceeds loop nesting depth {}",
288                        n, self.loop_depth
289                    ),
290                ));
291            }
292    }
293
294    /// Validate a continue statement.
295    fn validate_continue(&mut self, levels: Option<usize>) {
296        if self.loop_depth == 0 {
297            self.issues.push(ValidationIssue::error(
298                IssueCode::BreakOutsideLoop,
299                "continue used outside of a loop",
300            ));
301        } else if let Some(n) = levels
302            && n > self.loop_depth {
303                self.issues.push(ValidationIssue::warning(
304                    IssueCode::BreakOutsideLoop,
305                    format!(
306                        "continue {} exceeds loop nesting depth {}",
307                        n, self.loop_depth
308                    ),
309                ));
310            }
311    }
312
313    /// Validate a return statement.
314    fn validate_return(&mut self, expr: Option<&Expr>) {
315        if let Some(e) = expr {
316            self.validate_expr(e);
317        }
318
319        if self.function_depth == 0 {
320            self.issues.push(ValidationIssue::error(
321                IssueCode::ReturnOutsideFunction,
322                "return used outside of a function",
323            ));
324        }
325    }
326
327    /// Validate a tool definition.
328    fn validate_tool_def(&mut self, tool_def: &ToolDef) {
329        self.function_depth += 1;
330        self.scope.push_frame();
331
332        // Bind parameters
333        for param in &tool_def.params {
334            self.scope.bind(&param.name);
335            // Validate default expressions
336            if let Some(default) = &param.default {
337                self.validate_expr(default);
338            }
339        }
340
341        // Validate body
342        for stmt in &tool_def.body {
343            self.validate_stmt(stmt);
344        }
345
346        self.scope.pop_frame();
347        self.function_depth -= 1;
348    }
349
350    /// Validate a test expression.
351    fn validate_test(&mut self, test: &TestExpr) {
352        match test {
353            TestExpr::FileTest { path, .. } => self.validate_expr(path),
354            TestExpr::StringTest { value, .. } => self.validate_expr(value),
355            TestExpr::Comparison { left, right, .. } => {
356                self.validate_expr(left);
357                self.validate_expr(right);
358            }
359            TestExpr::And { left, right } | TestExpr::Or { left, right } => {
360                self.validate_test(left);
361                self.validate_test(right);
362            }
363            TestExpr::Not { expr } => self.validate_test(expr),
364        }
365    }
366
367    /// Validate an expression.
368    fn validate_expr(&mut self, expr: &Expr) {
369        match expr {
370            Expr::Literal(_) => {}
371            Expr::VarRef(path) => self.validate_var_ref(path),
372            Expr::Interpolated(parts) => {
373                for part in parts {
374                    self.validate_string_part(part);
375                }
376            }
377            Expr::HereDocBody { parts, .. } => {
378                for sp in parts {
379                    self.validate_spanned_string_part(sp);
380                }
381            }
382            Expr::BinaryOp { left, right, .. } => {
383                self.validate_expr(left);
384                self.validate_expr(right);
385            }
386            Expr::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
387            Expr::Test(test) => self.validate_test(test),
388            Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
389            Expr::VarLength(name) => self.check_var_defined(name),
390            Expr::VarWithDefault { name, .. } => {
391                // Don't warn - default handles undefined case
392                let _ = name;
393            }
394            Expr::Arithmetic(_) => {
395                // Arithmetic parsing is done at runtime
396            }
397            Expr::Command(cmd) => self.validate_command(cmd),
398            Expr::LastExitCode | Expr::CurrentPid => {}
399            Expr::GlobPattern(_) => {}
400        }
401    }
402
403    /// Validate a variable reference.
404    fn validate_var_ref(&mut self, path: &VarPath) {
405        if let Some(VarSegment::Field(name)) = path.segments.first() {
406            // `${?.field}` is removed — $? is the POSIX integer exit code.
407            // Use `kaish-last` to access the previous command's structured data.
408            if name == "?" && path.segments.len() > 1 {
409                self.issues.push(
410                    ValidationIssue::error(
411                        IssueCode::LastResultFieldAccess,
412                        "${?.field} is removed; $? is the POSIX exit code",
413                    )
414                    .with_suggestion(
415                        "use `kaish-last` to read the previous command's data or stdout",
416                    ),
417                );
418                return;
419            }
420            self.check_var_defined(name);
421        }
422    }
423
424    /// Validate a spanned heredoc-body part, attaching the part's span to any
425    /// new issues raised during the inner walk. Issues already carrying a span
426    /// are left alone (e.g., from a nested validator that already knew better).
427    fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
428        let issues_before = self.issues.len();
429        self.validate_string_part(&sp.part);
430        let span = Span::new(sp.offset, sp.offset + sp.len);
431        for issue in &mut self.issues[issues_before..] {
432            if issue.span.is_none() {
433                issue.span = Some(span);
434            }
435        }
436    }
437
438    /// Validate a string interpolation part.
439    fn validate_string_part(&mut self, part: &StringPart) {
440        match part {
441            StringPart::Literal(_) => {}
442            StringPart::Var(path) => self.validate_var_ref(path),
443            StringPart::VarWithDefault { default, .. } => {
444                // Validate nested parts in the default value
445                for p in default {
446                    self.validate_string_part(p);
447                }
448            }
449            StringPart::VarLength(name) => self.check_var_defined(name),
450            StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
451            StringPart::Arithmetic(_) => {} // Arithmetic expressions are validated at eval time
452            StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
453            StringPart::LastExitCode | StringPart::CurrentPid => {}
454        }
455    }
456
457    /// Check if a variable is defined and warn if not.
458    fn check_var_defined(&mut self, name: &str) {
459        // Skip underscore-prefixed vars (external/unchecked convention)
460        if ScopeTracker::should_skip_undefined_check(name) {
461            return;
462        }
463
464        if !self.scope.is_bound(name) {
465            self.issues.push(ValidationIssue::warning(
466                IssueCode::PossiblyUndefinedVariable,
467                format!("variable '{}' may be undefined", name),
468            ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
469        }
470    }
471
472    /// Validate arguments against a user-defined tool's parameters.
473    fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
474        let positional_count = args
475            .iter()
476            .filter(|a| matches!(a, Arg::Positional(_)))
477            .count();
478
479        let required_count = tool_def
480            .params
481            .iter()
482            .filter(|p| p.default.is_none())
483            .count();
484
485        if positional_count < required_count {
486            self.issues.push(ValidationIssue::error(
487                IssueCode::MissingRequiredArg,
488                format!(
489                    "'{}' requires {} arguments, got {}",
490                    tool_def.name, required_count, positional_count
491                ),
492            ));
493        }
494    }
495}
496
497/// Check if a command name is static (not a variable expansion).
498fn is_static_command_name(name: &str) -> bool {
499    !name.starts_with('$') && !name.contains("$(")
500}
501
502/// Check if a command is a special built-in that we don't validate.
503fn is_special_command(name: &str) -> bool {
504    matches!(
505        name,
506        "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
507    )
508}
509
510/// Build ToolArgs from AST Args for validation purposes.
511///
512/// This is a simplified version that doesn't evaluate expressions -
513/// it uses placeholder values since we only care about argument structure.
514pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
515    let mut tool_args = ToolArgs::new();
516
517    for arg in args {
518        match arg {
519            Arg::Positional(expr) => {
520                tool_args.positional.push(expr_to_placeholder(expr));
521            }
522            Arg::Named { key, value } => {
523                tool_args.named.insert(key.clone(), expr_to_placeholder(value));
524            }
525            Arg::ShortFlag(flag) => {
526                tool_args.flags.insert(flag.clone());
527            }
528            Arg::LongFlag(flag) => {
529                tool_args.flags.insert(flag.clone());
530            }
531            Arg::DoubleDash => {}
532        }
533    }
534
535    tool_args
536}
537
538/// Convert an expression to a placeholder value for validation.
539///
540/// For literal values, return the actual value.
541/// For dynamic expressions (var refs, command subst), return a placeholder.
542fn expr_to_placeholder(expr: &Expr) -> Value {
543    match expr {
544        Expr::Literal(val) => val.clone(),
545        Expr::Interpolated(parts) if parts.len() == 1 => {
546            if let StringPart::Literal(s) = &parts[0] {
547                Value::String(s.clone())
548            } else {
549                Value::String("<dynamic>".to_string())
550            }
551        }
552        // For variable refs, command substitution, etc. - use placeholder
553        _ => Value::String("<dynamic>".to_string()),
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::tools::{register_builtins, ToolRegistry};
561
562    fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
563        let mut registry = ToolRegistry::new();
564        register_builtins(&mut registry);
565        let user_tools = HashMap::new();
566        (registry, user_tools)
567    }
568
569    #[test]
570    fn validates_undefined_command() {
571        let (registry, user_tools) = make_validator();
572        let validator = Validator::new(&registry, &user_tools);
573
574        let program = Program {
575            statements: vec![Stmt::Command(Command {
576                name: "nonexistent_command".to_string(),
577                args: vec![],
578                redirects: vec![],
579            })],
580        };
581
582        let issues = validator.validate(&program);
583        assert!(!issues.is_empty());
584        assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
585    }
586
587    #[test]
588    fn validates_known_command() {
589        let (registry, user_tools) = make_validator();
590        let validator = Validator::new(&registry, &user_tools);
591
592        let program = Program {
593            statements: vec![Stmt::Command(Command {
594                name: "echo".to_string(),
595                args: vec![Arg::Positional(Expr::Literal(Value::String(
596                    "hello".to_string(),
597                )))],
598                redirects: vec![],
599            })],
600        };
601
602        let issues = validator.validate(&program);
603        // echo should not produce an undefined command error
604        assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
605    }
606
607    #[test]
608    fn validates_break_outside_loop() {
609        let (registry, user_tools) = make_validator();
610        let validator = Validator::new(&registry, &user_tools);
611
612        let program = Program {
613            statements: vec![Stmt::Break(None)],
614        };
615
616        let issues = validator.validate(&program);
617        assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
618    }
619
620    #[test]
621    fn validates_break_inside_loop() {
622        let (registry, user_tools) = make_validator();
623        let validator = Validator::new(&registry, &user_tools);
624
625        let program = Program {
626            statements: vec![Stmt::For(ForLoop {
627                variable: "i".to_string(),
628                items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
629                body: vec![Stmt::Break(None)],
630            })],
631        };
632
633        let issues = validator.validate(&program);
634        // Break inside loop should NOT produce an error
635        assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
636    }
637
638    #[test]
639    fn validates_undefined_variable() {
640        let (registry, user_tools) = make_validator();
641        let validator = Validator::new(&registry, &user_tools);
642
643        let program = Program {
644            statements: vec![Stmt::Command(Command {
645                name: "echo".to_string(),
646                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
647                    "UNDEFINED_VAR",
648                )))],
649                redirects: vec![],
650            })],
651        };
652
653        let issues = validator.validate(&program);
654        assert!(issues
655            .iter()
656            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
657    }
658
659    #[test]
660    fn validates_defined_variable() {
661        let (registry, user_tools) = make_validator();
662        let validator = Validator::new(&registry, &user_tools);
663
664        let program = Program {
665            statements: vec![
666                // First assign the variable
667                Stmt::Assignment(Assignment {
668                    name: "MY_VAR".to_string(),
669                    value: Expr::Literal(Value::String("value".to_string())),
670                    local: false,
671                }),
672                // Then use it
673                Stmt::Command(Command {
674                    name: "echo".to_string(),
675                    args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
676                    redirects: vec![],
677                }),
678            ],
679        };
680
681        let issues = validator.validate(&program);
682        // Should NOT warn about MY_VAR
683        assert!(!issues
684            .iter()
685            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
686                && i.message.contains("MY_VAR")));
687    }
688
689    #[test]
690    fn skips_underscore_prefixed_vars() {
691        let (registry, user_tools) = make_validator();
692        let validator = Validator::new(&registry, &user_tools);
693
694        let program = Program {
695            statements: vec![Stmt::Command(Command {
696                name: "echo".to_string(),
697                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
698                redirects: vec![],
699            })],
700        };
701
702        let issues = validator.validate(&program);
703        // Should NOT warn about _EXTERNAL
704        assert!(!issues
705            .iter()
706            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
707    }
708
709    #[test]
710    fn builtin_vars_are_defined() {
711        let (registry, user_tools) = make_validator();
712        let validator = Validator::new(&registry, &user_tools);
713
714        let program = Program {
715            statements: vec![Stmt::Command(Command {
716                name: "echo".to_string(),
717                args: vec![
718                    Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
719                    Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
720                    Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
721                ],
722                redirects: vec![],
723            })],
724        };
725
726        let issues = validator.validate(&program);
727        // Should NOT warn about HOME, PATH, PWD
728        assert!(!issues
729            .iter()
730            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
731    }
732
733    #[test]
734    fn validates_scatter_without_gather() {
735        let (registry, user_tools) = make_validator();
736        let validator = Validator::new(&registry, &user_tools);
737
738        let program = Program {
739            statements: vec![Stmt::Pipeline(Pipeline {
740                commands: vec![
741                    Command { name: "seq".to_string(), args: vec![
742                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
743                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
744                    ], redirects: vec![] },
745                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
746                    Command { name: "echo".to_string(), args: vec![
747                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
748                    ], redirects: vec![] },
749                ],
750                background: false,
751            })],
752        };
753
754        let issues = validator.validate(&program);
755        assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
756            "should flag scatter without gather: {:?}", issues);
757    }
758
759    #[test]
760    fn allows_scatter_with_gather() {
761        let (registry, user_tools) = make_validator();
762        let validator = Validator::new(&registry, &user_tools);
763
764        let program = Program {
765            statements: vec![Stmt::Pipeline(Pipeline {
766                commands: vec![
767                    Command { name: "seq".to_string(), args: vec![
768                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
769                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
770                    ], redirects: vec![] },
771                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
772                    Command { name: "echo".to_string(), args: vec![
773                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
774                    ], redirects: vec![] },
775                    Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
776                ],
777                background: false,
778            })],
779        };
780
781        let issues = validator.validate(&program);
782        assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
783            "scatter with gather should pass: {:?}", issues);
784    }
785}