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