1#![warn(missing_docs)]
2#![cfg_attr(not(test), warn(clippy::unwrap_used))]
3#![recursion_limit = "256"]
8
9#[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
36pub use crate::options::{
38 IndentStyle, LineEnding, ResolvedShellFormatOptions, ShellDialect, ShellFormatOptions,
39};
40
41#[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#[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
90pub type Result<T> = std::result::Result<T, FormatError>;
92
93pub 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
127pub 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}