#[derive(Debug, Clone, Copy)]
pub struct FmtOptions {
pub indent_width: usize,
pub use_tabs: bool,
}
impl Default for FmtOptions {
fn default() -> Self {
FmtOptions {
indent_width: 4,
use_tabs: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Block {
If { open: bool },
Loop { open: bool },
Brace,
Paren,
DCond,
Case { sub: CaseSub },
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum CaseSub {
Pattern,
Body,
}
impl Block {
fn contributes(&self) -> usize {
match self {
Block::If { open } | Block::Loop { open } => usize::from(*open),
Block::Brace | Block::Paren | Block::DCond => 1,
Block::Case { sub } => match sub {
CaseSub::Pattern => 1,
CaseSub::Body => 2,
},
}
}
}
#[derive(Debug, Clone)]
struct Heredoc {
tag: String,
strip_tabs: bool,
}
pub fn format_source(src: &str, opts: &FmtOptions) -> String {
let mut out = String::with_capacity(src.len() + 64);
let mut stack: Vec<Block> = Vec::new();
let mut pending_heredocs: Vec<Heredoc> = Vec::new();
let mut active_heredoc: Option<Heredoc> = None;
let mut continuation = false;
for line in src.split('\n') {
if let Some(h) = &active_heredoc {
let term_candidate = if h.strip_tabs {
line.trim_start_matches('\t')
} else {
line
};
out.push_str(line);
out.push('\n');
if term_candidate == h.tag {
active_heredoc = pending_heredocs.pop_front_or_none();
}
continue;
}
let body = line.trim_start();
if body.is_empty() {
out.push('\n');
continue;
}
let norm_ctx = NormCtx {
paren_depth: stack
.iter()
.filter(|b| matches!(b, Block::Paren | Block::DCond))
.count(),
in_case_pattern: matches!(
stack.last(),
Some(Block::Case { sub: CaseSub::Pattern })
),
};
let body = normalize_spacing(body, norm_ctx);
let body = body.as_str();
if !continuation {
let join_kw = match (body, stack.last()) {
("then", Some(Block::If { open: false })) => Some("; then"),
("do", Some(Block::Loop { open: false })) => Some("; do"),
_ => None,
};
if let Some(kw) = join_kw {
let prev_nonblank = out
.rsplit_once('\n')
.map(|(_, _last)| ())
.is_some()
&& !out.ends_with("\n\n")
&& out.len() >= 2;
if prev_nonblank {
if let Some(b) = stack.last_mut() {
match b {
Block::If { open } | Block::Loop { open } => *open = true,
_ => {}
}
}
out.pop(); out.push_str(kw);
out.push('\n');
continue;
}
}
}
let scan = scan_line(body, &mut stack, &mut pending_heredocs);
let mut depth: usize = scan.indent_basis;
if continuation {
depth += 1;
}
let indent = if opts.use_tabs {
"\t".repeat(depth)
} else {
" ".repeat(depth * opts.indent_width)
};
out.push_str(&indent);
out.push_str(body.trim_end());
out.push('\n');
continuation = scan.ends_with_continuation;
if !pending_heredocs.is_empty() && active_heredoc.is_none() {
active_heredoc = pending_heredocs.pop_front_or_none();
}
}
while out.ends_with("\n\n") {
out.pop();
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
trait PopFront<T> {
fn pop_front_or_none(&mut self) -> Option<T>;
}
impl<T> PopFront<T> for Vec<T> {
fn pop_front_or_none(&mut self) -> Option<T> {
if self.is_empty() {
None
} else {
Some(self.remove(0))
}
}
}
struct LineScan {
indent_basis: usize,
ends_with_continuation: bool,
}
fn stack_indent(stack: &[Block]) -> usize {
stack.iter().map(|b| b.contributes()).sum()
}
fn scan_line(
body: &str,
stack: &mut Vec<Block>,
heredocs: &mut Vec<Heredoc>,
) -> LineScan {
let b = body.as_bytes();
let n = b.len();
let mut i = 0usize;
let mut seen_token = false;
let mut indent_basis = stack_indent(stack);
let mut ends_with_continuation = false;
macro_rules! at_line_start {
() => {
!seen_token
};
}
while i < n {
let c = b[i];
match c {
b' ' | b'\t' => {
i += 1;
}
b'\\' => {
if i + 1 >= n {
ends_with_continuation = true;
}
i += 2;
seen_token = true;
}
b'\'' => {
i += 1;
while i < n && b[i] != b'\'' {
i += 1;
}
i += 1;
seen_token = true;
}
b'"' => {
i += 1;
while i < n {
if b[i] == b'\\' {
i += 2;
continue;
}
if b[i] == b'"' {
break;
}
i += 1;
}
i += 1;
seen_token = true;
}
b'`' => {
i += 1;
while i < n && b[i] != b'`' {
if b[i] == b'\\' {
i += 1;
}
i += 1;
}
i += 1;
seen_token = true;
}
b'#' => {
break;
}
b'$' => {
seen_token = true;
if i + 1 < n && b[i + 1] == b'\'' {
i += 2;
while i < n && b[i] != b'\'' {
if b[i] == b'\\' {
i += 1;
}
i += 1;
}
i += 1;
} else if i + 1 < n && b[i + 1] == b'{' {
i += 2;
let mut depth = 1usize;
while i < n && depth > 0 {
match b[i] {
b'{' => depth += 1,
b'}' => depth -= 1,
b'\\' => i += 1,
_ => {}
}
i += 1;
}
} else {
i += 1;
}
}
b'{' => {
stack.push(Block::Brace);
seen_token = true;
i += 1;
}
b'}' => {
let popped = matches!(stack.last(), Some(Block::Brace));
if popped {
stack.pop();
if at_line_start!() {
indent_basis = indent_basis.saturating_sub(1);
}
}
seen_token = true;
i += 1;
}
b'(' => {
if let Some(Block::Case { sub: CaseSub::Pattern }) = stack.last() {
i += 1;
seen_token = true;
continue;
}
stack.push(Block::Paren);
seen_token = true;
i += 1;
}
b')' => {
match stack.last_mut() {
Some(Block::Case { sub }) if *sub == CaseSub::Pattern => {
*sub = CaseSub::Body;
}
Some(Block::Paren) => {
stack.pop();
if at_line_start!() {
indent_basis = indent_basis.saturating_sub(1);
}
}
_ => {}
}
seen_token = true;
i += 1;
}
b';' => {
let two = b.get(i + 1).copied();
if matches!(two, Some(b';') | Some(b'&') | Some(b'|')) {
if let Some(Block::Case { sub }) = stack.last_mut() {
if *sub == CaseSub::Body {
*sub = CaseSub::Pattern;
if at_line_start!() {
}
}
}
i += 2;
} else {
i += 1;
}
seen_token = true;
}
b'<' => {
if i + 1 < n && b[i + 1] == b'<' {
if i + 2 < n && b[i + 2] == b'<' {
i += 3; } else {
i += 2;
let strip_tabs = i < n && b[i] == b'-';
if strip_tabs {
i += 1;
}
while i < n && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
let mut tag = String::new();
let mut quote: Option<u8> = None;
while i < n {
let ch = b[i];
match quote {
Some(q) if ch == q => {
quote = None;
i += 1;
}
Some(_) => {
tag.push(ch as char);
i += 1;
}
None => match ch {
b'\'' | b'"' => {
quote = Some(ch);
i += 1;
}
b'\\' => {
i += 1;
if i < n {
tag.push(b[i] as char);
i += 1;
}
}
b' ' | b'\t' | b';' | b'&' | b'|' | b'<' | b'>'
| b'(' | b')' => break,
_ => {
tag.push(ch as char);
i += 1;
}
},
}
}
if !tag.is_empty() {
heredocs.push(Heredoc { tag, strip_tabs });
}
}
} else {
i += 1;
}
seen_token = true;
}
b'>' => {
i += 1;
seen_token = true;
}
b'[' => {
if i + 1 < n && b[i + 1] == b'[' && is_word_boundary(b, i, 2) {
stack.push(Block::DCond);
i += 2;
} else {
i += 1;
}
seen_token = true;
}
b']' => {
if i + 1 < n
&& b[i + 1] == b']'
&& matches!(stack.last(), Some(Block::DCond))
{
stack.pop();
if at_line_start!() {
indent_basis = indent_basis.saturating_sub(1);
}
i += 2;
} else {
i += 1;
}
seen_token = true;
}
_ => {
let start = i;
while i < n {
match b[i] {
b' ' | b'\t' | b';' | b'(' | b')' | b'<' | b'>' | b'\''
| b'"' | b'`' | b'\\' => break,
b'{' | b'}' => {
i += 1;
}
_ => i += 1,
}
}
if i == start {
i += 1;
seen_token = true;
continue;
}
let word = &body[start..i];
let leading = at_line_start!();
seen_token = true;
match word {
"if" => stack.push(Block::If { open: false }),
"then" => {
if let Some(Block::If { open }) = stack.last_mut() {
if leading && *open {
indent_basis = indent_basis.saturating_sub(1);
}
*open = true;
}
}
"elif" | "else" => {
if leading {
if let Some(Block::If { open: true }) = stack.last() {
indent_basis = indent_basis.saturating_sub(1);
}
}
}
"fi" => {
if matches!(stack.last(), Some(Block::If { .. })) {
let was_open =
matches!(stack.last(), Some(Block::If { open: true }));
stack.pop();
if leading && was_open {
indent_basis = indent_basis.saturating_sub(1);
}
}
}
"for" | "while" | "until" | "select" | "repeat" => {
stack.push(Block::Loop { open: false });
}
"do" => {
if let Some(Block::Loop { open }) = stack.last_mut() {
if leading && *open {
indent_basis = indent_basis.saturating_sub(1);
}
*open = true;
}
}
"done" => {
if matches!(stack.last(), Some(Block::Loop { .. })) {
let was_open =
matches!(stack.last(), Some(Block::Loop { open: true }));
stack.pop();
if leading && was_open {
indent_basis = indent_basis.saturating_sub(1);
}
}
}
"case" => stack.push(Block::Case {
sub: CaseSub::Pattern,
}),
"esac" => {
if let Some(Block::Case { sub }) = stack.last() {
let contrib = Block::Case { sub: *sub }.contributes();
stack.pop();
if leading {
indent_basis =
indent_basis.saturating_sub(contrib);
}
}
}
_ => {}
}
}
}
}
LineScan {
indent_basis,
ends_with_continuation,
}
}
#[derive(Debug, Clone, Copy)]
struct NormCtx {
paren_depth: usize,
in_case_pattern: bool,
}
fn normalize_spacing(body: &str, ctx: NormCtx) -> String {
let b = body.as_bytes();
let n = b.len();
let mut out = String::with_capacity(n + 8);
let mut i = 0usize;
let mut paren_depth = ctx.paren_depth;
let mut case_pattern = ctx.in_case_pattern;
let mut case_kw_seen = false;
macro_rules! one_space {
() => {
while out.ends_with(' ') || out.ends_with('\t') {
out.pop();
}
if !out.is_empty() {
out.push(' ');
}
};
}
macro_rules! no_space {
() => {
while out.ends_with(' ') || out.ends_with('\t') {
out.pop();
}
};
}
while i < n {
let c = b[i];
match c {
b' ' | b'\t' => {
while i < n && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
if !out.is_empty() && i < n {
out.push(' ');
}
}
b'\\' => {
out.push('\\');
if i + 1 < n {
out.push(b[i + 1] as char);
}
i += 2;
}
b'\'' => {
let start = i;
i += 1;
while i < n && b[i] != b'\'' {
i += 1;
}
i = (i + 1).min(n);
out.push_str(&body[start..i]);
}
b'"' => {
let start = i;
i += 1;
while i < n {
if b[i] == b'\\' {
i += 2;
continue;
}
if b[i] == b'"' {
break;
}
i += 1;
}
i = (i + 1).min(n);
out.push_str(&body[start..i]);
}
b'`' => {
let start = i;
i += 1;
while i < n && b[i] != b'`' {
if b[i] == b'\\' {
i += 1;
}
i += 1;
}
i = (i + 1).min(n);
out.push_str(&body[start..i]);
}
b'#' => {
one_space!();
out.push_str(&body[i..]);
i = n;
}
b'$' => {
if i + 1 < n && b[i + 1] == b'\'' {
let start = i;
i += 2;
while i < n && b[i] != b'\'' {
if b[i] == b'\\' {
i += 1;
}
i += 1;
}
i = (i + 1).min(n);
out.push_str(&body[start..i]);
} else if i + 1 < n && b[i + 1] == b'{' {
let start = i;
i += 2;
let mut depth = 1usize;
while i < n && depth > 0 {
match b[i] {
b'{' => depth += 1,
b'}' => depth -= 1,
b'\\' => i += 1,
_ => {}
}
i += 1;
}
out.push_str(&body[start..i]);
} else {
out.push('$');
i += 1;
}
}
b'(' => {
let mut j = i + 1;
while j < n && (b[j] == b' ' || b[j] == b'\t') {
j += 1;
}
let empty_pair = j < n && b[j] == b')';
let after_word = out
.trim_end()
.chars()
.last()
.map(|ch| ch.is_alphanumeric() || ch == '_' || ch == '.' || ch == ':' || ch == '-')
.unwrap_or(false);
if empty_pair && after_word {
no_space!();
out.push_str("()");
i = j + 1;
continue;
}
paren_depth += 1;
out.push('(');
i += 1;
}
b')' => {
paren_depth = paren_depth.saturating_sub(1);
if case_pattern {
case_pattern = false;
}
out.push(')');
i += 1;
}
b'&' => {
let nx = b.get(i + 1).copied();
match nx {
Some(b'&') => {
if paren_depth == 0 && !case_pattern {
one_space!();
out.push_str("&&");
i += 2;
while i < n && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
if i < n {
out.push(' ');
}
} else {
out.push_str("&&");
i += 2;
}
}
Some(b'>') => {
one_space!();
out.push_str("&>");
i += 2;
}
Some(b'|') | Some(b'!') => {
if paren_depth == 0 && !case_pattern {
one_space!();
}
out.push('&');
out.push(nx.unwrap() as char);
i += 2;
}
_ => {
if paren_depth == 0 && !case_pattern {
one_space!();
}
out.push('&');
i += 1;
}
}
}
b'|' => {
let nx = b.get(i + 1).copied();
if paren_depth == 0 && !case_pattern {
if nx == Some(b'|') {
one_space!();
out.push_str("||");
i += 2;
} else if nx == Some(b'&') {
one_space!();
out.push_str("|&");
i += 2;
} else {
one_space!();
out.push('|');
i += 1;
}
while i < n && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
if i < n {
out.push(' ');
}
} else {
out.push('|');
i += 1;
}
}
b';' => {
let nx = b.get(i + 1).copied();
if matches!(nx, Some(b';') | Some(b'&') | Some(b'|')) {
one_space!();
out.push(';');
out.push(nx.unwrap() as char);
i += 2;
while i < n && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
if i < n {
out.push(' ');
}
case_pattern = true;
} else if paren_depth == 0 {
no_space!();
out.push(';');
i += 1;
while i < n && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
if i < n {
out.push(' ');
}
} else {
out.push(';');
i += 1;
}
}
b'<' | b'>' => {
let start = i;
i += 1;
while i < n && matches!(b[i], b'<' | b'>' | b'&' | b'-' | b'|') {
i += 1;
}
if i > start + 1 && b[i - 1] == b'&' {
while i < n && b[i].is_ascii_digit() {
i += 1;
}
if i < n && b[i] == b'-' {
i += 1;
}
}
out.push_str(&body[start..i]);
}
_ => {
let start = i;
while i < n {
match b[i] {
b' ' | b'\t' | b'\\' | b'\'' | b'"' | b'`' | b'#'
| b'$' | b'(' | b')' | b'&' | b'|' | b';' | b'<'
| b'>' => break,
_ => i += 1,
}
}
if i == start {
out.push(b[i] as char);
i += 1;
continue;
}
let word = &body[start..i];
out.push_str(word);
if word == "case" {
case_kw_seen = true;
} else if word == "in" && case_kw_seen {
case_kw_seen = false;
case_pattern = true;
}
}
}
}
out
}
fn is_word_boundary(b: &[u8], i: usize, len: usize) -> bool {
let before_ok = i == 0 || matches!(b[i - 1], b' ' | b'\t' | b'(' | b'!' | b'{');
let after = i + len;
let after_ok = after >= b.len() || matches!(b[after], b' ' | b'\t');
before_ok && after_ok
}
#[cfg(test)]
mod tests {
use super::*;
fn fmt(s: &str) -> String {
format_source(s, &FmtOptions::default())
}
#[test]
fn nested_blocks_reindent() {
let src = "if true; then\nfor x in a b; do\nprint $x\ndone\nfi\n";
let want = "if true; then\n for x in a b; do\n print $x\n done\nfi\n";
assert_eq!(fmt(src), want);
}
#[test]
fn case_arms_and_bodies() {
let src = "case $x in\na)\nprint a\n;;\n(b|c)\nprint bc\n;;\nesac\n";
let want = "case $x in\n a)\n print a\n ;;\n (b|c)\n print bc\n ;;\nesac\n";
assert_eq!(fmt(src), want);
}
#[test]
fn param_and_expansion_braces_ignored() {
let src = "print ${name:-x} file{1,2}\nprint after\n";
assert_eq!(fmt(src), src);
}
#[test]
fn function_braces() {
let src = "f() {\nprint hi\n}\n";
let want = "f() {\n print hi\n}\n";
assert_eq!(fmt(src), want);
}
#[test]
fn heredoc_verbatim() {
let src = "if true; then\ncat <<EOF\n raw spaces \n\tand tabs\nEOF\nfi\n";
let want = "if true; then\n cat <<EOF\n raw spaces \n\tand tabs\nEOF\nfi\n";
assert_eq!(fmt(src), want);
}
#[test]
fn heredoc_dash_tab_terminator() {
let src = "cat <<-EOF\n\tbody\n\tEOF\nprint after\n";
let want = "cat <<-EOF\n\tbody\n\tEOF\nprint after\n";
assert_eq!(fmt(src), want);
}
#[test]
fn quoted_keywords_inert() {
let src = "print 'if then fi'\nprint \"do done\" # case in\nprint x\n";
assert_eq!(fmt(src), src);
}
#[test]
fn continuation_indents() {
let src = "print one \\\ntwo\nprint three\n";
let want = "print one \\\n two\nprint three\n";
assert_eq!(fmt(src), want);
}
#[test]
fn multiline_cmdsubst_and_array() {
let src = "files=(\na\nb\n)\nprint $files\n";
let want = "files=(\n a\n b\n)\nprint $files\n";
assert_eq!(fmt(src), want);
}
#[test]
fn else_elif_level() {
let src = "if a; then\nb\nelif c; then\nd\nelse\ne\nfi\n";
let want = "if a; then\n b\nelif c; then\n d\nelse\n e\nfi\n";
assert_eq!(fmt(src), want);
}
#[test]
fn trailing_ws_and_final_newline() {
assert_eq!(fmt("print x \n\n\n"), "print x\n");
assert_eq!(fmt("print x"), "print x\n");
}
#[test]
fn idempotent() {
let src = "f() {\nif x; then\ncase $1 in\na) y ;;\nesac\nfi\n}\ncat <<EOF\nkeep\nEOF\n";
let once = fmt(src);
assert_eq!(fmt(&once), once);
}
#[test]
fn tabs_mode() {
let opts = FmtOptions {
indent_width: 4,
use_tabs: true,
};
let got = format_source("if x; then\ny\nfi\n", &opts);
assert_eq!(got, "if x; then\n\ty\nfi\n");
}
#[test]
fn then_do_join_idiomatic() {
let src = "if true\nthen\nprint a\nfi\nfor i in 1 2\ndo\nprint $i\ndone\n";
let want = "if true; then\n print a\nfi\nfor i in 1 2; do\n print $i\ndone\n";
assert_eq!(fmt(src), want);
}
#[test]
fn then_join_multiline_condition_and_blank_gap() {
let src = "if true &&\nfalse\nthen\nx\nfi\nif true\n\nthen\ny\nfi\n";
let want = "if true &&\nfalse; then\n x\nfi\nif true\n\nthen\n y\nfi\n";
assert_eq!(fmt(src), want);
}
#[test]
fn spacing_normalized_idiomatic() {
let src = "print a b;print c\nfoo&&bar||baz\nls -l|wc -l\nsleep 1&\nprint \"two sp\" 'kept sp' ${a:- x}\n";
let want = "print a b; print c\nfoo && bar || baz\nls -l | wc -l\nsleep 1 &\nprint \"two sp\" 'kept sp' ${a:- x}\n";
assert_eq!(fmt(src), want);
}
#[test]
fn funcdef_paren_glue() {
assert_eq!(fmt("f () {\nx\n}\n"), "f() {\n x\n}\n");
}
#[test]
fn operator_guards_hold() {
let src = "print x >/tmp/o 2>&1\nexec 3>&-\ncmd &>/dev/null\nfor ((i=0;i<3;i++)); do\ny\ndone\ncase $x in\na|b) z ;;\nesac\n";
let want = "print x >/tmp/o 2>&1\nexec 3>&-\ncmd &>/dev/null\nfor ((i=0;i<3;i++)); do\n y\ndone\ncase $x in\n a|b) z ;;\nesac\n";
assert_eq!(fmt(src), want);
}
#[test]
fn case_terminator_spacing() {
let src = "case $x in\na) y;;\nb) z ;;\nesac\n";
let want = "case $x in\n a) y ;;\n b) z ;;\nesac\n";
assert_eq!(fmt(src), want);
}
#[test]
fn hash_in_word_and_arith() {
let src = "print ${#arr}\nif (( x > 1 )); then\ny\nfi\n";
let want = "print ${#arr}\nif (( x > 1 )); then\n y\nfi\n";
assert_eq!(fmt(src), want);
}
#[test]
fn multiline_dcond() {
let src = "if [[ -n $a &&\n-n $b ]]; then\nx\nfi\n";
let want = "if [[ -n $a &&\n -n $b ]]; then\n x\nfi\n";
assert_eq!(fmt(src), want);
}
}