1use std::path::Path;
2
3use shuck_parser::ShellDialect as ParseDialect;
4
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
6pub enum IndentStyle {
7 #[default]
8 Tab,
9 Space,
10}
11
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub enum LineEnding {
14 #[default]
15 Lf,
16 CrLf,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum ShellDialect {
21 #[default]
22 Auto,
23 Bash,
24 Posix,
25 Mksh,
26 Zsh,
27}
28
29impl ShellDialect {
30 #[must_use]
31 pub fn resolve(self, source: &str, path: Option<&Path>) -> ParseDialect {
32 match self {
33 Self::Auto => infer_dialect(source, path),
34 Self::Bash => ParseDialect::Bash,
35 Self::Posix => ParseDialect::Posix,
36 Self::Mksh => ParseDialect::Mksh,
37 Self::Zsh => ParseDialect::Zsh,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ShellFormatOptions {
44 dialect: ShellDialect,
45 indent_style: IndentStyle,
46 indent_width: u8,
47 binary_next_line: bool,
48 switch_case_indent: bool,
49 space_redirects: bool,
50 keep_padding: bool,
51 function_next_line: bool,
52 never_split: bool,
53 simplify: bool,
54 minify: bool,
55}
56
57impl Default for ShellFormatOptions {
58 fn default() -> Self {
59 Self {
60 dialect: ShellDialect::Auto,
61 indent_style: IndentStyle::Tab,
62 indent_width: 8,
63 binary_next_line: false,
64 switch_case_indent: false,
65 space_redirects: false,
66 keep_padding: false,
67 function_next_line: false,
68 never_split: false,
69 simplify: false,
70 minify: false,
71 }
72 }
73}
74
75macro_rules! option_getters {
76 ($($method:ident: $field:ident -> $ty:ty;)+) => {
77 $(
78 #[must_use]
79 pub fn $method(&self) -> $ty {
80 self.$field
81 }
82 )+
83 };
84}
85
86macro_rules! option_builders {
87 ($($method:ident: $field:ident -> $ty:ty;)+) => {
88 $(
89 #[must_use]
90 pub fn $method(mut self, value: $ty) -> Self {
91 self.$field = value;
92 self
93 }
94 )+
95 };
96}
97
98macro_rules! resolved_option_getters {
99 ($($method:ident: $field:ident -> $ty:ty;)+) => {
100 $(
101 #[must_use]
102 pub fn $method(&self) -> $ty {
103 self.options.$field
104 }
105 )+
106 };
107}
108
109impl ShellFormatOptions {
110 option_getters! {
111 dialect: dialect -> ShellDialect;
112 indent_style: indent_style -> IndentStyle;
113 indent_width: indent_width -> u8;
114 binary_next_line: binary_next_line -> bool;
115 switch_case_indent: switch_case_indent -> bool;
116 space_redirects: space_redirects -> bool;
117 keep_padding: keep_padding -> bool;
118 function_next_line: function_next_line -> bool;
119 never_split: never_split -> bool;
120 simplify: simplify -> bool;
121 minify: minify -> bool;
122 }
123
124 option_builders! {
125 with_dialect: dialect -> ShellDialect;
126 with_indent_style: indent_style -> IndentStyle;
127 with_binary_next_line: binary_next_line -> bool;
128 with_switch_case_indent: switch_case_indent -> bool;
129 with_space_redirects: space_redirects -> bool;
130 with_keep_padding: keep_padding -> bool;
131 with_function_next_line: function_next_line -> bool;
132 with_never_split: never_split -> bool;
133 with_simplify: simplify -> bool;
134 with_minify: minify -> bool;
135 }
136
137 #[must_use]
138 pub fn with_indent_width(mut self, indent_width: u8) -> Self {
139 self.indent_width = indent_width.max(1);
140 self
141 }
142
143 #[must_use]
144 pub fn resolve(&self, source: &str, path: Option<&Path>) -> ResolvedShellFormatOptions {
145 let mut resolved = self.resolve_for_format(source, path);
146 resolved.line_ending = line_ending_from_source_index(source);
147 resolved
148 }
149
150 pub(crate) fn resolve_for_format(
151 &self,
152 source: &str,
153 path: Option<&Path>,
154 ) -> ResolvedShellFormatOptions {
155 let mut options = self.clone();
156 options.indent_width = options.indent_width.max(1);
157 ResolvedShellFormatOptions {
158 dialect: self.dialect.resolve(source, path),
159 options,
160 line_ending: LineEnding::Lf,
161 }
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct ResolvedShellFormatOptions {
167 options: ShellFormatOptions,
168 dialect: ParseDialect,
169 line_ending: LineEnding,
170}
171
172impl ResolvedShellFormatOptions {
173 option_getters! {
174 dialect: dialect -> ParseDialect;
175 }
176
177 resolved_option_getters! {
178 indent_style: indent_style -> IndentStyle;
179 indent_width: indent_width -> u8;
180 }
181
182 pub(crate) fn indent_unit_columns(&self) -> usize {
183 match self.indent_style() {
184 IndentStyle::Tab => 1,
185 IndentStyle::Space => usize::from(self.indent_width()),
186 }
187 }
188
189 pub(crate) fn indent_columns(&self, levels: usize) -> usize {
190 levels * self.indent_unit_columns()
191 }
192
193 pub(crate) fn push_indent_units(&self, target: &mut String, levels: usize) {
194 self.push_indent_columns(target, self.indent_columns(levels));
195 }
196
197 pub(crate) fn push_indent_columns(&self, target: &mut String, columns: usize) {
198 let ch = match self.indent_style() {
199 IndentStyle::Tab => '\t',
200 IndentStyle::Space => ' ',
201 };
202 target.extend(std::iter::repeat_n(ch, columns));
203 }
204
205 pub(crate) fn indent_prefix(&self, levels: usize) -> String {
206 let mut prefix = String::new();
207 self.push_indent_units(&mut prefix, levels);
208 prefix
209 }
210
211 resolved_option_getters! {
212 binary_next_line: binary_next_line -> bool;
213 switch_case_indent: switch_case_indent -> bool;
214 space_redirects: space_redirects -> bool;
215 keep_padding: keep_padding -> bool;
216 function_next_line: function_next_line -> bool;
217 never_split: never_split -> bool;
218 simplify: simplify -> bool;
219 minify: minify -> bool;
220 }
221
222 #[must_use]
223 pub fn compact_layout(&self) -> bool {
224 self.minify() || self.never_split()
225 }
226
227 option_getters! {
228 line_ending: line_ending -> LineEnding;
229 }
230
231 pub(crate) fn with_line_ending(mut self, line_ending: LineEnding) -> Self {
232 self.line_ending = line_ending;
233 self
234 }
235}
236
237fn line_ending_from_source_index(source: &str) -> LineEnding {
238 match shuck_indexer::LineIndex::new(source).line_ending() {
239 shuck_indexer::LineEndingStyle::Lf => LineEnding::Lf,
240 shuck_indexer::LineEndingStyle::CrLf => LineEnding::CrLf,
241 }
242}
243
244fn infer_dialect(source: &str, path: Option<&Path>) -> ParseDialect {
245 if let Some(first_line) = source.lines().next()
246 && let Some(interpreter) = shuck_parser::shebang::interpreter_name(first_line)
247 {
248 return ParseDialect::from_name(interpreter);
249 }
250
251 path.and_then(Path::extension)
252 .and_then(|extension| extension.to_str())
253 .map(ParseDialect::from_name)
254 .unwrap_or(ParseDialect::Bash)
255}
256
257#[cfg(test)]
258mod tests {
259 use std::path::Path;
260
261 use super::*;
262
263 #[test]
264 fn zsh_extension_resolves_to_zsh_dialect() {
265 let resolved = ShellFormatOptions::default()
266 .resolve("print ${(m)name}\n", Some(Path::new("script.zsh")));
267
268 assert_eq!(resolved.dialect(), ParseDialect::Zsh);
269 }
270
271 #[test]
272 fn zsh_shebang_resolves_to_zsh_dialect() {
273 let resolved = ShellFormatOptions::default().resolve(
274 "#!/bin/zsh\nprint ${(m)name}\n",
275 Some(Path::new("script.sh")),
276 );
277
278 assert_eq!(resolved.dialect(), ParseDialect::Zsh);
279 }
280
281 #[test]
282 fn explicit_zsh_dialect_overrides_path_inference() {
283 let options = ShellFormatOptions::default().with_dialect(ShellDialect::Zsh);
284 let resolved = options.resolve("print ${(m)name}\n", Some(Path::new("script.sh")));
285
286 assert_eq!(resolved.dialect(), ParseDialect::Zsh);
287 }
288}