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        Self::infer_from_shellcheck_header(source)
53            .or_else(|| Self::infer_from_shebang(source))
54            .unwrap_or_else(|| {
55                path.map_or(Self::Unknown, |path| {
56                    match path
57                        .extension()
58                        .and_then(|ext| ext.to_str())
59                        .map(|ext| ext.to_ascii_lowercase())
60                        .as_deref()
61                    {
62                        Some("sh") => Self::Sh,
63                        Some("bash") => Self::Bash,
64                        Some("dash") => Self::Dash,
65                        Some("ksh") => Self::Ksh,
66                        Some("mksh") => Self::Mksh,
67                        Some("zsh") => Self::Zsh,
68                        _ => Self::Unknown,
69                    }
70                })
71            })
72    }
73
74    fn infer_from_shebang(source: &str) -> Option<Self> {
75        let interpreter = shuck_parser::shebang::interpreter_name(source.lines().next()?)?;
76        Some(Self::from_name(interpreter))
77    }
78
79    fn infer_from_shellcheck_header(source: &str) -> Option<Self> {
80        for line in source.lines() {
81            let trimmed = line.trim_start();
82            if trimmed.is_empty() || trimmed.starts_with("#!") {
83                continue;
84            }
85
86            let Some(comment) = trimmed.strip_prefix('#') else {
87                break;
88            };
89            let body = comment.trim_start().to_ascii_lowercase();
90            let Some(shell_name) = body.strip_prefix("shellcheck shell=") else {
91                continue;
92            };
93
94            let dialect = Self::from_name(shell_name.split_whitespace().next().unwrap_or_default());
95            if dialect != Self::Unknown {
96                return Some(dialect);
97            }
98        }
99
100        None
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn infers_from_shebang_before_extension() {
110        let inferred = ShellDialect::infer("#!/usr/bin/env bash\nlocal foo=bar\n", None);
111        assert_eq!(inferred, ShellDialect::Bash);
112    }
113
114    #[test]
115    fn infers_from_env_split_shebang_before_extension() {
116        let inferred = ShellDialect::infer("#!/usr/bin/env -S bash -e\nlocal foo=bar\n", None);
117        assert_eq!(inferred, ShellDialect::Bash);
118    }
119
120    #[test]
121    fn infers_from_extension_when_shebang_is_missing() {
122        let inferred = ShellDialect::infer("local foo=bar\n", Some(Path::new("/tmp/example.bash")));
123        assert_eq!(inferred, ShellDialect::Bash);
124    }
125
126    #[test]
127    fn infers_from_shellcheck_shell_directive_without_shebang() {
128        let inferred = ShellDialect::infer(
129            "# shellcheck shell=sh\nprintf '%s\\n' \"${!arr[*]}\"\n",
130            Some(Path::new("/tmp/example")),
131        );
132        assert_eq!(inferred, ShellDialect::Sh);
133    }
134
135    #[test]
136    fn shellcheck_shell_directive_overrides_shebang() {
137        let inferred = ShellDialect::infer(
138            "#!/bin/bash\n# shellcheck shell=sh\nprintf '%s\\n' \"${!arr[*]}\"\n",
139            Some(Path::new("/tmp/example.sh")),
140        );
141        assert_eq!(inferred, ShellDialect::Sh);
142    }
143
144    #[test]
145    fn parser_dialect_matches_linter_shell_policy() {
146        assert_eq!(
147            ShellDialect::Unknown.parser_dialect(),
148            shuck_parser::ShellDialect::Bash
149        );
150        assert_eq!(
151            ShellDialect::Bash.parser_dialect(),
152            shuck_parser::ShellDialect::Bash
153        );
154        assert_eq!(
155            ShellDialect::Sh.parser_dialect(),
156            shuck_parser::ShellDialect::Bash
157        );
158        assert_eq!(
159            ShellDialect::Dash.parser_dialect(),
160            shuck_parser::ShellDialect::Bash
161        );
162        assert_eq!(
163            ShellDialect::Ksh.parser_dialect(),
164            shuck_parser::ShellDialect::Bash
165        );
166        assert_eq!(
167            ShellDialect::Mksh.parser_dialect(),
168            shuck_parser::ShellDialect::Mksh
169        );
170        assert_eq!(
171            ShellDialect::Zsh.parser_dialect(),
172            shuck_parser::ShellDialect::Zsh
173        );
174    }
175
176    #[test]
177    fn semantic_dialect_matches_linter_shell_policy() {
178        assert_eq!(
179            ShellDialect::Unknown.semantic_dialect(),
180            shuck_parser::ShellDialect::Bash
181        );
182        assert_eq!(
183            ShellDialect::Bash.semantic_dialect(),
184            shuck_parser::ShellDialect::Bash
185        );
186        assert_eq!(
187            ShellDialect::Sh.semantic_dialect(),
188            shuck_parser::ShellDialect::Posix
189        );
190        assert_eq!(
191            ShellDialect::Dash.semantic_dialect(),
192            shuck_parser::ShellDialect::Posix
193        );
194        assert_eq!(
195            ShellDialect::Ksh.semantic_dialect(),
196            shuck_parser::ShellDialect::Posix
197        );
198        assert_eq!(
199            ShellDialect::Mksh.semantic_dialect(),
200            shuck_parser::ShellDialect::Mksh
201        );
202        assert_eq!(
203            ShellDialect::Zsh.semantic_dialect(),
204            shuck_parser::ShellDialect::Zsh
205        );
206    }
207}