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