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    Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop, Value,
8};
9use crate::tools::{ToolArgs, ToolRegistry};
10
11use super::issue::{IssueCode, ValidationIssue};
12use super::scope_tracker::ScopeTracker;
13
14/// AST validator that checks for issues before execution.
15pub struct Validator<'a> {
16    /// Reference to the tool registry.
17    registry: &'a ToolRegistry,
18    /// User-defined tools.
19    user_tools: &'a HashMap<String, ToolDef>,
20    /// Variable scope tracker.
21    scope: ScopeTracker,
22    /// Current loop nesting depth.
23    loop_depth: usize,
24    /// Current function nesting depth.
25    function_depth: usize,
26    /// Collected validation issues.
27    issues: Vec<ValidationIssue>,
28}
29
30impl<'a> Validator<'a> {
31    /// Create a new validator.
32    pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
33        Self {
34            registry,
35            user_tools,
36            scope: ScopeTracker::new(),
37            loop_depth: 0,
38            function_depth: 0,
39            issues: Vec::new(),
40        }
41    }
42
43    /// Validate a program and return all issues found.
44    pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
45        for stmt in &program.statements {
46            self.validate_stmt(stmt);
47        }
48        self.issues
49    }
50
51    /// Validate a single statement.
52    fn validate_stmt(&mut self, stmt: &Stmt) {
53        match stmt {
54            Stmt::Assignment(assign) => self.validate_assignment(assign),
55            Stmt::Command(cmd) => self.validate_command(cmd),
56            Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
57            Stmt::If(if_stmt) => self.validate_if(if_stmt),
58            Stmt::For(for_loop) => self.validate_for(for_loop),
59            Stmt::While(while_loop) => self.validate_while(while_loop),
60            Stmt::Case(case_stmt) => self.validate_case(case_stmt),
61            Stmt::Break(levels) => self.validate_break(*levels),
62            Stmt::Continue(levels) => self.validate_continue(*levels),
63            Stmt::Return(expr) => self.validate_return(expr.as_deref()),
64            Stmt::Exit(expr) => {
65                if let Some(e) = expr {
66                    self.validate_expr(e);
67                }
68            }
69            Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
70            Stmt::Test(test_expr) => self.validate_test(test_expr),
71            Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
72                self.validate_stmt(left);
73                self.validate_stmt(right);
74            }
75            Stmt::Empty => {}
76        }
77    }
78
79    /// Validate an assignment statement.
80    fn validate_assignment(&mut self, assign: &Assignment) {
81        // Validate the value expression
82        self.validate_expr(&assign.value);
83        // Bind the variable name in scope
84        self.scope.bind(&assign.name);
85    }
86
87    /// Validate a command invocation.
88    fn validate_command(&mut self, cmd: &Command) {
89        // Skip source/. commands - they're dynamic
90        if cmd.name == "source" || cmd.name == "." {
91            return;
92        }
93
94        // Skip dynamic command names (variable expansions)
95        if !is_static_command_name(&cmd.name) {
96            return;
97        }
98
99        // Check if command exists
100        let is_builtin = self.registry.contains(&cmd.name);
101        let is_user_tool = self.user_tools.contains_key(&cmd.name);
102        let is_special = is_special_command(&cmd.name);
103
104        if !is_builtin && !is_user_tool && !is_special {
105            // Warning only - command might be a script in PATH or external tool
106            self.issues.push(ValidationIssue::warning(
107                IssueCode::UndefinedCommand,
108                format!("command '{}' not found in builtin registry", cmd.name),
109            ).with_suggestion("this may be a script in PATH or external command"));
110        }
111
112        // Validate arguments expressions
113        for arg in &cmd.args {
114            self.validate_arg(arg);
115        }
116
117        // Check for shell glob patterns in arguments (unless command expects patterns)
118        if !command_expects_pattern_or_text(&cmd.name) {
119            for arg in &cmd.args {
120                if let Arg::Positional(expr) = arg
121                    && let Some(pattern) = self.extract_unquoted_glob_pattern(expr) {
122                        self.issues.push(
123                            ValidationIssue::error(
124                                IssueCode::ShellGlobPattern,
125                                format!(
126                                    "glob pattern '{}' won't expand (kaish has no implicit globbing)",
127                                    pattern
128                                ),
129                            )
130                            .with_suggestion(
131                                "use: glob \"pattern\" | xargs cmd  OR  for f in $(glob \"pattern\")",
132                            ),
133                        );
134                    }
135            }
136        }
137
138        // If we have a schema, validate args against it
139        if let Some(tool) = self.registry.get(&cmd.name) {
140            let tool_args = build_tool_args_for_validation(&cmd.args);
141            let tool_issues = tool.validate(&tool_args);
142            self.issues.extend(tool_issues);
143        } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
144            // Validate against user-defined tool parameters
145            self.validate_user_tool_args(user_tool, &cmd.args);
146        }
147
148        // Validate redirects
149        for redirect in &cmd.redirects {
150            self.validate_expr(&redirect.target);
151        }
152    }
153
154    /// Validate a command argument.
155    fn validate_arg(&mut self, arg: &Arg) {
156        match arg {
157            Arg::Positional(expr) => self.validate_expr(expr),
158            Arg::Named { value, .. } => self.validate_expr(value),
159            Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
160        }
161    }
162
163    /// Validate a pipeline.
164    fn validate_pipeline(&mut self, pipe: &Pipeline) {
165        // Check for scatter without gather
166        let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
167        let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
168        if has_scatter && !has_gather {
169            self.issues.push(
170                ValidationIssue::error(
171                    IssueCode::ScatterWithoutGather,
172                    "scatter without gather — parallel results would be lost",
173                ).with_suggestion("add gather: ... | scatter | cmd | gather")
174            );
175        }
176
177        for cmd in &pipe.commands {
178            self.validate_command(cmd);
179        }
180    }
181
182    /// Validate an if statement.
183    fn validate_if(&mut self, if_stmt: &IfStmt) {
184        self.validate_expr(&if_stmt.condition);
185
186        self.scope.push_frame();
187        for stmt in &if_stmt.then_branch {
188            self.validate_stmt(stmt);
189        }
190        self.scope.pop_frame();
191
192        if let Some(else_branch) = &if_stmt.else_branch {
193            self.scope.push_frame();
194            for stmt in else_branch {
195                self.validate_stmt(stmt);
196            }
197            self.scope.pop_frame();
198        }
199    }
200
201    /// Validate a for loop.
202    fn validate_for(&mut self, for_loop: &ForLoop) {
203        // Validate item expressions and check for bare scalar variables
204        for item in &for_loop.items {
205            self.validate_expr(item);
206
207            // Detect `for i in $VAR` pattern - always a mistake in kaish
208            // since we don't do implicit word splitting
209            if self.is_bare_scalar_var(item) {
210                self.issues.push(
211                    ValidationIssue::error(
212                        IssueCode::ForLoopScalarVar,
213                        "bare variable in for loop iterates once (kaish has no implicit word splitting)",
214                    )
215                    .with_suggestion(concat!(
216                        "use one of:\n",
217                        "    for i in $(split \"$VAR\")      # split on whitespace\n",
218                        "    for i in $(split \"$VAR\" \":\")  # split on delimiter\n",
219                        "    for i in $(seq 1 10)          # iterate numbers\n",
220                        "    for i in $(glob \"*.rs\")       # iterate files",
221                    )),
222                );
223            }
224        }
225
226        self.loop_depth += 1;
227        self.scope.push_frame();
228
229        // Bind loop variable
230        self.scope.bind(&for_loop.variable);
231
232        for stmt in &for_loop.body {
233            self.validate_stmt(stmt);
234        }
235
236        self.scope.pop_frame();
237        self.loop_depth -= 1;
238    }
239
240    /// Extract glob pattern from unquoted literal expressions.
241    ///
242    /// Returns `Some(pattern)` if the expression is an unquoted literal that
243    /// looks like a shell glob pattern (e.g., `*.txt`, `file?.log`).
244    fn extract_unquoted_glob_pattern(&self, expr: &Expr) -> Option<String> {
245        match expr {
246            Expr::Literal(Value::String(s)) if looks_like_shell_glob(s) => Some(s.clone()),
247            // Interpolated strings that are just a literal (parser may produce these)
248            Expr::Interpolated(parts) if parts.len() == 1 => {
249                if let StringPart::Literal(s) = &parts[0]
250                    && looks_like_shell_glob(s) {
251                        return Some(s.clone());
252                    }
253                None
254            }
255            _ => None,
256        }
257    }
258
259    /// Check if an expression is a bare scalar variable reference.
260    ///
261    /// Returns true for `$VAR` or `${VAR}` but not for `$(cmd)` or `"$VAR"`.
262    fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
263        match expr {
264            // Direct variable reference like $VAR or ${VAR}
265            Expr::VarRef(_) => true,
266            // Variable with default like ${VAR:-default} - also problematic
267            Expr::VarWithDefault { .. } => true,
268            // NOT a problem: command substitution like $(cmd) - returns structured data
269            Expr::CommandSubst(_) => false,
270            // NOT a problem: literals are fine
271            Expr::Literal(_) => false,
272            // NOT a problem: interpolated strings are a single value
273            Expr::Interpolated(_) => false,
274            // Everything else: not a bare scalar var
275            _ => false,
276        }
277    }
278
279    /// Validate a while loop.
280    fn validate_while(&mut self, while_loop: &WhileLoop) {
281        self.validate_expr(&while_loop.condition);
282
283        self.loop_depth += 1;
284        self.scope.push_frame();
285
286        for stmt in &while_loop.body {
287            self.validate_stmt(stmt);
288        }
289
290        self.scope.pop_frame();
291        self.loop_depth -= 1;
292    }
293
294    /// Validate a case statement.
295    fn validate_case(&mut self, case_stmt: &CaseStmt) {
296        self.validate_expr(&case_stmt.expr);
297
298        for branch in &case_stmt.branches {
299            self.validate_case_branch(branch);
300        }
301    }
302
303    /// Validate a case branch.
304    fn validate_case_branch(&mut self, branch: &CaseBranch) {
305        self.scope.push_frame();
306        for stmt in &branch.body {
307            self.validate_stmt(stmt);
308        }
309        self.scope.pop_frame();
310    }
311
312    /// Validate a break statement.
313    fn validate_break(&mut self, levels: Option<usize>) {
314        if self.loop_depth == 0 {
315            self.issues.push(ValidationIssue::error(
316                IssueCode::BreakOutsideLoop,
317                "break used outside of a loop",
318            ));
319        } else if let Some(n) = levels
320            && n > self.loop_depth {
321                self.issues.push(ValidationIssue::warning(
322                    IssueCode::BreakOutsideLoop,
323                    format!(
324                        "break {} exceeds loop nesting depth {}",
325                        n, self.loop_depth
326                    ),
327                ));
328            }
329    }
330
331    /// Validate a continue statement.
332    fn validate_continue(&mut self, levels: Option<usize>) {
333        if self.loop_depth == 0 {
334            self.issues.push(ValidationIssue::error(
335                IssueCode::BreakOutsideLoop,
336                "continue used outside of a loop",
337            ));
338        } else if let Some(n) = levels
339            && n > self.loop_depth {
340                self.issues.push(ValidationIssue::warning(
341                    IssueCode::BreakOutsideLoop,
342                    format!(
343                        "continue {} exceeds loop nesting depth {}",
344                        n, self.loop_depth
345                    ),
346                ));
347            }
348    }
349
350    /// Validate a return statement.
351    fn validate_return(&mut self, expr: Option<&Expr>) {
352        if let Some(e) = expr {
353            self.validate_expr(e);
354        }
355
356        if self.function_depth == 0 {
357            self.issues.push(ValidationIssue::error(
358                IssueCode::ReturnOutsideFunction,
359                "return used outside of a function",
360            ));
361        }
362    }
363
364    /// Validate a tool definition.
365    fn validate_tool_def(&mut self, tool_def: &ToolDef) {
366        self.function_depth += 1;
367        self.scope.push_frame();
368
369        // Bind parameters
370        for param in &tool_def.params {
371            self.scope.bind(&param.name);
372            // Validate default expressions
373            if let Some(default) = &param.default {
374                self.validate_expr(default);
375            }
376        }
377
378        // Validate body
379        for stmt in &tool_def.body {
380            self.validate_stmt(stmt);
381        }
382
383        self.scope.pop_frame();
384        self.function_depth -= 1;
385    }
386
387    /// Validate a test expression.
388    fn validate_test(&mut self, test: &TestExpr) {
389        match test {
390            TestExpr::FileTest { path, .. } => self.validate_expr(path),
391            TestExpr::StringTest { value, .. } => self.validate_expr(value),
392            TestExpr::Comparison { left, right, .. } => {
393                self.validate_expr(left);
394                self.validate_expr(right);
395            }
396            TestExpr::And { left, right } | TestExpr::Or { left, right } => {
397                self.validate_test(left);
398                self.validate_test(right);
399            }
400            TestExpr::Not { expr } => self.validate_test(expr),
401        }
402    }
403
404    /// Validate an expression.
405    fn validate_expr(&mut self, expr: &Expr) {
406        match expr {
407            Expr::Literal(_) => {}
408            Expr::VarRef(path) => self.validate_var_ref(path),
409            Expr::Interpolated(parts) => {
410                for part in parts {
411                    self.validate_string_part(part);
412                }
413            }
414            Expr::BinaryOp { left, right, .. } => {
415                self.validate_expr(left);
416                self.validate_expr(right);
417            }
418            Expr::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
419            Expr::Test(test) => self.validate_test(test),
420            Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
421            Expr::VarLength(name) => self.check_var_defined(name),
422            Expr::VarWithDefault { name, .. } => {
423                // Don't warn - default handles undefined case
424                let _ = name;
425            }
426            Expr::Arithmetic(_) => {
427                // Arithmetic parsing is done at runtime
428            }
429            Expr::Command(cmd) => self.validate_command(cmd),
430            Expr::LastExitCode | Expr::CurrentPid => {}
431        }
432    }
433
434    /// Validate a variable reference.
435    fn validate_var_ref(&mut self, path: &VarPath) {
436        if let Some(VarSegment::Field(name)) = path.segments.first() {
437            self.check_var_defined(name);
438        }
439    }
440
441    /// Validate a string interpolation part.
442    fn validate_string_part(&mut self, part: &StringPart) {
443        match part {
444            StringPart::Literal(_) => {}
445            StringPart::Var(path) => self.validate_var_ref(path),
446            StringPart::VarWithDefault { default, .. } => {
447                // Validate nested parts in the default value
448                for p in default {
449                    self.validate_string_part(p);
450                }
451            }
452            StringPart::VarLength(name) => self.check_var_defined(name),
453            StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
454            StringPart::Arithmetic(_) => {} // Arithmetic expressions are validated at eval time
455            StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
456            StringPart::LastExitCode | StringPart::CurrentPid => {}
457        }
458    }
459
460    /// Check if a variable is defined and warn if not.
461    fn check_var_defined(&mut self, name: &str) {
462        // Skip underscore-prefixed vars (external/unchecked convention)
463        if ScopeTracker::should_skip_undefined_check(name) {
464            return;
465        }
466
467        if !self.scope.is_bound(name) {
468            self.issues.push(ValidationIssue::warning(
469                IssueCode::PossiblyUndefinedVariable,
470                format!("variable '{}' may be undefined", name),
471            ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
472        }
473    }
474
475    /// Validate arguments against a user-defined tool's parameters.
476    fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
477        let positional_count = args
478            .iter()
479            .filter(|a| matches!(a, Arg::Positional(_)))
480            .count();
481
482        let required_count = tool_def
483            .params
484            .iter()
485            .filter(|p| p.default.is_none())
486            .count();
487
488        if positional_count < required_count {
489            self.issues.push(ValidationIssue::error(
490                IssueCode::MissingRequiredArg,
491                format!(
492                    "'{}' requires {} arguments, got {}",
493                    tool_def.name, required_count, positional_count
494                ),
495            ));
496        }
497    }
498}
499
500/// Check if a command name is static (not a variable expansion).
501fn is_static_command_name(name: &str) -> bool {
502    !name.starts_with('$') && !name.contains("$(")
503}
504
505/// Check if a string looks like a shell glob pattern.
506///
507/// Detects: `*`, `?`, `[x]`, `[a-z]`, etc. when they appear in what looks
508/// like a filename pattern.
509fn looks_like_shell_glob(s: &str) -> bool {
510    // Skip if it looks like a regex anchor or common non-glob uses
511    if s.starts_with('^') || s.ends_with('$') {
512        return false;
513    }
514
515    let has_star = s.contains('*');
516    let has_question = s.contains('?') && !s.contains("??"); // ?? is often intentional
517    let has_bracket = s.contains('[') && s.contains(']');
518
519    // Must look like a filename pattern (has extension or path separator)
520    let looks_like_path = s.contains('.') || s.contains('/');
521
522    (has_star || has_question || has_bracket) && looks_like_path
523}
524
525/// Check if a command expects pattern arguments or text (not filenames).
526///
527/// Returns true for commands where glob-like patterns in arguments are
528/// intentional and shouldn't trigger validation warnings.
529fn command_expects_pattern_or_text(cmd: &str) -> bool {
530    matches!(
531        cmd,
532        // Pattern-based commands
533        "grep" | "egrep" | "fgrep" | "sed" | "awk" | "find" | "glob" | "regex"
534        | "ls" | "cat" | "head" | "tail" | "wc"
535        // Text output commands - anything is valid text
536        | "echo" | "printf"
537        // JSON/text processing
538        | "jq"
539    )
540}
541
542/// Check if a command is a special built-in that we don't validate.
543fn is_special_command(name: &str) -> bool {
544    matches!(
545        name,
546        "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
547    )
548}
549
550/// Build ToolArgs from AST Args for validation purposes.
551///
552/// This is a simplified version that doesn't evaluate expressions -
553/// it uses placeholder values since we only care about argument structure.
554pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
555    let mut tool_args = ToolArgs::new();
556
557    for arg in args {
558        match arg {
559            Arg::Positional(expr) => {
560                tool_args.positional.push(expr_to_placeholder(expr));
561            }
562            Arg::Named { key, value } => {
563                tool_args.named.insert(key.clone(), expr_to_placeholder(value));
564            }
565            Arg::ShortFlag(flag) => {
566                tool_args.flags.insert(flag.clone());
567            }
568            Arg::LongFlag(flag) => {
569                tool_args.flags.insert(flag.clone());
570            }
571            Arg::DoubleDash => {}
572        }
573    }
574
575    tool_args
576}
577
578/// Convert an expression to a placeholder value for validation.
579///
580/// For literal values, return the actual value.
581/// For dynamic expressions (var refs, command subst), return a placeholder.
582fn expr_to_placeholder(expr: &Expr) -> Value {
583    match expr {
584        Expr::Literal(val) => val.clone(),
585        Expr::Interpolated(parts) if parts.len() == 1 => {
586            if let StringPart::Literal(s) = &parts[0] {
587                Value::String(s.clone())
588            } else {
589                Value::String("<dynamic>".to_string())
590            }
591        }
592        // For variable refs, command substitution, etc. - use placeholder
593        _ => Value::String("<dynamic>".to_string()),
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use crate::tools::{register_builtins, ToolRegistry};
601
602    fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
603        let mut registry = ToolRegistry::new();
604        register_builtins(&mut registry);
605        let user_tools = HashMap::new();
606        (registry, user_tools)
607    }
608
609    #[test]
610    fn validates_undefined_command() {
611        let (registry, user_tools) = make_validator();
612        let validator = Validator::new(&registry, &user_tools);
613
614        let program = Program {
615            statements: vec![Stmt::Command(Command {
616                name: "nonexistent_command".to_string(),
617                args: vec![],
618                redirects: vec![],
619            })],
620        };
621
622        let issues = validator.validate(&program);
623        assert!(!issues.is_empty());
624        assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
625    }
626
627    #[test]
628    fn validates_known_command() {
629        let (registry, user_tools) = make_validator();
630        let validator = Validator::new(&registry, &user_tools);
631
632        let program = Program {
633            statements: vec![Stmt::Command(Command {
634                name: "echo".to_string(),
635                args: vec![Arg::Positional(Expr::Literal(Value::String(
636                    "hello".to_string(),
637                )))],
638                redirects: vec![],
639            })],
640        };
641
642        let issues = validator.validate(&program);
643        // echo should not produce an undefined command error
644        assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
645    }
646
647    #[test]
648    fn validates_break_outside_loop() {
649        let (registry, user_tools) = make_validator();
650        let validator = Validator::new(&registry, &user_tools);
651
652        let program = Program {
653            statements: vec![Stmt::Break(None)],
654        };
655
656        let issues = validator.validate(&program);
657        assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
658    }
659
660    #[test]
661    fn validates_break_inside_loop() {
662        let (registry, user_tools) = make_validator();
663        let validator = Validator::new(&registry, &user_tools);
664
665        let program = Program {
666            statements: vec![Stmt::For(ForLoop {
667                variable: "i".to_string(),
668                items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
669                body: vec![Stmt::Break(None)],
670            })],
671        };
672
673        let issues = validator.validate(&program);
674        // Break inside loop should NOT produce an error
675        assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
676    }
677
678    #[test]
679    fn validates_undefined_variable() {
680        let (registry, user_tools) = make_validator();
681        let validator = Validator::new(&registry, &user_tools);
682
683        let program = Program {
684            statements: vec![Stmt::Command(Command {
685                name: "echo".to_string(),
686                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
687                    "UNDEFINED_VAR",
688                )))],
689                redirects: vec![],
690            })],
691        };
692
693        let issues = validator.validate(&program);
694        assert!(issues
695            .iter()
696            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
697    }
698
699    #[test]
700    fn validates_defined_variable() {
701        let (registry, user_tools) = make_validator();
702        let validator = Validator::new(&registry, &user_tools);
703
704        let program = Program {
705            statements: vec![
706                // First assign the variable
707                Stmt::Assignment(Assignment {
708                    name: "MY_VAR".to_string(),
709                    value: Expr::Literal(Value::String("value".to_string())),
710                    local: false,
711                }),
712                // Then use it
713                Stmt::Command(Command {
714                    name: "echo".to_string(),
715                    args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
716                    redirects: vec![],
717                }),
718            ],
719        };
720
721        let issues = validator.validate(&program);
722        // Should NOT warn about MY_VAR
723        assert!(!issues
724            .iter()
725            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
726                && i.message.contains("MY_VAR")));
727    }
728
729    #[test]
730    fn skips_underscore_prefixed_vars() {
731        let (registry, user_tools) = make_validator();
732        let validator = Validator::new(&registry, &user_tools);
733
734        let program = Program {
735            statements: vec![Stmt::Command(Command {
736                name: "echo".to_string(),
737                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
738                redirects: vec![],
739            })],
740        };
741
742        let issues = validator.validate(&program);
743        // Should NOT warn about _EXTERNAL
744        assert!(!issues
745            .iter()
746            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
747    }
748
749    #[test]
750    fn builtin_vars_are_defined() {
751        let (registry, user_tools) = make_validator();
752        let validator = Validator::new(&registry, &user_tools);
753
754        let program = Program {
755            statements: vec![Stmt::Command(Command {
756                name: "echo".to_string(),
757                args: vec![
758                    Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
759                    Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
760                    Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
761                ],
762                redirects: vec![],
763            })],
764        };
765
766        let issues = validator.validate(&program);
767        // Should NOT warn about HOME, PATH, PWD
768        assert!(!issues
769            .iter()
770            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
771    }
772
773    #[test]
774    fn looks_like_shell_glob_detects_star() {
775        assert!(looks_like_shell_glob("*.txt"));
776        assert!(looks_like_shell_glob("src/*.rs"));
777        assert!(looks_like_shell_glob("**/*.json"));
778    }
779
780    #[test]
781    fn looks_like_shell_glob_detects_question() {
782        assert!(looks_like_shell_glob("file?.log"));
783        assert!(looks_like_shell_glob("test?.txt"));
784    }
785
786    #[test]
787    fn looks_like_shell_glob_detects_brackets() {
788        assert!(looks_like_shell_glob("[abc].txt"));
789        assert!(looks_like_shell_glob("[a-z].log"));
790        assert!(looks_like_shell_glob("file[0-9].rs"));
791    }
792
793    #[test]
794    fn looks_like_shell_glob_rejects_non_patterns() {
795        // No wildcard chars
796        assert!(!looks_like_shell_glob("readme.txt"));
797        assert!(!looks_like_shell_glob("src/main.rs"));
798        // Has wildcard but no extension/path (not filename-like)
799        assert!(!looks_like_shell_glob("hello*world"));
800        // Regex anchors (not globs)
801        assert!(!looks_like_shell_glob("^start"));
802        assert!(!looks_like_shell_glob("end$"));
803    }
804
805    #[test]
806    fn command_expects_pattern_or_text_returns_true() {
807        assert!(command_expects_pattern_or_text("grep"));
808        assert!(command_expects_pattern_or_text("echo"));
809        assert!(command_expects_pattern_or_text("find"));
810        assert!(command_expects_pattern_or_text("glob"));
811    }
812
813    #[test]
814    fn command_expects_pattern_or_text_returns_false() {
815        assert!(!command_expects_pattern_or_text("rm"));
816        assert!(!command_expects_pattern_or_text("cp"));
817    }
818
819    #[test]
820    fn validates_glob_pattern_in_ls() {
821        let (registry, user_tools) = make_validator();
822        let validator = Validator::new(&registry, &user_tools);
823
824        let program = Program {
825            statements: vec![Stmt::Command(Command {
826                name: "ls".to_string(),
827                args: vec![Arg::Positional(Expr::Literal(Value::String(
828                    "*.txt".to_string(),
829                )))],
830                redirects: vec![],
831            })],
832        };
833
834        let issues = validator.validate(&program);
835        assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
836    }
837
838    #[test]
839    fn allows_glob_pattern_in_grep() {
840        let (registry, user_tools) = make_validator();
841        let validator = Validator::new(&registry, &user_tools);
842
843        let program = Program {
844            statements: vec![Stmt::Command(Command {
845                name: "grep".to_string(),
846                args: vec![Arg::Positional(Expr::Literal(Value::String(
847                    "func.*test".to_string(),
848                )))],
849                redirects: vec![],
850            })],
851        };
852
853        let issues = validator.validate(&program);
854        // grep expects patterns, so should NOT warn
855        assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
856    }
857
858    #[test]
859    fn allows_glob_pattern_in_echo() {
860        let (registry, user_tools) = make_validator();
861        let validator = Validator::new(&registry, &user_tools);
862
863        let program = Program {
864            statements: vec![Stmt::Command(Command {
865                name: "echo".to_string(),
866                args: vec![Arg::Positional(Expr::Literal(Value::String(
867                    "*.txt".to_string(),
868                )))],
869                redirects: vec![],
870            })],
871        };
872
873        let issues = validator.validate(&program);
874        // echo is text output, so should NOT warn
875        assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
876    }
877
878    #[test]
879    fn validates_scatter_without_gather() {
880        let (registry, user_tools) = make_validator();
881        let validator = Validator::new(&registry, &user_tools);
882
883        let program = Program {
884            statements: vec![Stmt::Pipeline(Pipeline {
885                commands: vec![
886                    Command { name: "seq".to_string(), args: vec![
887                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
888                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
889                    ], redirects: vec![] },
890                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
891                    Command { name: "echo".to_string(), args: vec![
892                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
893                    ], redirects: vec![] },
894                ],
895                background: false,
896            })],
897        };
898
899        let issues = validator.validate(&program);
900        assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
901            "should flag scatter without gather: {:?}", issues);
902    }
903
904    #[test]
905    fn allows_scatter_with_gather() {
906        let (registry, user_tools) = make_validator();
907        let validator = Validator::new(&registry, &user_tools);
908
909        let program = Program {
910            statements: vec![Stmt::Pipeline(Pipeline {
911                commands: vec![
912                    Command { name: "seq".to_string(), args: vec![
913                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
914                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
915                    ], redirects: vec![] },
916                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
917                    Command { name: "echo".to_string(), args: vec![
918                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
919                    ], redirects: vec![] },
920                    Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
921                ],
922                background: false,
923            })],
924        };
925
926        let issues = validator.validate(&program);
927        assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
928            "scatter with gather should pass: {:?}", issues);
929    }
930}