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        for cmd in &pipe.commands {
166            self.validate_command(cmd);
167        }
168    }
169
170    /// Validate an if statement.
171    fn validate_if(&mut self, if_stmt: &IfStmt) {
172        self.validate_expr(&if_stmt.condition);
173
174        self.scope.push_frame();
175        for stmt in &if_stmt.then_branch {
176            self.validate_stmt(stmt);
177        }
178        self.scope.pop_frame();
179
180        if let Some(else_branch) = &if_stmt.else_branch {
181            self.scope.push_frame();
182            for stmt in else_branch {
183                self.validate_stmt(stmt);
184            }
185            self.scope.pop_frame();
186        }
187    }
188
189    /// Validate a for loop.
190    fn validate_for(&mut self, for_loop: &ForLoop) {
191        // Validate item expressions and check for bare scalar variables
192        for item in &for_loop.items {
193            self.validate_expr(item);
194
195            // Detect `for i in $VAR` pattern - always a mistake in kaish
196            // since we don't do implicit word splitting
197            if self.is_bare_scalar_var(item) {
198                self.issues.push(
199                    ValidationIssue::error(
200                        IssueCode::ForLoopScalarVar,
201                        "bare variable in for loop iterates once (kaish has no implicit word splitting)",
202                    )
203                    .with_suggestion(concat!(
204                        "use one of:\n",
205                        "    for i in $(split \"$VAR\")      # split on whitespace\n",
206                        "    for i in $(split \"$VAR\" \":\")  # split on delimiter\n",
207                        "    for i in $(seq 1 10)          # iterate numbers\n",
208                        "    for i in $(glob \"*.rs\")       # iterate files",
209                    )),
210                );
211            }
212        }
213
214        self.loop_depth += 1;
215        self.scope.push_frame();
216
217        // Bind loop variable
218        self.scope.bind(&for_loop.variable);
219
220        for stmt in &for_loop.body {
221            self.validate_stmt(stmt);
222        }
223
224        self.scope.pop_frame();
225        self.loop_depth -= 1;
226    }
227
228    /// Extract glob pattern from unquoted literal expressions.
229    ///
230    /// Returns `Some(pattern)` if the expression is an unquoted literal that
231    /// looks like a shell glob pattern (e.g., `*.txt`, `file?.log`).
232    fn extract_unquoted_glob_pattern(&self, expr: &Expr) -> Option<String> {
233        match expr {
234            Expr::Literal(Value::String(s)) if looks_like_shell_glob(s) => Some(s.clone()),
235            // Interpolated strings that are just a literal (parser may produce these)
236            Expr::Interpolated(parts) if parts.len() == 1 => {
237                if let StringPart::Literal(s) = &parts[0]
238                    && looks_like_shell_glob(s) {
239                        return Some(s.clone());
240                    }
241                None
242            }
243            _ => None,
244        }
245    }
246
247    /// Check if an expression is a bare scalar variable reference.
248    ///
249    /// Returns true for `$VAR` or `${VAR}` but not for `$(cmd)` or `"$VAR"`.
250    fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
251        match expr {
252            // Direct variable reference like $VAR or ${VAR}
253            Expr::VarRef(_) => true,
254            // Variable with default like ${VAR:-default} - also problematic
255            Expr::VarWithDefault { .. } => true,
256            // NOT a problem: command substitution like $(cmd) - returns structured data
257            Expr::CommandSubst(_) => false,
258            // NOT a problem: literals are fine
259            Expr::Literal(_) => false,
260            // NOT a problem: interpolated strings are a single value
261            Expr::Interpolated(_) => false,
262            // Everything else: not a bare scalar var
263            _ => false,
264        }
265    }
266
267    /// Validate a while loop.
268    fn validate_while(&mut self, while_loop: &WhileLoop) {
269        self.validate_expr(&while_loop.condition);
270
271        self.loop_depth += 1;
272        self.scope.push_frame();
273
274        for stmt in &while_loop.body {
275            self.validate_stmt(stmt);
276        }
277
278        self.scope.pop_frame();
279        self.loop_depth -= 1;
280    }
281
282    /// Validate a case statement.
283    fn validate_case(&mut self, case_stmt: &CaseStmt) {
284        self.validate_expr(&case_stmt.expr);
285
286        for branch in &case_stmt.branches {
287            self.validate_case_branch(branch);
288        }
289    }
290
291    /// Validate a case branch.
292    fn validate_case_branch(&mut self, branch: &CaseBranch) {
293        self.scope.push_frame();
294        for stmt in &branch.body {
295            self.validate_stmt(stmt);
296        }
297        self.scope.pop_frame();
298    }
299
300    /// Validate a break statement.
301    fn validate_break(&mut self, levels: Option<usize>) {
302        if self.loop_depth == 0 {
303            self.issues.push(ValidationIssue::error(
304                IssueCode::BreakOutsideLoop,
305                "break used outside of a loop",
306            ));
307        } else if let Some(n) = levels
308            && n > self.loop_depth {
309                self.issues.push(ValidationIssue::warning(
310                    IssueCode::BreakOutsideLoop,
311                    format!(
312                        "break {} exceeds loop nesting depth {}",
313                        n, self.loop_depth
314                    ),
315                ));
316            }
317    }
318
319    /// Validate a continue statement.
320    fn validate_continue(&mut self, levels: Option<usize>) {
321        if self.loop_depth == 0 {
322            self.issues.push(ValidationIssue::error(
323                IssueCode::BreakOutsideLoop,
324                "continue used outside of a loop",
325            ));
326        } else if let Some(n) = levels
327            && n > self.loop_depth {
328                self.issues.push(ValidationIssue::warning(
329                    IssueCode::BreakOutsideLoop,
330                    format!(
331                        "continue {} exceeds loop nesting depth {}",
332                        n, self.loop_depth
333                    ),
334                ));
335            }
336    }
337
338    /// Validate a return statement.
339    fn validate_return(&mut self, expr: Option<&Expr>) {
340        if let Some(e) = expr {
341            self.validate_expr(e);
342        }
343
344        if self.function_depth == 0 {
345            self.issues.push(ValidationIssue::error(
346                IssueCode::ReturnOutsideFunction,
347                "return used outside of a function",
348            ));
349        }
350    }
351
352    /// Validate a tool definition.
353    fn validate_tool_def(&mut self, tool_def: &ToolDef) {
354        self.function_depth += 1;
355        self.scope.push_frame();
356
357        // Bind parameters
358        for param in &tool_def.params {
359            self.scope.bind(&param.name);
360            // Validate default expressions
361            if let Some(default) = &param.default {
362                self.validate_expr(default);
363            }
364        }
365
366        // Validate body
367        for stmt in &tool_def.body {
368            self.validate_stmt(stmt);
369        }
370
371        self.scope.pop_frame();
372        self.function_depth -= 1;
373    }
374
375    /// Validate a test expression.
376    fn validate_test(&mut self, test: &TestExpr) {
377        match test {
378            TestExpr::FileTest { path, .. } => self.validate_expr(path),
379            TestExpr::StringTest { value, .. } => self.validate_expr(value),
380            TestExpr::Comparison { left, right, .. } => {
381                self.validate_expr(left);
382                self.validate_expr(right);
383            }
384            TestExpr::And { left, right } | TestExpr::Or { left, right } => {
385                self.validate_test(left);
386                self.validate_test(right);
387            }
388            TestExpr::Not { expr } => self.validate_test(expr),
389        }
390    }
391
392    /// Validate an expression.
393    fn validate_expr(&mut self, expr: &Expr) {
394        match expr {
395            Expr::Literal(_) => {}
396            Expr::VarRef(path) => self.validate_var_ref(path),
397            Expr::Interpolated(parts) => {
398                for part in parts {
399                    self.validate_string_part(part);
400                }
401            }
402            Expr::BinaryOp { left, right, .. } => {
403                self.validate_expr(left);
404                self.validate_expr(right);
405            }
406            Expr::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
407            Expr::Test(test) => self.validate_test(test),
408            Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
409            Expr::VarLength(name) => self.check_var_defined(name),
410            Expr::VarWithDefault { name, .. } => {
411                // Don't warn - default handles undefined case
412                let _ = name;
413            }
414            Expr::Arithmetic(_) => {
415                // Arithmetic parsing is done at runtime
416            }
417            Expr::Command(cmd) => self.validate_command(cmd),
418            Expr::LastExitCode | Expr::CurrentPid => {}
419        }
420    }
421
422    /// Validate a variable reference.
423    fn validate_var_ref(&mut self, path: &VarPath) {
424        if let Some(VarSegment::Field(name)) = path.segments.first() {
425            self.check_var_defined(name);
426        }
427    }
428
429    /// Validate a string interpolation part.
430    fn validate_string_part(&mut self, part: &StringPart) {
431        match part {
432            StringPart::Literal(_) => {}
433            StringPart::Var(path) => self.validate_var_ref(path),
434            StringPart::VarWithDefault { default, .. } => {
435                // Validate nested parts in the default value
436                for p in default {
437                    self.validate_string_part(p);
438                }
439            }
440            StringPart::VarLength(name) => self.check_var_defined(name),
441            StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
442            StringPart::Arithmetic(_) => {} // Arithmetic expressions are validated at eval time
443            StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
444            StringPart::LastExitCode | StringPart::CurrentPid => {}
445        }
446    }
447
448    /// Check if a variable is defined and warn if not.
449    fn check_var_defined(&mut self, name: &str) {
450        // Skip underscore-prefixed vars (external/unchecked convention)
451        if ScopeTracker::should_skip_undefined_check(name) {
452            return;
453        }
454
455        if !self.scope.is_bound(name) {
456            self.issues.push(ValidationIssue::warning(
457                IssueCode::PossiblyUndefinedVariable,
458                format!("variable '{}' may be undefined", name),
459            ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
460        }
461    }
462
463    /// Validate arguments against a user-defined tool's parameters.
464    fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
465        let positional_count = args
466            .iter()
467            .filter(|a| matches!(a, Arg::Positional(_)))
468            .count();
469
470        let required_count = tool_def
471            .params
472            .iter()
473            .filter(|p| p.default.is_none())
474            .count();
475
476        if positional_count < required_count {
477            self.issues.push(ValidationIssue::error(
478                IssueCode::MissingRequiredArg,
479                format!(
480                    "'{}' requires {} arguments, got {}",
481                    tool_def.name, required_count, positional_count
482                ),
483            ));
484        }
485    }
486}
487
488/// Check if a command name is static (not a variable expansion).
489fn is_static_command_name(name: &str) -> bool {
490    !name.starts_with('$') && !name.contains("$(")
491}
492
493/// Check if a string looks like a shell glob pattern.
494///
495/// Detects: `*`, `?`, `[x]`, `[a-z]`, etc. when they appear in what looks
496/// like a filename pattern.
497fn looks_like_shell_glob(s: &str) -> bool {
498    // Skip if it looks like a regex anchor or common non-glob uses
499    if s.starts_with('^') || s.ends_with('$') {
500        return false;
501    }
502
503    let has_star = s.contains('*');
504    let has_question = s.contains('?') && !s.contains("??"); // ?? is often intentional
505    let has_bracket = s.contains('[') && s.contains(']');
506
507    // Must look like a filename pattern (has extension or path separator)
508    let looks_like_path = s.contains('.') || s.contains('/');
509
510    (has_star || has_question || has_bracket) && looks_like_path
511}
512
513/// Check if a command expects pattern arguments or text (not filenames).
514///
515/// Returns true for commands where glob-like patterns in arguments are
516/// intentional and shouldn't trigger validation warnings.
517fn command_expects_pattern_or_text(cmd: &str) -> bool {
518    matches!(
519        cmd,
520        // Pattern-based commands
521        "grep" | "egrep" | "fgrep" | "sed" | "awk" | "find" | "glob" | "regex"
522        // Text output commands - anything is valid text
523        | "echo" | "printf"
524        // JSON/text processing
525        | "jq"
526    )
527}
528
529/// Check if a command is a special built-in that we don't validate.
530fn is_special_command(name: &str) -> bool {
531    matches!(
532        name,
533        "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
534    )
535}
536
537/// Build ToolArgs from AST Args for validation purposes.
538///
539/// This is a simplified version that doesn't evaluate expressions -
540/// it uses placeholder values since we only care about argument structure.
541pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
542    let mut tool_args = ToolArgs::new();
543
544    for arg in args {
545        match arg {
546            Arg::Positional(expr) => {
547                tool_args.positional.push(expr_to_placeholder(expr));
548            }
549            Arg::Named { key, value } => {
550                tool_args.named.insert(key.clone(), expr_to_placeholder(value));
551            }
552            Arg::ShortFlag(flag) => {
553                tool_args.flags.insert(flag.clone());
554            }
555            Arg::LongFlag(flag) => {
556                tool_args.flags.insert(flag.clone());
557            }
558            Arg::DoubleDash => {}
559        }
560    }
561
562    tool_args
563}
564
565/// Convert an expression to a placeholder value for validation.
566///
567/// For literal values, return the actual value.
568/// For dynamic expressions (var refs, command subst), return a placeholder.
569fn expr_to_placeholder(expr: &Expr) -> Value {
570    match expr {
571        Expr::Literal(val) => val.clone(),
572        Expr::Interpolated(parts) if parts.len() == 1 => {
573            if let StringPart::Literal(s) = &parts[0] {
574                Value::String(s.clone())
575            } else {
576                Value::String("<dynamic>".to_string())
577            }
578        }
579        // For variable refs, command substitution, etc. - use placeholder
580        _ => Value::String("<dynamic>".to_string()),
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use crate::tools::{register_builtins, ToolRegistry};
588
589    fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
590        let mut registry = ToolRegistry::new();
591        register_builtins(&mut registry);
592        let user_tools = HashMap::new();
593        (registry, user_tools)
594    }
595
596    #[test]
597    fn validates_undefined_command() {
598        let (registry, user_tools) = make_validator();
599        let validator = Validator::new(&registry, &user_tools);
600
601        let program = Program {
602            statements: vec![Stmt::Command(Command {
603                name: "nonexistent_command".to_string(),
604                args: vec![],
605                redirects: vec![],
606            })],
607        };
608
609        let issues = validator.validate(&program);
610        assert!(!issues.is_empty());
611        assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
612    }
613
614    #[test]
615    fn validates_known_command() {
616        let (registry, user_tools) = make_validator();
617        let validator = Validator::new(&registry, &user_tools);
618
619        let program = Program {
620            statements: vec![Stmt::Command(Command {
621                name: "echo".to_string(),
622                args: vec![Arg::Positional(Expr::Literal(Value::String(
623                    "hello".to_string(),
624                )))],
625                redirects: vec![],
626            })],
627        };
628
629        let issues = validator.validate(&program);
630        // echo should not produce an undefined command error
631        assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
632    }
633
634    #[test]
635    fn validates_break_outside_loop() {
636        let (registry, user_tools) = make_validator();
637        let validator = Validator::new(&registry, &user_tools);
638
639        let program = Program {
640            statements: vec![Stmt::Break(None)],
641        };
642
643        let issues = validator.validate(&program);
644        assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
645    }
646
647    #[test]
648    fn validates_break_inside_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::For(ForLoop {
654                variable: "i".to_string(),
655                items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
656                body: vec![Stmt::Break(None)],
657            })],
658        };
659
660        let issues = validator.validate(&program);
661        // Break inside loop should NOT produce an error
662        assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
663    }
664
665    #[test]
666    fn validates_undefined_variable() {
667        let (registry, user_tools) = make_validator();
668        let validator = Validator::new(&registry, &user_tools);
669
670        let program = Program {
671            statements: vec![Stmt::Command(Command {
672                name: "echo".to_string(),
673                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
674                    "UNDEFINED_VAR",
675                )))],
676                redirects: vec![],
677            })],
678        };
679
680        let issues = validator.validate(&program);
681        assert!(issues
682            .iter()
683            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
684    }
685
686    #[test]
687    fn validates_defined_variable() {
688        let (registry, user_tools) = make_validator();
689        let validator = Validator::new(&registry, &user_tools);
690
691        let program = Program {
692            statements: vec![
693                // First assign the variable
694                Stmt::Assignment(Assignment {
695                    name: "MY_VAR".to_string(),
696                    value: Expr::Literal(Value::String("value".to_string())),
697                    local: false,
698                }),
699                // Then use it
700                Stmt::Command(Command {
701                    name: "echo".to_string(),
702                    args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
703                    redirects: vec![],
704                }),
705            ],
706        };
707
708        let issues = validator.validate(&program);
709        // Should NOT warn about MY_VAR
710        assert!(!issues
711            .iter()
712            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
713                && i.message.contains("MY_VAR")));
714    }
715
716    #[test]
717    fn skips_underscore_prefixed_vars() {
718        let (registry, user_tools) = make_validator();
719        let validator = Validator::new(&registry, &user_tools);
720
721        let program = Program {
722            statements: vec![Stmt::Command(Command {
723                name: "echo".to_string(),
724                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
725                redirects: vec![],
726            })],
727        };
728
729        let issues = validator.validate(&program);
730        // Should NOT warn about _EXTERNAL
731        assert!(!issues
732            .iter()
733            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
734    }
735
736    #[test]
737    fn builtin_vars_are_defined() {
738        let (registry, user_tools) = make_validator();
739        let validator = Validator::new(&registry, &user_tools);
740
741        let program = Program {
742            statements: vec![Stmt::Command(Command {
743                name: "echo".to_string(),
744                args: vec![
745                    Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
746                    Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
747                    Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
748                ],
749                redirects: vec![],
750            })],
751        };
752
753        let issues = validator.validate(&program);
754        // Should NOT warn about HOME, PATH, PWD
755        assert!(!issues
756            .iter()
757            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
758    }
759
760    #[test]
761    fn looks_like_shell_glob_detects_star() {
762        assert!(looks_like_shell_glob("*.txt"));
763        assert!(looks_like_shell_glob("src/*.rs"));
764        assert!(looks_like_shell_glob("**/*.json"));
765    }
766
767    #[test]
768    fn looks_like_shell_glob_detects_question() {
769        assert!(looks_like_shell_glob("file?.log"));
770        assert!(looks_like_shell_glob("test?.txt"));
771    }
772
773    #[test]
774    fn looks_like_shell_glob_detects_brackets() {
775        assert!(looks_like_shell_glob("[abc].txt"));
776        assert!(looks_like_shell_glob("[a-z].log"));
777        assert!(looks_like_shell_glob("file[0-9].rs"));
778    }
779
780    #[test]
781    fn looks_like_shell_glob_rejects_non_patterns() {
782        // No wildcard chars
783        assert!(!looks_like_shell_glob("readme.txt"));
784        assert!(!looks_like_shell_glob("src/main.rs"));
785        // Has wildcard but no extension/path (not filename-like)
786        assert!(!looks_like_shell_glob("hello*world"));
787        // Regex anchors (not globs)
788        assert!(!looks_like_shell_glob("^start"));
789        assert!(!looks_like_shell_glob("end$"));
790    }
791
792    #[test]
793    fn command_expects_pattern_or_text_returns_true() {
794        assert!(command_expects_pattern_or_text("grep"));
795        assert!(command_expects_pattern_or_text("echo"));
796        assert!(command_expects_pattern_or_text("find"));
797        assert!(command_expects_pattern_or_text("glob"));
798    }
799
800    #[test]
801    fn command_expects_pattern_or_text_returns_false() {
802        assert!(!command_expects_pattern_or_text("ls"));
803        assert!(!command_expects_pattern_or_text("cat"));
804        assert!(!command_expects_pattern_or_text("rm"));
805        assert!(!command_expects_pattern_or_text("cp"));
806    }
807
808    #[test]
809    fn validates_glob_pattern_in_ls() {
810        let (registry, user_tools) = make_validator();
811        let validator = Validator::new(&registry, &user_tools);
812
813        let program = Program {
814            statements: vec![Stmt::Command(Command {
815                name: "ls".to_string(),
816                args: vec![Arg::Positional(Expr::Literal(Value::String(
817                    "*.txt".to_string(),
818                )))],
819                redirects: vec![],
820            })],
821        };
822
823        let issues = validator.validate(&program);
824        assert!(issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
825    }
826
827    #[test]
828    fn allows_glob_pattern_in_grep() {
829        let (registry, user_tools) = make_validator();
830        let validator = Validator::new(&registry, &user_tools);
831
832        let program = Program {
833            statements: vec![Stmt::Command(Command {
834                name: "grep".to_string(),
835                args: vec![Arg::Positional(Expr::Literal(Value::String(
836                    "func.*test".to_string(),
837                )))],
838                redirects: vec![],
839            })],
840        };
841
842        let issues = validator.validate(&program);
843        // grep expects patterns, so should NOT warn
844        assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
845    }
846
847    #[test]
848    fn allows_glob_pattern_in_echo() {
849        let (registry, user_tools) = make_validator();
850        let validator = Validator::new(&registry, &user_tools);
851
852        let program = Program {
853            statements: vec![Stmt::Command(Command {
854                name: "echo".to_string(),
855                args: vec![Arg::Positional(Expr::Literal(Value::String(
856                    "*.txt".to_string(),
857                )))],
858                redirects: vec![],
859            })],
860        };
861
862        let issues = validator.validate(&program);
863        // echo is text output, so should NOT warn
864        assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
865    }
866}