Skip to main content

reef/
detect.rs

1//! Fast bash detection heuristic.
2//!
3//! Runs on every Enter keypress — zero allocation, no regex, no parsing.
4//! Scans raw bytes for bash-specific patterns and triggers.
5
6/// Quick check: does this string contain bash-specific syntax?
7/// This must be FAST — it runs on every Enter keypress.
8/// No regex, no parsing — just byte-level pattern scanning.
9///
10/// # Examples
11///
12/// ```
13/// use reef::detect::looks_like_bash;
14///
15/// assert!(looks_like_bash("if [[ $x == 1 ]]; then echo yes; fi"));
16/// assert!(looks_like_bash("export FOO=bar"));
17/// assert!(!looks_like_bash("echo hello"));
18/// ```
19#[must_use]
20pub fn looks_like_bash(input: &str) -> bool {
21    let bytes = input.as_bytes();
22    let len = bytes.len();
23
24    // Single pass: check 2-byte trigger patterns and set flags for slower checks.
25    // Most fish commands bail here immediately (no trigger bytes at all).
26    let mut has_keyword_char = false;
27    let mut has_brace = false;
28    let mut has_eq = false;
29    let mut has_paren = false;
30    let mut in_dquote = false;
31    let mut i = 0;
32    while i < len {
33        let b = bytes[i];
34        let next = if i + 1 < len { bytes[i + 1] } else { 0 };
35        match b {
36            // Track double-quote state so we don't treat ' inside "..." as
37            // a single-quote delimiter (e.g. "it's $((2+2)) o'clock").
38            b'\\' if in_dquote => { i += 2; continue; }
39            b'"' if !in_dquote => { in_dquote = true; i += 1; continue; }
40            b'"' if in_dquote => { in_dquote = false; i += 1; continue; }
41            // Skip single-quoted sections — everything is literal inside.
42            // Prevents false positives like awk '{print $1}'.
43            // But inside double quotes, ' is just a literal character.
44            b'\'' if !in_dquote => {
45                // $'...' (ANSI-C quoting) IS bash-specific.
46                if i > 0 && bytes[i - 1] == b'$' {
47                    return true;
48                }
49                i += 1;
50                while i < len && bytes[i] != b'\'' {
51                    i += 1;
52                }
53            }
54            b'`' => return true,
55            // $( alone is valid fish 3.4+ command substitution — don't trigger.
56            // $(( is bash arithmetic expansion — not valid fish.
57            b'$' => match next {
58                b'{' | b'$' | b'#' | b'?' | b'!' | b'0'..=b'9' | b'@' | b'*' => return true,
59                b'(' if i + 2 < len && bytes[i + 2] == b'(' => return true,
60                _ => {}
61            },
62            b'<' if matches!(next, b'<' | b'(') => return true,
63            b'>' if next == b'(' => return true,
64            b'[' if next == b'[' => return true,
65            b'(' if next == b'(' && (i == 0 || bytes[i - 1] != b'$') => return true,
66            b'(' => has_paren = true,
67            b'=' => has_eq = true,
68            b'{' => has_brace = true,
69            b' ' | b';' | b'\t' | b'\n' => has_keyword_char = true,
70            _ => {}
71        }
72        i += 1;
73    }
74
75    // Bash-specific syntax at command position: NAME=, NAME+=, NAME[..]=,
76    // NAME(), ( subshell, or { brace group.
77    if (has_eq || has_paren || has_brace) && has_bash_cmd_start(bytes) {
78        return true;
79    }
80
81    // Bash-only variable names: $RANDOM, $SECONDS, etc.
82    // Fish doesn't have these as built-in variables.
83    if has_bash_var(bytes) {
84        return true;
85    }
86
87    // Bash-only fd redirections: fd number >= 3 followed by > or <.
88    // Fish supports 0<, 1>, and 2> natively; anything higher is bash-only.
89    // Catches: 3>&1, 4>&2, 5>/dev/null, etc.
90    if has_bash_fd_redirect(bytes) {
91        return true;
92    }
93
94    // Keyword-based checks — only if separator chars were seen.
95    if has_keyword_char {
96        // Substring indicators with enough built-in context to avoid false positives.
97        const INDICATORS: &[&str] = &[
98            "export ",
99            "unset ",
100            "declare ",
101            "typeset ",
102            "readonly ",
103            "local ",
104            " do ",
105            ";do ",
106            "do\n",
107            "do;",
108            "shopt ",
109            "read -p",
110            "read -r",
111            "for ((",
112            "trap ",
113            "eval ",
114            "select ",
115            "getopts ",
116        ];
117        // Control-flow keywords checked with word boundaries to avoid
118        // false positives (e.g. " fi" inside "file", " done" in "done!").
119        const BOUNDARY_KEYWORDS: &[&[u8]] = &[
120            b"fi", b"esac", b"let",
121        ];
122        for kw in INDICATORS {
123            if input.contains(kw) {
124                return true;
125            }
126        }
127        for kw in BOUNDARY_KEYWORDS {
128            if has_word(bytes, kw) {
129                return true;
130            }
131        }
132    }
133
134    // Brace range expansion: {1..5}, {a..z}, {1..10..2} — needs quote-aware scan
135    if has_brace && has_brace_range(bytes) {
136        return true;
137    }
138
139    false
140}
141
142/// Check for bash-only variable references like `$RANDOM`, `$SECONDS`, etc.
143/// Requires a word boundary after the name to avoid matching `$RANDOM_SEED`.
144fn has_bash_var(bytes: &[u8]) -> bool {
145    const BASH_VARS: &[&[u8]] = &[
146        b"BASH_VERSION", b"BASH_REMATCH", b"BASH_SOURCE",
147        b"RANDOM", b"SECONDS", b"LINENO", b"FUNCNAME",
148        b"SHELLOPTS", b"BASHOPTS", b"PIPESTATUS",
149    ];
150    let len = bytes.len();
151    let mut i = 0;
152    while i < len {
153        // Skip single-quoted sections
154        if bytes[i] == b'\'' {
155            i += 1;
156            while i < len && bytes[i] != b'\'' {
157                i += 1;
158            }
159            i += 1;
160            continue;
161        }
162        if bytes[i] == b'$' {
163            let start = i + 1;
164            for var in BASH_VARS {
165                let end = start + var.len();
166                if end <= len
167                    && bytes[start..end] == **var
168                    && (end == len
169                        || !bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_')
170                {
171                    return true;
172                }
173            }
174        }
175        i += 1;
176    }
177    false
178}
179
180/// Check for bash brace range expansion like {1..5} or {a..z}.
181/// Skips single- and double-quoted sections to avoid false positives.
182fn has_brace_range(bytes: &[u8]) -> bool {
183    let len = bytes.len();
184    let mut i = 0;
185    while i < len {
186        match bytes[i] {
187            b'\'' => {
188                i += 1;
189                while i < len && bytes[i] != b'\'' {
190                    i += 1;
191                }
192            }
193            b'"' => {
194                i += 1;
195                while i < len && bytes[i] != b'"' {
196                    if bytes[i] == b'\\' {
197                        i += 1;
198                    }
199                    i += 1;
200                }
201            }
202            b'{' => {
203                let start = i + 1;
204                i = start;
205                while i < len && bytes[i] != b'}' {
206                    i += 1;
207                }
208                if i < len {
209                    let inner = &bytes[start..i];
210                    if let Some(dot_pos) = inner.windows(2).position(|w| w == b"..")
211                        && dot_pos > 0
212                        && dot_pos + 2 < inner.len()
213                    {
214                        return true;
215                    }
216                }
217            }
218            _ => {}
219        }
220        i += 1;
221    }
222    false
223}
224
225/// Detect bash-only fd redirections: a digit followed by `>` or `<` where the
226/// fd number is >= 3. Fish natively supports `0<`, `1>`, and `2>`.
227/// Skips single- and double-quoted sections.
228fn has_bash_fd_redirect(bytes: &[u8]) -> bool {
229    let len = bytes.len();
230    let mut i = 0;
231    while i < len {
232        match bytes[i] {
233            b'\'' => {
234                i += 1;
235                while i < len && bytes[i] != b'\'' { i += 1; }
236            }
237            b'"' => {
238                i += 1;
239                while i < len && bytes[i] != b'"' {
240                    if bytes[i] == b'\\' { i += 1; }
241                    i += 1;
242                }
243            }
244            b'0'..=b'9' => {
245                let start = i;
246                // Consume all contiguous digits. The `continue` at the end of
247                // this arm skips the `i += 1` at the bottom of the outer loop,
248                // which is correct because we already advanced `i` past the
249                // digit run (and possibly past the redirect operator).
250                while i < len && bytes[i].is_ascii_digit() { i += 1; }
251                if i < len && matches!(bytes[i], b'>' | b'<') {
252                    // Only flag if at a word boundary (not mid-token like "echo 300>f")
253                    let is_word_start = start == 0
254                        || matches!(bytes[start - 1], b' ' | b'\t' | b';' | b'\n' | b'|' | b'&');
255                    if is_word_start {
256                        // Fish supports 0<, 1>, 2> natively. Anything >= 3 is bash-only.
257                        let num = &bytes[start..i];
258                        let is_fish_fd = matches!(num, b"0" | b"1" | b"2");
259                        if !is_fish_fd {
260                            return true;
261                        }
262                    }
263                }
264                continue;
265            }
266            _ => {}
267        }
268        i += 1;
269    }
270    false
271}
272
273/// Check if `kw` appears as a standalone word: preceded by a separator
274/// (or start of input) and followed by a separator (or end of input).
275fn has_word(bytes: &[u8], kw: &[u8]) -> bool {
276    let len = bytes.len();
277    let kw_len = kw.len();
278    let mut i = 0;
279    while i + kw_len <= len {
280        if bytes[i..i + kw_len] == *kw {
281            let pre = i == 0 || matches!(bytes[i - 1], b' ' | b'\t' | b';' | b'\n' | b'|' | b'&');
282            let post = i + kw_len == len
283                || matches!(bytes[i + kw_len], b' ' | b'\t' | b';' | b'\n' | b'|' | b'&' | b')');
284            if pre && post {
285                return true;
286            }
287        }
288        i += 1;
289    }
290    false
291}
292
293/// Given `NAME=` at `eq_pos`, skip past the value and check whether a command
294/// follows. Returns `Some(pos)` if a token follows (prefix assignment — valid
295/// fish 3.1+), or `None` if bare (bash-only).
296fn skip_prefix_value(bytes: &[u8], eq_pos: usize) -> Option<usize> {
297    let len = bytes.len();
298    let mut j = eq_pos + 1;
299    // Skip value (handles mixed quoting)
300    while j < len && !matches!(bytes[j], b' ' | b'\t' | b'\n' | b';' | b'|' | b'&') {
301        match bytes[j] {
302            // Skip quoted values; if unterminated, j stays at len and
303            // the outer while condition (j < len) exits gracefully.
304            b'\'' => {
305                j += 1;
306                while j < len && bytes[j] != b'\'' { j += 1; }
307                if j < len { j += 1; }
308            }
309            b'"' => {
310                j += 1;
311                while j < len && bytes[j] != b'"' {
312                    if bytes[j] == b'\\' { j += 1; }
313                    j += 1;
314                }
315                if j < len { j += 1; }
316            }
317            _ => j += 1,
318        }
319    }
320    // Skip whitespace after value
321    while j < len && matches!(bytes[j], b' ' | b'\t') { j += 1; }
322    // Bare if nothing or a separator follows; otherwise a command is next
323    if j >= len || matches!(bytes[j], b'\n' | b';' | b'|' | b'&') {
324        None
325    } else {
326        Some(j)
327    }
328}
329
330/// Check for bash-specific syntax at command position:
331/// `NAME=` (bare), `NAME+=`, `NAME[..]=`, `NAME()`, `(` subshell, or `{` brace group.
332/// `NAME=value cmd` (prefix assignment) is valid fish 3.1+ and is NOT flagged.
333/// Fish only allows `(cmd)` in argument position. Skips quoted sections.
334fn has_bash_cmd_start(bytes: &[u8]) -> bool {
335    let len = bytes.len();
336    let mut i = 0;
337    // 0 = expecting first word (skip whitespace), 1 = inside first word, 2 = past it
338    let mut state: u8 = 0;
339    while i < len {
340        match bytes[i] {
341            b'\'' => {
342                state = 2;
343                i += 1;
344                while i < len && bytes[i] != b'\'' {
345                    i += 1;
346                }
347            }
348            b'"' => {
349                state = 2;
350                i += 1;
351                while i < len && bytes[i] != b'"' {
352                    if bytes[i] == b'\\' {
353                        i += 1;
354                    }
355                    i += 1;
356                }
357            }
358            b';' | b'\n' | b'|' | b'&' => state = 0,
359            b' ' | b'\t' if state == 0 => {}
360            b' ' | b'\t' => state = 2,
361            b'(' if state == 0 => return true, // subshell at command start
362            // { at command start with whitespace after = bash brace group
363            // (fish brace expansion {a,b,c} has no space after {)
364            b'{' if state == 0
365                && i + 1 < len
366                && matches!(bytes[i + 1], b' ' | b'\t' | b'\n') =>
367            {
368                return true;
369            }
370            b'(' if state == 1 => return true, // NAME() — bash function def
371            b'=' if state == 1 => match skip_prefix_value(bytes, i) {
372                None => return true, // bare NAME=val — bash-only
373                Some(next) => { i = next; state = 0; continue; }
374            }
375            _ if state == 0 => {
376                if bytes[i].is_ascii_alphabetic() || bytes[i] == b'_' {
377                    state = 1;
378                } else {
379                    state = 2;
380                }
381            }
382            _ if state == 1 => {
383                if bytes[i] == b'+' && i + 1 < len && bytes[i + 1] == b'=' {
384                    return true; // NAME+=
385                }
386                // NAME[...]= or NAME[...]+=  (array element assignment)
387                if bytes[i] == b'[' {
388                    let mut j = i + 1;
389                    while j < len && bytes[j] != b']' {
390                        j += 1;
391                    }
392                    if j + 1 < len && bytes[j + 1] == b'=' {
393                        return true;
394                    }
395                    if j + 2 < len && bytes[j + 1] == b'+' && bytes[j + 2] == b'=' {
396                        return true;
397                    }
398                }
399                if !bytes[i].is_ascii_alphanumeric() && bytes[i] != b'_' {
400                    state = 2;
401                }
402            }
403            _ => {}
404        }
405        i += 1;
406    }
407    false
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn detects_export() {
416        assert!(looks_like_bash("export PATH=/usr/bin:$PATH"));
417        assert!(looks_like_bash("export EDITOR=vim"));
418    }
419
420    #[test]
421    fn detects_for_loop() {
422        assert!(looks_like_bash("for i in $(seq 5); do echo $i; done"));
423    }
424
425    #[test]
426    fn detects_if_then() {
427        assert!(looks_like_bash("if [ -f foo ]; then echo yes; fi"));
428    }
429
430    #[test]
431    fn dollar_paren_is_valid_fish() {
432        // $() is valid fish 3.4+ command substitution — not bash-specific
433        assert!(!looks_like_bash("echo $(whoami)"));
434        assert!(!looks_like_bash("set myvar $(string upper hello)"));
435        assert!(!looks_like_bash("echo $(date)"));
436        // But $(( )) is bash arithmetic — still detected
437        assert!(looks_like_bash("echo $((2 + 2))"));
438        assert!(looks_like_bash("echo $((1+2))"));
439        // $(( )) inside double quotes with apostrophes must still be detected
440        assert!(looks_like_bash(r#"echo "Hello $(whoami), it's $((2+2)) o'clock""#));
441    }
442
443    #[test]
444    fn detects_double_brackets() {
445        assert!(looks_like_bash("[[ -n \"$HOME\" ]] && echo yes"));
446    }
447
448    #[test]
449    fn detects_parameter_expansion() {
450        assert!(looks_like_bash("echo ${HOME:-/tmp}"));
451    }
452
453    #[test]
454    fn detects_standalone_double_paren() {
455        assert!(looks_like_bash("(( i++ ))"));
456        assert!(looks_like_bash("(( x += 5 ))"));
457        assert!(looks_like_bash("(( count = 0 ))"));
458        assert!(looks_like_bash("echo $((2 + 2))"));
459    }
460
461    #[test]
462    fn ignores_plain_fish() {
463        assert!(!looks_like_bash("echo hello"));
464        assert!(!looks_like_bash("set -gx PATH /usr/bin $PATH"));
465        assert!(!looks_like_bash("for i in (seq 5); echo $i; end"));
466    }
467
468    #[test]
469    fn brace_range_unquoted() {
470        assert!(has_brace_range(b"{1..5}"));
471        assert!(has_brace_range(b"echo {a..z}"));
472        assert!(has_brace_range(b"{1..10..2}"));
473        assert!(!has_brace_range(b"{..5}"));
474        assert!(!has_brace_range(b"{1..}"));
475    }
476
477    #[test]
478    fn brace_range_skips_quotes() {
479        assert!(!has_brace_range(b"echo '{1..5}'"));
480        assert!(!has_brace_range(br#"echo "{1..5}""#));
481        assert!(has_brace_range(b"echo '{skip}' {1..5}"));
482    }
483
484    #[test]
485    fn ignores_fish_and_or_operators() {
486        // && and || are valid fish 3.0+ syntax — not bash-specific
487        assert!(!looks_like_bash("echo foo && echo bar"));
488        assert!(!looks_like_bash("echo foo || echo bar"));
489        assert!(!looks_like_bash("true && false || echo fallback"));
490    }
491
492    #[test]
493    fn detects_bare_assignment() {
494        assert!(looks_like_bash("FOO=hello"));
495        assert!(looks_like_bash("FOO=hello && echo $FOO"));
496        assert!(looks_like_bash("x=1"));
497        assert!(looks_like_bash("_VAR=value"));
498        assert!(looks_like_bash("echo ok; FOO=bar"));
499    }
500
501    #[test]
502    fn detects_subshell() {
503        assert!(looks_like_bash("(cd /tmp && pwd)"));
504        assert!(looks_like_bash("(echo a; echo b) | sort"));
505        assert!(looks_like_bash("echo ok; (cd /tmp)"));
506    }
507
508    #[test]
509    fn subshell_skips_fish_cmd_substitution() {
510        // fish (cmd) in argument position — not a subshell
511        assert!(!looks_like_bash("for i in (seq 5); echo $i; end"));
512        assert!(!looks_like_bash("echo (date)"));
513        assert!(!looks_like_bash("set x (pwd)"));
514    }
515
516    #[test]
517    fn bare_assignment_skips_false_positives() {
518        // fish set command — not a bash assignment
519        assert!(!looks_like_bash("set -gx PATH /usr/bin"));
520        // = inside quotes
521        assert!(!looks_like_bash("echo 'FOO=bar'"));
522        assert!(!looks_like_bash(r#"echo "FOO=bar""#));
523        // Not at token boundary (part of a larger word)
524        assert!(!looks_like_bash("echo FOO=bar"));
525    }
526
527    #[test]
528    fn detects_assignment_after_operators() {
529        // Bare assignments after operators — bash-only
530        assert!(looks_like_bash("echo ok && FOO=bar"));
531        assert!(looks_like_bash("echo ok || FOO=bar"));
532        assert!(looks_like_bash("echo ok & FOO=bar"));
533        // Prefix assignment before command — valid fish 3.1+
534        assert!(!looks_like_bash("echo ok | FOO=bar cat"));
535    }
536
537    #[test]
538    fn prefix_assignment_is_valid_fish() {
539        // NAME=value command is valid fish 3.1+ — not bash-specific
540        assert!(!looks_like_bash("FOO=bar echo hello"));
541        assert!(!looks_like_bash("GIT_DIR=. git status"));
542        assert!(!looks_like_bash("FOO=bar BAZ=qux echo hello"));
543        assert!(!looks_like_bash("FOO='hello world' echo test"));
544        assert!(!looks_like_bash("FOO= echo hello"));
545        // But bare assignments (no command after) ARE bash-only
546        assert!(looks_like_bash("FOO=bar"));
547        assert!(looks_like_bash("FOO=bar BAZ=qux"));
548        assert!(looks_like_bash("A=1 B=2"));
549    }
550
551    #[test]
552    fn detects_function_definition() {
553        assert!(looks_like_bash("greet() { echo hello; }"));
554        assert!(looks_like_bash("greet() { echo \"Hello, $1!\"; }; greet \"World\""));
555        assert!(looks_like_bash("_my_func() { pwd; }"));
556    }
557
558    #[test]
559    fn detects_special_variables() {
560        assert!(looks_like_bash("echo $#"));
561        assert!(looks_like_bash("echo \"args: $#\""));
562        assert!(looks_like_bash("echo $?"));
563        assert!(looks_like_bash("echo $!"));
564        assert!(looks_like_bash("echo $$"));
565        assert!(looks_like_bash("echo $0"));
566        assert!(looks_like_bash("echo $1"));
567        assert!(looks_like_bash("echo $@"));
568        assert!(looks_like_bash("echo $*"));
569    }
570
571    #[test]
572    fn detects_backtick_substitution() {
573        assert!(looks_like_bash("echo `hostname`"));
574        assert!(looks_like_bash("`whoami`"));
575    }
576
577    #[test]
578    fn detects_compound_assignment() {
579        assert!(looks_like_bash("arr+=(4 5)"));
580        assert!(looks_like_bash("str+=hello"));
581        assert!(looks_like_bash("echo ok; x+=1"));
582    }
583
584    #[test]
585    fn detects_array_element_assignment() {
586        assert!(looks_like_bash("arr[0]=hello"));
587        assert!(looks_like_bash("arr[1]+=more"));
588        assert!(looks_like_bash("echo ok; arr[2]=val"));
589    }
590
591    #[test]
592    fn detects_brace_group() {
593        assert!(looks_like_bash("{ echo a; echo b; }"));
594        assert!(looks_like_bash("{ echo a; } > /tmp/out"));
595        assert!(looks_like_bash("echo ok; { echo a; }"));
596    }
597
598    #[test]
599    fn brace_group_skips_fish_brace_expansion() {
600        // fish brace expansion — no space after {
601        assert!(!looks_like_bash("echo {a,b,c}"));
602        assert!(!looks_like_bash("mkdir -p /tmp/{x,y,z}"));
603    }
604
605    #[test]
606    fn detects_ansi_c_quoting() {
607        assert!(looks_like_bash("echo $'hello\\nworld'"));
608        assert!(looks_like_bash("echo $'\\t'"));
609    }
610
611    #[test]
612    fn keyword_boundary_avoids_false_positives() {
613        // "fi" inside words like "file", "find", "diff"
614        assert!(!looks_like_bash("cat file.txt"));
615        assert!(!looks_like_bash("diff file1 file2"));
616        assert!(!looks_like_bash("find . -name '*.py'"));
617        // "then" in normal text (no longer a boundary keyword)
618        assert!(!looks_like_bash("echo \"and then\""));
619        assert!(!looks_like_bash("echo then we go home"));
620        // "done" inside normal text
621        assert!(!looks_like_bash("echo \"I am done\""));
622        // "let" inside normal text (quoted avoids boundary match)
623        assert!(!looks_like_bash("echo \"let me think\""));
624        // But real bash keywords still detected
625        assert!(looks_like_bash("if true; then echo yes; fi"));
626        assert!(looks_like_bash("for i in 1 2; do echo $i; done"));
627        assert!(looks_like_bash("let x=5"));
628    }
629
630    #[test]
631    fn skips_dollar_in_single_quotes() {
632        // awk/sed with $1, $2 etc. inside single quotes — NOT bash
633        assert!(!looks_like_bash("awk '{print $1}' file"));
634        assert!(!looks_like_bash("awk '{print $1, $2}' file.txt"));
635        assert!(!looks_like_bash("sed 's/$HOME/foo/'"));
636        // But $1 outside quotes IS bash
637        assert!(looks_like_bash("echo $1"));
638        // $'...' (ANSI-C quoting) should still be detected
639        assert!(looks_like_bash("echo $'hello\\nworld'"));
640    }
641
642    #[test]
643    fn skips_bash_vars_in_single_quotes() {
644        assert!(!looks_like_bash("echo '$RANDOM'"));
645        assert!(!looks_like_bash("awk '{print $RANDOM}'"));
646        // But outside quotes, still detected
647        assert!(looks_like_bash("echo $RANDOM"));
648    }
649
650    #[test]
651    fn skips_commands_with_quoted_dollar() {
652        // Common tools with $ inside single quotes — NOT bash
653        assert!(!looks_like_bash("sed 's/foo/bar/g' file"));
654        assert!(!looks_like_bash("sed -i 's/old/new/g' file.txt"));
655        assert!(!looks_like_bash("grep -E 'pattern' file"));
656        assert!(!looks_like_bash("grep -r 'TODO' ."));
657        assert!(!looks_like_bash("find . -name '*.txt'"));
658    }
659
660    #[test]
661    fn ignores_fish_builtins() {
662        assert!(!looks_like_bash("set -l myvar hello"));
663        assert!(!looks_like_bash("set -gx PATH /usr/bin $PATH"));
664        assert!(!looks_like_bash("string match -r 'pattern' input"));
665        assert!(!looks_like_bash("string replace -a old new $var"));
666        assert!(!looks_like_bash("math '2 + 2'"));
667    }
668
669    #[test]
670    fn ignores_simple_commands() {
671        assert!(!looks_like_bash("echo hello world"));
672        assert!(!looks_like_bash("ls -la /tmp"));
673        assert!(!looks_like_bash("cd /tmp && ls"));
674        assert!(!looks_like_bash("mkdir -p /tmp/test"));
675    }
676
677    #[test]
678    fn detects_heredoc() {
679        assert!(looks_like_bash("cat <<'EOF'\nhello\nEOF"));
680        assert!(looks_like_bash("cat <<EOF\nhello\nEOF"));
681        assert!(looks_like_bash("cat <<-'EOF'\nhello\nEOF"));
682    }
683
684    #[test]
685    fn detects_bash_only_variables() {
686        assert!(looks_like_bash("echo $RANDOM"));
687        assert!(looks_like_bash("echo $SECONDS"));
688        assert!(looks_like_bash("echo $BASH_VERSION"));
689        assert!(looks_like_bash("echo $LINENO"));
690        assert!(looks_like_bash("echo $FUNCNAME"));
691        assert!(looks_like_bash("echo $PIPESTATUS"));
692        // Should not match variable names that start with a bash var name
693        assert!(!looks_like_bash("echo $RANDOM_SEED"));
694        assert!(!looks_like_bash("echo $SECONDS_ELAPSED"));
695    }
696
697    #[test]
698    fn detects_fd_redirections() {
699        // exec with fd manipulation — bash-only (fd >= 3)
700        assert!(looks_like_bash("exec 3>&1 4>&2"));
701        assert!(looks_like_bash("exec 3>/dev/null"));
702        // Standalone fd >= 3
703        assert!(looks_like_bash("echo hello 3>&1"));
704        assert!(looks_like_bash("cmd 5>/tmp/log"));
705        // Fish natively supports 0<, 1>, 2> — don't flag these
706        assert!(!looks_like_bash("echo hello 2>/dev/null"));
707        assert!(!looks_like_bash("cmd 2>&1"));
708        assert!(!looks_like_bash("cmd 1>/dev/null"));
709        assert!(!looks_like_bash("cat 0</dev/stdin"));
710        // Digits in other contexts — not fd redirections
711        assert!(!looks_like_bash("echo 300"));
712        assert!(!looks_like_bash("echo 3 > file"));  // space before > = not fd redirect
713        assert!(!looks_like_bash("seq 1 10"));
714    }
715}