#![warn(missing_docs)]
#![cfg_attr(not(test), warn(clippy::unwrap_used))]
#![recursion_limit = "256"]
#[allow(missing_docs)]
mod command;
#[allow(missing_docs)]
mod comments;
mod facts;
#[allow(missing_docs)]
mod options;
#[allow(missing_docs)]
mod scan;
#[allow(missing_docs)]
mod simplify;
#[allow(missing_docs)]
mod streaming;
#[allow(missing_docs)]
mod visit;
#[allow(missing_docs)]
mod word;
use std::path::Path;
use shuck_ast::File;
use shuck_parser::{Error as ParseError, parser::Parser};
use crate::facts::FormatterFacts;
pub use crate::options::{
IndentStyle, LineEnding, ResolvedShellFormatOptions, ShellDialect, ShellFormatOptions,
};
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormattedSource {
Unchanged,
Formatted(String),
}
#[allow(missing_docs)]
impl FormattedSource {
#[must_use]
pub fn is_changed(&self) -> bool {
matches!(self, Self::Formatted(_))
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatError {
Parse {
message: String,
line: usize,
column: usize,
},
Internal(String),
}
impl std::fmt::Display for FormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse {
message,
line,
column,
} => {
if *line > 0 {
write!(f, "parse error at line {line}, column {column}: {message}")
} else {
write!(f, "parse error: {message}")
}
}
Self::Internal(message) => f.write_str(message),
}
}
}
impl std::error::Error for FormatError {}
pub type Result<T> = std::result::Result<T, FormatError>;
pub fn format_source(
source: &str,
path: Option<&Path>,
options: &ShellFormatOptions,
) -> Result<FormattedSource> {
let resolved = options.resolve_for_format(source, path);
let dialect = resolved.dialect();
let parsed = Parser::with_dialect(source, dialect).parse();
if parsed.is_err() {
return Err(map_parse_error(parsed.strict_error()));
}
format_file_ast(source, parsed.file, path, options)
}
#[doc(hidden)]
pub fn source_is_formatted(
source: &str,
path: Option<&Path>,
options: &ShellFormatOptions,
) -> Result<bool> {
let resolved = options.resolve_for_format(source, path);
let dialect = resolved.dialect();
let parsed = Parser::with_dialect(source, dialect).parse();
if parsed.is_err() {
return Err(map_parse_error(parsed.strict_error()));
}
check_file(source, parsed.file, resolved)
}
pub fn format_file_ast(
source: &str,
file: File,
path: Option<&Path>,
options: &ShellFormatOptions,
) -> Result<FormattedSource> {
let resolved = options.resolve_for_format(source, path);
let output = format_output(source, file, &resolved)?;
Ok(formatted_source_from_output(source, output))
}
fn check_file(source: &str, file: File, resolved: ResolvedShellFormatOptions) -> Result<bool> {
if resolved.minify() {
let output = render_file::<BufferedRender>(source, file, &resolved)?;
return Ok(output == source);
}
render_file::<CompareRender>(source, file, &resolved)
}
fn format_output(
source: &str,
file: File,
resolved: &ResolvedShellFormatOptions,
) -> Result<String> {
render_file::<BufferedRender>(source, file, resolved)
}
trait RenderMode {
type Output;
fn render(
source: &str,
file: &File,
resolved: &ResolvedShellFormatOptions,
facts: &FormatterFacts<'_>,
) -> Result<Self::Output>;
}
struct BufferedRender;
impl RenderMode for BufferedRender {
type Output = String;
fn render(
source: &str,
file: &File,
resolved: &ResolvedShellFormatOptions,
facts: &FormatterFacts<'_>,
) -> Result<Self::Output> {
let mut output =
streaming::format_file_streaming_with_facts(source, file, resolved, facts)?;
if resolved.minify() {
preserve_initial_shebang(source, &mut output, resolved.line_ending());
}
ensure_single_trailing_newline(&mut output, resolved.line_ending());
Ok(output)
}
}
struct CompareRender;
impl RenderMode for CompareRender {
type Output = bool;
fn render(
source: &str,
file: &File,
resolved: &ResolvedShellFormatOptions,
facts: &FormatterFacts<'_>,
) -> Result<Self::Output> {
streaming::format_file_streaming_matches_source_with_facts(source, file, resolved, facts)
}
}
fn render_file<M: RenderMode>(
source: &str,
mut file: File,
resolved: &ResolvedShellFormatOptions,
) -> Result<M::Output> {
if resolved.simplify() || resolved.minify() {
simplify::simplify_file(&mut file, source);
}
let facts = FormatterFacts::build(source, &file, resolved);
let resolved = resolved.clone().with_line_ending(facts.line_ending());
M::render(source, &file, &resolved, &facts)
}
fn formatted_source_from_output(source: &str, output: String) -> FormattedSource {
if output == source {
FormattedSource::Unchanged
} else {
FormattedSource::Formatted(output)
}
}
#[cfg(feature = "benchmarking")]
#[doc(hidden)]
#[must_use]
pub fn build_formatter_facts(source: &str, file: &File) -> usize {
let resolved = ShellFormatOptions::default().resolve_for_format(source, None);
FormatterFacts::build(source, file, &resolved).len()
}
fn ensure_single_trailing_newline(output: &mut String, line_ending: LineEnding) {
while let Some(start) = trailing_line_ending_start(output)
.filter(|start| trailing_line_ending_start(&output[..*start]).is_some())
{
output.truncate(start);
}
if trailing_line_ending_start(output).is_none() {
if trailing_backslash_count(output) % 2 == 1 && !trailing_backslash_is_in_comment(output) {
output.push('\\');
}
output.push_str(line_ending_str(line_ending));
}
}
fn trailing_line_ending_start(text: &str) -> Option<usize> {
if text.ends_with("\r\n") {
Some(text.len() - 2)
} else if text.ends_with('\n') {
Some(text.len() - 1)
} else {
None
}
}
fn line_ending_str(line_ending: LineEnding) -> &'static str {
match line_ending {
LineEnding::Lf => "\n",
LineEnding::CrLf => "\r\n",
}
}
fn preserve_initial_shebang(source: &str, output: &mut String, line_ending: LineEnding) {
if !source.starts_with("#!") || output.starts_with("#!") {
return;
}
let shebang_end = source.find(['\r', '\n']).unwrap_or(source.len());
let shebang = &source[..shebang_end];
let line_ending = line_ending_str(line_ending);
let body = output.trim_start_matches(['\r', '\n']);
let mut prefixed = String::with_capacity(shebang.len() + line_ending.len() + body.len());
prefixed.push_str(shebang);
prefixed.push_str(line_ending);
prefixed.push_str(body);
*output = prefixed;
}
fn trailing_backslash_count(text: &str) -> usize {
text.as_bytes()
.iter()
.rev()
.take_while(|byte| **byte == b'\\')
.count()
}
fn trailing_backslash_is_in_comment(text: &str) -> bool {
let line = text.rsplit_once('\n').map_or(text, |(_, line)| line);
scan::RawShellScanner::new(line)
.find_comment(0, line.len())
.is_some()
}
fn map_parse_error(error: ParseError) -> FormatError {
match error {
ParseError::Parse {
message,
line,
column,
} => FormatError::Parse {
message,
line,
column,
},
}
}