1use std::path::Path;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
4pub enum ShellDialect {
5 #[default]
6 Unknown,
7 Sh,
8 Bash,
9 Dash,
10 Ksh,
11 Mksh,
12 Zsh,
13}
14
15impl ShellDialect {
16 pub fn parser_dialect(self) -> shuck_parser::ShellDialect {
17 match self {
18 Self::Mksh => shuck_parser::ShellDialect::Mksh,
19 Self::Zsh => shuck_parser::ShellDialect::Zsh,
20 Self::Unknown | Self::Sh | Self::Bash | Self::Dash | Self::Ksh => {
21 shuck_parser::ShellDialect::Bash
22 }
23 }
24 }
25
26 pub fn semantic_dialect(self) -> shuck_parser::ShellDialect {
27 match self {
28 Self::Sh | Self::Dash | Self::Ksh => shuck_parser::ShellDialect::Posix,
29 Self::Mksh => shuck_parser::ShellDialect::Mksh,
30 Self::Zsh => shuck_parser::ShellDialect::Zsh,
31 Self::Unknown | Self::Bash => shuck_parser::ShellDialect::Bash,
32 }
33 }
34
35 pub fn shell_profile(self) -> shuck_parser::ShellProfile {
36 shuck_parser::ShellProfile::native(self.semantic_dialect())
37 }
38
39 pub fn from_name(name: &str) -> Self {
40 match name.trim().to_ascii_lowercase().as_str() {
41 "sh" => Self::Sh,
42 "bash" => Self::Bash,
43 "dash" => Self::Dash,
44 "ksh" => Self::Ksh,
45 "mksh" => Self::Mksh,
46 "zsh" => Self::Zsh,
47 _ => Self::Unknown,
48 }
49 }
50
51 pub fn infer(source: &str, path: Option<&Path>) -> Self {
52 let path_dialect = path.map_or(Self::Unknown, Self::infer_from_path);
53 let shebang_dialect = Self::infer_from_shebang(source);
54 Self::infer_from_shellcheck_header(source)
55 .or_else(|| {
56 Self::zsh_extension_compat_shebang_override(source, path_dialect, shebang_dialect)
57 })
58 .or(shebang_dialect)
59 .or_else(|| match path_dialect {
60 Self::Unknown | Self::Sh => Self::infer_from_source_markers(source),
61 dialect => Some(dialect),
62 })
63 .unwrap_or(path_dialect)
64 }
65
66 pub fn infer_from_path(path: &Path) -> Self {
67 match path
68 .extension()
69 .and_then(|ext| ext.to_str())
70 .map(|ext| ext.to_ascii_lowercase())
71 .as_deref()
72 {
73 Some("sh") => Self::Sh,
74 Some("bash") => Self::Bash,
75 Some("dash") => Self::Dash,
76 Some("ksh") => Self::Ksh,
77 Some("mksh") => Self::Mksh,
78 Some("zsh") => Self::Zsh,
79 _ => path
80 .file_name()
81 .and_then(|name| name.to_str())
82 .filter(|name| is_known_zsh_dotfile_name(name))
83 .map(|_| Self::Zsh)
84 .unwrap_or(Self::Unknown),
85 }
86 }
87
88 fn infer_from_shebang(source: &str) -> Option<Self> {
89 let interpreter = shuck_parser::shebang::interpreter_name(source.lines().next()?)?;
90 Some(Self::from_name(interpreter))
91 }
92
93 fn zsh_extension_compat_shebang_override(
94 source: &str,
95 extension_dialect: Self,
96 shebang_dialect: Option<Self>,
97 ) -> Option<Self> {
98 if extension_dialect != Self::Zsh || shebang_dialect != Some(Self::Bash) {
99 return None;
100 }
101
102 (Self::infer_from_source_markers(source) == Some(Self::Zsh)).then_some(Self::Zsh)
103 }
104
105 fn infer_from_shellcheck_header(source: &str) -> Option<Self> {
106 for line in source.lines() {
107 let trimmed = line.trim_start();
108 if trimmed.is_empty() || trimmed.starts_with("#!") {
109 continue;
110 }
111
112 let Some(comment) = trimmed.strip_prefix('#') else {
113 break;
114 };
115 let body = comment.trim_start().to_ascii_lowercase();
116 let Some(shell_name) = body.strip_prefix("shellcheck shell=") else {
117 continue;
118 };
119
120 let dialect = Self::from_name(shell_name.split_whitespace().next().unwrap_or_default());
121 if dialect != Self::Unknown {
122 return Some(dialect);
123 }
124 }
125
126 None
127 }
128
129 fn infer_from_source_markers(source: &str) -> Option<Self> {
130 let mut saw_bash_marker = false;
131 let mut saw_zsh_marker = false;
132 let mut at_directive_prefix = true;
133 let mut heredoc_delimiters: Vec<(String, bool)> = Vec::new();
134
135 for line in source.lines() {
136 let trimmed = line.trim_start();
137 if let Some((delimiter, strip_tabs)) = heredoc_delimiters.first() {
138 let candidate = if *strip_tabs {
139 line.trim_start_matches('\t')
140 } else {
141 line
142 };
143 if candidate == delimiter {
144 heredoc_delimiters.remove(0);
145 }
146 continue;
147 }
148 if trimmed.is_empty() || trimmed.starts_with("#!") {
149 continue;
150 }
151 if trimmed.starts_with('#') {
152 let comment = trimmed.strip_prefix('#').unwrap_or_default();
153 if at_directive_prefix
154 && (comment.starts_with("compdef") || comment.starts_with("autoload"))
155 {
156 saw_zsh_marker = true;
157 }
158 at_directive_prefix = false;
159 continue;
160 }
161
162 let code = code_before_comment(trimmed);
163 saw_bash_marker |= line_has_bash_marker(code);
164 saw_zsh_marker |= line_has_zsh_marker(code);
165 heredoc_delimiters.extend(line_heredoc_delimiters(code));
166 at_directive_prefix = false;
167
168 if saw_bash_marker && saw_zsh_marker {
169 return None;
170 }
171 }
172
173 match (saw_bash_marker, saw_zsh_marker) {
174 (true, false) => Some(Self::Bash),
175 (false, true) => Some(Self::Zsh),
176 _ => None,
177 }
178 }
179}
180
181fn line_has_bash_marker(line: &str) -> bool {
182 contains_unquoted_parameter(line, "BASH_SOURCE")
183 || contains_unquoted_parameter(line, "BASH_VERSION")
184 || contains_unquoted_parameter(line, "PROMPT_COMMAND")
185 || starts_with_assignment(line, "PROMPT_COMMAND")
186 || starts_with_shell_word(line, "shopt")
187}
188
189fn line_has_zsh_marker(line: &str) -> bool {
190 contains_unquoted_parameter(line, "ZSH_VERSION")
191 || contains_unquoted_parameter(line, "ZSH_EVAL_CONTEXT")
192 || starts_with_shell_word(line, "zstyle")
193 || starts_with_shell_word(line, "zmodload")
194 || line_has_zsh_emulate_marker(line)
195 || line_has_zsh_autoload_marker(line)
196 || contains_unquoted_literal(line, "${${")
197 || contains_unquoted_literal(line, "${(%):-%x}")
198 || contains_unquoted_literal(line, "${+commands[")
199}
200
201fn line_has_zsh_emulate_marker(line: &str) -> bool {
202 let words = shell_words(line);
203 words.first().is_some_and(|word| *word == "emulate")
204 && words.iter().skip(1).any(|word| *word == "zsh")
205}
206
207fn line_has_zsh_autoload_marker(line: &str) -> bool {
208 let trimmed = line.trim_start();
209 trimmed.starts_with("autoload ") && trimmed.split_whitespace().any(|word| word.starts_with('-'))
210}
211
212fn code_before_comment(line: &str) -> &str {
213 let bytes = line.as_bytes();
214 let mut index = 0usize;
215 let mut in_single_quotes = false;
216 let mut in_double_quotes = false;
217 let mut escaped = false;
218
219 while index < bytes.len() {
220 if escaped {
221 escaped = false;
222 index += 1;
223 continue;
224 }
225
226 let byte = bytes[index];
227 if byte == b'\\' {
228 escaped = true;
229 index += 1;
230 continue;
231 }
232 if byte == b'\'' && !in_double_quotes {
233 in_single_quotes = !in_single_quotes;
234 index += 1;
235 continue;
236 }
237 if byte == b'"' && !in_single_quotes {
238 in_double_quotes = !in_double_quotes;
239 index += 1;
240 continue;
241 }
242 if byte == b'#'
243 && !in_single_quotes
244 && !in_double_quotes
245 && hash_starts_comment(bytes, index)
246 {
247 return &line[..index];
248 }
249 index += 1;
250 }
251
252 line
253}
254
255fn hash_starts_comment(bytes: &[u8], index: usize) -> bool {
256 if index == 0 {
257 return true;
258 }
259 let previous_index = index - 1;
260 let previous = bytes[previous_index];
261 (previous.is_ascii_whitespace() || shell_separator(previous))
262 && !is_escaped_byte(bytes, previous_index)
263}
264
265fn is_escaped_byte(bytes: &[u8], index: usize) -> bool {
266 let mut backslashes = 0usize;
267 for byte in bytes[..index].iter().rev() {
268 if *byte == b'\\' {
269 backslashes += 1;
270 } else {
271 break;
272 }
273 }
274 backslashes % 2 == 1
275}
276
277fn line_heredoc_delimiters(line: &str) -> Vec<(String, bool)> {
278 let mut delimiters = Vec::new();
279 let mut rest = line;
280
281 while let Some((delimiter, consumed)) = next_heredoc_delimiter(rest) {
282 delimiters.push(delimiter);
283 rest = &rest[consumed.min(rest.len())..];
284 }
285
286 delimiters
287}
288
289fn next_heredoc_delimiter(line: &str) -> Option<((String, bool), usize)> {
290 let redirect_start = heredoc_redirect_start(line)?;
291 let mut rest = &line[redirect_start + 2..];
292 let strip_tabs = rest.starts_with('-');
293 let mut consumed = redirect_start + 2;
294 if strip_tabs {
295 rest = &rest[1..];
296 consumed += 1;
297 }
298 let blanks = rest.len() - rest.trim_start().len();
299 rest = &rest[blanks..];
300 consumed += blanks;
301 let delimiter = heredoc_delimiter_token(rest)?;
302 consumed += delimiter.len();
303 let delimiter = normalize_heredoc_delimiter(delimiter);
304 (!delimiter.is_empty()).then(|| ((delimiter.to_owned(), strip_tabs), consumed))
305}
306
307fn normalize_heredoc_delimiter(delimiter: &str) -> String {
308 let mut normalized = String::with_capacity(delimiter.len());
309 let mut chars = delimiter.chars();
310 let mut in_single_quotes = false;
311 let mut in_double_quotes = false;
312
313 while let Some(ch) = chars.next() {
314 match ch {
315 '\'' if !in_double_quotes => in_single_quotes = !in_single_quotes,
316 '"' if !in_single_quotes => in_double_quotes = !in_double_quotes,
317 '\\' if !in_single_quotes => {
318 if let Some(escaped) = chars.next() {
319 normalized.push(escaped);
320 } else {
321 normalized.push(ch);
322 }
323 }
324 _ => normalized.push(ch),
325 }
326 }
327
328 normalized
329}
330
331fn heredoc_delimiter_token(rest: &str) -> Option<&str> {
332 let mut end = rest.len();
333 let mut in_single_quotes = false;
334 let mut in_double_quotes = false;
335 let mut escaped = false;
336
337 for (index, ch) in rest.char_indices() {
338 if escaped {
339 escaped = false;
340 continue;
341 }
342 if ch == '\\' && !in_single_quotes {
343 escaped = true;
344 continue;
345 }
346 if ch == '\'' && !in_double_quotes {
347 in_single_quotes = !in_single_quotes;
348 continue;
349 }
350 if ch == '"' && !in_single_quotes {
351 in_double_quotes = !in_double_quotes;
352 continue;
353 }
354 if !in_single_quotes
355 && !in_double_quotes
356 && (ch.is_whitespace() || shell_separator_char(ch))
357 {
358 end = index;
359 break;
360 }
361 }
362
363 (end > 0).then(|| &rest[..end])
364}
365
366fn heredoc_redirect_start(line: &str) -> Option<usize> {
367 let bytes = line.as_bytes();
368 let mut index = 0usize;
369 let mut in_single_quotes = false;
370 let mut in_double_quotes = false;
371 let mut escaped = false;
372 let mut arithmetic_depth = 0usize;
373
374 while index + 1 < bytes.len() {
375 if escaped {
376 escaped = false;
377 index += 1;
378 continue;
379 }
380
381 let byte = bytes[index];
382 if byte == b'\\' {
383 escaped = true;
384 index += 1;
385 continue;
386 }
387
388 if arithmetic_depth > 0 {
389 if bytes.get(index..index + 2) == Some(b"))") {
390 arithmetic_depth -= 1;
391 index += 2;
392 } else {
393 index += 1;
394 }
395 continue;
396 }
397
398 if byte == b'\'' && !in_double_quotes {
399 in_single_quotes = !in_single_quotes;
400 index += 1;
401 continue;
402 }
403 if byte == b'"' && !in_single_quotes {
404 in_double_quotes = !in_double_quotes;
405 index += 1;
406 continue;
407 }
408 if in_single_quotes || in_double_quotes {
409 index += 1;
410 continue;
411 }
412
413 if bytes.get(index..index + 3) == Some(b"$((") {
414 arithmetic_depth += 1;
415 index += 3;
416 continue;
417 }
418 if bytes.get(index..index + 2) == Some(b"((") && arithmetic_command_start(bytes, index) {
419 arithmetic_depth += 1;
420 index += 2;
421 continue;
422 }
423
424 if bytes.get(index..index + 2) == Some(b"<<") {
425 if bytes.get(index + 2) != Some(&b'<') {
426 return Some(index);
427 }
428 index += 3;
429 continue;
430 }
431
432 index += 1;
433 }
434
435 None
436}
437
438fn arithmetic_command_start(bytes: &[u8], index: usize) -> bool {
439 bytes[..index]
440 .iter()
441 .rev()
442 .find(|byte| !byte.is_ascii_whitespace())
443 .is_none_or(|byte| matches!(*byte, b';' | b'&' | b'|' | b'('))
444}
445
446fn shell_separator(byte: u8) -> bool {
447 matches!(byte, b';' | b'&' | b'|' | b'(' | b')')
448}
449
450fn shell_separator_char(ch: char) -> bool {
451 matches!(ch, ';' | '&' | '|' | '(' | ')' | '<' | '>')
452}
453
454fn starts_with_shell_word(line: &str, needle: &str) -> bool {
455 shell_words(line)
456 .first()
457 .is_some_and(|word| *word == needle)
458}
459
460fn starts_with_assignment(line: &str, name: &str) -> bool {
461 let Some(suffix) = line.trim_start().strip_prefix(name) else {
462 return false;
463 };
464 suffix.starts_with('=') || suffix.starts_with("+=")
465}
466
467fn contains_unquoted_parameter(line: &str, name: &str) -> bool {
468 let name_bytes = name.as_bytes();
469 contains_unquoted_marker(line, |bytes, index| {
470 if bytes.get(index) != Some(&b'$') {
471 return false;
472 }
473 let braced_start = index + 2;
474 let braced_end = braced_start + name_bytes.len();
475 if bytes.get(index + 1) == Some(&b'{')
476 && bytes
477 .get(braced_start..braced_end)
478 .is_some_and(|candidate| candidate == name_bytes)
479 && bytes
480 .get(braced_end)
481 .is_none_or(|byte| !is_shell_name_byte(*byte))
482 {
483 return true;
484 }
485 let plain_start = index + 1;
486 let plain_end = plain_start + name_bytes.len();
487 bytes
488 .get(plain_start..plain_end)
489 .is_some_and(|candidate| candidate == name_bytes)
490 && bytes
491 .get(plain_end)
492 .is_none_or(|byte| !is_shell_name_byte(*byte))
493 })
494}
495
496fn contains_unquoted_literal(line: &str, literal: &str) -> bool {
497 contains_unquoted_marker(line, |bytes, index| {
498 bytes
499 .get(index..index + literal.len())
500 .is_some_and(|candidate| candidate == literal.as_bytes())
501 })
502}
503
504fn contains_unquoted_marker(line: &str, mut matches_at: impl FnMut(&[u8], usize) -> bool) -> bool {
505 let bytes = line.as_bytes();
506 let mut index = 0;
507 let mut in_single_quotes = false;
508 let mut in_double_quotes = false;
509 let mut escaped = false;
510
511 while index < bytes.len() {
512 let byte = bytes[index];
513 if escaped {
514 escaped = false;
515 index += 1;
516 continue;
517 }
518 if byte == b'\\' {
519 escaped = true;
520 index += 1;
521 continue;
522 }
523 if byte == b'\'' && !in_double_quotes {
524 in_single_quotes = !in_single_quotes;
525 index += 1;
526 continue;
527 }
528 if byte == b'"' && !in_single_quotes {
529 in_double_quotes = !in_double_quotes;
530 index += 1;
531 continue;
532 }
533 if !in_single_quotes && matches_at(bytes, index) {
534 return true;
535 }
536 index += 1;
537 }
538
539 false
540}
541
542fn is_shell_name_byte(byte: u8) -> bool {
543 byte == b'_' || byte.is_ascii_alphanumeric()
544}
545
546fn is_known_zsh_dotfile_name(name: &str) -> bool {
547 matches!(
548 name.to_ascii_lowercase().as_str(),
549 ".zshrc"
550 | "zshrc"
551 | ".zshenv"
552 | "zshenv"
553 | ".zprofile"
554 | "zprofile"
555 | ".zlogin"
556 | "zlogin"
557 | ".zlogout"
558 | "zlogout"
559 )
560}
561
562fn shell_words(line: &str) -> Vec<&str> {
563 line.split(|ch: char| !(ch == '_' || ch.is_ascii_alphanumeric()))
564 .filter(|word| !word.is_empty())
565 .collect()
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn infers_from_shebang_before_extension() {
574 let inferred = ShellDialect::infer("#!/usr/bin/env bash\nlocal foo=bar\n", None);
575 assert_eq!(inferred, ShellDialect::Bash);
576 }
577
578 #[test]
579 fn infers_from_env_split_shebang_before_extension() {
580 let inferred = ShellDialect::infer("#!/usr/bin/env -S bash -e\nlocal foo=bar\n", None);
581 assert_eq!(inferred, ShellDialect::Bash);
582 }
583
584 #[test]
585 fn infers_from_extension_when_shebang_is_missing() {
586 let inferred = ShellDialect::infer("local foo=bar\n", Some(Path::new("/tmp/example.bash")));
587 assert_eq!(inferred, ShellDialect::Bash);
588 }
589
590 #[test]
591 fn infers_known_zsh_dotfiles_without_source_markers() {
592 let inferred = ShellDialect::infer(
593 "plugins=(git)\nsource \"$ZDOTDIR/oh-my-zsh.sh\"\n",
594 Some(Path::new("/tmp/.zshrc")),
595 );
596 assert_eq!(inferred, ShellDialect::Zsh);
597 }
598
599 #[test]
600 fn explicit_bash_extension_wins_over_embedded_zsh_guards() {
601 let source = "\
602if [[ -n ${ZSH_VERSION-} ]]; then
603 emulate -L zsh
604fi
605";
606 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/git-completion.bash")));
607 assert_eq!(inferred, ShellDialect::Bash);
608 }
609
610 #[test]
611 fn infers_zsh_from_source_markers_before_sh_extension() {
612 let source = r#"
613[[ -n "$ZSH" ]] || export ZSH="${${(%):-%x}:a:h}"
614zstyle -s ':omz:update' mode update_mode
615autoload -U compaudit compinit
616"#;
617 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/oh-my-zsh.sh")));
618 assert_eq!(inferred, ShellDialect::Zsh);
619 }
620
621 #[test]
622 fn zsh_extension_and_markers_win_over_bash_compat_shebang() {
623 let source = r#"#!/usr/bin/bash
6240="${${ZERO:-${0:#$ZSH_ARGZERO}}:-${(%):-%N}}"
625"#;
626 let inferred = ShellDialect::infer(
627 source,
628 Some(Path::new("/tmp/plugin/shell-proxy.plugin.zsh")),
629 );
630 assert_eq!(inferred, ShellDialect::Zsh);
631 }
632
633 #[test]
634 fn infers_zsh_from_compdef_comment_before_sh_extension() {
635 let inferred = ShellDialect::infer(
636 "#compdef git\n_arguments '*:: :->args'\n",
637 Some(Path::new("/tmp/_git.sh")),
638 );
639 assert_eq!(inferred, ShellDialect::Zsh);
640 }
641
642 #[test]
643 fn ignores_late_compdef_comment_markers_after_real_content() {
644 let inferred = ShellDialect::infer(
645 "printf '%s\\n' ok\n#compdef git\n",
646 Some(Path::new("/tmp/example.sh")),
647 );
648 assert_eq!(inferred, ShellDialect::Sh);
649 }
650
651 #[test]
652 fn ignores_free_form_comments_that_mention_zsh_directive_words() {
653 let inferred = ShellDialect::infer(
654 "# autoload helper cache\n# compdef examples live elsewhere\nprintf '%s\\n' ok\n",
655 Some(Path::new("/tmp/example.sh")),
656 );
657 assert_eq!(inferred, ShellDialect::Sh);
658 }
659
660 #[test]
661 fn ignores_quoted_dialect_marker_names_without_shell_usage() {
662 let inferred = ShellDialect::infer(
663 "printf '%s\\n' \"ZSH_VERSION\" \"BASH_VERSION\" \"PROMPT_COMMAND\"\n",
664 Some(Path::new("/tmp/example.sh")),
665 );
666 assert_eq!(inferred, ShellDialect::Sh);
667 }
668
669 #[test]
670 fn ignores_single_literal_dialect_marker_names_without_dollar_prefix() {
671 let inferred =
672 ShellDialect::infer("echo ZSH_VERSION\n", Some(Path::new("/tmp/example.sh")));
673 assert_eq!(inferred, ShellDialect::Sh);
674 }
675
676 #[test]
677 fn ignores_literal_or_escaped_dialect_parameter_markers() {
678 let inferred = ShellDialect::infer(
679 "printf '%s\\n' '${ZSH_VERSION}' \"\\$BASH_VERSION\" \"$ZSH_VERSIONED\"\n",
680 Some(Path::new("/tmp/example.sh")),
681 );
682 assert_eq!(inferred, ShellDialect::Sh);
683 }
684
685 #[test]
686 fn ignores_literal_or_escaped_zsh_expansion_markers() {
687 let inferred = ShellDialect::infer(
688 "printf '%s\\n' '${${(%):-%x}:a:h}' \"\\${+commands[git]}\"\n",
689 Some(Path::new("/tmp/example.sh")),
690 );
691 assert_eq!(inferred, ShellDialect::Sh);
692 }
693
694 #[test]
695 fn ignores_dialect_markers_inside_heredoc_bodies() {
696 let source = "\
697cat <<'EOF'
698$ZSH_VERSION
699${BASH_SOURCE[0]}
700EOF
701";
702 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
703 assert_eq!(inferred, ShellDialect::Sh);
704 }
705
706 #[test]
707 fn here_strings_do_not_hide_later_source_markers() {
708 let source = "\
709cat <<< \"$value\"
710zstyle -s ':omz:update' mode update_mode
711";
712 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
713 assert_eq!(inferred, ShellDialect::Zsh);
714 }
715
716 #[test]
717 fn arithmetic_shifts_do_not_hide_later_source_markers() {
718 let source = "\
719((x<<1))
720value=$((1<<2))
721zstyle -s ':omz:update' mode update_mode
722";
723 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
724 assert_eq!(inferred, ShellDialect::Zsh);
725 }
726
727 #[test]
728 fn hash_expansions_do_not_hide_later_source_markers_on_the_same_line() {
729 let source = "\
730prefix=${name#refs/heads/}; printf '%s\\n' \"$ZSH_VERSION\"
731";
732 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
733 assert_eq!(inferred, ShellDialect::Zsh);
734 }
735
736 #[test]
737 fn comments_after_separators_do_not_count_as_source_markers() {
738 let source = "\
739printf '%s\\n' ok;# \"$ZSH_VERSION\"
740";
741 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
742 assert_eq!(inferred, ShellDialect::Sh);
743 }
744
745 #[test]
746 fn heredoc_delimiters_stop_before_shell_separators() {
747 let source = "\
748cat <<EOF; printf '%s\\n' done
749$ZSH_VERSION
750EOF
751printf '%s\\n' \"$ZSH_VERSION\"
752";
753 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
754 assert_eq!(inferred, ShellDialect::Zsh);
755 }
756
757 #[test]
758 fn multiple_heredoc_bodies_stay_inert_during_source_marker_inference() {
759 let source = "\
760cat <<EOF <<BAR
761plain text
762EOF
763$ZSH_VERSION
764BAR
765";
766 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
767 assert_eq!(inferred, ShellDialect::Sh);
768 }
769
770 #[test]
771 fn heredoc_terminators_do_not_allow_trailing_blanks() {
772 let source = "cat <<EOF\nEOF \n$ZSH_VERSION\nEOF\n";
773 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
774 assert_eq!(inferred, ShellDialect::Sh);
775 }
776
777 #[test]
778 fn heredoc_delimiters_allow_blanks_after_redirect_operator() {
779 let source = "\
780cat << EOF
781$ZSH_VERSION
782EOF
783cat <<- \tBAR
784\t$ZSH_VERSION
785\tBAR
786";
787 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
788 assert_eq!(inferred, ShellDialect::Sh);
789 }
790
791 #[test]
792 fn heredoc_delimiter_quote_removal_preserves_escaped_backslash() {
793 let source = "cat <<\\\\EOF\n$ZSH_VERSION\n\\EOF\n";
794 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
795 assert_eq!(inferred, ShellDialect::Sh);
796 }
797
798 #[test]
799 fn quoted_heredoc_delimiter_separators_do_not_stick_scanner() {
800 let source = "\
801cat <<'EOF)'
802$ZSH_VERSION
803EOF)
804printf '%s\\n' \"$ZSH_VERSION\"
805";
806 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
807 assert_eq!(inferred, ShellDialect::Zsh);
808 }
809
810 #[test]
811 fn heredoc_delimiter_stops_before_following_redirection() {
812 let source = "\
813cat <<EOF>/tmp/out
814$ZSH_VERSION
815EOF
816printf '%s\\n' \"$ZSH_VERSION\"
817";
818 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
819 assert_eq!(inferred, ShellDialect::Zsh);
820 }
821
822 #[test]
823 fn escaped_whitespace_before_hash_does_not_start_a_comment() {
824 let source = "\
825printf '%s\\n' foo\\ # \"$ZSH_VERSION\"
826";
827 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/example.sh")));
828 assert_eq!(inferred, ShellDialect::Zsh);
829 }
830
831 #[test]
832 fn infers_from_executed_dialect_parameter_markers() {
833 let zsh = ShellDialect::infer(
834 "printf '%s\\n' \"$ZSH_VERSION\"\n",
835 Some(Path::new("/tmp/example.sh")),
836 );
837 assert_eq!(zsh, ShellDialect::Zsh);
838
839 let zsh_after_apostrophe = ShellDialect::infer(
840 "printf '%s\\n' \"can't\" \"$ZSH_VERSION\"\n",
841 Some(Path::new("/tmp/example.sh")),
842 );
843 assert_eq!(zsh_after_apostrophe, ShellDialect::Zsh);
844
845 let bash = ShellDialect::infer(
846 "printf '%s\\n' \"${BASH_SOURCE[0]}\"\n",
847 Some(Path::new("/tmp/example.sh")),
848 );
849 assert_eq!(bash, ShellDialect::Bash);
850 }
851
852 #[test]
853 fn infers_bash_from_source_markers_before_sh_extension() {
854 let source = r#"
855if [[ "${BASH_SOURCE[0]}" == */* ]]; then
856 shopt -s promptvars
857 PROMPT_COMMAND=update_prompt
858fi
859"#;
860 let inferred = ShellDialect::infer(source, Some(Path::new("/tmp/gitstatus.plugin.sh")));
861 assert_eq!(inferred, ShellDialect::Bash);
862 }
863
864 #[test]
865 fn keeps_plain_sh_extension_as_sh_without_specific_markers() {
866 let inferred = ShellDialect::infer(
867 "local foo=bar\n[[ -n $foo ]] && echo \"$foo\"\n",
868 Some(Path::new("/tmp/example.sh")),
869 );
870 assert_eq!(inferred, ShellDialect::Sh);
871 }
872
873 #[test]
874 fn ambiguous_bash_and_zsh_markers_fall_back_to_extension() {
875 let inferred = ShellDialect::infer(
876 "echo \"$BASH_VERSION $ZSH_VERSION\"\n",
877 Some(Path::new("/tmp/example.sh")),
878 );
879 assert_eq!(inferred, ShellDialect::Sh);
880 }
881
882 #[test]
883 fn infers_from_shellcheck_shell_directive_without_shebang() {
884 let inferred = ShellDialect::infer(
885 "# shellcheck shell=sh\nprintf '%s\\n' \"${!arr[*]}\"\n",
886 Some(Path::new("/tmp/example")),
887 );
888 assert_eq!(inferred, ShellDialect::Sh);
889 }
890
891 #[test]
892 fn shellcheck_shell_directive_overrides_shebang() {
893 let inferred = ShellDialect::infer(
894 "#!/bin/bash\n# shellcheck shell=sh\nprintf '%s\\n' \"${!arr[*]}\"\n",
895 Some(Path::new("/tmp/example.sh")),
896 );
897 assert_eq!(inferred, ShellDialect::Sh);
898 }
899
900 #[test]
901 fn parser_dialect_matches_linter_shell_policy() {
902 assert_eq!(
903 ShellDialect::Unknown.parser_dialect(),
904 shuck_parser::ShellDialect::Bash
905 );
906 assert_eq!(
907 ShellDialect::Bash.parser_dialect(),
908 shuck_parser::ShellDialect::Bash
909 );
910 assert_eq!(
911 ShellDialect::Sh.parser_dialect(),
912 shuck_parser::ShellDialect::Bash
913 );
914 assert_eq!(
915 ShellDialect::Dash.parser_dialect(),
916 shuck_parser::ShellDialect::Bash
917 );
918 assert_eq!(
919 ShellDialect::Ksh.parser_dialect(),
920 shuck_parser::ShellDialect::Bash
921 );
922 assert_eq!(
923 ShellDialect::Mksh.parser_dialect(),
924 shuck_parser::ShellDialect::Mksh
925 );
926 assert_eq!(
927 ShellDialect::Zsh.parser_dialect(),
928 shuck_parser::ShellDialect::Zsh
929 );
930 }
931
932 #[test]
933 fn semantic_dialect_matches_linter_shell_policy() {
934 assert_eq!(
935 ShellDialect::Unknown.semantic_dialect(),
936 shuck_parser::ShellDialect::Bash
937 );
938 assert_eq!(
939 ShellDialect::Bash.semantic_dialect(),
940 shuck_parser::ShellDialect::Bash
941 );
942 assert_eq!(
943 ShellDialect::Sh.semantic_dialect(),
944 shuck_parser::ShellDialect::Posix
945 );
946 assert_eq!(
947 ShellDialect::Dash.semantic_dialect(),
948 shuck_parser::ShellDialect::Posix
949 );
950 assert_eq!(
951 ShellDialect::Ksh.semantic_dialect(),
952 shuck_parser::ShellDialect::Posix
953 );
954 assert_eq!(
955 ShellDialect::Mksh.semantic_dialect(),
956 shuck_parser::ShellDialect::Mksh
957 );
958 assert_eq!(
959 ShellDialect::Zsh.semantic_dialect(),
960 shuck_parser::ShellDialect::Zsh
961 );
962 }
963}