@tishlang/tish-format 1.0.12 → 2.0.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.
Files changed (189) hide show
  1. package/Cargo.toml +51 -0
  2. package/LICENSE +13 -0
  3. package/bin/tish-format +0 -0
  4. package/crates/js_to_tish/Cargo.toml +11 -0
  5. package/crates/js_to_tish/README.md +18 -0
  6. package/crates/js_to_tish/src/error.rs +55 -0
  7. package/crates/js_to_tish/src/lib.rs +11 -0
  8. package/crates/js_to_tish/src/span_util.rs +35 -0
  9. package/crates/js_to_tish/src/transform/expr.rs +611 -0
  10. package/crates/js_to_tish/src/transform/stmt.rs +503 -0
  11. package/crates/js_to_tish/src/transform.rs +60 -0
  12. package/crates/tish/Cargo.toml +62 -0
  13. package/crates/tish/build.rs +21 -0
  14. package/crates/tish/src/cargo_native_registry.rs +32 -0
  15. package/crates/tish/src/cli_help.rs +576 -0
  16. package/crates/tish/src/main.rs +853 -0
  17. package/crates/tish/src/repl_completion.rs +199 -0
  18. package/crates/tish/tests/cargo_example_compile.rs +67 -0
  19. package/crates/tish/tests/error_source_location.rs +36 -0
  20. package/crates/tish/tests/fixtures/cargo_example_project/Cargo.toml +3 -0
  21. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/Cargo.toml +11 -0
  22. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +12 -0
  23. package/crates/tish/tests/fixtures/cargo_example_project/package.json +10 -0
  24. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +3 -0
  25. package/crates/tish/tests/fixtures/runtime_error_location.tish +5 -0
  26. package/crates/tish/tests/fixtures/trycatch_runtime_errors.tish +15 -0
  27. package/crates/tish/tests/fixtures/tty_capability.tish +9 -0
  28. package/crates/tish/tests/integration_test.rs +1406 -0
  29. package/crates/tish/tests/run_optimize_stdout_parity.rs +50 -0
  30. package/crates/tish/tests/shortcircuit.rs +65 -0
  31. package/crates/tish/tests/trycatch_runtime_errors.rs +45 -0
  32. package/crates/tish/tests/tty_capability.rs +43 -0
  33. package/crates/tish_ast/Cargo.toml +9 -0
  34. package/crates/tish_ast/src/ast.rs +649 -0
  35. package/crates/tish_ast/src/lib.rs +5 -0
  36. package/crates/tish_build_utils/Cargo.toml +11 -0
  37. package/crates/tish_build_utils/src/lib.rs +577 -0
  38. package/crates/tish_builtins/Cargo.toml +22 -0
  39. package/crates/tish_builtins/src/array.rs +803 -0
  40. package/crates/tish_builtins/src/collections.rs +481 -0
  41. package/crates/tish_builtins/src/construct.rs +199 -0
  42. package/crates/tish_builtins/src/date.rs +538 -0
  43. package/crates/tish_builtins/src/globals.rs +293 -0
  44. package/crates/tish_builtins/src/helpers.rs +35 -0
  45. package/crates/tish_builtins/src/iterator.rs +129 -0
  46. package/crates/tish_builtins/src/lib.rs +21 -0
  47. package/crates/tish_builtins/src/math.rs +89 -0
  48. package/crates/tish_builtins/src/number.rs +96 -0
  49. package/crates/tish_builtins/src/object.rs +36 -0
  50. package/crates/tish_builtins/src/string.rs +646 -0
  51. package/crates/tish_builtins/src/symbol.rs +83 -0
  52. package/crates/tish_builtins/src/typedarrays.rs +298 -0
  53. package/crates/tish_bytecode/Cargo.toml +17 -0
  54. package/crates/tish_bytecode/src/chunk.rs +164 -0
  55. package/crates/tish_bytecode/src/compiler.rs +2604 -0
  56. package/crates/tish_bytecode/src/encoding.rs +102 -0
  57. package/crates/tish_bytecode/src/lib.rs +20 -0
  58. package/crates/tish_bytecode/src/opcode.rs +185 -0
  59. package/crates/tish_bytecode/src/peephole.rs +189 -0
  60. package/crates/tish_bytecode/src/serialize.rs +193 -0
  61. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +44 -0
  62. package/crates/tish_bytecode/tests/constant_folding.rs +84 -0
  63. package/crates/tish_bytecode/tests/sort_optimization.rs +31 -0
  64. package/crates/tish_compile/Cargo.toml +27 -0
  65. package/crates/tish_compile/src/check.rs +774 -0
  66. package/crates/tish_compile/src/codegen.rs +7317 -0
  67. package/crates/tish_compile/src/infer.rs +1681 -0
  68. package/crates/tish_compile/src/lib.rs +206 -0
  69. package/crates/tish_compile/src/resolve.rs +1951 -0
  70. package/crates/tish_compile/src/types.rs +605 -0
  71. package/crates/tish_compile_js/Cargo.toml +18 -0
  72. package/crates/tish_compile_js/examples/jsx_vdom_smoke.tish +8 -0
  73. package/crates/tish_compile_js/src/codegen.rs +938 -0
  74. package/crates/tish_compile_js/src/error.rs +20 -0
  75. package/crates/tish_compile_js/src/lib.rs +26 -0
  76. package/crates/tish_compile_js/src/tests_jsx.rs +414 -0
  77. package/crates/tish_compiler_wasm/Cargo.toml +21 -0
  78. package/crates/tish_compiler_wasm/src/lib.rs +57 -0
  79. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +473 -0
  80. package/crates/tish_core/Cargo.toml +32 -0
  81. package/crates/tish_core/src/console_style.rs +170 -0
  82. package/crates/tish_core/src/json.rs +430 -0
  83. package/crates/tish_core/src/lib.rs +20 -0
  84. package/crates/tish_core/src/macros.rs +36 -0
  85. package/crates/tish_core/src/shape.rs +85 -0
  86. package/crates/tish_core/src/uri.rs +118 -0
  87. package/crates/tish_core/src/value.rs +1350 -0
  88. package/crates/tish_core/src/vmref.rs +183 -0
  89. package/crates/tish_cranelift/Cargo.toml +19 -0
  90. package/crates/tish_cranelift/src/lib.rs +43 -0
  91. package/crates/tish_cranelift/src/link.rs +130 -0
  92. package/crates/tish_cranelift/src/lower.rs +85 -0
  93. package/crates/tish_cranelift_runtime/Cargo.toml +26 -0
  94. package/crates/tish_cranelift_runtime/src/lib.rs +45 -0
  95. package/crates/tish_eval/Cargo.toml +51 -0
  96. package/crates/tish_eval/src/eval.rs +4265 -0
  97. package/crates/tish_eval/src/http.rs +191 -0
  98. package/crates/tish_eval/src/lib.rs +99 -0
  99. package/crates/tish_eval/src/natives.rs +551 -0
  100. package/crates/tish_eval/src/promise.rs +179 -0
  101. package/crates/tish_eval/src/regex.rs +299 -0
  102. package/crates/tish_eval/src/timers.rs +120 -0
  103. package/crates/tish_eval/src/value.rs +336 -0
  104. package/crates/tish_eval/src/value_convert.rs +117 -0
  105. package/crates/tish_ffi/Cargo.toml +26 -0
  106. package/crates/tish_ffi/src/lib.rs +518 -0
  107. package/crates/tish_ffi/tests/fixtures/testmod/Cargo.toml +18 -0
  108. package/crates/tish_ffi/tests/fixtures/testmod/src/lib.rs +46 -0
  109. package/crates/tish_ffi/tests/loader.rs +65 -0
  110. package/crates/tish_fmt/Cargo.toml +16 -0
  111. package/crates/tish_fmt/src/bin/tish-fmt.rs +41 -0
  112. package/crates/tish_fmt/src/lib.rs +2157 -0
  113. package/crates/tish_jsx_web/Cargo.toml +9 -0
  114. package/crates/tish_jsx_web/README.md +5 -0
  115. package/crates/tish_jsx_web/src/lib.rs +2 -0
  116. package/crates/tish_lexer/Cargo.toml +9 -0
  117. package/crates/tish_lexer/src/lib.rs +1104 -0
  118. package/crates/tish_lexer/src/token.rs +170 -0
  119. package/crates/tish_lint/Cargo.toml +18 -0
  120. package/crates/tish_lint/src/bin/tish-lint.rs +195 -0
  121. package/crates/tish_lint/src/lib.rs +281 -0
  122. package/crates/tish_llvm/Cargo.toml +13 -0
  123. package/crates/tish_llvm/src/lib.rs +115 -0
  124. package/crates/tish_lsp/Cargo.toml +25 -0
  125. package/crates/tish_lsp/README.md +26 -0
  126. package/crates/tish_lsp/src/builtin_goto.rs +362 -0
  127. package/crates/tish_lsp/src/import_goto.rs +564 -0
  128. package/crates/tish_lsp/src/main.rs +1459 -0
  129. package/crates/tish_native/Cargo.toml +16 -0
  130. package/crates/tish_native/src/build.rs +481 -0
  131. package/crates/tish_native/src/config.rs +48 -0
  132. package/crates/tish_native/src/lib.rs +416 -0
  133. package/crates/tish_opt/Cargo.toml +13 -0
  134. package/crates/tish_opt/src/lib.rs +1046 -0
  135. package/crates/tish_parser/Cargo.toml +11 -0
  136. package/crates/tish_parser/src/lib.rs +386 -0
  137. package/crates/tish_parser/src/parser.rs +2726 -0
  138. package/crates/tish_pg/Cargo.toml +34 -0
  139. package/crates/tish_pg/README.md +38 -0
  140. package/crates/tish_pg/src/error.rs +52 -0
  141. package/crates/tish_pg/src/lib.rs +955 -0
  142. package/crates/tish_resolve/Cargo.toml +13 -0
  143. package/crates/tish_resolve/src/lib.rs +3601 -0
  144. package/crates/tish_resolve/src/pos.rs +141 -0
  145. package/crates/tish_runtime/Cargo.toml +100 -0
  146. package/crates/tish_runtime/src/http.rs +1347 -0
  147. package/crates/tish_runtime/src/http_fetch.rs +492 -0
  148. package/crates/tish_runtime/src/http_hyper.rs +441 -0
  149. package/crates/tish_runtime/src/http_prefork.rs +189 -0
  150. package/crates/tish_runtime/src/lib.rs +1447 -0
  151. package/crates/tish_runtime/src/native_promise.rs +15 -0
  152. package/crates/tish_runtime/src/promise.rs +558 -0
  153. package/crates/tish_runtime/src/promise_io.rs +38 -0
  154. package/crates/tish_runtime/src/timers.rs +172 -0
  155. package/crates/tish_runtime/src/tty.rs +226 -0
  156. package/crates/tish_runtime/src/ws.rs +778 -0
  157. package/crates/tish_runtime/tests/fetch_readable_stream.rs +102 -0
  158. package/crates/tish_ui/Cargo.toml +17 -0
  159. package/crates/tish_ui/src/jsx.rs +692 -0
  160. package/crates/tish_ui/src/lib.rs +20 -0
  161. package/crates/tish_ui/src/runtime/hooks.rs +573 -0
  162. package/crates/tish_ui/src/runtime/mod.rs +183 -0
  163. package/crates/tish_vm/Cargo.toml +60 -0
  164. package/crates/tish_vm/src/jit.rs +1050 -0
  165. package/crates/tish_vm/src/lib.rs +41 -0
  166. package/crates/tish_vm/src/vm.rs +3536 -0
  167. package/crates/tish_vm/tests/concurrent_shared_state.rs +140 -0
  168. package/crates/tish_vm/tests/fixtures/or_string_cmd.tish +2 -0
  169. package/crates/tish_vm/tests/lexical_scope_declare.rs +34 -0
  170. package/crates/tish_vm/tests/peephole_jump_chain_logical_or.rs +150 -0
  171. package/crates/tish_wasm/Cargo.toml +15 -0
  172. package/crates/tish_wasm/src/lib.rs +428 -0
  173. package/crates/tish_wasm_runtime/Cargo.toml +37 -0
  174. package/crates/tish_wasm_runtime/src/gpu.rs +429 -0
  175. package/crates/tish_wasm_runtime/src/lib.rs +42 -0
  176. package/crates/tishlang_cargo_bindgen/Cargo.toml +26 -0
  177. package/crates/tishlang_cargo_bindgen/src/classify.rs +261 -0
  178. package/crates/tishlang_cargo_bindgen/src/discover.rs +125 -0
  179. package/crates/tishlang_cargo_bindgen/src/infer.rs +382 -0
  180. package/crates/tishlang_cargo_bindgen/src/lib.rs +349 -0
  181. package/crates/tishlang_cargo_bindgen/src/main.rs +167 -0
  182. package/crates/tishlang_cargo_bindgen/src/metadata.rs +117 -0
  183. package/justfile +276 -0
  184. package/package.json +2 -2
  185. package/platform/darwin-arm64/tish-fmt +0 -0
  186. package/platform/darwin-x64/tish-fmt +0 -0
  187. package/platform/linux-arm64/tish-fmt +0 -0
  188. package/platform/linux-x64/tish-fmt +0 -0
  189. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -0,0 +1,2157 @@
1
+ //! Pretty-print Tish AST to source. Style: 2-space indent, braces for blocks, trailing newline.
2
+
3
+ use std::collections::HashMap;
4
+
5
+ use tishlang_ast::{
6
+ ArrayElement, ArrowBody, BinOp, CallArg, CompoundOp, DestructElement, DestructPattern,
7
+ ExportDeclaration, Expr, FunParam, ImportSpecifier, JsxAttrValue, JsxChild, JsxProp, Literal,
8
+ LogicalAssignOp, MemberProp, ObjectProp, Program, Span, Statement, TypeAnnotation, TypedParam,
9
+ UnaryOp,
10
+ };
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
+
31
+ /// Format Tish source. On parse error, returns the parser message.
32
+ pub fn format_source(source: &str) -> Result<String, String> {
33
+ let program = tishlang_parser::parse(source)?;
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
+ ))
43
+ }
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.
49
+ pub fn format_program(program: &Program) -> String {
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();
75
+ }
76
+ p.buf.push('\n');
77
+ p.buf
78
+ }
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
+
88
+ struct Printer {
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)>,
119
+ }
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
+
129
+ impl Printer {
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
+ }
145
+ Self {
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();
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()
175
+ }
176
+
177
+ fn indent(&mut self, level: usize) {
178
+ for _ in 0..level {
179
+ self.buf.push_str(" ");
180
+ }
181
+ }
182
+
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(", ");
321
+ }
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(", ");
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),
511
+ // Comma-declarators: render each as its own statement line. The caller
512
+ // (print_seq) emits the trailing newline, so only separate internally.
513
+ Statement::Multi { statements, .. } => {
514
+ for (i, st) in statements.iter().enumerate() {
515
+ if i > 0 {
516
+ self.buf.push('\n');
517
+ }
518
+ self.stmt(st, level);
519
+ }
520
+ }
521
+ Statement::VarDecl {
522
+ name,
523
+ mutable,
524
+ type_ann,
525
+ init,
526
+ ..
527
+ } => {
528
+ self.indent(level);
529
+ self.buf.push_str(if *mutable { "let " } else { "const " });
530
+ self.buf.push_str(name);
531
+ if let Some(t) = type_ann {
532
+ self.buf.push_str(": ");
533
+ self.type_ann(t);
534
+ }
535
+ if let Some(e) = init {
536
+ self.buf.push_str(" = ");
537
+ self.expr(e);
538
+ }
539
+ }
540
+ Statement::VarDeclDestructure {
541
+ pattern,
542
+ mutable,
543
+ init,
544
+ ..
545
+ } => {
546
+ self.indent(level);
547
+ self.buf.push_str(if *mutable { "let " } else { "const " });
548
+ self.destruct_pat(pattern);
549
+ self.buf.push_str(" = ");
550
+ self.expr(init);
551
+ }
552
+ Statement::ExprStmt { expr, .. } => {
553
+ self.indent(level);
554
+ self.expr(expr);
555
+ }
556
+ Statement::If {
557
+ cond,
558
+ then_branch,
559
+ else_branch,
560
+ ..
561
+ } => {
562
+ self.indent(level);
563
+ self.buf.push_str("if (");
564
+ self.expr(cond);
565
+ self.buf.push_str(") ");
566
+ self.stmt_inline_or_block(then_branch, level);
567
+ if let Some(else_b) = else_branch {
568
+ self.buf.push_str(" else ");
569
+ self.stmt_inline_or_block(else_b, level);
570
+ }
571
+ }
572
+ Statement::While { cond, body, .. } => {
573
+ self.indent(level);
574
+ self.buf.push_str("while (");
575
+ self.expr(cond);
576
+ self.buf.push_str(") ");
577
+ self.stmt_inline_or_block(body, level);
578
+ }
579
+ Statement::For {
580
+ init,
581
+ cond,
582
+ update,
583
+ body,
584
+ ..
585
+ } => {
586
+ self.indent(level);
587
+ self.buf.push_str("for (");
588
+ if let Some(i) = init {
589
+ self.stmt_for_header(i);
590
+ }
591
+ self.buf.push_str("; ");
592
+ if let Some(c) = cond {
593
+ self.expr(c);
594
+ }
595
+ self.buf.push_str("; ");
596
+ if let Some(u) = update {
597
+ self.expr(u);
598
+ }
599
+ self.buf.push_str(") ");
600
+ self.stmt_inline_or_block(body, level);
601
+ }
602
+ Statement::ForOf {
603
+ name,
604
+ iterable,
605
+ body,
606
+ ..
607
+ } => {
608
+ self.indent(level);
609
+ self.buf.push_str("for (let ");
610
+ self.buf.push_str(name);
611
+ self.buf.push_str(" of ");
612
+ self.expr(iterable);
613
+ self.buf.push_str(") ");
614
+ self.stmt_inline_or_block(body, level);
615
+ }
616
+ Statement::Return { value, .. } => {
617
+ self.indent(level);
618
+ self.buf.push_str("return");
619
+ if let Some(v) = value {
620
+ self.buf.push(' ');
621
+ self.expr(v);
622
+ }
623
+ }
624
+ Statement::Break { .. } => {
625
+ self.indent(level);
626
+ self.buf.push_str("break");
627
+ }
628
+ Statement::Continue { .. } => {
629
+ self.indent(level);
630
+ self.buf.push_str("continue");
631
+ }
632
+ Statement::FunDecl {
633
+ async_,
634
+ name,
635
+ params,
636
+ rest_param,
637
+ return_type,
638
+ body,
639
+ ..
640
+ } => {
641
+ self.indent(level);
642
+ if *async_ {
643
+ self.buf.push_str("async ");
644
+ }
645
+ self.buf.push_str("fn ");
646
+ self.buf.push_str(name);
647
+ self.buf.push('(');
648
+ self.param_list(params, rest_param);
649
+ self.buf.push(')');
650
+ if let Some(rt) = return_type {
651
+ self.buf.push_str(": ");
652
+ self.type_ann(rt);
653
+ }
654
+ if let Statement::ExprStmt { expr, .. } = body.as_ref() {
655
+ self.buf.push_str(" = ");
656
+ self.expr(expr);
657
+ } else {
658
+ self.buf.push(' ');
659
+ self.stmt_inline_or_block(body, level);
660
+ }
661
+ }
662
+ Statement::Switch {
663
+ expr,
664
+ cases,
665
+ default_body,
666
+ ..
667
+ } => {
668
+ self.indent(level);
669
+ self.buf.push_str("switch (");
670
+ self.expr(expr);
671
+ self.buf.push_str(") {\n");
672
+ for (case_e, stmts) in cases {
673
+ self.indent(level + 1);
674
+ match case_e {
675
+ Some(e) => {
676
+ self.buf.push_str("case ");
677
+ self.expr(e);
678
+ self.buf.push_str(":\n");
679
+ }
680
+ None => self.buf.push_str("default:\n"),
681
+ }
682
+ self.emitted = 0;
683
+ self.print_seq(stmts, level + 2);
684
+ }
685
+ if let Some(def) = default_body {
686
+ self.indent(level + 1);
687
+ self.buf.push_str("default:\n");
688
+ self.emitted = 0;
689
+ self.print_seq(def, level + 2);
690
+ }
691
+ self.indent(level);
692
+ self.buf.push('}');
693
+ }
694
+ Statement::DoWhile { body, cond, .. } => {
695
+ self.indent(level);
696
+ self.buf.push_str("do ");
697
+ self.stmt_inline_or_block(body, level);
698
+ self.depth = level; // body recursion moved depth; restore for cond
699
+ self.buf.push_str(" while (");
700
+ self.expr(cond);
701
+ self.buf.push(')');
702
+ }
703
+ Statement::Throw { value, .. } => {
704
+ self.indent(level);
705
+ self.buf.push_str("throw ");
706
+ self.expr(value);
707
+ }
708
+ Statement::Try {
709
+ body,
710
+ catch_param,
711
+ catch_body,
712
+ finally_body,
713
+ ..
714
+ } => {
715
+ self.indent(level);
716
+ self.buf.push_str("try ");
717
+ self.stmt_inline_or_block(body, level);
718
+ if let (Some(p), Some(cb)) = (catch_param, catch_body) {
719
+ self.buf.push_str(" catch (");
720
+ self.buf.push_str(p);
721
+ self.buf.push_str(") ");
722
+ self.stmt_inline_or_block(cb, level);
723
+ }
724
+ if let Some(fb) = finally_body {
725
+ self.buf.push_str(" finally ");
726
+ self.stmt_inline_or_block(fb, level);
727
+ }
728
+ }
729
+ Statement::Import {
730
+ specifiers, from, ..
731
+ } => {
732
+ self.indent(level);
733
+ self.buf.push_str("import ");
734
+ self.import_specs(specifiers);
735
+ self.buf.push_str(" from ");
736
+ self.string_lit(from.as_ref());
737
+ }
738
+ Statement::TypeAlias { name, ty, .. } => {
739
+ self.indent(level);
740
+ self.buf.push_str("type ");
741
+ self.buf.push_str(name);
742
+ self.buf.push_str(" = ");
743
+ self.type_ann(ty);
744
+ }
745
+ Statement::DeclareVar {
746
+ name,
747
+ type_ann,
748
+ const_,
749
+ ..
750
+ } => {
751
+ self.indent(level);
752
+ self.buf.push_str("declare ");
753
+ self.buf.push_str(if *const_ { "const " } else { "let " });
754
+ self.buf.push_str(name);
755
+ if let Some(t) = type_ann {
756
+ self.buf.push_str(": ");
757
+ self.type_ann(t);
758
+ }
759
+ }
760
+ Statement::DeclareFun {
761
+ async_,
762
+ name,
763
+ params,
764
+ rest_param,
765
+ return_type,
766
+ ..
767
+ } => {
768
+ self.indent(level);
769
+ self.buf.push_str("declare ");
770
+ if *async_ {
771
+ self.buf.push_str("async ");
772
+ }
773
+ self.buf.push_str("fn ");
774
+ self.buf.push_str(name);
775
+ self.buf.push('(');
776
+ self.param_list(params, rest_param);
777
+ self.buf.push(')');
778
+ if let Some(rt) = return_type {
779
+ self.buf.push_str(": ");
780
+ self.type_ann(rt);
781
+ }
782
+ }
783
+ Statement::Export { declaration, .. } => {
784
+ self.indent(level);
785
+ self.buf.push_str("export ");
786
+ match declaration.as_ref() {
787
+ ExportDeclaration::Named(inner) => {
788
+ if let Statement::FunDecl {
789
+ async_,
790
+ name,
791
+ params,
792
+ rest_param,
793
+ return_type,
794
+ body,
795
+ ..
796
+ } = inner.as_ref()
797
+ {
798
+ if *async_ {
799
+ self.buf.push_str("async ");
800
+ }
801
+ self.buf.push_str("fn ");
802
+ self.buf.push_str(name);
803
+ self.buf.push('(');
804
+ self.param_list(params, rest_param);
805
+ self.buf.push(')');
806
+ if let Some(rt) = return_type {
807
+ self.buf.push_str(": ");
808
+ self.type_ann(rt);
809
+ }
810
+ self.buf.push(' ');
811
+ self.stmt_inline_or_block(body, level);
812
+ } else {
813
+ self.stmt(inner, level);
814
+ }
815
+ }
816
+ ExportDeclaration::Default(e) => {
817
+ self.buf.push_str("default ");
818
+ self.expr(e);
819
+ }
820
+ }
821
+ }
822
+ }
823
+ }
824
+
825
+ fn stmt_for_header(&mut self, s: &Statement) {
826
+ match s {
827
+ Statement::VarDecl {
828
+ name,
829
+ mutable,
830
+ type_ann,
831
+ init,
832
+ ..
833
+ } => {
834
+ self.buf.push_str(if *mutable { "let " } else { "const " });
835
+ self.buf.push_str(name);
836
+ if let Some(t) = type_ann {
837
+ self.buf.push_str(": ");
838
+ self.type_ann(t);
839
+ }
840
+ if let Some(e) = init {
841
+ self.buf.push_str(" = ");
842
+ self.expr(e);
843
+ }
844
+ }
845
+ Statement::ExprStmt { expr, .. } => self.expr(expr),
846
+ _ => {}
847
+ }
848
+ }
849
+
850
+ /// Print a `{ … }` block. `lead_indent` controls whether the opening brace is
851
+ /// indented (true for a standalone block statement) or written at the current
852
+ /// position (false when it follows `if (…) `, `fn f() `, `else `, etc.).
853
+ fn block(&mut self, statements: &[Statement], span: Span, level: usize, lead_indent: bool) {
854
+ if lead_indent {
855
+ self.indent(level);
856
+ }
857
+ self.buf.push_str("{\n");
858
+ // Fresh sequence: suppress any blank line immediately after `{`.
859
+ self.emitted = 0;
860
+ self.print_seq(statements, level + 1);
861
+ // Dangling comments between the last statement and `}` (e.g. a note inside an
862
+ // otherwise-empty block). Bound by the real closing brace — `span.end`
863
+ // overshoots to the next token. Brace-less (indent) blocks have no map entry,
864
+ // so `span.start` flushes nothing and the comment migrates to the next sibling.
865
+ let bound = self.braces.get(&span.start).copied().unwrap_or(span.start);
866
+ self.emit_leading_comments(bound, level + 1);
867
+ self.indent(level);
868
+ self.buf.push('}');
869
+ self.emitted = span.start.0;
870
+ }
871
+
872
+ fn stmt_inline_or_block(&mut self, s: &Statement, level: usize) {
873
+ if let Statement::Block { statements, span } = s {
874
+ self.block(statements, *span, level, false);
875
+ } else {
876
+ let sp = s.span();
877
+ self.buf.push_str("{\n");
878
+ self.emitted = 0;
879
+ self.emit_leading_comments(sp.start, level + 1);
880
+ self.stmt(s, level + 1);
881
+ self.emitted = sp.start.0;
882
+ self.emit_trailing_comments(sp.start.0);
883
+ self.buf.push('\n');
884
+ self.indent(level);
885
+ self.buf.push('}');
886
+ self.emitted = sp.start.0;
887
+ }
888
+ }
889
+
890
+ fn import_specs(&mut self, specs: &[ImportSpecifier]) {
891
+ if specs.len() == 1 {
892
+ match &specs[0] {
893
+ ImportSpecifier::Default { name, .. } => self.buf.push_str(name.as_ref()),
894
+ ImportSpecifier::Namespace { name, .. } => {
895
+ self.buf.push_str("* as ");
896
+ self.buf.push_str(name.as_ref());
897
+ }
898
+ ImportSpecifier::Named { name, alias, .. } => {
899
+ self.buf.push_str("{ ");
900
+ self.import_named(name.as_ref(), alias.as_deref());
901
+ self.buf.push_str(" }");
902
+ }
903
+ }
904
+ return;
905
+ }
906
+ // A long named-import list wraps one name per line.
907
+ self.fit(
908
+ |s| s.import_list_inline(specs),
909
+ |s| s.import_list_broken(specs),
910
+ );
911
+ }
912
+
913
+ fn import_named(&mut self, name: &str, alias: Option<&str>) {
914
+ self.buf.push_str(name);
915
+ if let Some(a) = alias {
916
+ self.buf.push_str(" as ");
917
+ self.buf.push_str(a);
918
+ }
919
+ }
920
+
921
+ fn import_list_inline(&mut self, specs: &[ImportSpecifier]) {
922
+ self.buf.push_str("{ ");
923
+ for (i, sp) in specs.iter().enumerate() {
924
+ if i > 0 {
925
+ self.buf.push_str(", ");
926
+ }
927
+ if let ImportSpecifier::Named { name, alias, .. } = sp {
928
+ self.import_named(name.as_ref(), alias.as_deref());
929
+ }
930
+ }
931
+ self.buf.push_str(" }");
932
+ }
933
+
934
+ fn import_list_broken(&mut self, specs: &[ImportSpecifier]) {
935
+ self.buf.push_str("{\n");
936
+ self.depth += 1;
937
+ for (i, sp) in specs.iter().enumerate() {
938
+ if i > 0 {
939
+ self.buf.push_str(",\n");
940
+ }
941
+ self.indent(self.depth);
942
+ if let ImportSpecifier::Named { name, alias, .. } = sp {
943
+ self.import_named(name.as_ref(), alias.as_deref());
944
+ }
945
+ }
946
+ self.depth -= 1;
947
+ self.buf.push('\n');
948
+ self.indent(self.depth);
949
+ self.buf.push('}');
950
+ }
951
+
952
+ fn param_list(&mut self, params: &[FunParam], rest: &Option<TypedParam>) {
953
+ for (i, p) in params.iter().enumerate() {
954
+ if i > 0 {
955
+ self.buf.push_str(", ");
956
+ }
957
+ match p {
958
+ FunParam::Simple(tp) => {
959
+ self.buf.push_str(tp.name.as_ref());
960
+ if let Some(t) = &tp.type_ann {
961
+ self.buf.push_str(": ");
962
+ self.type_ann(t);
963
+ }
964
+ if let Some(e) = &tp.default {
965
+ self.buf.push_str(" = ");
966
+ self.expr(e);
967
+ }
968
+ }
969
+ FunParam::Destructure {
970
+ pattern,
971
+ type_ann,
972
+ default,
973
+ } => {
974
+ self.destruct_pat(pattern);
975
+ if let Some(t) = type_ann {
976
+ self.buf.push_str(": ");
977
+ self.type_ann(t);
978
+ }
979
+ if let Some(e) = default {
980
+ self.buf.push_str(" = ");
981
+ self.expr(e);
982
+ }
983
+ }
984
+ }
985
+ }
986
+ if let Some(r) = rest {
987
+ if !params.is_empty() {
988
+ self.buf.push_str(", ");
989
+ }
990
+ self.buf.push_str("...");
991
+ self.buf.push_str(r.name.as_ref());
992
+ if let Some(t) = &r.type_ann {
993
+ self.buf.push_str(": ");
994
+ self.type_ann(t);
995
+ }
996
+ }
997
+ }
998
+
999
+ fn destruct_pat(&mut self, p: &DestructPattern) {
1000
+ match p {
1001
+ DestructPattern::Array(elems) => {
1002
+ self.buf.push('[');
1003
+ for (i, e) in elems.iter().enumerate() {
1004
+ if i > 0 {
1005
+ self.buf.push_str(", ");
1006
+ }
1007
+ match e {
1008
+ Some(DestructElement::Ident(n, _)) => self.buf.push_str(n.as_ref()),
1009
+ Some(DestructElement::Pattern(inner)) => self.destruct_pat(inner),
1010
+ Some(DestructElement::Rest(n, _)) => {
1011
+ self.buf.push_str("...");
1012
+ self.buf.push_str(n.as_ref());
1013
+ }
1014
+ None => {}
1015
+ }
1016
+ }
1017
+ self.buf.push(']');
1018
+ }
1019
+ DestructPattern::Object(props) => {
1020
+ self.buf.push_str("{ ");
1021
+ for (i, pr) in props.iter().enumerate() {
1022
+ if i > 0 {
1023
+ self.buf.push_str(", ");
1024
+ }
1025
+ self.buf.push_str(pr.key.as_ref());
1026
+ match &pr.value {
1027
+ DestructElement::Ident(n, _) if n.as_ref() != pr.key.as_ref() => {
1028
+ self.buf.push_str(": ");
1029
+ self.buf.push_str(n.as_ref());
1030
+ }
1031
+ DestructElement::Ident(_, _) => {}
1032
+ DestructElement::Pattern(inner) => {
1033
+ self.buf.push_str(": ");
1034
+ self.destruct_pat(inner);
1035
+ }
1036
+ DestructElement::Rest(n, _) => {
1037
+ self.buf.push_str(": ...");
1038
+ self.buf.push_str(n.as_ref());
1039
+ }
1040
+ }
1041
+ }
1042
+ self.buf.push_str(" }");
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ fn type_ann(&mut self, t: &TypeAnnotation) {
1048
+ match t {
1049
+ TypeAnnotation::Simple(s) => self.buf.push_str(s.as_ref()),
1050
+ TypeAnnotation::Array(inner) => {
1051
+ self.type_ann(inner);
1052
+ self.buf.push_str("[]");
1053
+ }
1054
+ TypeAnnotation::Object(props) => {
1055
+ self.buf.push_str("{ ");
1056
+ for (i, (k, v)) in props.iter().enumerate() {
1057
+ if i > 0 {
1058
+ self.buf.push_str(", ");
1059
+ }
1060
+ self.buf.push_str(k.as_ref());
1061
+ self.buf.push_str(": ");
1062
+ self.type_ann(v);
1063
+ }
1064
+ self.buf.push_str(" }");
1065
+ }
1066
+ TypeAnnotation::Function { params, returns } => {
1067
+ self.buf.push('(');
1068
+ for (i, p) in params.iter().enumerate() {
1069
+ if i > 0 {
1070
+ self.buf.push_str(", ");
1071
+ }
1072
+ self.type_ann(p);
1073
+ }
1074
+ self.buf.push_str(") => ");
1075
+ self.type_ann(returns);
1076
+ }
1077
+ TypeAnnotation::Union(u) => {
1078
+ for (i, x) in u.iter().enumerate() {
1079
+ if i > 0 {
1080
+ self.buf.push_str(" | ");
1081
+ }
1082
+ self.type_ann(x);
1083
+ }
1084
+ }
1085
+ TypeAnnotation::Tuple(elems) => {
1086
+ self.buf.push('[');
1087
+ for (i, x) in elems.iter().enumerate() {
1088
+ if i > 0 {
1089
+ self.buf.push_str(", ");
1090
+ }
1091
+ self.type_ann(x);
1092
+ }
1093
+ self.buf.push(']');
1094
+ }
1095
+ TypeAnnotation::Literal(lit) => match lit {
1096
+ tishlang_ast::TypeLiteral::Str(s) => {
1097
+ self.buf.push('"');
1098
+ self.buf.push_str(s.as_ref());
1099
+ self.buf.push('"');
1100
+ }
1101
+ tishlang_ast::TypeLiteral::Num(n) => self.buf.push_str(&n.to_string()),
1102
+ tishlang_ast::TypeLiteral::Bool(b) => self.buf.push_str(&b.to_string()),
1103
+ },
1104
+ TypeAnnotation::Intersection(parts) => {
1105
+ for (i, x) in parts.iter().enumerate() {
1106
+ if i > 0 {
1107
+ self.buf.push_str(" & ");
1108
+ }
1109
+ self.type_ann(x);
1110
+ }
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ /// Print `e` as a sub-expression, wrapping it in parentheses when its operator
1116
+ /// binds looser than `min_prec` (so the printed form re-parses to the same AST).
1117
+ fn child(&mut self, e: &Expr, min_prec: u8) {
1118
+ if expr_prec(e) < min_prec {
1119
+ self.buf.push('(');
1120
+ self.expr(e);
1121
+ self.buf.push(')');
1122
+ } else {
1123
+ self.expr(e);
1124
+ }
1125
+ }
1126
+
1127
+ fn expr(&mut self, e: &Expr) {
1128
+ match e {
1129
+ Expr::Literal { value, .. } => match value {
1130
+ Literal::Number(n) => {
1131
+ if n.fract() == 0.0 && n.abs() < 1e15 {
1132
+ self.buf.push_str(&format!("{}", *n as i64));
1133
+ } else {
1134
+ self.buf.push_str(&format!("{}", n));
1135
+ }
1136
+ }
1137
+ Literal::String(s) => self.string_lit(s.as_ref()),
1138
+ Literal::Bool(b) => self.buf.push_str(if *b { "true" } else { "false" }),
1139
+ Literal::Null => self.buf.push_str("null"),
1140
+ },
1141
+ Expr::Ident { name, .. } => self.buf.push_str(name.as_ref()),
1142
+ Expr::Binary {
1143
+ left, op, right, ..
1144
+ } => {
1145
+ // Parenthesize operands by precedence/associativity so the printed
1146
+ // grouping re-parses to the same tree (the AST has no paren nodes).
1147
+ let p = binop_prec(*op);
1148
+ let right_assoc = matches!(op, BinOp::Pow);
1149
+ let (lmin, rmin) = if right_assoc { (p + 1, p) } else { (p, p + 1) };
1150
+ self.child(left, lmin);
1151
+ self.buf.push(' ');
1152
+ self.buf.push_str(binop(*op));
1153
+ self.buf.push(' ');
1154
+ self.child(right, rmin);
1155
+ }
1156
+ Expr::Unary { op, operand, .. } => {
1157
+ match op {
1158
+ UnaryOp::Not => self.buf.push('!'),
1159
+ UnaryOp::Neg => self.buf.push('-'),
1160
+ UnaryOp::Pos => self.buf.push('+'),
1161
+ UnaryOp::BitNot => self.buf.push('~'),
1162
+ UnaryOp::Void => self.buf.push_str("void "),
1163
+ }
1164
+ self.child(operand, PREC_POSTFIX);
1165
+ }
1166
+ Expr::Call { callee, args, .. } => {
1167
+ self.child(callee, PREC_POSTFIX);
1168
+ self.emit_args(args);
1169
+ }
1170
+ Expr::New { callee, args, .. } => {
1171
+ self.buf.push_str("new ");
1172
+ self.child(callee, PREC_POSTFIX);
1173
+ if !args.is_empty() {
1174
+ self.emit_args(args);
1175
+ }
1176
+ }
1177
+ Expr::Member {
1178
+ object,
1179
+ prop,
1180
+ optional,
1181
+ ..
1182
+ } => {
1183
+ self.child(object, PREC_POSTFIX);
1184
+ if *optional {
1185
+ self.buf.push_str("?.");
1186
+ } else {
1187
+ self.buf.push('.');
1188
+ }
1189
+ match prop {
1190
+ MemberProp::Name { name, .. } => self.buf.push_str(name.as_ref()),
1191
+ MemberProp::Expr(ex) => {
1192
+ self.buf.push('[');
1193
+ self.expr(ex);
1194
+ self.buf.push(']');
1195
+ }
1196
+ }
1197
+ }
1198
+ Expr::Index {
1199
+ object,
1200
+ index,
1201
+ optional,
1202
+ ..
1203
+ } => {
1204
+ self.child(object, PREC_POSTFIX);
1205
+ if *optional {
1206
+ self.buf.push_str("?.[");
1207
+ } else {
1208
+ self.buf.push('[');
1209
+ }
1210
+ self.expr(index);
1211
+ self.buf.push(']');
1212
+ }
1213
+ Expr::Conditional {
1214
+ cond,
1215
+ then_branch,
1216
+ else_branch,
1217
+ ..
1218
+ } => {
1219
+ // cond binds tighter than `?:`; the else chains (right-assoc).
1220
+ self.child(cond, PREC_NULLISH);
1221
+ self.buf.push_str(" ? ");
1222
+ self.expr(then_branch);
1223
+ self.buf.push_str(" : ");
1224
+ self.child(else_branch, PREC_CONDITIONAL);
1225
+ }
1226
+ Expr::NullishCoalesce { left, right, .. } => {
1227
+ self.child(left, PREC_NULLISH);
1228
+ self.buf.push_str(" ?? ");
1229
+ self.child(right, PREC_NULLISH + 1);
1230
+ }
1231
+ Expr::Array { elements, .. } => self.emit_array(elements),
1232
+ Expr::Object { props, .. } => self.emit_object(props),
1233
+ Expr::Assign { name, value, .. } => {
1234
+ self.buf.push_str(name.as_ref());
1235
+ self.buf.push_str(" = ");
1236
+ self.expr(value);
1237
+ }
1238
+ Expr::TypeOf { operand, .. } => {
1239
+ self.buf.push_str("typeof ");
1240
+ self.child(operand, PREC_POSTFIX);
1241
+ }
1242
+ Expr::Delete { target, .. } => {
1243
+ self.buf.push_str("delete ");
1244
+ self.child(target, PREC_POSTFIX);
1245
+ }
1246
+ Expr::PostfixInc { name, .. } => {
1247
+ self.buf.push_str(name.as_ref());
1248
+ self.buf.push_str("++");
1249
+ }
1250
+ Expr::PostfixDec { name, .. } => {
1251
+ self.buf.push_str(name.as_ref());
1252
+ self.buf.push_str("--");
1253
+ }
1254
+ Expr::PrefixInc { name, .. } => {
1255
+ self.buf.push_str("++");
1256
+ self.buf.push_str(name.as_ref());
1257
+ }
1258
+ Expr::PrefixDec { name, .. } => {
1259
+ self.buf.push_str("--");
1260
+ self.buf.push_str(name.as_ref());
1261
+ }
1262
+ Expr::CompoundAssign {
1263
+ name, op, value, ..
1264
+ } => {
1265
+ self.buf.push_str(name.as_ref());
1266
+ self.buf.push_str(compound(*op));
1267
+ self.expr(value);
1268
+ }
1269
+ Expr::LogicalAssign {
1270
+ name, op, value, ..
1271
+ } => {
1272
+ self.buf.push_str(name.as_ref());
1273
+ self.buf.push_str(logical_assign(*op));
1274
+ self.expr(value);
1275
+ }
1276
+ Expr::MemberAssign {
1277
+ object,
1278
+ prop,
1279
+ value,
1280
+ ..
1281
+ } => {
1282
+ self.child(object, PREC_POSTFIX);
1283
+ self.buf.push('.');
1284
+ self.buf.push_str(prop.as_ref());
1285
+ self.buf.push_str(" = ");
1286
+ self.expr(value);
1287
+ }
1288
+ Expr::IndexAssign {
1289
+ object,
1290
+ index,
1291
+ value,
1292
+ ..
1293
+ } => {
1294
+ self.child(object, PREC_POSTFIX);
1295
+ self.buf.push('[');
1296
+ self.expr(index);
1297
+ self.buf.push_str("] = ");
1298
+ self.expr(value);
1299
+ }
1300
+ Expr::ArrowFunction { params, body, .. } => {
1301
+ self.buf.push('(');
1302
+ self.param_list(params, &None);
1303
+ self.buf.push_str(") => ");
1304
+ match body {
1305
+ // A bare object-literal body must be parenthesized, else `=> {`
1306
+ // re-parses as a block.
1307
+ ArrowBody::Expr(e) => {
1308
+ if matches!(e.as_ref(), Expr::Object { .. }) {
1309
+ self.buf.push('(');
1310
+ self.expr(e);
1311
+ self.buf.push(')');
1312
+ } else {
1313
+ self.expr(e);
1314
+ }
1315
+ }
1316
+ // Indent the block body relative to the arrow's own line
1317
+ // (`self.depth`), printed inline after `=> ` (no leading indent).
1318
+ ArrowBody::Block(b) => self.stmt_inline_or_block(b, self.depth),
1319
+ }
1320
+ }
1321
+ Expr::TemplateLiteral { quasis, exprs, .. } => {
1322
+ self.buf.push('`');
1323
+ for (i, q) in quasis.iter().enumerate() {
1324
+ self.buf.push_str(&escape_template(q.as_ref()));
1325
+ if i < exprs.len() {
1326
+ self.buf.push_str("${");
1327
+ self.expr(&exprs[i]);
1328
+ self.buf.push('}');
1329
+ }
1330
+ }
1331
+ self.buf.push('`');
1332
+ }
1333
+ Expr::Await { operand, .. } => {
1334
+ self.buf.push_str("await ");
1335
+ self.child(operand, PREC_POSTFIX);
1336
+ }
1337
+ Expr::JsxElement {
1338
+ tag,
1339
+ props,
1340
+ children,
1341
+ ..
1342
+ } => {
1343
+ self.buf.push('<');
1344
+ self.buf.push_str(tag.as_ref());
1345
+ for pr in props {
1346
+ match pr {
1347
+ JsxProp::Attr { name, value } => {
1348
+ self.buf.push(' ');
1349
+ self.buf.push_str(name.as_ref());
1350
+ match value {
1351
+ JsxAttrValue::String(s) => {
1352
+ self.buf.push('=');
1353
+ self.string_lit(s.as_ref());
1354
+ }
1355
+ JsxAttrValue::Expr(e) => {
1356
+ self.buf.push_str("={");
1357
+ self.expr(e);
1358
+ self.buf.push('}');
1359
+ }
1360
+ JsxAttrValue::ImplicitTrue => {}
1361
+ }
1362
+ }
1363
+ JsxProp::Spread(e) => {
1364
+ self.buf.push_str(" {...");
1365
+ self.expr(e);
1366
+ self.buf.push_str("} ");
1367
+ }
1368
+ }
1369
+ }
1370
+ if children.is_empty() {
1371
+ self.buf.push_str(" />");
1372
+ } else {
1373
+ let compact = children.len() == 1
1374
+ && matches!(
1375
+ &children[0],
1376
+ JsxChild::Text(t) if !t.as_ref().contains('\n')
1377
+ );
1378
+ if compact {
1379
+ self.buf.push('>');
1380
+ if let JsxChild::Text(t) = &children[0] {
1381
+ self.buf.push_str(t.as_ref());
1382
+ }
1383
+ self.buf.push_str("</");
1384
+ self.buf.push_str(tag.as_ref());
1385
+ self.buf.push('>');
1386
+ } else {
1387
+ self.buf.push('>');
1388
+ self.buf.push('\n');
1389
+ for ch in children {
1390
+ self.buf.push_str(" ");
1391
+ match ch {
1392
+ JsxChild::Text(t) => self.buf.push_str(t.as_ref()),
1393
+ JsxChild::Expr(e) => {
1394
+ self.buf.push('{');
1395
+ self.expr(e);
1396
+ self.buf.push('}');
1397
+ }
1398
+ }
1399
+ self.buf.push('\n');
1400
+ }
1401
+ self.buf.push_str(" </");
1402
+ self.buf.push_str(tag.as_ref());
1403
+ self.buf.push('>');
1404
+ }
1405
+ }
1406
+ }
1407
+ Expr::JsxFragment { children, .. } => {
1408
+ self.buf.push_str("<>");
1409
+ if children.is_empty() {
1410
+ self.buf.push_str("</>");
1411
+ } else {
1412
+ let compact = children.len() == 1
1413
+ && matches!(
1414
+ &children[0],
1415
+ JsxChild::Text(t) if !t.as_ref().contains('\n')
1416
+ );
1417
+ if compact {
1418
+ if let JsxChild::Text(t) = &children[0] {
1419
+ self.buf.push_str(t.as_ref());
1420
+ }
1421
+ self.buf.push_str("</>");
1422
+ } else {
1423
+ self.buf.push('\n');
1424
+ for ch in children {
1425
+ self.buf.push_str(" ");
1426
+ match ch {
1427
+ JsxChild::Text(t) => self.buf.push_str(t.as_ref()),
1428
+ JsxChild::Expr(e) => {
1429
+ self.buf.push('{');
1430
+ self.expr(e);
1431
+ self.buf.push('}');
1432
+ }
1433
+ }
1434
+ self.buf.push('\n');
1435
+ }
1436
+ self.buf.push_str("</>");
1437
+ }
1438
+ }
1439
+ }
1440
+ Expr::NativeModuleLoad {
1441
+ spec, export_name, ..
1442
+ } => {
1443
+ self.buf.push_str("import { ");
1444
+ self.buf.push_str(export_name.as_ref());
1445
+ self.buf.push_str(" } from ");
1446
+ self.string_lit(spec.as_ref());
1447
+ }
1448
+ }
1449
+ }
1450
+
1451
+ fn string_lit(&mut self, s: &str) {
1452
+ self.buf.push('"');
1453
+ for c in s.chars() {
1454
+ match c {
1455
+ '\\' => self.buf.push_str("\\\\"),
1456
+ '"' => self.buf.push_str("\\\""),
1457
+ '\n' => self.buf.push_str("\\n"),
1458
+ '\r' => self.buf.push_str("\\r"),
1459
+ '\t' => self.buf.push_str("\\t"),
1460
+ c if c.is_control() => self.buf.push_str(&format!("\\u{:04x}", c as u32)),
1461
+ c => self.buf.push(c),
1462
+ }
1463
+ }
1464
+ self.buf.push('"');
1465
+ }
1466
+ }
1467
+
1468
+ fn escape_template(s: &str) -> String {
1469
+ s.replace('\\', "\\\\")
1470
+ .replace('`', "\\`")
1471
+ .replace('$', "\\$")
1472
+ }
1473
+
1474
+ // Expression precedence levels (higher binds tighter), mirroring the parser's
1475
+ // descent chain (parse_conditional → … → parse_unary → primary). Used to decide
1476
+ // when a sub-expression needs parentheses.
1477
+ const PREC_CONDITIONAL: u8 = 1;
1478
+ const PREC_NULLISH: u8 = 2;
1479
+ const PREC_POSTFIX: u8 = 15; // call / member / index — tighter than any operator
1480
+ const PREC_ATOM: u8 = 16; // literals, identifiers, array/object/template/jsx
1481
+
1482
+ /// True when a comment is exactly the ignore marker — `// tish-fmt-ignore` or the
1483
+ /// `/* tish-fmt-ignore */` block form.
1484
+ fn is_ignore_marker(text: &str) -> bool {
1485
+ let t = text.trim();
1486
+ let inner = if let Some(r) = t.strip_prefix("//") {
1487
+ r
1488
+ } else if let Some(r) = t.strip_prefix("/*").and_then(|x| x.strip_suffix("*/")) {
1489
+ r
1490
+ } else {
1491
+ return false;
1492
+ };
1493
+ inner.trim() == IGNORE_MARKER
1494
+ }
1495
+
1496
+ /// Precedence of a binary operator, matching the parser (parser.rs `parse_*`).
1497
+ fn binop_prec(op: BinOp) -> u8 {
1498
+ match op {
1499
+ BinOp::Or => 3,
1500
+ BinOp::And => 4,
1501
+ BinOp::BitOr => 5,
1502
+ BinOp::BitXor => 6,
1503
+ BinOp::BitAnd => 7,
1504
+ BinOp::Shl | BinOp::Shr | BinOp::UShr => 8,
1505
+ BinOp::Eq | BinOp::Ne | BinOp::StrictEq | BinOp::StrictNe => 9,
1506
+ BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge | BinOp::In => 10,
1507
+ BinOp::Add | BinOp::Sub => 11,
1508
+ BinOp::Mul | BinOp::Div | BinOp::Mod => 12,
1509
+ BinOp::Pow => 13,
1510
+ }
1511
+ }
1512
+
1513
+ /// Precedence of an expression's outermost operator (atoms bind tightest).
1514
+ fn expr_prec(e: &Expr) -> u8 {
1515
+ match e {
1516
+ Expr::Assign { .. }
1517
+ | Expr::MemberAssign { .. }
1518
+ | Expr::IndexAssign { .. }
1519
+ | Expr::CompoundAssign { .. }
1520
+ | Expr::LogicalAssign { .. } => 0,
1521
+ Expr::Conditional { .. } => PREC_CONDITIONAL,
1522
+ Expr::NullishCoalesce { .. } => PREC_NULLISH,
1523
+ Expr::Binary { op, .. } => binop_prec(*op),
1524
+ Expr::Unary { .. }
1525
+ | Expr::TypeOf { .. }
1526
+ | Expr::Delete { .. }
1527
+ | Expr::Await { .. }
1528
+ | Expr::PrefixInc { .. }
1529
+ | Expr::PrefixDec { .. } => 14,
1530
+ Expr::Call { .. }
1531
+ | Expr::New { .. }
1532
+ | Expr::Member { .. }
1533
+ | Expr::Index { .. }
1534
+ | Expr::PostfixInc { .. }
1535
+ | Expr::PostfixDec { .. } => PREC_POSTFIX,
1536
+ Expr::ArrowFunction { .. } => PREC_ATOM,
1537
+ _ => PREC_ATOM,
1538
+ }
1539
+ }
1540
+
1541
+ fn binop(op: BinOp) -> &'static str {
1542
+ match op {
1543
+ BinOp::Add => "+",
1544
+ BinOp::Sub => "-",
1545
+ BinOp::Mul => "*",
1546
+ BinOp::Div => "/",
1547
+ BinOp::Mod => "%",
1548
+ BinOp::Pow => "**",
1549
+ BinOp::Eq => "==",
1550
+ BinOp::Ne => "!=",
1551
+ BinOp::StrictEq => "===",
1552
+ BinOp::StrictNe => "!==",
1553
+ BinOp::Lt => "<",
1554
+ BinOp::Le => "<=",
1555
+ BinOp::Gt => ">",
1556
+ BinOp::Ge => ">=",
1557
+ BinOp::And => "&&",
1558
+ BinOp::Or => "||",
1559
+ BinOp::BitAnd => "&",
1560
+ BinOp::BitOr => "|",
1561
+ BinOp::BitXor => "^",
1562
+ BinOp::Shl => "<<",
1563
+ BinOp::Shr => ">>",
1564
+ BinOp::UShr => ">>>",
1565
+ BinOp::In => "in",
1566
+ }
1567
+ }
1568
+
1569
+ fn compound(op: CompoundOp) -> &'static str {
1570
+ match op {
1571
+ CompoundOp::Add => " += ",
1572
+ CompoundOp::Sub => " -= ",
1573
+ CompoundOp::Mul => " *= ",
1574
+ CompoundOp::Div => " /= ",
1575
+ CompoundOp::Mod => " %= ",
1576
+ }
1577
+ }
1578
+
1579
+ fn logical_assign(op: LogicalAssignOp) -> &'static str {
1580
+ match op {
1581
+ LogicalAssignOp::AndAnd => " &&= ",
1582
+ LogicalAssignOp::OrOr => " ||= ",
1583
+ LogicalAssignOp::Nullish => " ??= ",
1584
+ }
1585
+ }
1586
+
1587
+ /// Recover `//` and `/* */` comments from source, in order, with their 1-based
1588
+ /// (line, col) positions — matching `ast::Span`'s convention so the printer can
1589
+ /// re-insert them by position. The lexer discards comments, so this is a separate
1590
+ /// pass; it skips string and template literals so a `//` inside `"…"` or `` `…` ``
1591
+ /// is never mistaken for a comment. (Tish has no regex literals, so a bare `/` is
1592
+ /// only division or a comment opener — no further disambiguation is needed.)
1593
+ fn scan_comments(source: &str) -> (Vec<CommentTok>, BraceMap, Vec<(usize, usize)>) {
1594
+ let mut s = Scanner {
1595
+ chars: source.chars().collect(),
1596
+ i: 0,
1597
+ line: 1,
1598
+ col: 1,
1599
+ seen_nonws: false,
1600
+ out: Vec::new(),
1601
+ brace_stack: Vec::new(),
1602
+ braces: BraceMap::new(),
1603
+ bracket_open: Vec::new(),
1604
+ bracket_spans: Vec::new(),
1605
+ };
1606
+ s.scan_code(false);
1607
+ (s.out, s.braces, s.bracket_spans)
1608
+ }
1609
+
1610
+ struct Scanner {
1611
+ chars: Vec<char>,
1612
+ i: usize,
1613
+ line: usize,
1614
+ col: usize,
1615
+ /// Whether any non-whitespace has appeared on the current line yet.
1616
+ seen_nonws: bool,
1617
+ out: Vec<CommentTok>,
1618
+ /// Open `{` positions awaiting their match, for building `braces`.
1619
+ brace_stack: Vec<(usize, usize)>,
1620
+ /// `{`→`}` position pairs (see [`BraceMap`]).
1621
+ braces: BraceMap,
1622
+ /// Open lines of every bracket (`{ [ (`) awaiting its match.
1623
+ bracket_open: Vec<usize>,
1624
+ /// `(open_line, close_line)` for every bracket pair — lets an ignored statement's
1625
+ /// verbatim slice extend over the full lines of any `{}`/`[]`/`()` it opens.
1626
+ bracket_spans: Vec<(usize, usize)>,
1627
+ }
1628
+
1629
+ impl Scanner {
1630
+ fn peek(&self) -> Option<char> {
1631
+ self.chars.get(self.i).copied()
1632
+ }
1633
+
1634
+ fn peek2(&self) -> Option<char> {
1635
+ self.chars.get(self.i + 1).copied()
1636
+ }
1637
+
1638
+ /// Consume one char, tracking line/col like the lexer (col resets after `\n`).
1639
+ fn bump(&mut self) -> Option<char> {
1640
+ let c = *self.chars.get(self.i)?;
1641
+ self.i += 1;
1642
+ if c == '\n' {
1643
+ self.line += 1;
1644
+ self.col = 1;
1645
+ self.seen_nonws = false;
1646
+ } else {
1647
+ self.col += 1;
1648
+ }
1649
+ Some(c)
1650
+ }
1651
+
1652
+ /// Scan ordinary code, collecting comments. When `stop_at_close_brace` is set
1653
+ /// (inside a `${ … }` template interpolation), returns at the matching `}`
1654
+ /// without consuming it.
1655
+ fn scan_code(&mut self, stop_at_close_brace: bool) {
1656
+ let mut depth = 0usize;
1657
+ while let Some(c) = self.peek() {
1658
+ match c {
1659
+ '}' if stop_at_close_brace && depth == 0 => return,
1660
+ '{' => {
1661
+ self.brace_stack.push((self.line, self.col));
1662
+ self.bracket_open.push(self.line);
1663
+ depth += 1;
1664
+ self.bump();
1665
+ self.seen_nonws = true;
1666
+ }
1667
+ '}' => {
1668
+ let close = (self.line, self.col);
1669
+ if let Some(open) = self.brace_stack.pop() {
1670
+ self.braces.insert(open, close);
1671
+ }
1672
+ if let Some(open_line) = self.bracket_open.pop() {
1673
+ self.bracket_spans.push((open_line, self.line));
1674
+ }
1675
+ depth = depth.saturating_sub(1);
1676
+ self.bump();
1677
+ self.seen_nonws = true;
1678
+ }
1679
+ '(' | '[' => {
1680
+ self.bracket_open.push(self.line);
1681
+ self.bump();
1682
+ self.seen_nonws = true;
1683
+ }
1684
+ ')' | ']' => {
1685
+ if let Some(open_line) = self.bracket_open.pop() {
1686
+ self.bracket_spans.push((open_line, self.line));
1687
+ }
1688
+ self.bump();
1689
+ self.seen_nonws = true;
1690
+ }
1691
+ '"' | '\'' => self.scan_string(c),
1692
+ '`' => {
1693
+ self.bump();
1694
+ self.seen_nonws = true;
1695
+ self.scan_template();
1696
+ }
1697
+ '/' if self.peek2() == Some('/') => self.scan_line_comment(),
1698
+ '/' if self.peek2() == Some('*') => self.scan_block_comment(),
1699
+ ' ' | '\t' | '\r' | '\n' => {
1700
+ self.bump();
1701
+ }
1702
+ _ => {
1703
+ self.bump();
1704
+ self.seen_nonws = true;
1705
+ }
1706
+ }
1707
+ }
1708
+ }
1709
+
1710
+ fn scan_string(&mut self, quote: char) {
1711
+ self.bump(); // opening quote
1712
+ self.seen_nonws = true;
1713
+ while let Some(c) = self.bump() {
1714
+ if c == '\\' {
1715
+ self.bump(); // escaped char
1716
+ } else if c == quote {
1717
+ break;
1718
+ }
1719
+ }
1720
+ }
1721
+
1722
+ /// Scan a template literal body (opening backtick already consumed), recursing
1723
+ /// into `${ … }` interpolations so their strings/comments are handled too.
1724
+ fn scan_template(&mut self) {
1725
+ while let Some(c) = self.peek() {
1726
+ match c {
1727
+ '`' => {
1728
+ self.bump();
1729
+ return;
1730
+ }
1731
+ '\\' => {
1732
+ self.bump();
1733
+ self.bump();
1734
+ }
1735
+ '$' if self.peek2() == Some('{') => {
1736
+ self.bump();
1737
+ self.bump();
1738
+ self.scan_code(true);
1739
+ if self.peek() == Some('}') {
1740
+ self.bump();
1741
+ }
1742
+ }
1743
+ _ => {
1744
+ self.bump();
1745
+ }
1746
+ }
1747
+ }
1748
+ }
1749
+
1750
+ fn scan_line_comment(&mut self) {
1751
+ let start = (self.line, self.col);
1752
+ let own_line = !self.seen_nonws;
1753
+ let mut text = String::new();
1754
+ while let Some(c) = self.peek() {
1755
+ if c == '\n' {
1756
+ break;
1757
+ }
1758
+ text.push(c);
1759
+ self.bump();
1760
+ }
1761
+ self.out.push(CommentTok {
1762
+ start,
1763
+ text: text.trim_end().to_string(),
1764
+ own_line,
1765
+ });
1766
+ }
1767
+
1768
+ fn scan_block_comment(&mut self) {
1769
+ let start = (self.line, self.col);
1770
+ let own_line = !self.seen_nonws;
1771
+ let mut text = String::new();
1772
+ let mut depth = 0usize;
1773
+ loop {
1774
+ if self.peek() == Some('/') && self.peek2() == Some('*') {
1775
+ text.push('/');
1776
+ text.push('*');
1777
+ self.bump();
1778
+ self.bump();
1779
+ depth += 1;
1780
+ } else if self.peek() == Some('*') && self.peek2() == Some('/') {
1781
+ text.push('*');
1782
+ text.push('/');
1783
+ self.bump();
1784
+ self.bump();
1785
+ depth -= 1;
1786
+ if depth == 0 {
1787
+ break;
1788
+ }
1789
+ } else {
1790
+ match self.bump() {
1791
+ Some(c) => text.push(c),
1792
+ None => break,
1793
+ }
1794
+ }
1795
+ }
1796
+ self.out.push(CommentTok {
1797
+ start,
1798
+ text,
1799
+ own_line,
1800
+ });
1801
+ self.seen_nonws = true;
1802
+ }
1803
+ }
1804
+
1805
+ #[cfg(test)]
1806
+ mod tests {
1807
+ use super::*;
1808
+
1809
+ #[test]
1810
+ fn round_trip_simple() {
1811
+ let src = "fn add(a, b) {\n return a + b\n}\n";
1812
+ let out = format_source(src).unwrap();
1813
+ let _ = tishlang_parser::parse(&out).unwrap();
1814
+ }
1815
+
1816
+ #[test]
1817
+ fn jsx_multiline_when_mixed_children() {
1818
+ let src = "let x = <div>a{b}</div>\n";
1819
+ let out = format_source(src).unwrap();
1820
+ assert!(
1821
+ out.contains('\n'),
1822
+ "expected line breaks in formatted JSX: {out:?}"
1823
+ );
1824
+ let _ = tishlang_parser::parse(&out).unwrap();
1825
+ }
1826
+
1827
+ #[test]
1828
+ fn preserves_leading_and_section_comments() {
1829
+ let src = "\
1830
+ // file header
1831
+ // second line
1832
+ let a = 1
1833
+
1834
+ // a section
1835
+ let b = 2
1836
+ ";
1837
+ let out = format_source(src).unwrap();
1838
+ assert!(out.contains("// file header"), "{out:?}");
1839
+ assert!(out.contains("// second line"), "{out:?}");
1840
+ assert!(out.contains("// a section"), "{out:?}");
1841
+ // header hugs the statement it documents; blank line before the section.
1842
+ assert!(out.contains("// a section\nlet b = 2"), "{out:?}");
1843
+ let _ = tishlang_parser::parse(&out).unwrap();
1844
+ }
1845
+
1846
+ #[test]
1847
+ fn preserves_trailing_comment() {
1848
+ let src = "let a = 1 // inline note\n";
1849
+ let out = format_source(src).unwrap();
1850
+ assert!(out.contains("let a = 1 // inline note"), "{out:?}");
1851
+ let _ = tishlang_parser::parse(&out).unwrap();
1852
+ }
1853
+
1854
+ #[test]
1855
+ fn preserves_comments_inside_block() {
1856
+ let src = "\
1857
+ fn f() {
1858
+ // step one
1859
+ let a = 1
1860
+ // step two
1861
+ return a
1862
+ }
1863
+ ";
1864
+ let out = format_source(src).unwrap();
1865
+ assert!(out.contains(" // step one"), "{out:?}");
1866
+ assert!(out.contains(" // step two"), "{out:?}");
1867
+ let _ = tishlang_parser::parse(&out).unwrap();
1868
+ }
1869
+
1870
+ #[test]
1871
+ fn preserves_dangling_comment_in_empty_block() {
1872
+ let src = "fn f() {\n // nothing yet\n}\n";
1873
+ let out = format_source(src).unwrap();
1874
+ assert!(out.contains("// nothing yet"), "{out:?}");
1875
+ let _ = tishlang_parser::parse(&out).unwrap();
1876
+ }
1877
+
1878
+ #[test]
1879
+ fn preserves_block_comment() {
1880
+ let src = "/* a block comment */\nlet a = 1\n";
1881
+ let out = format_source(src).unwrap();
1882
+ assert!(out.contains("/* a block comment */"), "{out:?}");
1883
+ let _ = tishlang_parser::parse(&out).unwrap();
1884
+ }
1885
+
1886
+ #[test]
1887
+ fn double_slash_in_string_is_not_a_comment() {
1888
+ let src = "let url = \"http://example.com\"\n";
1889
+ let out = format_source(src).unwrap();
1890
+ assert_eq!(out, "let url = \"http://example.com\"\n", "{out:?}");
1891
+ }
1892
+
1893
+ #[test]
1894
+ fn idempotent_with_comments() {
1895
+ let src = "\
1896
+ // header
1897
+ let a = 1
1898
+
1899
+ fn f() {
1900
+ // body note
1901
+ let b = 2 // trailing
1902
+ return b
1903
+ }
1904
+ ";
1905
+ let once = format_source(src).unwrap();
1906
+ let twice = format_source(&once).unwrap();
1907
+ assert_eq!(
1908
+ once, twice,
1909
+ "formatting is not idempotent:\n{once}\n---\n{twice}"
1910
+ );
1911
+ }
1912
+
1913
+ #[test]
1914
+ fn collapses_multiple_blank_lines_to_one() {
1915
+ let src = "let a = 1\n\n\n\nlet b = 2\n";
1916
+ let out = format_source(src).unwrap();
1917
+ assert_eq!(out, "let a = 1\n\nlet b = 2\n", "{out:?}");
1918
+ }
1919
+
1920
+ #[test]
1921
+ fn comment_after_inner_block_stays_at_outer_level() {
1922
+ // Regression: a comment after an inner block's `}` belongs to the outer
1923
+ // scope, not inside the inner block (the parser's block span.end overshoots
1924
+ // past `}`, which previously pulled this comment in and broke idempotency).
1925
+ let src = "\
1926
+ fn f() {
1927
+ if (x) {
1928
+ a()
1929
+ }
1930
+ // after the if
1931
+ b()
1932
+ }
1933
+ ";
1934
+ let out = format_source(src).unwrap();
1935
+ assert!(out.contains(" // after the if\n b()"), "{out:?}");
1936
+ let twice = format_source(&out).unwrap();
1937
+ assert_eq!(out, twice, "not idempotent:\n{out}\n---\n{twice}");
1938
+ }
1939
+
1940
+ #[test]
1941
+ fn trailing_comment_inside_block_stays_inside() {
1942
+ let src = "\
1943
+ fn f() {
1944
+ a()
1945
+ // last note
1946
+ }
1947
+ ";
1948
+ let out = format_source(src).unwrap();
1949
+ assert!(out.contains(" // last note\n}"), "{out:?}");
1950
+ let twice = format_source(&out).unwrap();
1951
+ assert_eq!(out, twice, "{out:?}");
1952
+ }
1953
+
1954
+ #[test]
1955
+ fn preserves_operator_grouping() {
1956
+ // The AST has no parenthesis nodes, so the printer must re-derive parens
1957
+ // from precedence. Each `want` must re-parse to the same tree.
1958
+ let cases = [
1959
+ ("let a = 1 / (b - c)\n", "1 / (b - c)"),
1960
+ ("let a = 0 - (x + y + z)\n", "0 - (x + y + z)"),
1961
+ ("let a = (1 - (p + q)) * s\n", "(1 - (p + q)) * s"),
1962
+ ("let a = b * c + d\n", "b * c + d"),
1963
+ ("let a = b + c * d\n", "b + c * d"),
1964
+ ("let a = (b + c) * d\n", "(b + c) * d"),
1965
+ ("let a = 1 - 2 - 3\n", "1 - 2 - 3"),
1966
+ ("let a = 1 - (2 - 3)\n", "1 - (2 - 3)"),
1967
+ ("let a = -(b + c)\n", "-(b + c)"),
1968
+ ("let a = !(b && c)\n", "!(b && c)"),
1969
+ ("let a = (a | b) & c\n", "(a | b) & c"),
1970
+ ];
1971
+ for (src, want) in cases {
1972
+ let out = format_source(src).unwrap();
1973
+ assert!(
1974
+ out.contains(want),
1975
+ "for {src:?} expected to contain {want:?}, got {out:?}"
1976
+ );
1977
+ let twice = format_source(&out).unwrap();
1978
+ assert_eq!(out, twice, "not idempotent for {src:?}: {out:?}");
1979
+ }
1980
+ }
1981
+
1982
+ #[test]
1983
+ fn nested_control_flow_brace_spacing() {
1984
+ let src = "fn f() {\n if (a) {\n b()\n }\n}\n";
1985
+ let out = format_source(src).unwrap();
1986
+ assert!(!out.contains(") {"), "double-space before brace: {out:?}");
1987
+ assert!(out.contains(" if (a) {\n"), "{out:?}");
1988
+ let twice = format_source(&out).unwrap();
1989
+ assert_eq!(out, twice, "{out:?}");
1990
+ }
1991
+
1992
+ #[test]
1993
+ fn short_object_stays_inline() {
1994
+ let src = "let a = { x: 1, y: 2 }\n";
1995
+ assert_eq!(format_source(src).unwrap(), src);
1996
+ }
1997
+
1998
+ #[test]
1999
+ fn long_object_breaks_one_per_line() {
2000
+ let props: Vec<String> = (0..12).map(|i| format!("key{i}: {i}")).collect();
2001
+ let src = format!("let a = {{ {} }}\n", props.join(", "));
2002
+ let out = format_source(&src).unwrap();
2003
+ assert!(
2004
+ out.contains("let a = {\n key0: 0,\n"),
2005
+ "expected broken object:\n{out}"
2006
+ );
2007
+ assert!(out.ends_with("\n}\n"), "{out:?}");
2008
+ // idempotent and re-parses
2009
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent:\n{out}");
2010
+ tishlang_parser::parse(&out).unwrap();
2011
+ }
2012
+
2013
+ #[test]
2014
+ fn long_array_breaks_one_per_line() {
2015
+ let elems: Vec<String> = (0..40).map(|i| i.to_string()).collect();
2016
+ let src = format!("let a = [{}]\n", elems.join(", "));
2017
+ let out = format_source(&src).unwrap();
2018
+ assert!(
2019
+ out.contains("[\n 0,\n 1,\n"),
2020
+ "expected broken array:\n{out}"
2021
+ );
2022
+ assert_eq!(format_source(&out).unwrap(), out);
2023
+ }
2024
+
2025
+ #[test]
2026
+ fn last_arg_object_hugs_parens() {
2027
+ let props: Vec<String> = (0..20).map(|i| format!("k{i}: {i}")).collect();
2028
+ let src = format!("f(a, {{ {} }})\n", props.join(", "));
2029
+ let out = format_source(&src).unwrap();
2030
+ assert!(
2031
+ out.starts_with("f(a, {\n"),
2032
+ "expected hugged object:\n{out}"
2033
+ );
2034
+ assert!(out.contains("\n})\n"), "expected hugged close:\n{out}");
2035
+ assert_eq!(format_source(&out).unwrap(), out);
2036
+ tishlang_parser::parse(&out).unwrap();
2037
+ }
2038
+
2039
+ #[test]
2040
+ fn nested_containers_indent_progressively() {
2041
+ let inner: Vec<String> = (0..16).map(|i| format!("p{i}: {i}")).collect();
2042
+ let src = format!("let a = {{ outer: {{ {} }} }}\n", inner.join(", "));
2043
+ let out = format_source(&src).unwrap();
2044
+ // outer object at 2 spaces, inner props at 4 spaces
2045
+ assert!(
2046
+ out.contains("\n outer: {\n p0: 0,"),
2047
+ "expected nested indent:\n{out}"
2048
+ );
2049
+ assert_eq!(format_source(&out).unwrap(), out);
2050
+ }
2051
+
2052
+ #[test]
2053
+ fn arrow_block_body_indents_to_context() {
2054
+ let src =
2055
+ "export fn make() {\n let s = {}\n s.go = (x) => {\n foo(x)\n }\n return s\n}\n";
2056
+ let out = format_source(src).unwrap();
2057
+ // Arrow body one level past its `s.go` line (4 spaces); closing `}` at 2.
2058
+ assert!(
2059
+ out.contains(" s.go = (x) => {\n foo(x)\n }\n"),
2060
+ "arrow body mis-indented:\n{out}"
2061
+ );
2062
+ assert_eq!(format_source(&out).unwrap(), out);
2063
+ }
2064
+
2065
+ #[test]
2066
+ fn ignore_marker_preserves_statement_verbatim() {
2067
+ 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";
2068
+ let out = format_source(src).unwrap();
2069
+ // The ignored function keeps its exact source (aligned, no spaces around `=`).
2070
+ assert!(
2071
+ out.contains("export fn m(out) {\n out[0]=1; out[1]=0\n out[2]=0; out[3]=1\n}"),
2072
+ "ignored block not verbatim:\n{out}"
2073
+ );
2074
+ // Surrounding code is still formatted normally.
2075
+ assert!(
2076
+ out.contains("let x = { a: 1, b: 2 }"),
2077
+ "neighbour not formatted:\n{out}"
2078
+ );
2079
+ // The marker itself is kept.
2080
+ assert!(out.contains("// tish-fmt-ignore\n"), "{out}");
2081
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent:\n{out}");
2082
+ tishlang_parser::parse(&out).unwrap();
2083
+ }
2084
+
2085
+ #[test]
2086
+ fn ignore_marker_block_comment_form() {
2087
+ let src = "/* tish-fmt-ignore */\nlet a = [1,2, 3]\n";
2088
+ let out = format_source(src).unwrap();
2089
+ assert!(
2090
+ out.contains("let a = [1,2, 3]"),
2091
+ "expected verbatim array:\n{out}"
2092
+ );
2093
+ }
2094
+
2095
+ #[test]
2096
+ fn ignore_in_switch_case_does_not_overrun() {
2097
+ // Regression: an ignored last statement of a non-final case must not swallow
2098
+ // the following cases / closing brace / trailing code.
2099
+ 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";
2100
+ let out = format_source(src).unwrap();
2101
+ assert!(out.contains("foo( a,b )"), "ignored not verbatim:\n{out}");
2102
+ assert!(out.contains("case 2:"), "case 2 was swallowed:\n{out}");
2103
+ assert!(out.contains("bar()"), "case 2 body swallowed:\n{out}");
2104
+ assert!(
2105
+ out.contains("let after = 1"),
2106
+ "trailing code swallowed:\n{out}"
2107
+ );
2108
+ tishlang_parser::parse(&out).unwrap();
2109
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent:\n{out}");
2110
+ }
2111
+
2112
+ #[test]
2113
+ fn ignore_preserves_multiline_bracket_statement() {
2114
+ // The verbatim extent must follow `[]`/`()`, not just `{}`.
2115
+ let src = "// tish-fmt-ignore\nlet m = [\n 1,2,\n 3,4\n]\nlet n = 5\n";
2116
+ let out = format_source(src).unwrap();
2117
+ assert!(
2118
+ out.contains("let m = [\n 1,2,\n 3,4\n]"),
2119
+ "multiline array truncated:\n{out}"
2120
+ );
2121
+ assert!(out.contains("let n = 5"), "{out}");
2122
+ assert_eq!(format_source(&out).unwrap(), out);
2123
+ }
2124
+
2125
+ #[test]
2126
+ fn without_marker_is_formatted_normally() {
2127
+ let src = "let a = [1,2, 3]\n";
2128
+ let out = format_source(src).unwrap();
2129
+ assert_eq!(out, "let a = [1, 2, 3]\n", "{out:?}");
2130
+ }
2131
+
2132
+ #[test]
2133
+ fn no_comments_round_trips_without_loss() {
2134
+ let src = "\
2135
+ fn add(a, b) {
2136
+ return a + b
2137
+ }
2138
+
2139
+ let x = add(1, 2)
2140
+ ";
2141
+ let out = format_source(src).unwrap();
2142
+ assert_eq!(out, src, "{out:?}");
2143
+ }
2144
+
2145
+ #[test]
2146
+ fn formats_delete_expression() {
2147
+ // Regression: Expr::Delete (the `delete` operator) must be handled by the formatter —
2148
+ // a non-exhaustive `match` here broke the `tish-format` build once the delete feature landed.
2149
+ let src = "fn f(o, k) {\ndelete o.a\ndelete o[\"b\"]\nlet x = delete o[k]\nreturn x\n}\n";
2150
+ let out = format_source(src).unwrap();
2151
+ assert!(out.contains("delete o.a"), "{out}");
2152
+ assert!(out.contains("delete o[\"b\"]"), "{out}");
2153
+ assert!(out.contains("delete o[k]"), "{out}");
2154
+ tishlang_parser::parse(&out).unwrap();
2155
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent:\n{out}");
2156
+ }
2157
+ }