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