Skip to main content

zsh/extensions/
fmt.rs

1//! zsh source formatter — syntax-aware reindenter.
2//!
3//! zshrs extension (no C counterpart: zsh ships no formatter, and
4//! shfmt explicitly does not support the zsh dialect). Exposed as:
5//!   * `zshrs --fmt [-w] [-i N] [-t] [FILE…]` (bins/zshrs.rs)
6//!   * LSP `textDocument/formatting` (src/extensions/lsp.rs), which
7//!     the IntelliJ plugin's `LspFormattingSupport` drives via
8//!     Reformat Code.
9//!
10//! Design contract:
11//!   * Indentation and trailing whitespace are rewritten; INNER
12//!     spacing is normalized to idiomatic zsh — whitespace runs
13//!     between tokens collapse to one space (never joining tokens,
14//!     never deleting a lone separator), and the command operators
15//!     get canonical spacing: `a;b` → `a; b`, `a&&b` → `a && b`,
16//!     `a||b` → `a || b`, `a|b` → `a | b`, `cmd&` → `cmd &`,
17//!     case terminators `x;;` → `x ;;`, funcdef `name ()` →
18//!     `name()`.
19//!   * Token TEXT is never rewritten, and spacing inside every
20//!     semantic-bearing region is preserved byte-for-byte: quoted
21//!     strings (`'…'`, `"…"`, `$'…'`, backticks), `${…}` bodies,
22//!     comments' text, heredoc operators' tags, and redirection fd
23//!     forms (`2>&1`, `&>`, `<&-`). Pattern contexts keep their `|`
24//!     untouched: inside any `( … )` (glob alternation is always
25//!     parenthesized) and in case arm-pattern position.
26//!   * Heredoc bodies (`<<TAG` … `TAG`) pass through verbatim,
27//!     including their terminators.
28//!   * Idempotent: `format(format(x)) == format(x)`.
29//!
30//! The line scanner tracks just enough lexical state to find the
31//! STRUCTURAL tokens that drive indentation: quotes (`'…'`, `"…"`,
32//! `$'…'`), backslash escapes, comments, `${…}` parameter braces
33//! (which must NOT count as block braces), command/subshell parens,
34//! `[[ … ]]`, and the reserved-word pairs if/fi, do/done (for, while,
35//! until, select, repeat), case/esac with pattern-arm handling, and
36//! `{ … }` grouping.
37
38/// Formatting options, mirroring the LSP `FormattingOptions` shape.
39#[derive(Debug, Clone, Copy)]
40pub struct FmtOptions {
41    /// Spaces per indent level (ignored for emission when `use_tabs`).
42    pub indent_width: usize,
43    /// Emit one tab per level instead of spaces.
44    pub use_tabs: bool,
45}
46
47impl Default for FmtOptions {
48    fn default() -> Self {
49        FmtOptions {
50            indent_width: 4,
51            use_tabs: false,
52        }
53    }
54}
55
56/// One open block on the indent stack.
57#[derive(Debug, Clone, Copy, PartialEq)]
58enum Block {
59    /// `if` … `fi`. `open` flips true once `then` is seen.
60    If { open: bool },
61    /// for/while/until/select/repeat … `done`. `open` on `do`.
62    Loop { open: bool },
63    /// `{` … `}` command grouping / function body.
64    Brace,
65    /// `(` … `)` subshell / array literal / `$(…)` / `((…))` halves.
66    Paren,
67    /// `[[ … ]]` conditional (multi-line conditions).
68    DCond,
69    /// `case` … `esac`. `sub` tracks the arm cycle.
70    Case { sub: CaseSub },
71}
72
73#[derive(Debug, Clone, Copy, PartialEq)]
74enum CaseSub {
75    /// Between `in` / `;;` and the next `pattern)` — expecting an arm.
76    Pattern,
77    /// Inside an arm body (after `pattern)`), until `;;` / `;&` / `;|`.
78    Body,
79}
80
81impl Block {
82    /// Whether this block currently indents the lines inside it.
83    fn contributes(&self) -> usize {
84        match self {
85            Block::If { open } | Block::Loop { open } => usize::from(*open),
86            Block::Brace | Block::Paren | Block::DCond => 1,
87            // case body lines sit two deeper than `case` itself
88            // (one for the arm label, one for the body); arm labels
89            // sit one deeper. The Pattern state contributes the arm
90            // level; Body adds the second level.
91            Block::Case { sub } => match sub {
92                CaseSub::Pattern => 1,
93                CaseSub::Body => 2,
94            },
95        }
96    }
97}
98
99/// A heredoc awaiting its terminator.
100#[derive(Debug, Clone)]
101struct Heredoc {
102    tag: String,
103    /// `<<-` — terminator may be preceded by tabs.
104    strip_tabs: bool,
105}
106
107/// Format zsh source: reindent by block structure, strip trailing
108/// whitespace, ensure exactly one trailing newline. See module doc
109/// for the conservation guarantees.
110pub fn format_source(src: &str, opts: &FmtOptions) -> String {
111    let mut out = String::with_capacity(src.len() + 64);
112    let mut stack: Vec<Block> = Vec::new();
113    let mut pending_heredocs: Vec<Heredoc> = Vec::new();
114    let mut active_heredoc: Option<Heredoc> = None;
115    // Previous line ended with an unquoted `\` — indent one extra.
116    let mut continuation = false;
117
118    for line in src.split('\n') {
119        // ── Heredoc body passthrough ────────────────────────────────
120        if let Some(h) = &active_heredoc {
121            let term_candidate = if h.strip_tabs {
122                line.trim_start_matches('\t')
123            } else {
124                line
125            };
126            out.push_str(line);
127            out.push('\n');
128            if term_candidate == h.tag {
129                active_heredoc = pending_heredocs.pop_front_or_none();
130            }
131            continue;
132        }
133
134        let body = line.trim_start();
135        if body.is_empty() {
136            // Blank line: no indent whitespace emitted.
137            out.push('\n');
138            continue;
139        }
140
141        // ── Normalize inner spacing to idiomatic zsh ────────────────
142        // Runs BEFORE the structural scan so heredoc-tag parsing and
143        // indent both see the canonical text. Seed the normalizer
144        // with the line-start context the stack knows about (pattern
145        // `|` and subshell-vs-glob disambiguation).
146        let norm_ctx = NormCtx {
147            paren_depth: stack
148                .iter()
149                .filter(|b| matches!(b, Block::Paren | Block::DCond))
150                .count(),
151            in_case_pattern: matches!(
152                stack.last(),
153                Some(Block::Case {
154                    sub: CaseSub::Pattern
155                })
156            ),
157        };
158        let body = normalize_spacing(body, norm_ctx);
159        let body = body.as_str();
160
161        // ── Idiomatic `; then` / `; do` joining ─────────────────────
162        // All three style authorities agree (zsh's own
163        // Etc/completion-style-guide: "the `then' or `do' should be
164        // on the end of the line after a semi-colon"; OMZ wiki: "Put
165        // `; do` and `; then` on the same line"; corpus counts:
166        // zsh Functions/Completion 4556 `; then` vs 164 bare, zpwr
167        // 636 vs 0). When the line is EXACTLY the keyword and the
168        // immediately preceding emitted line is the still-pending
169        // opener (top of stack If/Loop with open=false, previous
170        // line non-blank, no continuation), splice `; then` / `; do`
171        // onto it instead of emitting a bare-keyword line.
172        if !continuation {
173            let join_kw = match (body, stack.last()) {
174                ("then", Some(Block::If { open: false })) => Some("; then"),
175                ("do", Some(Block::Loop { open: false })) => Some("; do"),
176                _ => None,
177            };
178            if let Some(kw) = join_kw {
179                let prev_nonblank = out.rsplit_once('\n').map(|(_, _last)| ()).is_some()
180                    && !out.ends_with("\n\n")
181                    && out.len() >= 2;
182                if prev_nonblank {
183                    // Mark the block open (the scanner would have).
184                    if let Some(b) = stack.last_mut() {
185                        match b {
186                            Block::If { open } | Block::Loop { open } => *open = true,
187                            _ => {}
188                        }
189                    }
190                    out.pop(); // trailing \n of the opener line
191                    out.push_str(kw);
192                    out.push('\n');
193                    continue;
194                }
195            }
196        }
197
198        // ── Scan the line for structural tokens ─────────────────────
199        let scan = scan_line(body, &mut stack, &mut pending_heredocs);
200
201        // ── Compute this line's indent ──────────────────────────────
202        // `leading_closers` blocks were popped by tokens at the very
203        // start of the line (fi/done/esac/}/)/else/…) — the line
204        // itself prints at the dedented level, which is exactly the
205        // post-pop stack depth plus any re-opened mid-line blocks
206        // counted in `depth_before_line`.
207        let mut depth: usize = scan.indent_basis;
208        if continuation {
209            depth += 1;
210        }
211        let indent = if opts.use_tabs {
212            "\t".repeat(depth)
213        } else {
214            " ".repeat(depth * opts.indent_width)
215        };
216        out.push_str(&indent);
217        out.push_str(body.trim_end());
218        out.push('\n');
219
220        continuation = scan.ends_with_continuation;
221        if !pending_heredocs.is_empty() && active_heredoc.is_none() {
222            active_heredoc = pending_heredocs.pop_front_or_none();
223        }
224    }
225
226    // Exactly one trailing newline.
227    while out.ends_with("\n\n") {
228        out.pop();
229    }
230    if !out.ends_with('\n') {
231        out.push('\n');
232    }
233    // src.split('\n') yields a trailing "" for newline-terminated
234    // input which the blank-line arm turned into one extra \n — the
235    // dedup loop above already collapsed it.
236    out
237}
238
239/// Small helper: Vec-as-FIFO for the heredoc queue.
240trait PopFront<T> {
241    fn pop_front_or_none(&mut self) -> Option<T>;
242}
243impl<T> PopFront<T> for Vec<T> {
244    fn pop_front_or_none(&mut self) -> Option<T> {
245        if self.is_empty() {
246            None
247        } else {
248            Some(self.remove(0))
249        }
250    }
251}
252
253struct LineScan {
254    /// Indent level (in block levels) this line should print at.
255    indent_basis: usize,
256    /// Line ends with an unquoted backslash.
257    ends_with_continuation: bool,
258}
259
260/// Current stack indent = sum of every block's contribution.
261fn stack_indent(stack: &[Block]) -> usize {
262    stack.iter().map(|b| b.contributes()).sum()
263}
264
265/// Scan one (whitespace-trimmed) line: update `stack` with every
266/// structural token, queue heredocs, and report the indent level the
267/// line itself should print at.
268fn scan_line(body: &str, stack: &mut Vec<Block>, heredocs: &mut Vec<Heredoc>) -> LineScan {
269    let b = body.as_bytes();
270    let n = b.len();
271    let mut i = 0usize;
272    // Tokens seen so far on this line (false = still at line start —
273    // leading closers dedent the line itself).
274    let mut seen_token = false;
275    // The indent the line prints at. Starts at the current stack
276    // level and is lowered by leading closers as they pop.
277    let mut indent_basis = stack_indent(stack);
278    let mut ends_with_continuation = false;
279
280    // Word accumulator for reserved-word recognition.
281    macro_rules! at_line_start {
282        () => {
283            !seen_token
284        };
285    }
286
287    while i < n {
288        let c = b[i];
289        match c {
290            b' ' | b'\t' => {
291                i += 1;
292            }
293            b'\\' => {
294                // Escape: skip the next char. A `\` as the LAST char
295                // is a line continuation.
296                if i + 1 >= n {
297                    ends_with_continuation = true;
298                }
299                i += 2;
300                seen_token = true;
301            }
302            b'\'' => {
303                // Single-quoted: skip to closing '.
304                i += 1;
305                while i < n && b[i] != b'\'' {
306                    i += 1;
307                }
308                i += 1;
309                seen_token = true;
310            }
311            b'"' => {
312                // Double-quoted: skip to closing unescaped ". `$(…)`
313                // inside DQ can span structure, but multi-line DQ
314                // strings pass through verbatim anyway (the quote
315                // state is line-local by design: a string that spans
316                // lines leaves the remainder lines untouched at their
317                // original indent only if they parse as body text —
318                // acceptable for a conservative formatter).
319                i += 1;
320                while i < n {
321                    if b[i] == b'\\' {
322                        i += 2;
323                        continue;
324                    }
325                    if b[i] == b'"' {
326                        break;
327                    }
328                    i += 1;
329                }
330                i += 1;
331                seen_token = true;
332            }
333            b'`' => {
334                i += 1;
335                while i < n && b[i] != b'`' {
336                    if b[i] == b'\\' {
337                        i += 1;
338                    }
339                    i += 1;
340                }
341                i += 1;
342                seen_token = true;
343            }
344            b'#' => {
345                // Comment for the rest of the line (we are always at
346                // a word boundary here: the word arm below consumes
347                // `#` inside words like `${#var}` via the `$` arm or
348                // the word scanner).
349                break;
350            }
351            b'$' => {
352                seen_token = true;
353                if i + 1 < n && b[i + 1] == b'\'' {
354                    // $'…' ANSI-C string.
355                    i += 2;
356                    while i < n && b[i] != b'\'' {
357                        if b[i] == b'\\' {
358                            i += 1;
359                        }
360                        i += 1;
361                    }
362                    i += 1;
363                } else if i + 1 < n && b[i + 1] == b'{' {
364                    // ${…} parameter brace — depth-track to its `}`
365                    // so it never counts as a block brace. Nested
366                    // quotes inside are skipped coarsely.
367                    i += 2;
368                    let mut depth = 1usize;
369                    while i < n && depth > 0 {
370                        match b[i] {
371                            b'{' => depth += 1,
372                            b'}' => depth -= 1,
373                            b'\\' => i += 1,
374                            _ => {}
375                        }
376                        i += 1;
377                    }
378                } else {
379                    // `$(`/`$((` fall through to the paren arms below
380                    // on the next iteration; bare `$var` is a word.
381                    i += 1;
382                }
383            }
384            b'{' => {
385                // Block-open brace. zsh brace expansion `{a,b}` is
386                // glued to a word (no preceding whitespace); the
387                // word scanner consumes those inline, so a bare `{`
388                // here is command grouping.
389                stack.push(Block::Brace);
390                seen_token = true;
391                i += 1;
392            }
393            b'}' => {
394                let popped = matches!(stack.last(), Some(Block::Brace));
395                if popped {
396                    stack.pop();
397                    if at_line_start!() {
398                        indent_basis = indent_basis.saturating_sub(1);
399                    }
400                }
401                seen_token = true;
402                i += 1;
403            }
404            b'(' => {
405                // In a case arm-pattern position, `(pat)` parens are
406                // part of the pattern: push nothing, the matching `)`
407                // closes the pattern.
408                if let Some(Block::Case {
409                    sub: CaseSub::Pattern,
410                }) = stack.last()
411                {
412                    // Optional leading `(` of an arm pattern: skip.
413                    i += 1;
414                    seen_token = true;
415                    continue;
416                }
417                stack.push(Block::Paren);
418                seen_token = true;
419                i += 1;
420            }
421            b')' => {
422                match stack.last_mut() {
423                    Some(Block::Case { sub }) if *sub == CaseSub::Pattern => {
424                        // `pattern)` — the arm opens its body.
425                        *sub = CaseSub::Body;
426                        // The arm-label line prints at the Pattern
427                        // contribution level; recompute nothing —
428                        // label was already being printed at the
429                        // pattern level via indent_basis (computed
430                        // before this token executed only if the
431                        // label began the line; for mid-line `)` the
432                        // next lines pick up Body level naturally).
433                    }
434                    Some(Block::Paren) => {
435                        stack.pop();
436                        if at_line_start!() {
437                            indent_basis = indent_basis.saturating_sub(1);
438                        }
439                    }
440                    _ => {}
441                }
442                seen_token = true;
443                i += 1;
444            }
445            b';' => {
446                // `;;` / `;&` / `;|` terminate a case arm body.
447                let two = b.get(i + 1).copied();
448                if matches!(two, Some(b';') | Some(b'&') | Some(b'|')) {
449                    if let Some(Block::Case { sub }) = stack.last_mut() {
450                        if *sub == CaseSub::Body {
451                            *sub = CaseSub::Pattern;
452                            if at_line_start!() {
453                                // `;;` on its own line prints at the
454                                // BODY level (one deeper than the arm
455                                // label) — matches the dominant zsh
456                                // style. indent_basis already holds
457                                // the pre-pop (body) level; keep it.
458                            }
459                        }
460                    }
461                    i += 2;
462                } else {
463                    i += 1;
464                }
465                seen_token = true;
466            }
467            b'<' => {
468                // Heredoc `<<TAG` / `<<-TAG` (but not herestring `<<<`).
469                if i + 1 < n && b[i + 1] == b'<' {
470                    if i + 2 < n && b[i + 2] == b'<' {
471                        i += 3; // <<< herestring
472                    } else {
473                        i += 2;
474                        let strip_tabs = i < n && b[i] == b'-';
475                        if strip_tabs {
476                            i += 1;
477                        }
478                        while i < n && (b[i] == b' ' || b[i] == b'\t') {
479                            i += 1;
480                        }
481                        // Delimiter word, possibly quoted.
482                        let mut tag = String::new();
483                        let mut quote: Option<u8> = None;
484                        while i < n {
485                            let ch = b[i];
486                            match quote {
487                                Some(q) if ch == q => {
488                                    quote = None;
489                                    i += 1;
490                                }
491                                Some(_) => {
492                                    tag.push(ch as char);
493                                    i += 1;
494                                }
495                                None => match ch {
496                                    b'\'' | b'"' => {
497                                        quote = Some(ch);
498                                        i += 1;
499                                    }
500                                    b'\\' => {
501                                        i += 1;
502                                        if i < n {
503                                            tag.push(b[i] as char);
504                                            i += 1;
505                                        }
506                                    }
507                                    b' ' | b'\t' | b';' | b'&' | b'|' | b'<' | b'>' | b'('
508                                    | b')' => break,
509                                    _ => {
510                                        tag.push(ch as char);
511                                        i += 1;
512                                    }
513                                },
514                            }
515                        }
516                        if !tag.is_empty() {
517                            heredocs.push(Heredoc { tag, strip_tabs });
518                        }
519                    }
520                } else {
521                    i += 1;
522                }
523                seen_token = true;
524            }
525            b'>' => {
526                // Redirection operator — structurally inert here, but
527                // it MUST have its own arm: `>` is in the word
528                // scanner's break set, so falling into the word arm
529                // produced a zero-length word and the scan loop never
530                // advanced (infinite loop on `(( x > 1 ))`).
531                i += 1;
532                seen_token = true;
533            }
534            b'[' => {
535                // `[[ … ]]` — push only the DOUBLE form; single `[`
536                // is the test command (word).
537                if i + 1 < n && b[i + 1] == b'[' && is_word_boundary(b, i, 2) {
538                    stack.push(Block::DCond);
539                    i += 2;
540                } else {
541                    i += 1;
542                }
543                seen_token = true;
544            }
545            b']' => {
546                if i + 1 < n && b[i + 1] == b']' && matches!(stack.last(), Some(Block::DCond)) {
547                    stack.pop();
548                    if at_line_start!() {
549                        indent_basis = indent_basis.saturating_sub(1);
550                    }
551                    i += 2;
552                } else {
553                    i += 1;
554                }
555                seen_token = true;
556            }
557            _ => {
558                // Word: consume [^ \t;()<>{}'"`\\#]+ and check the
559                // reserved words that drive indentation. `#` inside a
560                // word (e.g. `a#b`, extendedglob) must not start a
561                // comment — include it in the word.
562                let start = i;
563                while i < n {
564                    match b[i] {
565                        b' ' | b'\t' | b';' | b'(' | b')' | b'<' | b'>' | b'\'' | b'"' | b'`'
566                        | b'\\' => break,
567                        b'{' | b'}' => {
568                            // Brace glued inside a word = brace
569                            // expansion / ${…} tail — consume it as
570                            // word text (depth-track pairs).
571                            i += 1;
572                        }
573                        _ => i += 1,
574                    }
575                }
576                // Defensive forward-progress guarantee: if a byte is
577                // in the word break-set but lacks its own outer arm,
578                // a zero-length word would loop forever. Consume one
579                // byte and move on instead.
580                if i == start {
581                    i += 1;
582                    seen_token = true;
583                    continue;
584                }
585                let word = &body[start..i];
586                let leading = at_line_start!();
587                seen_token = true;
588                match word {
589                    "if" => stack.push(Block::If { open: false }),
590                    "then" => {
591                        if let Some(Block::If { open }) = stack.last_mut() {
592                            if leading && *open {
593                                // bare `then` line after a multi-line
594                                // condition prints at the `if` level.
595                                indent_basis = indent_basis.saturating_sub(1);
596                            }
597                            *open = true;
598                        }
599                    }
600                    "elif" | "else" => {
601                        if leading {
602                            if let Some(Block::If { open: true }) = stack.last() {
603                                indent_basis = indent_basis.saturating_sub(1);
604                            }
605                        }
606                        // elif re-arms the then-cycle; body depth is
607                        // unchanged (If stays open).
608                    }
609                    "fi" => {
610                        if matches!(stack.last(), Some(Block::If { .. })) {
611                            let was_open = matches!(stack.last(), Some(Block::If { open: true }));
612                            stack.pop();
613                            if leading && was_open {
614                                indent_basis = indent_basis.saturating_sub(1);
615                            }
616                        }
617                    }
618                    "for" | "while" | "until" | "select" | "repeat" => {
619                        // `while` also appears as `do … done` driver in
620                        // `repeat N; do`; all push a Loop.
621                        stack.push(Block::Loop { open: false });
622                    }
623                    "do" => {
624                        if let Some(Block::Loop { open }) = stack.last_mut() {
625                            if leading && *open {
626                                indent_basis = indent_basis.saturating_sub(1);
627                            }
628                            *open = true;
629                        }
630                    }
631                    "done" => {
632                        if matches!(stack.last(), Some(Block::Loop { .. })) {
633                            let was_open = matches!(stack.last(), Some(Block::Loop { open: true }));
634                            stack.pop();
635                            if leading && was_open {
636                                indent_basis = indent_basis.saturating_sub(1);
637                            }
638                        }
639                    }
640                    "case" => stack.push(Block::Case {
641                        sub: CaseSub::Pattern,
642                    }),
643                    "esac" => {
644                        if let Some(Block::Case { sub }) = stack.last() {
645                            let contrib = Block::Case { sub: *sub }.contributes();
646                            stack.pop();
647                            if leading {
648                                indent_basis = indent_basis.saturating_sub(contrib);
649                            }
650                        }
651                    }
652                    _ => {}
653                }
654            }
655        }
656    }
657
658    LineScan {
659        indent_basis,
660        ends_with_continuation,
661    }
662}
663
664/// Line-start context for [`normalize_spacing`], seeded from the
665/// indent stack so multi-line constructs keep their guards.
666#[derive(Debug, Clone, Copy)]
667struct NormCtx {
668    /// Open `( … )` / `[[ … ]]` levels at line start — inside any
669    /// paren level `|` may be glob alternation, `;` may be a
670    /// `(( ; ; ))` separator: operator spacing is left alone.
671    paren_depth: usize,
672    /// Stack top is a case arm-pattern position — `|` is pattern
673    /// alternation, never a pipe.
674    in_case_pattern: bool,
675}
676
677/// Normalize inner spacing on one (indent-trimmed) line to idiomatic
678/// zsh. Token text is never rewritten; protected regions (quotes,
679/// `${…}`, comments, fd-redirections) pass through verbatim. The
680/// transform set:
681///   * whitespace runs between tokens → one space (never joins
682///     tokens, never deletes a lone separator),
683///   * `a ; b` / `a ;b` / `a;b` → `a; b` (no space before, one
684///     after) — skipped inside parens (`(( i=0; i<n; i++ ))`),
685///   * `x;;` / `x ;; ` → `x ;;` (one space before, case style),
686///     likewise `;&` / `;|`,
687///   * `&&` / `||` → one space each side (also inside `[[ … ]]`;
688///     skipped in paren/pattern contexts where `||` can be glob
689///     alternation),
690///   * `|` / `|&` at top level outside case patterns → one space
691///     each side,
692///   * background `&` (not `&&`, not fd forms `>&` `<&` `&>`, not
693///     `&|`/`&!` which keep one space before as a unit) → one space
694///     before,
695///   * funcdef `name ()` → `name()`.
696fn normalize_spacing(body: &str, ctx: NormCtx) -> String {
697    let b = body.as_bytes();
698    let n = b.len();
699    let mut out = String::with_capacity(n + 8);
700    let mut i = 0usize;
701    let mut paren_depth = ctx.paren_depth;
702    let mut case_pattern = ctx.in_case_pattern;
703    // Set when we saw `case` on this line and expect `in` → patterns.
704    let mut case_kw_seen = false;
705
706    // Emit exactly one space, collapsing whatever `out` already ends
707    // with; no-op at line start.
708    macro_rules! one_space {
709        () => {
710            while out.ends_with(' ') || out.ends_with('\t') {
711                out.pop();
712            }
713            if !out.is_empty() {
714                out.push(' ');
715            }
716        };
717    }
718    // Strip any spacing `out` currently ends with (glue next token).
719    macro_rules! no_space {
720        () => {
721            while out.ends_with(' ') || out.ends_with('\t') {
722                out.pop();
723            }
724        };
725    }
726
727    while i < n {
728        let c = b[i];
729        match c {
730            b' ' | b'\t' => {
731                // Whitespace run → single space (the operator arms
732                // below may re-trim it).
733                while i < n && (b[i] == b' ' || b[i] == b'\t') {
734                    i += 1;
735                }
736                if !out.is_empty() && i < n {
737                    out.push(' ');
738                }
739            }
740            b'\\' => {
741                // Escape: verbatim with its target (or trailing `\`).
742                out.push('\\');
743                if i + 1 < n {
744                    out.push(b[i + 1] as char);
745                }
746                i += 2;
747            }
748            b'\'' => {
749                let start = i;
750                i += 1;
751                while i < n && b[i] != b'\'' {
752                    i += 1;
753                }
754                i = (i + 1).min(n);
755                out.push_str(&body[start..i]);
756            }
757            b'"' => {
758                let start = i;
759                i += 1;
760                while i < n {
761                    if b[i] == b'\\' {
762                        i += 2;
763                        continue;
764                    }
765                    if b[i] == b'"' {
766                        break;
767                    }
768                    i += 1;
769                }
770                i = (i + 1).min(n);
771                out.push_str(&body[start..i]);
772            }
773            b'`' => {
774                let start = i;
775                i += 1;
776                while i < n && b[i] != b'`' {
777                    if b[i] == b'\\' {
778                        i += 1;
779                    }
780                    i += 1;
781                }
782                i = (i + 1).min(n);
783                out.push_str(&body[start..i]);
784            }
785            b'#' => {
786                // Comment: one space before (when not at line start),
787                // text verbatim.
788                one_space!();
789                out.push_str(&body[i..]);
790                i = n;
791            }
792            b'$' => {
793                if i + 1 < n && b[i + 1] == b'\'' {
794                    let start = i;
795                    i += 2;
796                    while i < n && b[i] != b'\'' {
797                        if b[i] == b'\\' {
798                            i += 1;
799                        }
800                        i += 1;
801                    }
802                    i = (i + 1).min(n);
803                    out.push_str(&body[start..i]);
804                } else if i + 1 < n && b[i + 1] == b'{' {
805                    let start = i;
806                    i += 2;
807                    let mut depth = 1usize;
808                    while i < n && depth > 0 {
809                        match b[i] {
810                            b'{' => depth += 1,
811                            b'}' => depth -= 1,
812                            b'\\' => i += 1,
813                            _ => {}
814                        }
815                        i += 1;
816                    }
817                    out.push_str(&body[start..i]);
818                } else {
819                    out.push('$');
820                    i += 1;
821                }
822            }
823            b'(' => {
824                // Funcdef glue: `name ()` → `name()`. Only when the
825                // pair is EMPTY and follows a word char.
826                let mut j = i + 1;
827                while j < n && (b[j] == b' ' || b[j] == b'\t') {
828                    j += 1;
829                }
830                let empty_pair = j < n && b[j] == b')';
831                let after_word = out
832                    .trim_end()
833                    .chars()
834                    .last()
835                    .map(|ch| {
836                        ch.is_alphanumeric() || ch == '_' || ch == '.' || ch == ':' || ch == '-'
837                    })
838                    .unwrap_or(false);
839                if empty_pair && after_word {
840                    no_space!();
841                    out.push_str("()");
842                    i = j + 1;
843                    continue;
844                }
845                paren_depth += 1;
846                out.push('(');
847                i += 1;
848            }
849            b')' => {
850                paren_depth = paren_depth.saturating_sub(1);
851                if case_pattern {
852                    // `pat)` — pattern ends; body follows.
853                    case_pattern = false;
854                }
855                out.push(')');
856                i += 1;
857            }
858            b'&' => {
859                let nx = b.get(i + 1).copied();
860                match nx {
861                    Some(b'&') => {
862                        // `&&` — one space each side, except in
863                        // paren/pattern contexts (glob `(a&&b)` is
864                        // nonsense but cheap to guard anyway).
865                        if paren_depth == 0 && !case_pattern {
866                            one_space!();
867                            out.push_str("&&");
868                            i += 2;
869                            while i < n && (b[i] == b' ' || b[i] == b'\t') {
870                                i += 1;
871                            }
872                            if i < n {
873                                out.push(' ');
874                            }
875                        } else {
876                            out.push_str("&&");
877                            i += 2;
878                        }
879                    }
880                    Some(b'>') => {
881                        // `&>` / `&>>` redirection unit: one space
882                        // before, glued to what follows.
883                        one_space!();
884                        out.push_str("&>");
885                        i += 2;
886                    }
887                    Some(b'|') | Some(b'!') => {
888                        // `&|` / `&!` — background + disown.
889                        if paren_depth == 0 && !case_pattern {
890                            one_space!();
891                        }
892                        out.push('&');
893                        out.push(nx.unwrap() as char);
894                        i += 2;
895                    }
896                    _ => {
897                        // Bare `&`: background — one space before,
898                        // UNLESS it's an fd target glue (`>&`, `<&`
899                        // already consumed by the `<`/`>` arms; a
900                        // digit-fd form like `2>&1` arrives here only
901                        // via the `>` arm, never as bare `&`).
902                        if paren_depth == 0 && !case_pattern {
903                            one_space!();
904                        }
905                        out.push('&');
906                        i += 1;
907                    }
908                }
909            }
910            b'|' => {
911                let nx = b.get(i + 1).copied();
912                if paren_depth == 0 && !case_pattern {
913                    if nx == Some(b'|') {
914                        one_space!();
915                        out.push_str("||");
916                        i += 2;
917                    } else if nx == Some(b'&') {
918                        one_space!();
919                        out.push_str("|&");
920                        i += 2;
921                    } else {
922                        one_space!();
923                        out.push('|');
924                        i += 1;
925                    }
926                    while i < n && (b[i] == b' ' || b[i] == b'\t') {
927                        i += 1;
928                    }
929                    if i < n {
930                        out.push(' ');
931                    }
932                } else {
933                    out.push('|');
934                    i += 1;
935                }
936            }
937            b';' => {
938                let nx = b.get(i + 1).copied();
939                if matches!(nx, Some(b';') | Some(b'&') | Some(b'|')) {
940                    // Case terminator `;;` / `;&` / `;|` — one space
941                    // before (idiomatic `cmd ;;`), one after if more
942                    // text follows.
943                    one_space!();
944                    out.push(';');
945                    out.push(nx.unwrap() as char);
946                    i += 2;
947                    while i < n && (b[i] == b' ' || b[i] == b'\t') {
948                        i += 1;
949                    }
950                    if i < n {
951                        out.push(' ');
952                    }
953                    case_pattern = true;
954                } else if paren_depth == 0 {
955                    // Command separator: glue left, one space right.
956                    no_space!();
957                    out.push(';');
958                    i += 1;
959                    while i < n && (b[i] == b' ' || b[i] == b'\t') {
960                        i += 1;
961                    }
962                    if i < n {
963                        out.push(' ');
964                    }
965                } else {
966                    // Inside `( … )` / `(( … ))` — e.g. the C-style
967                    // for header — leave untouched.
968                    out.push(';');
969                    i += 1;
970                }
971            }
972            b'<' | b'>' => {
973                // Redirection cluster: consume the full operator
974                // (`<`, `<<`, `<<-`, `<<<`, `>`, `>>`, `>&`, `<&`,
975                // `>>&`, `>|`) verbatim so its glued fd targets
976                // (`2>&1`, `<&-`) are never spaced.
977                let start = i;
978                i += 1;
979                while i < n && matches!(b[i], b'<' | b'>' | b'&' | b'-' | b'|') {
980                    i += 1;
981                }
982                // Glue trailing fd digit of `>&1` / `<&0` forms.
983                if i > start + 1 && b[i - 1] == b'&' {
984                    while i < n && b[i].is_ascii_digit() {
985                        i += 1;
986                    }
987                    if i < n && b[i] == b'-' {
988                        i += 1;
989                    }
990                }
991                out.push_str(&body[start..i]);
992            }
993            _ => {
994                // Word chunk: copy verbatim up to the next byte any
995                // arm above handles. `{`/`}`/`[`/`]` stay word-glued.
996                let start = i;
997                while i < n {
998                    match b[i] {
999                        b' ' | b'\t' | b'\\' | b'\'' | b'"' | b'`' | b'#' | b'$' | b'(' | b')'
1000                        | b'&' | b'|' | b';' | b'<' | b'>' => break,
1001                        _ => i += 1,
1002                    }
1003                }
1004                if i == start {
1005                    out.push(b[i] as char);
1006                    i += 1;
1007                    continue;
1008                }
1009                let word = &body[start..i];
1010                out.push_str(word);
1011                if word == "case" {
1012                    case_kw_seen = true;
1013                } else if word == "in" && case_kw_seen {
1014                    case_kw_seen = false;
1015                    case_pattern = true;
1016                }
1017            }
1018        }
1019    }
1020    out
1021}
1022
1023/// True when the token starting at `i` with byte length `len` is
1024/// delimited by whitespace / line boundaries on both sides.
1025fn is_word_boundary(b: &[u8], i: usize, len: usize) -> bool {
1026    let before_ok = i == 0 || matches!(b[i - 1], b' ' | b'\t' | b'(' | b'!' | b'{');
1027    let after = i + len;
1028    let after_ok = after >= b.len() || matches!(b[after], b' ' | b'\t');
1029    before_ok && after_ok
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035
1036    fn fmt(s: &str) -> String {
1037        format_source(s, &FmtOptions::default())
1038    }
1039
1040    /// Indentation by block structure: if/for/case nesting.
1041    #[test]
1042    fn nested_blocks_reindent() {
1043        let src = "if true; then\nfor x in a b; do\nprint $x\ndone\nfi\n";
1044        let want = "if true; then\n    for x in a b; do\n        print $x\n    done\nfi\n";
1045        assert_eq!(fmt(src), want);
1046    }
1047
1048    /// case arms at +1, bodies at +2, `;;` at body level, `esac` at
1049    /// case level. Arm-pattern `)` must not pop a paren block.
1050    #[test]
1051    fn case_arms_and_bodies() {
1052        let src = "case $x in\na)\nprint a\n;;\n(b|c)\nprint bc\n;;\nesac\n";
1053        let want = "case $x in\n    a)\n        print a\n        ;;\n    (b|c)\n        print bc\n        ;;\nesac\n";
1054        assert_eq!(fmt(src), want);
1055    }
1056
1057    /// `${…}` braces never open a block; `{a,b}` brace expansion glued
1058    /// to a word never opens a block.
1059    #[test]
1060    fn param_and_expansion_braces_ignored() {
1061        let src = "print ${name:-x} file{1,2}\nprint after\n";
1062        assert_eq!(fmt(src), src);
1063    }
1064
1065    /// Function bodies via `{ … }` indent; closing `}` dedents.
1066    #[test]
1067    fn function_braces() {
1068        let src = "f() {\nprint hi\n}\n";
1069        let want = "f() {\n    print hi\n}\n";
1070        assert_eq!(fmt(src), want);
1071    }
1072
1073    /// Heredoc bodies and terminators pass through verbatim — no
1074    /// reindent, no trailing-space strip inside.
1075    #[test]
1076    fn heredoc_verbatim() {
1077        let src = "if true; then\ncat <<EOF\n  raw  spaces  \n\tand tabs\nEOF\nfi\n";
1078        let want = "if true; then\n    cat <<EOF\n  raw  spaces  \n\tand tabs\nEOF\nfi\n";
1079        assert_eq!(fmt(src), want);
1080    }
1081
1082    /// `<<-TAG` terminator with leading tabs is recognized; body kept.
1083    #[test]
1084    fn heredoc_dash_tab_terminator() {
1085        let src = "cat <<-EOF\n\tbody\n\tEOF\nprint after\n";
1086        let want = "cat <<-EOF\n\tbody\n\tEOF\nprint after\n";
1087        assert_eq!(fmt(src), want);
1088    }
1089
1090    /// Keywords inside quotes/comments are inert.
1091    #[test]
1092    fn quoted_keywords_inert() {
1093        let src = "print 'if then fi'\nprint \"do done\" # case in\nprint x\n";
1094        assert_eq!(fmt(src), src);
1095    }
1096
1097    /// Line continuations indent one extra level.
1098    #[test]
1099    fn continuation_indents() {
1100        let src = "print one \\\ntwo\nprint three\n";
1101        let want = "print one \\\n    two\nprint three\n";
1102        assert_eq!(fmt(src), want);
1103    }
1104
1105    /// Multi-line `$( … )` and array literals indent inside parens.
1106    #[test]
1107    fn multiline_cmdsubst_and_array() {
1108        let src = "files=(\na\nb\n)\nprint $files\n";
1109        let want = "files=(\n    a\n    b\n)\nprint $files\n";
1110        assert_eq!(fmt(src), want);
1111    }
1112
1113    /// else / elif dedent to the `if` level.
1114    #[test]
1115    fn else_elif_level() {
1116        let src = "if a; then\nb\nelif c; then\nd\nelse\ne\nfi\n";
1117        let want = "if a; then\n    b\nelif c; then\n    d\nelse\n    e\nfi\n";
1118        assert_eq!(fmt(src), want);
1119    }
1120
1121    /// Trailing whitespace stripped; exactly one final newline.
1122    #[test]
1123    fn trailing_ws_and_final_newline() {
1124        assert_eq!(fmt("print x   \n\n\n"), "print x\n");
1125        assert_eq!(fmt("print x"), "print x\n");
1126    }
1127
1128    /// Idempotence on a representative composite.
1129    #[test]
1130    fn idempotent() {
1131        let src = "f() {\nif x; then\ncase $1 in\na) y ;;\nesac\nfi\n}\ncat <<EOF\nkeep\nEOF\n";
1132        let once = fmt(src);
1133        assert_eq!(fmt(&once), once);
1134    }
1135
1136    /// Tabs mode emits one tab per level.
1137    #[test]
1138    fn tabs_mode() {
1139        let opts = FmtOptions {
1140            indent_width: 4,
1141            use_tabs: true,
1142        };
1143        let got = format_source("if x; then\ny\nfi\n", &opts);
1144        assert_eq!(got, "if x; then\n\ty\nfi\n");
1145    }
1146
1147    /// Bare `then` / `do` lines join onto their opener as `; then` /
1148    /// `; do` — zsh Etc/completion-style-guide + OMZ rule, 96-100%
1149    /// of both corpora (zsh Functions/Completion, zpwr).
1150    #[test]
1151    fn then_do_join_idiomatic() {
1152        let src = "if true\nthen\nprint a\nfi\nfor i in 1 2\ndo\nprint $i\ndone\n";
1153        let want = "if true; then\n    print a\nfi\nfor i in 1 2; do\n    print $i\ndone\n";
1154        assert_eq!(fmt(src), want);
1155    }
1156
1157    /// The join lands on the LAST condition line of a multi-line
1158    /// condition, and a blank-line gap suppresses the join.
1159    #[test]
1160    fn then_join_multiline_condition_and_blank_gap() {
1161        let src = "if true &&\nfalse\nthen\nx\nfi\nif true\n\nthen\ny\nfi\n";
1162        let want = "if true &&\nfalse; then\n    x\nfi\nif true\n\nthen\n    y\nfi\n";
1163        assert_eq!(fmt(src), want);
1164    }
1165
1166    /// Inner spacing: runs dedupe to one space; `;` glues left with
1167    /// one space right; `&&`/`||`/`|` get canonical spacing;
1168    /// background `&` gets a space; quotes and `${…}` keep their
1169    /// bytes.
1170    #[test]
1171    fn spacing_normalized_idiomatic() {
1172        let src = "print  a   b;print c\nfoo&&bar||baz\nls -l|wc -l\nsleep 1&\nprint \"two  sp\" 'kept  sp' ${a:-  x}\n";
1173        let want = "print a b; print c\nfoo && bar || baz\nls -l | wc -l\nsleep 1 &\nprint \"two  sp\" 'kept  sp' ${a:-  x}\n";
1174        assert_eq!(fmt(src), want);
1175    }
1176
1177    /// Funcdef `name ()` glues to `name()`; an empty subshell-ish
1178    /// pair after a non-word char is left alone.
1179    #[test]
1180    fn funcdef_paren_glue() {
1181        assert_eq!(fmt("f ()  {\nx\n}\n"), "f() {\n    x\n}\n");
1182    }
1183
1184    /// Redirection fd forms are never spaced: `2>&1`, `>&-`, `<&0`,
1185    /// `&>`; the C-style `for (( ; ; ))` semicolons and case-pattern
1186    /// `a|b` alternation stay untouched.
1187    #[test]
1188    fn operator_guards_hold() {
1189        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";
1190        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";
1191        assert_eq!(fmt(src), want);
1192    }
1193
1194    /// `;;` gets the idiomatic single space before it when trailing a
1195    /// command, and one space after when text follows.
1196    #[test]
1197    fn case_terminator_spacing() {
1198        let src = "case $x in\na) y;;\nb) z ;;\nesac\n";
1199        let want = "case $x in\n    a) y ;;\n    b) z ;;\nesac\n";
1200        assert_eq!(fmt(src), want);
1201    }
1202
1203    /// extendedglob `#` inside a word doesn't start a comment and
1204    /// `(( … ))` arithmetic stays balanced.
1205    #[test]
1206    fn hash_in_word_and_arith() {
1207        let src = "print ${#arr}\nif (( x > 1 )); then\ny\nfi\n";
1208        let want = "print ${#arr}\nif (( x > 1 )); then\n    y\nfi\n";
1209        assert_eq!(fmt(src), want);
1210    }
1211
1212    /// Multi-line [[ … ]] conditions indent their continuation.
1213    #[test]
1214    fn multiline_dcond() {
1215        let src = "if [[ -n $a &&\n-n $b ]]; then\nx\nfi\n";
1216        let want = "if [[ -n $a &&\n    -n $b ]]; then\n    x\nfi\n";
1217        assert_eq!(fmt(src), want);
1218    }
1219}