@tishlang/tish 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,44 +1,177 @@
1
1
  //! Pretty-print Tish AST to source. Style: 2-space indent, braces for blocks, trailing newline.
2
2
 
3
+ use std::collections::HashMap;
4
+
3
5
  use tishlang_ast::{
4
6
  ArrayElement, ArrowBody, BinOp, CallArg, CompoundOp, DestructElement, DestructPattern,
5
7
  ExportDeclaration, Expr, FunParam, ImportSpecifier, JsxAttrValue, JsxChild, JsxProp, Literal,
6
- LogicalAssignOp, MemberProp, ObjectProp, Program, Statement, TypeAnnotation, TypedParam,
8
+ LogicalAssignOp, MemberProp, ObjectProp, Program, Span, Statement, TypeAnnotation, TypedParam,
7
9
  UnaryOp,
8
10
  };
9
11
 
12
+ /// A comment recovered from source by [`scan_comments`]. The lexer discards
13
+ /// comments, so the parsed AST has none; the formatter re-inserts these by source
14
+ /// position so they survive a format pass.
15
+ #[derive(Clone)]
16
+ struct CommentTok {
17
+ /// 1-based (line, col) of the opening `/`, matching `ast::Span`.
18
+ start: (usize, usize),
19
+ /// Verbatim comment text, including the `//` or `/* */` delimiters.
20
+ text: String,
21
+ /// True when only whitespace precedes the comment on its line (a leading,
22
+ /// own-line comment); false for a trailing `code // note` comment.
23
+ own_line: bool,
24
+ }
25
+
26
+ /// Maps a `{`'s 1-based (line, col) to its matching `}`'s (line, col). Used to
27
+ /// bound a block's dangling-comment flush to the real closing brace, since the
28
+ /// parser's `Block` `span.end` overshoots to the next token.
29
+ type BraceMap = HashMap<(usize, usize), (usize, usize)>;
30
+
10
31
  /// Format Tish source. On parse error, returns the parser message.
11
32
  pub fn format_source(source: &str) -> Result<String, String> {
12
33
  let program = tishlang_parser::parse(source)?;
13
- Ok(format_program(&program))
34
+ let (comments, braces, bracket_spans) = scan_comments(source);
35
+ Ok(format_with_comments(
36
+ &program,
37
+ comments,
38
+ blank_line_map(source),
39
+ braces,
40
+ bracket_spans,
41
+ source,
42
+ ))
14
43
  }
15
44
 
45
+ /// Format an already-parsed program. Comments and blank lines are unavailable
46
+ /// here (the AST carries neither), so the output is comment-free and dense and
47
+ /// `// tish-fmt-ignore` has no effect; use [`format_source`] when the original
48
+ /// text is available.
16
49
  pub fn format_program(program: &Program) -> String {
17
- let mut p = Printer::new();
18
- for (i, s) in program.statements.iter().enumerate() {
19
- if i > 0 {
20
- p.buf.push('\n');
21
- }
22
- p.stmt(s, 0);
23
- if !matches!(s, Statement::Import { .. } | Statement::Export { .. }) {
24
- p.buf.push('\n');
25
- }
26
- }
27
- if !p.buf.ends_with('\n') {
28
- p.buf.push('\n');
50
+ format_with_comments(
51
+ program,
52
+ Vec::new(),
53
+ Vec::new(),
54
+ BraceMap::new(),
55
+ Vec::new(),
56
+ "",
57
+ )
58
+ }
59
+
60
+ fn format_with_comments(
61
+ program: &Program,
62
+ comments: Vec<CommentTok>,
63
+ blank_lines: Vec<bool>,
64
+ braces: BraceMap,
65
+ bracket_spans: Vec<(usize, usize)>,
66
+ source: &str,
67
+ ) -> String {
68
+ let mut p = Printer::new(comments, blank_lines, braces, bracket_spans, source);
69
+ p.print_seq(&program.statements, 0);
70
+ // Comments after the last statement (trailing file comments).
71
+ p.emit_leading_comments((usize::MAX, usize::MAX), 0);
72
+ // Exactly one trailing newline.
73
+ while p.buf.ends_with('\n') {
74
+ p.buf.pop();
29
75
  }
76
+ p.buf.push('\n');
30
77
  p.buf
31
78
  }
32
79
 
80
+ /// `out[line]` is true when 1-based source `line` is blank (empty or whitespace
81
+ /// only). Index 0 is unused so callers can index by 1-based line number.
82
+ fn blank_line_map(source: &str) -> Vec<bool> {
83
+ let mut v = vec![false];
84
+ v.extend(source.lines().map(|l| l.trim().is_empty()));
85
+ v
86
+ }
87
+
33
88
  struct Printer {
34
89
  buf: String,
90
+ /// Comments in source order; `ci` is the next one not yet emitted.
91
+ comments: Vec<CommentTok>,
92
+ ci: usize,
93
+ /// 1-indexed: `blank_lines[n]` is true when source line `n` is blank. Empty
94
+ /// when no source is available (`format_program`).
95
+ blank_lines: Vec<bool>,
96
+ /// `{`→`}` position map for bounding block dangling-comment flushes.
97
+ braces: BraceMap,
98
+ /// Nonzero once anything has been emitted in the current sequence; used only
99
+ /// to suppress a leading blank line at the very start of a file or block.
100
+ emitted: usize,
101
+ /// Current structural indentation level for expression layout — set to the
102
+ /// enclosing statement's level and bumped as broken containers nest. Continuation
103
+ /// lines of a broken object/array/argument-list indent to `depth + 1`.
104
+ depth: usize,
105
+ /// When set, containers render on one line regardless of width. Used to measure
106
+ /// a container's flat width before deciding whether to break it.
107
+ force_flat: bool,
108
+ /// Source as chars, plus 1-based line → starting char-index, for slicing the
109
+ /// original text of a `// tish-fmt-ignore`-d statement verbatim. Empty when no
110
+ /// source is available (`format_program`).
111
+ src: Vec<char>,
112
+ line_start: Vec<usize>,
113
+ /// Set by [`Printer::emit_leading_comments`] when the comment directly above the
114
+ /// next statement is `// tish-fmt-ignore`; consumed by [`Printer::print_seq`].
115
+ ignore_next: bool,
116
+ /// `(open_line, close_line)` for every bracket pair, used to bound an ignored
117
+ /// statement's verbatim slice to its own full extent.
118
+ bracket_spans: Vec<(usize, usize)>,
35
119
  }
36
120
 
121
+ /// Target line width: objects/arrays/argument lists that fit within this stay on
122
+ /// one line; longer ones break one item per line.
123
+ const WIDTH: usize = 100;
124
+
125
+ /// The escape-hatch marker (à la Prettier's `// prettier-ignore`): a comment with
126
+ /// exactly this content leaves the next statement's original source untouched.
127
+ const IGNORE_MARKER: &str = "tish-fmt-ignore";
128
+
37
129
  impl Printer {
38
- fn new() -> Self {
130
+ fn new(
131
+ comments: Vec<CommentTok>,
132
+ blank_lines: Vec<bool>,
133
+ braces: BraceMap,
134
+ bracket_spans: Vec<(usize, usize)>,
135
+ source: &str,
136
+ ) -> Self {
137
+ let src: Vec<char> = source.chars().collect();
138
+ // line_start[L] = char index where 1-based line L begins (index 0 unused).
139
+ let mut line_start = vec![0usize, 0usize];
140
+ for (i, &c) in src.iter().enumerate() {
141
+ if c == '\n' {
142
+ line_start.push(i + 1);
143
+ }
144
+ }
39
145
  Self {
40
146
  buf: String::with_capacity(4096),
147
+ comments,
148
+ ci: 0,
149
+ blank_lines,
150
+ braces,
151
+ emitted: 0,
152
+ depth: 0,
153
+ force_flat: false,
154
+ src,
155
+ line_start,
156
+ ignore_next: false,
157
+ bracket_spans,
158
+ }
159
+ }
160
+
161
+ /// Char index of a 1-based (line, col) position, clamped to the source length.
162
+ fn char_pos(&self, (line, col): (usize, usize)) -> usize {
163
+ if line >= self.line_start.len() {
164
+ return self.src.len();
41
165
  }
166
+ (self.line_start[line] + col - 1).min(self.src.len())
167
+ }
168
+
169
+ /// The original source spanning `[from, to)`, with trailing whitespace trimmed.
170
+ fn verbatim(&self, from: (usize, usize), to: (usize, usize)) -> String {
171
+ let a = self.char_pos(from);
172
+ let b = self.char_pos(to).max(a);
173
+ let s: String = self.src[a..b].iter().collect();
174
+ s.trim_end().to_string()
42
175
  }
43
176
 
44
177
  fn indent(&mut self, level: usize) {
@@ -47,18 +180,334 @@ impl Printer {
47
180
  }
48
181
  }
49
182
 
50
- fn stmt(&mut self, s: &Statement, level: usize) {
51
- match s {
52
- Statement::Block { statements, .. } => {
53
- self.indent(level);
54
- self.buf.push_str("{\n");
55
- for st in statements {
56
- self.stmt(st, level + 1);
57
- self.buf.push('\n');
183
+ /// Current column (chars since the last newline) — the start position for the
184
+ /// next thing to be printed.
185
+ fn col(&self) -> usize {
186
+ let line_start = self.buf.rfind('\n').map(|i| i + 1).unwrap_or(0);
187
+ self.buf[line_start..].chars().count()
188
+ }
189
+
190
+ /// Render a container flat into `buf`; if it fits in the remaining width keep it,
191
+ /// otherwise roll back and render the broken form. While measuring (and inside an
192
+ /// already-flat context) nested containers stay inline too, so the measured width
193
+ /// is the true single-line length. This is the layout decision in miniature — the
194
+ /// `fits` check of a Wadler/Prettier-style pretty-printer, done by rendering.
195
+ fn fit(&mut self, inline: impl Fn(&mut Self), broken: impl Fn(&mut Self)) {
196
+ if self.force_flat {
197
+ inline(self);
198
+ return;
199
+ }
200
+ let mark = self.buf.len();
201
+ let col = self.col();
202
+ self.force_flat = true;
203
+ inline(self);
204
+ self.force_flat = false;
205
+ if col + (self.buf.len() - mark) <= WIDTH {
206
+ return;
207
+ }
208
+ self.buf.truncate(mark);
209
+ broken(self);
210
+ }
211
+
212
+ // ---- Width-aware containers: inline when they fit, else one item per line. ----
213
+
214
+ fn emit_object(&mut self, props: &[ObjectProp]) {
215
+ if props.is_empty() {
216
+ self.buf.push_str("{}");
217
+ return;
218
+ }
219
+ self.fit(|s| s.object_inline(props), |s| s.object_broken(props));
220
+ }
221
+
222
+ fn object_inline(&mut self, props: &[ObjectProp]) {
223
+ self.buf.push_str("{ ");
224
+ for (i, pr) in props.iter().enumerate() {
225
+ if i > 0 {
226
+ self.buf.push_str(", ");
227
+ }
228
+ self.object_prop(pr);
229
+ }
230
+ self.buf.push_str(" }");
231
+ }
232
+
233
+ fn object_broken(&mut self, props: &[ObjectProp]) {
234
+ self.buf.push_str("{\n");
235
+ self.depth += 1;
236
+ for (i, pr) in props.iter().enumerate() {
237
+ if i > 0 {
238
+ self.buf.push_str(",\n");
239
+ }
240
+ self.indent(self.depth);
241
+ self.object_prop(pr);
242
+ }
243
+ self.depth -= 1;
244
+ self.buf.push('\n');
245
+ self.indent(self.depth);
246
+ self.buf.push('}');
247
+ }
248
+
249
+ fn object_prop(&mut self, pr: &ObjectProp) {
250
+ match pr {
251
+ ObjectProp::KeyValue(k, v) => {
252
+ self.buf.push_str(k.as_ref());
253
+ self.buf.push_str(": ");
254
+ self.expr(v);
255
+ }
256
+ ObjectProp::Spread(ex) => {
257
+ self.buf.push_str("...");
258
+ self.expr(ex);
259
+ }
260
+ }
261
+ }
262
+
263
+ fn emit_array(&mut self, elems: &[ArrayElement]) {
264
+ if elems.is_empty() {
265
+ self.buf.push_str("[]");
266
+ return;
267
+ }
268
+ self.fit(|s| s.array_inline(elems), |s| s.array_broken(elems));
269
+ }
270
+
271
+ fn array_inline(&mut self, elems: &[ArrayElement]) {
272
+ self.buf.push('[');
273
+ for (i, el) in elems.iter().enumerate() {
274
+ if i > 0 {
275
+ self.buf.push_str(", ");
276
+ }
277
+ self.array_elem(el);
278
+ }
279
+ self.buf.push(']');
280
+ }
281
+
282
+ fn array_broken(&mut self, elems: &[ArrayElement]) {
283
+ self.buf.push_str("[\n");
284
+ self.depth += 1;
285
+ for (i, el) in elems.iter().enumerate() {
286
+ if i > 0 {
287
+ self.buf.push_str(",\n");
288
+ }
289
+ self.indent(self.depth);
290
+ self.array_elem(el);
291
+ }
292
+ self.depth -= 1;
293
+ self.buf.push('\n');
294
+ self.indent(self.depth);
295
+ self.buf.push(']');
296
+ }
297
+
298
+ fn array_elem(&mut self, el: &ArrayElement) {
299
+ match el {
300
+ ArrayElement::Expr(ex) => self.expr(ex),
301
+ ArrayElement::Spread(ex) => {
302
+ self.buf.push_str("...");
303
+ self.expr(ex);
304
+ }
305
+ }
306
+ }
307
+
308
+ /// Argument list `(...)`. A sole/last object|array|arrow argument "hugs" the
309
+ /// parens — it breaks itself rather than forcing the whole list to break, e.g.
310
+ /// `f(layout, [\n …\n])`. Otherwise the list breaks as a unit when too wide.
311
+ fn emit_args(&mut self, args: &[CallArg]) {
312
+ if args.is_empty() {
313
+ self.buf.push_str("()");
314
+ return;
315
+ }
316
+ if !self.force_flat && self.last_arg_huggable(args) {
317
+ self.buf.push('(');
318
+ for (i, a) in args.iter().enumerate() {
319
+ if i > 0 {
320
+ self.buf.push_str(", ");
58
321
  }
59
- self.indent(level);
60
- self.buf.push('}');
322
+ self.call_arg(a);
323
+ }
324
+ self.buf.push(')');
325
+ return;
326
+ }
327
+ self.fit(|s| s.args_inline(args), |s| s.args_broken(args));
328
+ }
329
+
330
+ fn last_arg_huggable(&self, args: &[CallArg]) -> bool {
331
+ let huggable = |a: &CallArg| {
332
+ matches!(
333
+ a,
334
+ CallArg::Expr(Expr::Object { .. })
335
+ | CallArg::Expr(Expr::Array { .. })
336
+ | CallArg::Expr(Expr::ArrowFunction { .. })
337
+ )
338
+ };
339
+ let collection =
340
+ |a: &CallArg| matches!(a, CallArg::Expr(Expr::Object { .. } | Expr::Array { .. }));
341
+ match args.split_last() {
342
+ Some((last, rest)) => huggable(last) && !rest.iter().any(collection),
343
+ None => false,
344
+ }
345
+ }
346
+
347
+ fn args_inline(&mut self, args: &[CallArg]) {
348
+ self.buf.push('(');
349
+ for (i, a) in args.iter().enumerate() {
350
+ if i > 0 {
351
+ self.buf.push_str(", ");
61
352
  }
353
+ self.call_arg(a);
354
+ }
355
+ self.buf.push(')');
356
+ }
357
+
358
+ fn args_broken(&mut self, args: &[CallArg]) {
359
+ self.buf.push_str("(\n");
360
+ self.depth += 1;
361
+ for (i, a) in args.iter().enumerate() {
362
+ if i > 0 {
363
+ self.buf.push_str(",\n");
364
+ }
365
+ self.indent(self.depth);
366
+ self.call_arg(a);
367
+ }
368
+ self.depth -= 1;
369
+ self.buf.push('\n');
370
+ self.indent(self.depth);
371
+ self.buf.push(')');
372
+ }
373
+
374
+ fn call_arg(&mut self, a: &CallArg) {
375
+ match a {
376
+ CallArg::Expr(ex) => self.expr(ex),
377
+ CallArg::Spread(ex) => {
378
+ self.buf.push_str("...");
379
+ self.expr(ex);
380
+ }
381
+ }
382
+ }
383
+
384
+ fn is_blank(&self, line: usize) -> bool {
385
+ self.blank_lines.get(line).copied().unwrap_or(false)
386
+ }
387
+
388
+ /// Preserve a single blank line before the item starting at `next_line` when
389
+ /// the source line directly above it was blank. Statement `span.end` is
390
+ /// unreliable (it points at the next token), so spacing keys off the reliable
391
+ /// start line and the source blank-line map rather than on span ranges.
392
+ fn vspace(&mut self, next_line: usize) {
393
+ if self.emitted != 0
394
+ && self.is_blank(next_line.saturating_sub(1))
395
+ && !self.buf.ends_with("\n\n")
396
+ {
397
+ self.buf.push('\n');
398
+ }
399
+ }
400
+
401
+ /// Emit every pending comment positioned before `before`, each on its own line
402
+ /// at `level` indentation, preserving source blank-line separation. Used at
403
+ /// statement-sequence boundaries and before a block's closing brace.
404
+ fn emit_leading_comments(&mut self, before: (usize, usize), level: usize) {
405
+ while self.ci < self.comments.len() && self.comments[self.ci].start < before {
406
+ let c = self.comments[self.ci].clone();
407
+ self.ci += 1;
408
+ self.vspace(c.start.0);
409
+ self.indent(level);
410
+ self.buf.push_str(&c.text);
411
+ self.buf.push('\n');
412
+ self.emitted = c.start.0;
413
+ // A marker anywhere in the statement's leading comment group ignores it.
414
+ if is_ignore_marker(&c.text) {
415
+ self.ignore_next = true;
416
+ }
417
+ }
418
+ }
419
+
420
+ /// Emit trailing (same-line) comments sitting on source line `line`, inline
421
+ /// after the just-printed statement text.
422
+ fn emit_trailing_comments(&mut self, line: usize) {
423
+ while self.ci < self.comments.len() {
424
+ let c = &self.comments[self.ci];
425
+ if c.own_line || c.start.0 != line {
426
+ break;
427
+ }
428
+ let text = c.text.clone();
429
+ self.ci += 1;
430
+ self.buf.push(' ');
431
+ self.buf.push_str(&text);
432
+ }
433
+ }
434
+
435
+ /// Print a run of statements (top level, block body, or switch case body),
436
+ /// interleaving recovered comments and preserving blank-line grouping. Trailing
437
+ /// comments attach by the statement's reliable start line (single-line case);
438
+ /// a trailing comment on a multi-line statement's last line simply migrates to
439
+ /// a leading comment of the next item — preserved, never dropped.
440
+ fn print_seq(&mut self, stmts: &[Statement], level: usize) {
441
+ for (i, s) in stmts.iter().enumerate() {
442
+ let sp = s.span();
443
+ self.ignore_next = false;
444
+ self.emit_leading_comments(sp.start, level);
445
+ self.vspace(sp.start.0);
446
+ if self.ignore_next {
447
+ self.emit_ignored(s, i, stmts, level);
448
+ } else {
449
+ self.stmt(s, level);
450
+ }
451
+ self.emitted = sp.start.0;
452
+ self.emit_trailing_comments(sp.start.0);
453
+ self.buf.push('\n');
454
+ }
455
+ }
456
+
457
+ /// Last source line covered by the statement at `start` — its start line, grown
458
+ /// over the full lines of any bracket (`{}`/`[]`/`()`) it transitively opens. This
459
+ /// bounds an ignored statement to its own extent regardless of what follows (next
460
+ /// sibling, a `switch` case label, the enclosing `}`), so verbatim never overruns.
461
+ fn ignored_last_line(&self, start: (usize, usize)) -> usize {
462
+ let mut last = start.0;
463
+ loop {
464
+ let mut grew = false;
465
+ for &(open_line, close_line) in &self.bracket_spans {
466
+ if open_line >= start.0 && open_line <= last && close_line > last {
467
+ last = close_line;
468
+ grew = true;
469
+ }
470
+ }
471
+ if !grew {
472
+ break;
473
+ }
474
+ }
475
+ last
476
+ }
477
+
478
+ /// Emit a `// tish-fmt-ignore`-d statement as its original source, verbatim. The
479
+ /// slice ends at the smallest of: the statement's own bracket extent, the next
480
+ /// sibling, and the next own-line comment — so a same-line trailing comment is
481
+ /// kept but neither the next statement's leading comments nor anything past this
482
+ /// statement leaks in. Captured comments are skipped so they aren't re-emitted.
483
+ fn emit_ignored(&mut self, s: &Statement, i: usize, stmts: &[Statement], level: usize) {
484
+ let start = s.span().start;
485
+ let mut boundary = (self.ignored_last_line(start) + 1, 1);
486
+ if let Some(next) = stmts.get(i + 1) {
487
+ boundary = boundary.min(next.span().start);
488
+ }
489
+ let mut j = self.ci;
490
+ while j < self.comments.len() && self.comments[j].start < boundary {
491
+ let c = &self.comments[j];
492
+ if c.start > start && c.own_line {
493
+ boundary = c.start;
494
+ break;
495
+ }
496
+ j += 1;
497
+ }
498
+ self.indent(level);
499
+ let text = self.verbatim(start, boundary);
500
+ self.buf.push_str(&text);
501
+ while self.ci < self.comments.len() && self.comments[self.ci].start < boundary {
502
+ self.ci += 1;
503
+ }
504
+ }
505
+
506
+ fn stmt(&mut self, s: &Statement, level: usize) {
507
+ // Expression layout indents relative to this statement's level.
508
+ self.depth = level;
509
+ match s {
510
+ Statement::Block { statements, span } => self.block(statements, *span, level, true),
62
511
  Statement::VarDecl {
63
512
  name,
64
513
  mutable,
@@ -197,7 +646,7 @@ impl Printer {
197
646
  self.expr(expr);
198
647
  } else {
199
648
  self.buf.push(' ');
200
- self.stmt(body, level);
649
+ self.stmt_inline_or_block(body, level);
201
650
  }
202
651
  }
203
652
  Statement::Switch {
@@ -220,18 +669,14 @@ impl Printer {
220
669
  }
221
670
  None => self.buf.push_str("default:\n"),
222
671
  }
223
- for st in stmts {
224
- self.stmt(st, level + 2);
225
- self.buf.push('\n');
226
- }
672
+ self.emitted = 0;
673
+ self.print_seq(stmts, level + 2);
227
674
  }
228
675
  if let Some(def) = default_body {
229
676
  self.indent(level + 1);
230
677
  self.buf.push_str("default:\n");
231
- for st in def {
232
- self.stmt(st, level + 2);
233
- self.buf.push('\n');
234
- }
678
+ self.emitted = 0;
679
+ self.print_seq(def, level + 2);
235
680
  }
236
681
  self.indent(level);
237
682
  self.buf.push('}');
@@ -240,6 +685,7 @@ impl Printer {
240
685
  self.indent(level);
241
686
  self.buf.push_str("do ");
242
687
  self.stmt_inline_or_block(body, level);
688
+ self.depth = level; // body recursion moved depth; restore for cond
243
689
  self.buf.push_str(" while (");
244
690
  self.expr(cond);
245
691
  self.buf.push(')');
@@ -352,7 +798,7 @@ impl Printer {
352
798
  self.type_ann(rt);
353
799
  }
354
800
  self.buf.push(' ');
355
- self.stmt(body, level);
801
+ self.stmt_inline_or_block(body, level);
356
802
  } else {
357
803
  self.stmt(inner, level);
358
804
  }
@@ -391,15 +837,43 @@ impl Printer {
391
837
  }
392
838
  }
393
839
 
840
+ /// Print a `{ … }` block. `lead_indent` controls whether the opening brace is
841
+ /// indented (true for a standalone block statement) or written at the current
842
+ /// position (false when it follows `if (…) `, `fn f() `, `else `, etc.).
843
+ fn block(&mut self, statements: &[Statement], span: Span, level: usize, lead_indent: bool) {
844
+ if lead_indent {
845
+ self.indent(level);
846
+ }
847
+ self.buf.push_str("{\n");
848
+ // Fresh sequence: suppress any blank line immediately after `{`.
849
+ self.emitted = 0;
850
+ self.print_seq(statements, level + 1);
851
+ // Dangling comments between the last statement and `}` (e.g. a note inside an
852
+ // otherwise-empty block). Bound by the real closing brace — `span.end`
853
+ // overshoots to the next token. Brace-less (indent) blocks have no map entry,
854
+ // so `span.start` flushes nothing and the comment migrates to the next sibling.
855
+ let bound = self.braces.get(&span.start).copied().unwrap_or(span.start);
856
+ self.emit_leading_comments(bound, level + 1);
857
+ self.indent(level);
858
+ self.buf.push('}');
859
+ self.emitted = span.start.0;
860
+ }
861
+
394
862
  fn stmt_inline_or_block(&mut self, s: &Statement, level: usize) {
395
- if let Statement::Block { .. } = s {
396
- self.stmt(s, level);
863
+ if let Statement::Block { statements, span } = s {
864
+ self.block(statements, *span, level, false);
397
865
  } else {
866
+ let sp = s.span();
398
867
  self.buf.push_str("{\n");
868
+ self.emitted = 0;
869
+ self.emit_leading_comments(sp.start, level + 1);
399
870
  self.stmt(s, level + 1);
871
+ self.emitted = sp.start.0;
872
+ self.emit_trailing_comments(sp.start.0);
400
873
  self.buf.push('\n');
401
874
  self.indent(level);
402
875
  self.buf.push('}');
876
+ self.emitted = sp.start.0;
403
877
  }
404
878
  }
405
879
 
@@ -413,35 +887,58 @@ impl Printer {
413
887
  }
414
888
  ImportSpecifier::Named { name, alias, .. } => {
415
889
  self.buf.push_str("{ ");
416
- self.buf.push_str(name.as_ref());
417
- if let Some(a) = alias {
418
- self.buf.push_str(" as ");
419
- self.buf.push_str(a.as_ref());
420
- }
890
+ self.import_named(name.as_ref(), alias.as_deref());
421
891
  self.buf.push_str(" }");
422
892
  }
423
893
  }
424
894
  return;
425
895
  }
896
+ // A long named-import list wraps one name per line.
897
+ self.fit(
898
+ |s| s.import_list_inline(specs),
899
+ |s| s.import_list_broken(specs),
900
+ );
901
+ }
902
+
903
+ fn import_named(&mut self, name: &str, alias: Option<&str>) {
904
+ self.buf.push_str(name);
905
+ if let Some(a) = alias {
906
+ self.buf.push_str(" as ");
907
+ self.buf.push_str(a);
908
+ }
909
+ }
910
+
911
+ fn import_list_inline(&mut self, specs: &[ImportSpecifier]) {
426
912
  self.buf.push_str("{ ");
427
913
  for (i, sp) in specs.iter().enumerate() {
428
914
  if i > 0 {
429
915
  self.buf.push_str(", ");
430
916
  }
431
- match sp {
432
- ImportSpecifier::Named { name, alias, .. } => {
433
- self.buf.push_str(name.as_ref());
434
- if let Some(a) = alias {
435
- self.buf.push_str(" as ");
436
- self.buf.push_str(a.as_ref());
437
- }
438
- }
439
- _ => {}
917
+ if let ImportSpecifier::Named { name, alias, .. } = sp {
918
+ self.import_named(name.as_ref(), alias.as_deref());
440
919
  }
441
920
  }
442
921
  self.buf.push_str(" }");
443
922
  }
444
923
 
924
+ fn import_list_broken(&mut self, specs: &[ImportSpecifier]) {
925
+ self.buf.push_str("{\n");
926
+ self.depth += 1;
927
+ for (i, sp) in specs.iter().enumerate() {
928
+ if i > 0 {
929
+ self.buf.push_str(",\n");
930
+ }
931
+ self.indent(self.depth);
932
+ if let ImportSpecifier::Named { name, alias, .. } = sp {
933
+ self.import_named(name.as_ref(), alias.as_deref());
934
+ }
935
+ }
936
+ self.depth -= 1;
937
+ self.buf.push('\n');
938
+ self.indent(self.depth);
939
+ self.buf.push('}');
940
+ }
941
+
445
942
  fn param_list(&mut self, params: &[FunParam], rest: &Option<TypedParam>) {
446
943
  for (i, p) in params.iter().enumerate() {
447
944
  if i > 0 {
@@ -578,6 +1075,18 @@ impl Printer {
578
1075
  }
579
1076
  }
580
1077
 
1078
+ /// Print `e` as a sub-expression, wrapping it in parentheses when its operator
1079
+ /// binds looser than `min_prec` (so the printed form re-parses to the same AST).
1080
+ fn child(&mut self, e: &Expr, min_prec: u8) {
1081
+ if expr_prec(e) < min_prec {
1082
+ self.buf.push('(');
1083
+ self.expr(e);
1084
+ self.buf.push(')');
1085
+ } else {
1086
+ self.expr(e);
1087
+ }
1088
+ }
1089
+
581
1090
  fn expr(&mut self, e: &Expr) {
582
1091
  match e {
583
1092
  Expr::Literal { value, .. } => match value {
@@ -596,11 +1105,16 @@ impl Printer {
596
1105
  Expr::Binary {
597
1106
  left, op, right, ..
598
1107
  } => {
599
- self.expr(left);
1108
+ // Parenthesize operands by precedence/associativity so the printed
1109
+ // grouping re-parses to the same tree (the AST has no paren nodes).
1110
+ let p = binop_prec(*op);
1111
+ let right_assoc = matches!(op, BinOp::Pow);
1112
+ let (lmin, rmin) = if right_assoc { (p + 1, p) } else { (p, p + 1) };
1113
+ self.child(left, lmin);
600
1114
  self.buf.push(' ');
601
1115
  self.buf.push_str(binop(*op));
602
1116
  self.buf.push(' ');
603
- self.expr(right);
1117
+ self.child(right, rmin);
604
1118
  }
605
1119
  Expr::Unary { op, operand, .. } => {
606
1120
  match op {
@@ -610,43 +1124,17 @@ impl Printer {
610
1124
  UnaryOp::BitNot => self.buf.push_str("~"),
611
1125
  UnaryOp::Void => self.buf.push_str("void "),
612
1126
  }
613
- self.expr(operand);
1127
+ self.child(operand, PREC_POSTFIX);
614
1128
  }
615
1129
  Expr::Call { callee, args, .. } => {
616
- self.expr(callee);
617
- self.buf.push('(');
618
- for (i, a) in args.iter().enumerate() {
619
- if i > 0 {
620
- self.buf.push_str(", ");
621
- }
622
- match a {
623
- CallArg::Expr(ex) => self.expr(ex),
624
- CallArg::Spread(ex) => {
625
- self.buf.push_str("...");
626
- self.expr(ex);
627
- }
628
- }
629
- }
630
- self.buf.push(')');
1130
+ self.child(callee, PREC_POSTFIX);
1131
+ self.emit_args(args);
631
1132
  }
632
1133
  Expr::New { callee, args, .. } => {
633
1134
  self.buf.push_str("new ");
634
- self.expr(callee);
1135
+ self.child(callee, PREC_POSTFIX);
635
1136
  if !args.is_empty() {
636
- self.buf.push('(');
637
- for (i, a) in args.iter().enumerate() {
638
- if i > 0 {
639
- self.buf.push_str(", ");
640
- }
641
- match a {
642
- CallArg::Expr(ex) => self.expr(ex),
643
- CallArg::Spread(ex) => {
644
- self.buf.push_str("...");
645
- self.expr(ex);
646
- }
647
- }
648
- }
649
- self.buf.push(')');
1137
+ self.emit_args(args);
650
1138
  }
651
1139
  }
652
1140
  Expr::Member {
@@ -655,7 +1143,7 @@ impl Printer {
655
1143
  optional,
656
1144
  ..
657
1145
  } => {
658
- self.expr(object);
1146
+ self.child(object, PREC_POSTFIX);
659
1147
  if *optional {
660
1148
  self.buf.push_str("?.");
661
1149
  } else {
@@ -676,7 +1164,7 @@ impl Printer {
676
1164
  optional,
677
1165
  ..
678
1166
  } => {
679
- self.expr(object);
1167
+ self.child(object, PREC_POSTFIX);
680
1168
  if *optional {
681
1169
  self.buf.push_str("?.[");
682
1170
  } else {
@@ -691,53 +1179,20 @@ impl Printer {
691
1179
  else_branch,
692
1180
  ..
693
1181
  } => {
694
- self.expr(cond);
1182
+ // cond binds tighter than `?:`; the else chains (right-assoc).
1183
+ self.child(cond, PREC_NULLISH);
695
1184
  self.buf.push_str(" ? ");
696
1185
  self.expr(then_branch);
697
1186
  self.buf.push_str(" : ");
698
- self.expr(else_branch);
1187
+ self.child(else_branch, PREC_CONDITIONAL);
699
1188
  }
700
1189
  Expr::NullishCoalesce { left, right, .. } => {
701
- self.expr(left);
1190
+ self.child(left, PREC_NULLISH);
702
1191
  self.buf.push_str(" ?? ");
703
- self.expr(right);
704
- }
705
- Expr::Array { elements, .. } => {
706
- self.buf.push('[');
707
- for (i, el) in elements.iter().enumerate() {
708
- if i > 0 {
709
- self.buf.push_str(", ");
710
- }
711
- match el {
712
- ArrayElement::Expr(ex) => self.expr(ex),
713
- ArrayElement::Spread(ex) => {
714
- self.buf.push_str("...");
715
- self.expr(ex);
716
- }
717
- }
718
- }
719
- self.buf.push(']');
720
- }
721
- Expr::Object { props, .. } => {
722
- self.buf.push_str("{ ");
723
- for (i, pr) in props.iter().enumerate() {
724
- if i > 0 {
725
- self.buf.push_str(", ");
726
- }
727
- match pr {
728
- ObjectProp::KeyValue(k, v) => {
729
- self.buf.push_str(k.as_ref());
730
- self.buf.push_str(": ");
731
- self.expr(v);
732
- }
733
- ObjectProp::Spread(ex) => {
734
- self.buf.push_str("...");
735
- self.expr(ex);
736
- }
737
- }
738
- }
739
- self.buf.push_str(" }");
1192
+ self.child(right, PREC_NULLISH + 1);
740
1193
  }
1194
+ Expr::Array { elements, .. } => self.emit_array(elements),
1195
+ Expr::Object { props, .. } => self.emit_object(props),
741
1196
  Expr::Assign { name, value, .. } => {
742
1197
  self.buf.push_str(name.as_ref());
743
1198
  self.buf.push_str(" = ");
@@ -745,7 +1200,7 @@ impl Printer {
745
1200
  }
746
1201
  Expr::TypeOf { operand, .. } => {
747
1202
  self.buf.push_str("typeof ");
748
- self.expr(operand);
1203
+ self.child(operand, PREC_POSTFIX);
749
1204
  }
750
1205
  Expr::PostfixInc { name, .. } => {
751
1206
  self.buf.push_str(name.as_ref());
@@ -783,7 +1238,7 @@ impl Printer {
783
1238
  value,
784
1239
  ..
785
1240
  } => {
786
- self.expr(object);
1241
+ self.child(object, PREC_POSTFIX);
787
1242
  self.buf.push('.');
788
1243
  self.buf.push_str(prop.as_ref());
789
1244
  self.buf.push_str(" = ");
@@ -795,7 +1250,7 @@ impl Printer {
795
1250
  value,
796
1251
  ..
797
1252
  } => {
798
- self.expr(object);
1253
+ self.child(object, PREC_POSTFIX);
799
1254
  self.buf.push('[');
800
1255
  self.expr(index);
801
1256
  self.buf.push_str("] = ");
@@ -806,8 +1261,20 @@ impl Printer {
806
1261
  self.param_list(params, &None);
807
1262
  self.buf.push_str(") => ");
808
1263
  match body {
809
- ArrowBody::Expr(e) => self.expr(e),
810
- ArrowBody::Block(b) => self.stmt(b, 0),
1264
+ // A bare object-literal body must be parenthesized, else `=> {`
1265
+ // re-parses as a block.
1266
+ ArrowBody::Expr(e) => {
1267
+ if matches!(e.as_ref(), Expr::Object { .. }) {
1268
+ self.buf.push('(');
1269
+ self.expr(e);
1270
+ self.buf.push(')');
1271
+ } else {
1272
+ self.expr(e);
1273
+ }
1274
+ }
1275
+ // Indent the block body relative to the arrow's own line
1276
+ // (`self.depth`), printed inline after `=> ` (no leading indent).
1277
+ ArrowBody::Block(b) => self.stmt_inline_or_block(b, self.depth),
811
1278
  }
812
1279
  }
813
1280
  Expr::TemplateLiteral { quasis, exprs, .. } => {
@@ -824,7 +1291,7 @@ impl Printer {
824
1291
  }
825
1292
  Expr::Await { operand, .. } => {
826
1293
  self.buf.push_str("await ");
827
- self.expr(operand);
1294
+ self.child(operand, PREC_POSTFIX);
828
1295
  }
829
1296
  Expr::JsxElement {
830
1297
  tag,
@@ -963,6 +1430,72 @@ fn escape_template(s: &str) -> String {
963
1430
  .replace('$', "\\$")
964
1431
  }
965
1432
 
1433
+ // Expression precedence levels (higher binds tighter), mirroring the parser's
1434
+ // descent chain (parse_conditional → … → parse_unary → primary). Used to decide
1435
+ // when a sub-expression needs parentheses.
1436
+ const PREC_CONDITIONAL: u8 = 1;
1437
+ const PREC_NULLISH: u8 = 2;
1438
+ const PREC_POSTFIX: u8 = 15; // call / member / index — tighter than any operator
1439
+ const PREC_ATOM: u8 = 16; // literals, identifiers, array/object/template/jsx
1440
+
1441
+ /// True when a comment is exactly the ignore marker — `// tish-fmt-ignore` or the
1442
+ /// `/* tish-fmt-ignore */` block form.
1443
+ fn is_ignore_marker(text: &str) -> bool {
1444
+ let t = text.trim();
1445
+ let inner = if let Some(r) = t.strip_prefix("//") {
1446
+ r
1447
+ } else if let Some(r) = t.strip_prefix("/*").and_then(|x| x.strip_suffix("*/")) {
1448
+ r
1449
+ } else {
1450
+ return false;
1451
+ };
1452
+ inner.trim() == IGNORE_MARKER
1453
+ }
1454
+
1455
+ /// Precedence of a binary operator, matching the parser (parser.rs `parse_*`).
1456
+ fn binop_prec(op: BinOp) -> u8 {
1457
+ match op {
1458
+ BinOp::Or => 3,
1459
+ BinOp::And => 4,
1460
+ BinOp::BitOr => 5,
1461
+ BinOp::BitXor => 6,
1462
+ BinOp::BitAnd => 7,
1463
+ BinOp::Shl | BinOp::Shr => 8,
1464
+ BinOp::Eq | BinOp::Ne | BinOp::StrictEq | BinOp::StrictNe => 9,
1465
+ BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge | BinOp::In => 10,
1466
+ BinOp::Add | BinOp::Sub => 11,
1467
+ BinOp::Mul | BinOp::Div | BinOp::Mod => 12,
1468
+ BinOp::Pow => 13,
1469
+ }
1470
+ }
1471
+
1472
+ /// Precedence of an expression's outermost operator (atoms bind tightest).
1473
+ fn expr_prec(e: &Expr) -> u8 {
1474
+ match e {
1475
+ Expr::Assign { .. }
1476
+ | Expr::MemberAssign { .. }
1477
+ | Expr::IndexAssign { .. }
1478
+ | Expr::CompoundAssign { .. }
1479
+ | Expr::LogicalAssign { .. } => 0,
1480
+ Expr::Conditional { .. } => PREC_CONDITIONAL,
1481
+ Expr::NullishCoalesce { .. } => PREC_NULLISH,
1482
+ Expr::Binary { op, .. } => binop_prec(*op),
1483
+ Expr::Unary { .. }
1484
+ | Expr::TypeOf { .. }
1485
+ | Expr::Await { .. }
1486
+ | Expr::PrefixInc { .. }
1487
+ | Expr::PrefixDec { .. } => 14,
1488
+ Expr::Call { .. }
1489
+ | Expr::New { .. }
1490
+ | Expr::Member { .. }
1491
+ | Expr::Index { .. }
1492
+ | Expr::PostfixInc { .. }
1493
+ | Expr::PostfixDec { .. } => PREC_POSTFIX,
1494
+ Expr::ArrowFunction { .. } => PREC_ATOM,
1495
+ _ => PREC_ATOM,
1496
+ }
1497
+ }
1498
+
966
1499
  fn binop(op: BinOp) -> &'static str {
967
1500
  match op {
968
1501
  BinOp::Add => "+",
@@ -1008,6 +1541,224 @@ fn logical_assign(op: LogicalAssignOp) -> &'static str {
1008
1541
  }
1009
1542
  }
1010
1543
 
1544
+ /// Recover `//` and `/* */` comments from source, in order, with their 1-based
1545
+ /// (line, col) positions — matching `ast::Span`'s convention so the printer can
1546
+ /// re-insert them by position. The lexer discards comments, so this is a separate
1547
+ /// pass; it skips string and template literals so a `//` inside `"…"` or `` `…` ``
1548
+ /// is never mistaken for a comment. (Tish has no regex literals, so a bare `/` is
1549
+ /// only division or a comment opener — no further disambiguation is needed.)
1550
+ fn scan_comments(source: &str) -> (Vec<CommentTok>, BraceMap, Vec<(usize, usize)>) {
1551
+ let mut s = Scanner {
1552
+ chars: source.chars().collect(),
1553
+ i: 0,
1554
+ line: 1,
1555
+ col: 1,
1556
+ seen_nonws: false,
1557
+ out: Vec::new(),
1558
+ brace_stack: Vec::new(),
1559
+ braces: BraceMap::new(),
1560
+ bracket_open: Vec::new(),
1561
+ bracket_spans: Vec::new(),
1562
+ };
1563
+ s.scan_code(false);
1564
+ (s.out, s.braces, s.bracket_spans)
1565
+ }
1566
+
1567
+ struct Scanner {
1568
+ chars: Vec<char>,
1569
+ i: usize,
1570
+ line: usize,
1571
+ col: usize,
1572
+ /// Whether any non-whitespace has appeared on the current line yet.
1573
+ seen_nonws: bool,
1574
+ out: Vec<CommentTok>,
1575
+ /// Open `{` positions awaiting their match, for building `braces`.
1576
+ brace_stack: Vec<(usize, usize)>,
1577
+ /// `{`→`}` position pairs (see [`BraceMap`]).
1578
+ braces: BraceMap,
1579
+ /// Open lines of every bracket (`{ [ (`) awaiting its match.
1580
+ bracket_open: Vec<usize>,
1581
+ /// `(open_line, close_line)` for every bracket pair — lets an ignored statement's
1582
+ /// verbatim slice extend over the full lines of any `{}`/`[]`/`()` it opens.
1583
+ bracket_spans: Vec<(usize, usize)>,
1584
+ }
1585
+
1586
+ impl Scanner {
1587
+ fn peek(&self) -> Option<char> {
1588
+ self.chars.get(self.i).copied()
1589
+ }
1590
+
1591
+ fn peek2(&self) -> Option<char> {
1592
+ self.chars.get(self.i + 1).copied()
1593
+ }
1594
+
1595
+ /// Consume one char, tracking line/col like the lexer (col resets after `\n`).
1596
+ fn bump(&mut self) -> Option<char> {
1597
+ let c = *self.chars.get(self.i)?;
1598
+ self.i += 1;
1599
+ if c == '\n' {
1600
+ self.line += 1;
1601
+ self.col = 1;
1602
+ self.seen_nonws = false;
1603
+ } else {
1604
+ self.col += 1;
1605
+ }
1606
+ Some(c)
1607
+ }
1608
+
1609
+ /// Scan ordinary code, collecting comments. When `stop_at_close_brace` is set
1610
+ /// (inside a `${ … }` template interpolation), returns at the matching `}`
1611
+ /// without consuming it.
1612
+ fn scan_code(&mut self, stop_at_close_brace: bool) {
1613
+ let mut depth = 0usize;
1614
+ while let Some(c) = self.peek() {
1615
+ match c {
1616
+ '}' if stop_at_close_brace && depth == 0 => return,
1617
+ '{' => {
1618
+ self.brace_stack.push((self.line, self.col));
1619
+ self.bracket_open.push(self.line);
1620
+ depth += 1;
1621
+ self.bump();
1622
+ self.seen_nonws = true;
1623
+ }
1624
+ '}' => {
1625
+ let close = (self.line, self.col);
1626
+ if let Some(open) = self.brace_stack.pop() {
1627
+ self.braces.insert(open, close);
1628
+ }
1629
+ if let Some(open_line) = self.bracket_open.pop() {
1630
+ self.bracket_spans.push((open_line, self.line));
1631
+ }
1632
+ depth = depth.saturating_sub(1);
1633
+ self.bump();
1634
+ self.seen_nonws = true;
1635
+ }
1636
+ '(' | '[' => {
1637
+ self.bracket_open.push(self.line);
1638
+ self.bump();
1639
+ self.seen_nonws = true;
1640
+ }
1641
+ ')' | ']' => {
1642
+ if let Some(open_line) = self.bracket_open.pop() {
1643
+ self.bracket_spans.push((open_line, self.line));
1644
+ }
1645
+ self.bump();
1646
+ self.seen_nonws = true;
1647
+ }
1648
+ '"' | '\'' => self.scan_string(c),
1649
+ '`' => {
1650
+ self.bump();
1651
+ self.seen_nonws = true;
1652
+ self.scan_template();
1653
+ }
1654
+ '/' if self.peek2() == Some('/') => self.scan_line_comment(),
1655
+ '/' if self.peek2() == Some('*') => self.scan_block_comment(),
1656
+ ' ' | '\t' | '\r' | '\n' => {
1657
+ self.bump();
1658
+ }
1659
+ _ => {
1660
+ self.bump();
1661
+ self.seen_nonws = true;
1662
+ }
1663
+ }
1664
+ }
1665
+ }
1666
+
1667
+ fn scan_string(&mut self, quote: char) {
1668
+ self.bump(); // opening quote
1669
+ self.seen_nonws = true;
1670
+ while let Some(c) = self.bump() {
1671
+ if c == '\\' {
1672
+ self.bump(); // escaped char
1673
+ } else if c == quote {
1674
+ break;
1675
+ }
1676
+ }
1677
+ }
1678
+
1679
+ /// Scan a template literal body (opening backtick already consumed), recursing
1680
+ /// into `${ … }` interpolations so their strings/comments are handled too.
1681
+ fn scan_template(&mut self) {
1682
+ while let Some(c) = self.peek() {
1683
+ match c {
1684
+ '`' => {
1685
+ self.bump();
1686
+ return;
1687
+ }
1688
+ '\\' => {
1689
+ self.bump();
1690
+ self.bump();
1691
+ }
1692
+ '$' if self.peek2() == Some('{') => {
1693
+ self.bump();
1694
+ self.bump();
1695
+ self.scan_code(true);
1696
+ if self.peek() == Some('}') {
1697
+ self.bump();
1698
+ }
1699
+ }
1700
+ _ => {
1701
+ self.bump();
1702
+ }
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ fn scan_line_comment(&mut self) {
1708
+ let start = (self.line, self.col);
1709
+ let own_line = !self.seen_nonws;
1710
+ let mut text = String::new();
1711
+ while let Some(c) = self.peek() {
1712
+ if c == '\n' {
1713
+ break;
1714
+ }
1715
+ text.push(c);
1716
+ self.bump();
1717
+ }
1718
+ self.out.push(CommentTok {
1719
+ start,
1720
+ text: text.trim_end().to_string(),
1721
+ own_line,
1722
+ });
1723
+ }
1724
+
1725
+ fn scan_block_comment(&mut self) {
1726
+ let start = (self.line, self.col);
1727
+ let own_line = !self.seen_nonws;
1728
+ let mut text = String::new();
1729
+ let mut depth = 0usize;
1730
+ loop {
1731
+ if self.peek() == Some('/') && self.peek2() == Some('*') {
1732
+ text.push('/');
1733
+ text.push('*');
1734
+ self.bump();
1735
+ self.bump();
1736
+ depth += 1;
1737
+ } else if self.peek() == Some('*') && self.peek2() == Some('/') {
1738
+ text.push('*');
1739
+ text.push('/');
1740
+ self.bump();
1741
+ self.bump();
1742
+ depth -= 1;
1743
+ if depth == 0 {
1744
+ break;
1745
+ }
1746
+ } else {
1747
+ match self.bump() {
1748
+ Some(c) => text.push(c),
1749
+ None => break,
1750
+ }
1751
+ }
1752
+ }
1753
+ self.out.push(CommentTok {
1754
+ start,
1755
+ text,
1756
+ own_line,
1757
+ });
1758
+ self.seen_nonws = true;
1759
+ }
1760
+ }
1761
+
1011
1762
  #[cfg(test)]
1012
1763
  mod tests {
1013
1764
  use super::*;
@@ -1029,4 +1780,322 @@ mod tests {
1029
1780
  );
1030
1781
  let _ = tishlang_parser::parse(&out).unwrap();
1031
1782
  }
1783
+
1784
+ #[test]
1785
+ fn preserves_leading_and_section_comments() {
1786
+ let src = "\
1787
+ // file header
1788
+ // second line
1789
+ let a = 1
1790
+
1791
+ // a section
1792
+ let b = 2
1793
+ ";
1794
+ let out = format_source(src).unwrap();
1795
+ assert!(out.contains("// file header"), "{out:?}");
1796
+ assert!(out.contains("// second line"), "{out:?}");
1797
+ assert!(out.contains("// a section"), "{out:?}");
1798
+ // header hugs the statement it documents; blank line before the section.
1799
+ assert!(out.contains("// a section\nlet b = 2"), "{out:?}");
1800
+ let _ = tishlang_parser::parse(&out).unwrap();
1801
+ }
1802
+
1803
+ #[test]
1804
+ fn preserves_trailing_comment() {
1805
+ let src = "let a = 1 // inline note\n";
1806
+ let out = format_source(src).unwrap();
1807
+ assert!(out.contains("let a = 1 // inline note"), "{out:?}");
1808
+ let _ = tishlang_parser::parse(&out).unwrap();
1809
+ }
1810
+
1811
+ #[test]
1812
+ fn preserves_comments_inside_block() {
1813
+ let src = "\
1814
+ fn f() {
1815
+ // step one
1816
+ let a = 1
1817
+ // step two
1818
+ return a
1819
+ }
1820
+ ";
1821
+ let out = format_source(src).unwrap();
1822
+ assert!(out.contains(" // step one"), "{out:?}");
1823
+ assert!(out.contains(" // step two"), "{out:?}");
1824
+ let _ = tishlang_parser::parse(&out).unwrap();
1825
+ }
1826
+
1827
+ #[test]
1828
+ fn preserves_dangling_comment_in_empty_block() {
1829
+ let src = "fn f() {\n // nothing yet\n}\n";
1830
+ let out = format_source(src).unwrap();
1831
+ assert!(out.contains("// nothing yet"), "{out:?}");
1832
+ let _ = tishlang_parser::parse(&out).unwrap();
1833
+ }
1834
+
1835
+ #[test]
1836
+ fn preserves_block_comment() {
1837
+ let src = "/* a block comment */\nlet a = 1\n";
1838
+ let out = format_source(src).unwrap();
1839
+ assert!(out.contains("/* a block comment */"), "{out:?}");
1840
+ let _ = tishlang_parser::parse(&out).unwrap();
1841
+ }
1842
+
1843
+ #[test]
1844
+ fn double_slash_in_string_is_not_a_comment() {
1845
+ let src = "let url = \"http://example.com\"\n";
1846
+ let out = format_source(src).unwrap();
1847
+ assert_eq!(out, "let url = \"http://example.com\"\n", "{out:?}");
1848
+ }
1849
+
1850
+ #[test]
1851
+ fn idempotent_with_comments() {
1852
+ let src = "\
1853
+ // header
1854
+ let a = 1
1855
+
1856
+ fn f() {
1857
+ // body note
1858
+ let b = 2 // trailing
1859
+ return b
1860
+ }
1861
+ ";
1862
+ let once = format_source(src).unwrap();
1863
+ let twice = format_source(&once).unwrap();
1864
+ assert_eq!(
1865
+ once, twice,
1866
+ "formatting is not idempotent:\n{once}\n---\n{twice}"
1867
+ );
1868
+ }
1869
+
1870
+ #[test]
1871
+ fn collapses_multiple_blank_lines_to_one() {
1872
+ let src = "let a = 1\n\n\n\nlet b = 2\n";
1873
+ let out = format_source(src).unwrap();
1874
+ assert_eq!(out, "let a = 1\n\nlet b = 2\n", "{out:?}");
1875
+ }
1876
+
1877
+ #[test]
1878
+ fn comment_after_inner_block_stays_at_outer_level() {
1879
+ // Regression: a comment after an inner block's `}` belongs to the outer
1880
+ // scope, not inside the inner block (the parser's block span.end overshoots
1881
+ // past `}`, which previously pulled this comment in and broke idempotency).
1882
+ let src = "\
1883
+ fn f() {
1884
+ if (x) {
1885
+ a()
1886
+ }
1887
+ // after the if
1888
+ b()
1889
+ }
1890
+ ";
1891
+ let out = format_source(src).unwrap();
1892
+ assert!(out.contains(" // after the if\n b()"), "{out:?}");
1893
+ let twice = format_source(&out).unwrap();
1894
+ assert_eq!(out, twice, "not idempotent:\n{out}\n---\n{twice}");
1895
+ }
1896
+
1897
+ #[test]
1898
+ fn trailing_comment_inside_block_stays_inside() {
1899
+ let src = "\
1900
+ fn f() {
1901
+ a()
1902
+ // last note
1903
+ }
1904
+ ";
1905
+ let out = format_source(src).unwrap();
1906
+ assert!(out.contains(" // last note\n}"), "{out:?}");
1907
+ let twice = format_source(&out).unwrap();
1908
+ assert_eq!(out, twice, "{out:?}");
1909
+ }
1910
+
1911
+ #[test]
1912
+ fn preserves_operator_grouping() {
1913
+ // The AST has no parenthesis nodes, so the printer must re-derive parens
1914
+ // from precedence. Each `want` must re-parse to the same tree.
1915
+ let cases = [
1916
+ ("let a = 1 / (b - c)\n", "1 / (b - c)"),
1917
+ ("let a = 0 - (x + y + z)\n", "0 - (x + y + z)"),
1918
+ ("let a = (1 - (p + q)) * s\n", "(1 - (p + q)) * s"),
1919
+ ("let a = b * c + d\n", "b * c + d"),
1920
+ ("let a = b + c * d\n", "b + c * d"),
1921
+ ("let a = (b + c) * d\n", "(b + c) * d"),
1922
+ ("let a = 1 - 2 - 3\n", "1 - 2 - 3"),
1923
+ ("let a = 1 - (2 - 3)\n", "1 - (2 - 3)"),
1924
+ ("let a = -(b + c)\n", "-(b + c)"),
1925
+ ("let a = !(b && c)\n", "!(b && c)"),
1926
+ ("let a = (a | b) & c\n", "(a | b) & c"),
1927
+ ];
1928
+ for (src, want) in cases {
1929
+ let out = format_source(src).unwrap();
1930
+ assert!(
1931
+ out.contains(want),
1932
+ "for {src:?} expected to contain {want:?}, got {out:?}"
1933
+ );
1934
+ let twice = format_source(&out).unwrap();
1935
+ assert_eq!(out, twice, "not idempotent for {src:?}: {out:?}");
1936
+ }
1937
+ }
1938
+
1939
+ #[test]
1940
+ fn nested_control_flow_brace_spacing() {
1941
+ let src = "fn f() {\n if (a) {\n b()\n }\n}\n";
1942
+ let out = format_source(src).unwrap();
1943
+ assert!(!out.contains(") {"), "double-space before brace: {out:?}");
1944
+ assert!(out.contains(" if (a) {\n"), "{out:?}");
1945
+ let twice = format_source(&out).unwrap();
1946
+ assert_eq!(out, twice, "{out:?}");
1947
+ }
1948
+
1949
+ #[test]
1950
+ fn short_object_stays_inline() {
1951
+ let src = "let a = { x: 1, y: 2 }\n";
1952
+ assert_eq!(format_source(src).unwrap(), src);
1953
+ }
1954
+
1955
+ #[test]
1956
+ fn long_object_breaks_one_per_line() {
1957
+ let props: Vec<String> = (0..12).map(|i| format!("key{i}: {i}")).collect();
1958
+ let src = format!("let a = {{ {} }}\n", props.join(", "));
1959
+ let out = format_source(&src).unwrap();
1960
+ assert!(
1961
+ out.contains("let a = {\n key0: 0,\n"),
1962
+ "expected broken object:\n{out}"
1963
+ );
1964
+ assert!(out.ends_with("\n}\n"), "{out:?}");
1965
+ // idempotent and re-parses
1966
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent:\n{out}");
1967
+ tishlang_parser::parse(&out).unwrap();
1968
+ }
1969
+
1970
+ #[test]
1971
+ fn long_array_breaks_one_per_line() {
1972
+ let elems: Vec<String> = (0..40).map(|i| i.to_string()).collect();
1973
+ let src = format!("let a = [{}]\n", elems.join(", "));
1974
+ let out = format_source(&src).unwrap();
1975
+ assert!(
1976
+ out.contains("[\n 0,\n 1,\n"),
1977
+ "expected broken array:\n{out}"
1978
+ );
1979
+ assert_eq!(format_source(&out).unwrap(), out);
1980
+ }
1981
+
1982
+ #[test]
1983
+ fn last_arg_object_hugs_parens() {
1984
+ let props: Vec<String> = (0..20).map(|i| format!("k{i}: {i}")).collect();
1985
+ let src = format!("f(a, {{ {} }})\n", props.join(", "));
1986
+ let out = format_source(&src).unwrap();
1987
+ assert!(
1988
+ out.starts_with("f(a, {\n"),
1989
+ "expected hugged object:\n{out}"
1990
+ );
1991
+ assert!(out.contains("\n})\n"), "expected hugged close:\n{out}");
1992
+ assert_eq!(format_source(&out).unwrap(), out);
1993
+ tishlang_parser::parse(&out).unwrap();
1994
+ }
1995
+
1996
+ #[test]
1997
+ fn nested_containers_indent_progressively() {
1998
+ let inner: Vec<String> = (0..16).map(|i| format!("p{i}: {i}")).collect();
1999
+ let src = format!("let a = {{ outer: {{ {} }} }}\n", inner.join(", "));
2000
+ let out = format_source(&src).unwrap();
2001
+ // outer object at 2 spaces, inner props at 4 spaces
2002
+ assert!(
2003
+ out.contains("\n outer: {\n p0: 0,"),
2004
+ "expected nested indent:\n{out}"
2005
+ );
2006
+ assert_eq!(format_source(&out).unwrap(), out);
2007
+ }
2008
+
2009
+ #[test]
2010
+ fn arrow_block_body_indents_to_context() {
2011
+ let src =
2012
+ "export fn make() {\n let s = {}\n s.go = (x) => {\n foo(x)\n }\n return s\n}\n";
2013
+ let out = format_source(src).unwrap();
2014
+ // Arrow body one level past its `s.go` line (4 spaces); closing `}` at 2.
2015
+ assert!(
2016
+ out.contains(" s.go = (x) => {\n foo(x)\n }\n"),
2017
+ "arrow body mis-indented:\n{out}"
2018
+ );
2019
+ assert_eq!(format_source(&out).unwrap(), out);
2020
+ }
2021
+
2022
+ #[test]
2023
+ fn ignore_marker_preserves_statement_verbatim() {
2024
+ let src = "// tish-fmt-ignore\nexport fn m(out) {\n out[0]=1; out[1]=0\n out[2]=0; out[3]=1\n}\n\nlet x = {a:1,b:2}\n";
2025
+ let out = format_source(src).unwrap();
2026
+ // The ignored function keeps its exact source (aligned, no spaces around `=`).
2027
+ assert!(
2028
+ out.contains("export fn m(out) {\n out[0]=1; out[1]=0\n out[2]=0; out[3]=1\n}"),
2029
+ "ignored block not verbatim:\n{out}"
2030
+ );
2031
+ // Surrounding code is still formatted normally.
2032
+ assert!(
2033
+ out.contains("let x = { a: 1, b: 2 }"),
2034
+ "neighbour not formatted:\n{out}"
2035
+ );
2036
+ // The marker itself is kept.
2037
+ assert!(out.contains("// tish-fmt-ignore\n"), "{out}");
2038
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent:\n{out}");
2039
+ tishlang_parser::parse(&out).unwrap();
2040
+ }
2041
+
2042
+ #[test]
2043
+ fn ignore_marker_block_comment_form() {
2044
+ let src = "/* tish-fmt-ignore */\nlet a = [1,2, 3]\n";
2045
+ let out = format_source(src).unwrap();
2046
+ assert!(
2047
+ out.contains("let a = [1,2, 3]"),
2048
+ "expected verbatim array:\n{out}"
2049
+ );
2050
+ }
2051
+
2052
+ #[test]
2053
+ fn ignore_in_switch_case_does_not_overrun() {
2054
+ // Regression: an ignored last statement of a non-final case must not swallow
2055
+ // the following cases / closing brace / trailing code.
2056
+ let src = "switch (x) {\n case 1:\n // tish-fmt-ignore\n foo( a,b )\n case 2:\n bar()\n}\nlet after = 1\n";
2057
+ let out = format_source(src).unwrap();
2058
+ assert!(out.contains("foo( a,b )"), "ignored not verbatim:\n{out}");
2059
+ assert!(out.contains("case 2:"), "case 2 was swallowed:\n{out}");
2060
+ assert!(out.contains("bar()"), "case 2 body swallowed:\n{out}");
2061
+ assert!(
2062
+ out.contains("let after = 1"),
2063
+ "trailing code swallowed:\n{out}"
2064
+ );
2065
+ tishlang_parser::parse(&out).unwrap();
2066
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent:\n{out}");
2067
+ }
2068
+
2069
+ #[test]
2070
+ fn ignore_preserves_multiline_bracket_statement() {
2071
+ // The verbatim extent must follow `[]`/`()`, not just `{}`.
2072
+ let src = "// tish-fmt-ignore\nlet m = [\n 1,2,\n 3,4\n]\nlet n = 5\n";
2073
+ let out = format_source(src).unwrap();
2074
+ assert!(
2075
+ out.contains("let m = [\n 1,2,\n 3,4\n]"),
2076
+ "multiline array truncated:\n{out}"
2077
+ );
2078
+ assert!(out.contains("let n = 5"), "{out}");
2079
+ assert_eq!(format_source(&out).unwrap(), out);
2080
+ }
2081
+
2082
+ #[test]
2083
+ fn without_marker_is_formatted_normally() {
2084
+ let src = "let a = [1,2, 3]\n";
2085
+ let out = format_source(src).unwrap();
2086
+ assert_eq!(out, "let a = [1, 2, 3]\n", "{out:?}");
2087
+ }
2088
+
2089
+ #[test]
2090
+ fn no_comments_round_trips_without_loss() {
2091
+ let src = "\
2092
+ fn add(a, b) {
2093
+ return a + b
2094
+ }
2095
+
2096
+ let x = add(1, 2)
2097
+ ";
2098
+ let out = format_source(src).unwrap();
2099
+ assert_eq!(out, src, "{out:?}");
2100
+ }
1032
2101
  }