Skip to main content

shuck_linter/
shell.rs

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}