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}