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