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                .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    /// Returns `true` when `line` is a zsh autoload/completion tag comment
176    /// (`#compdef …` or `#autoload …`).
177    ///
178    /// compinit requires this tag on the *first* line of a completion/autoload
179    /// function file, so such files lead with the tag instead of a shebang and
180    /// are typically extensionless. Shared by dialect inference and by file
181    /// discovery (`shuck-discover`) so these files are recognized as zsh shell
182    /// scripts. The tag must directly follow `#` (no space), matching compinit.
183    #[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}