1#[must_use]
20pub fn looks_like_bash(input: &str) -> bool {
21 let bytes = input.as_bytes();
22 let len = bytes.len();
23
24 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 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 b'\'' if !in_dquote => {
45 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 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 if (has_eq || has_paren || has_brace) && has_bash_cmd_start(bytes) {
78 return true;
79 }
80
81 if has_bash_var(bytes) {
84 return true;
85 }
86
87 if has_bash_fd_redirect(bytes) {
91 return true;
92 }
93
94 if has_keyword_char {
96 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 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 if has_brace && has_brace_range(bytes) {
136 return true;
137 }
138
139 false
140}
141
142fn 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 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
180fn 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
225fn 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 while i < len && bytes[i].is_ascii_digit() { i += 1; }
251 if i < len && matches!(bytes[i], b'>' | b'<') {
252 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 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
273fn 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
293fn skip_prefix_value(bytes: &[u8], eq_pos: usize) -> Option<usize> {
297 let len = bytes.len();
298 let mut j = eq_pos + 1;
299 while j < len && !matches!(bytes[j], b' ' | b'\t' | b'\n' | b';' | b'|' | b'&') {
301 match bytes[j] {
302 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 while j < len && matches!(bytes[j], b' ' | b'\t') { j += 1; }
322 if j >= len || matches!(bytes[j], b'\n' | b';' | b'|' | b'&') {
324 None
325 } else {
326 Some(j)
327 }
328}
329
330fn has_bash_cmd_start(bytes: &[u8]) -> bool {
335 let len = bytes.len();
336 let mut i = 0;
337 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, 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, b'=' if state == 1 => match skip_prefix_value(bytes, i) {
372 None => return true, 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; }
386 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 assert!(!looks_like_bash("echo $(whoami)"));
434 assert!(!looks_like_bash("set myvar $(string upper hello)"));
435 assert!(!looks_like_bash("echo $(date)"));
436 assert!(looks_like_bash("echo $((2 + 2))"));
438 assert!(looks_like_bash("echo $((1+2))"));
439 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 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 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 assert!(!looks_like_bash("set -gx PATH /usr/bin"));
520 assert!(!looks_like_bash("echo 'FOO=bar'"));
522 assert!(!looks_like_bash(r#"echo "FOO=bar""#));
523 assert!(!looks_like_bash("echo FOO=bar"));
525 }
526
527 #[test]
528 fn detects_assignment_after_operators() {
529 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 assert!(!looks_like_bash("echo ok | FOO=bar cat"));
535 }
536
537 #[test]
538 fn prefix_assignment_is_valid_fish() {
539 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 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 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 assert!(!looks_like_bash("cat file.txt"));
615 assert!(!looks_like_bash("diff file1 file2"));
616 assert!(!looks_like_bash("find . -name '*.py'"));
617 assert!(!looks_like_bash("echo \"and then\""));
619 assert!(!looks_like_bash("echo then we go home"));
620 assert!(!looks_like_bash("echo \"I am done\""));
622 assert!(!looks_like_bash("echo \"let me think\""));
624 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 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 assert!(looks_like_bash("echo $1"));
638 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 assert!(looks_like_bash("echo $RANDOM"));
648 }
649
650 #[test]
651 fn skips_commands_with_quoted_dollar() {
652 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 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 assert!(looks_like_bash("exec 3>&1 4>&2"));
701 assert!(looks_like_bash("exec 3>/dev/null"));
702 assert!(looks_like_bash("echo hello 3>&1"));
704 assert!(looks_like_bash("cmd 5>/tmp/log"));
705 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 assert!(!looks_like_bash("echo 300"));
712 assert!(!looks_like_bash("echo 3 > file")); assert!(!looks_like_bash("seq 1 10"));
714 }
715}