Skip to main content

shuck_formatter/
options.rs

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}