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}