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