Skip to main content

shuck_formatter/
lib.rs

1#![warn(missing_docs)]
2#![cfg_attr(not(test), warn(clippy::unwrap_used))]
3//! Shell formatting entrypoints built on top of `shuck-parser`.
4//!
5//! Most callers will use [`format_source`] for source text or [`format_file_ast`] when they
6//! already have a parsed shell AST.
7#![recursion_limit = "256"]
8
9//! Shell script formatter with configurable style options.
10
11#[allow(missing_docs)]
12mod command;
13#[allow(missing_docs)]
14mod comments;
15mod facts;
16#[allow(missing_docs)]
17mod options;
18#[allow(missing_docs)]
19mod scan;
20#[allow(missing_docs)]
21mod simplify;
22#[allow(missing_docs)]
23mod streaming;
24#[allow(missing_docs)]
25mod visit;
26#[allow(missing_docs)]
27mod word;
28
29use std::path::Path;
30
31use shuck_ast::File;
32use shuck_parser::{Error as ParseError, parser::Parser};
33
34use crate::facts::FormatterFacts;
35
36/// Formatter option types exposed by the shell formatter.
37pub use crate::options::{
38    IndentStyle, LineEnding, ResolvedShellFormatOptions, ShellDialect, ShellFormatOptions,
39};
40
41/// Result of formatting shell source.
42#[allow(missing_docs)]
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum FormattedSource {
45    Unchanged,
46    Formatted(String),
47}
48
49#[allow(missing_docs)]
50impl FormattedSource {
51    #[must_use]
52    pub fn is_changed(&self) -> bool {
53        matches!(self, Self::Formatted(_))
54    }
55}
56
57/// Errors that can occur while parsing or formatting shell source.
58#[allow(missing_docs)]
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum FormatError {
61    Parse {
62        message: String,
63        line: usize,
64        column: usize,
65    },
66    Internal(String),
67}
68
69impl std::fmt::Display for FormatError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Self::Parse {
73                message,
74                line,
75                column,
76            } => {
77                if *line > 0 {
78                    write!(f, "parse error at line {line}, column {column}: {message}")
79                } else {
80                    write!(f, "parse error: {message}")
81                }
82            }
83            Self::Internal(message) => f.write_str(message),
84        }
85    }
86}
87
88impl std::error::Error for FormatError {}
89
90/// Convenient result alias for shell formatting operations.
91pub type Result<T> = std::result::Result<T, FormatError>;
92
93/// Formats a shell source string using the provided options.
94pub fn format_source(
95    source: &str,
96    path: Option<&Path>,
97    options: &ShellFormatOptions,
98) -> Result<FormattedSource> {
99    let resolved = options.resolve_for_format(source, path);
100
101    let dialect = resolved.dialect();
102    let parsed = Parser::with_dialect(source, dialect).parse();
103    if parsed.is_err() {
104        return Err(map_parse_error(parsed.strict_error()));
105    }
106
107    format_file_ast(source, parsed.file, path, options)
108}
109
110#[doc(hidden)]
111pub fn source_is_formatted(
112    source: &str,
113    path: Option<&Path>,
114    options: &ShellFormatOptions,
115) -> Result<bool> {
116    let resolved = options.resolve_for_format(source, path);
117
118    let dialect = resolved.dialect();
119    let parsed = Parser::with_dialect(source, dialect).parse();
120    if parsed.is_err() {
121        return Err(map_parse_error(parsed.strict_error()));
122    }
123
124    check_file(source, parsed.file, resolved)
125}
126
127/// Formats a parsed shell file using the provided options.
128pub fn format_file_ast(
129    source: &str,
130    file: File,
131    path: Option<&Path>,
132    options: &ShellFormatOptions,
133) -> Result<FormattedSource> {
134    let resolved = options.resolve_for_format(source, path);
135    let output = format_output(source, file, &resolved)?;
136
137    Ok(formatted_source_from_output(source, output))
138}
139
140fn check_file(source: &str, file: File, resolved: ResolvedShellFormatOptions) -> Result<bool> {
141    if resolved.minify() {
142        let output = render_file::<BufferedRender>(source, file, &resolved)?;
143        return Ok(output == source);
144    }
145
146    render_file::<CompareRender>(source, file, &resolved)
147}
148
149fn format_output(
150    source: &str,
151    file: File,
152    resolved: &ResolvedShellFormatOptions,
153) -> Result<String> {
154    render_file::<BufferedRender>(source, file, resolved)
155}
156
157trait RenderMode {
158    type Output;
159
160    fn render(
161        source: &str,
162        file: &File,
163        resolved: &ResolvedShellFormatOptions,
164        facts: &FormatterFacts<'_>,
165    ) -> Result<Self::Output>;
166}
167
168struct BufferedRender;
169
170impl RenderMode for BufferedRender {
171    type Output = String;
172
173    fn render(
174        source: &str,
175        file: &File,
176        resolved: &ResolvedShellFormatOptions,
177        facts: &FormatterFacts<'_>,
178    ) -> Result<Self::Output> {
179        let mut output =
180            streaming::format_file_streaming_with_facts(source, file, resolved, facts)?;
181        if resolved.minify() {
182            preserve_initial_shebang(source, &mut output, resolved.line_ending());
183        }
184        ensure_single_trailing_newline(&mut output, resolved.line_ending());
185
186        Ok(output)
187    }
188}
189
190struct CompareRender;
191
192impl RenderMode for CompareRender {
193    type Output = bool;
194
195    fn render(
196        source: &str,
197        file: &File,
198        resolved: &ResolvedShellFormatOptions,
199        facts: &FormatterFacts<'_>,
200    ) -> Result<Self::Output> {
201        streaming::format_file_streaming_matches_source_with_facts(source, file, resolved, facts)
202    }
203}
204
205fn render_file<M: RenderMode>(
206    source: &str,
207    mut file: File,
208    resolved: &ResolvedShellFormatOptions,
209) -> Result<M::Output> {
210    if resolved.simplify() || resolved.minify() {
211        simplify::simplify_file(&mut file, source);
212    }
213
214    let facts = FormatterFacts::build(source, &file, resolved);
215    let resolved = resolved.clone().with_line_ending(facts.line_ending());
216    M::render(source, &file, &resolved, &facts)
217}
218
219fn formatted_source_from_output(source: &str, output: String) -> FormattedSource {
220    if output == source {
221        FormattedSource::Unchanged
222    } else {
223        FormattedSource::Formatted(output)
224    }
225}
226
227#[cfg(feature = "benchmarking")]
228#[doc(hidden)]
229#[must_use]
230pub fn build_formatter_facts(source: &str, file: &File) -> usize {
231    let resolved = ShellFormatOptions::default().resolve_for_format(source, None);
232    FormatterFacts::build(source, file, &resolved).len()
233}
234
235fn ensure_single_trailing_newline(output: &mut String, line_ending: LineEnding) {
236    while let Some(start) = trailing_line_ending_start(output)
237        .filter(|start| trailing_line_ending_start(&output[..*start]).is_some())
238    {
239        output.truncate(start);
240    }
241    if trailing_line_ending_start(output).is_none() {
242        if trailing_backslash_count(output) % 2 == 1 && !trailing_backslash_is_in_comment(output) {
243            output.push('\\');
244        }
245        output.push_str(line_ending_str(line_ending));
246    }
247}
248
249fn trailing_line_ending_start(text: &str) -> Option<usize> {
250    if text.ends_with("\r\n") {
251        Some(text.len() - 2)
252    } else if text.ends_with('\n') {
253        Some(text.len() - 1)
254    } else {
255        None
256    }
257}
258
259fn line_ending_str(line_ending: LineEnding) -> &'static str {
260    match line_ending {
261        LineEnding::Lf => "\n",
262        LineEnding::CrLf => "\r\n",
263    }
264}
265
266fn preserve_initial_shebang(source: &str, output: &mut String, line_ending: LineEnding) {
267    if !source.starts_with("#!") || output.starts_with("#!") {
268        return;
269    }
270
271    let shebang_end = source.find(['\r', '\n']).unwrap_or(source.len());
272    let shebang = &source[..shebang_end];
273    let line_ending = line_ending_str(line_ending);
274    let body = output.trim_start_matches(['\r', '\n']);
275
276    let mut prefixed = String::with_capacity(shebang.len() + line_ending.len() + body.len());
277    prefixed.push_str(shebang);
278    prefixed.push_str(line_ending);
279    prefixed.push_str(body);
280    *output = prefixed;
281}
282
283fn trailing_backslash_count(text: &str) -> usize {
284    text.as_bytes()
285        .iter()
286        .rev()
287        .take_while(|byte| **byte == b'\\')
288        .count()
289}
290
291fn trailing_backslash_is_in_comment(text: &str) -> bool {
292    let line = text.rsplit_once('\n').map_or(text, |(_, line)| line);
293    scan::RawShellScanner::new(line)
294        .find_comment(0, line.len())
295        .is_some()
296}
297
298fn map_parse_error(error: ParseError) -> FormatError {
299    match error {
300        ParseError::Parse {
301            message,
302            line,
303            column,
304        } => FormatError::Parse {
305            message,
306            line,
307            column,
308        },
309    }
310}