rfmt 1.5.3 → 1.6.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.
@@ -0,0 +1,454 @@
1
+ //! IfRule and UnlessRule - Formats Ruby if/unless conditionals
2
+ //!
3
+ //! Handles:
4
+ //! - Normal if/unless: `if cond ... end`
5
+ //! - Postfix if/unless: `expr if cond`
6
+ //! - Ternary operator: `cond ? then_expr : else_expr`
7
+ //! - Inline then: `if cond then expr end`
8
+ //! - elsif/else chains
9
+
10
+ use crate::ast::{Node, NodeType};
11
+ use crate::doc::{concat, hardline, indent, text, Doc};
12
+ use crate::error::Result;
13
+ use crate::format::context::FormatContext;
14
+ use crate::format::registry::RuleRegistry;
15
+ use crate::format::rule::{
16
+ format_leading_comments, format_statements, format_trailing_comment,
17
+ strip_one_trailing_newline, FormatRule,
18
+ };
19
+
20
+ /// Rule for formatting if conditionals.
21
+ pub struct IfRule;
22
+
23
+ /// Rule for formatting unless conditionals.
24
+ pub struct UnlessRule;
25
+
26
+ impl FormatRule for IfRule {
27
+ fn format(&self, node: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) -> Result<Doc> {
28
+ format_if_unless(node, ctx, registry, "if", false)
29
+ }
30
+ }
31
+
32
+ impl FormatRule for UnlessRule {
33
+ fn format(&self, node: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) -> Result<Doc> {
34
+ format_if_unless(node, ctx, registry, "unless", false)
35
+ }
36
+ }
37
+
38
+ /// Formats if/unless/elsif/else constructs.
39
+ ///
40
+ /// # Arguments
41
+ /// * `node` - The if/unless node
42
+ /// * `ctx` - The formatting context
43
+ /// * `registry` - The rule registry for recursive formatting
44
+ /// * `keyword` - "if" or "unless"
45
+ /// * `is_elsif` - true if this is an elsif clause (don't emit 'end')
46
+ fn format_if_unless(
47
+ node: &Node,
48
+ ctx: &mut FormatContext,
49
+ registry: &RuleRegistry,
50
+ keyword: &str,
51
+ is_elsif: bool,
52
+ ) -> Result<Doc> {
53
+ // Check if this is a postfix if (modifier form)
54
+ let is_postfix = if let (Some(predicate), Some(statements)) =
55
+ (node.children.first(), node.children.get(1))
56
+ {
57
+ statements.location.start_offset < predicate.location.start_offset
58
+ } else {
59
+ false
60
+ };
61
+
62
+ // Postfix if/unless: "statement if/unless condition"
63
+ if is_postfix && !is_elsif {
64
+ return format_postfix(node, ctx, registry, keyword);
65
+ }
66
+
67
+ // Check for ternary operator
68
+ let is_ternary = node
69
+ .metadata
70
+ .get("is_ternary")
71
+ .map(|v| v == "true")
72
+ .unwrap_or(false);
73
+
74
+ if is_ternary && !is_elsif {
75
+ return format_ternary(node, ctx);
76
+ }
77
+
78
+ // Check for inline then style: "if true then 1 end"
79
+ let is_single_line = node.location.start_line == node.location.end_line;
80
+ let is_inline_then = !is_elsif && is_single_line && node.children.get(2).is_none();
81
+
82
+ if is_inline_then {
83
+ return format_inline_then(node, ctx, keyword);
84
+ }
85
+
86
+ // Normal if/unless/elsif
87
+ format_normal(node, ctx, registry, keyword, is_elsif)
88
+ }
89
+
90
+ /// Formats postfix if/unless: `statement if/unless condition`
91
+ fn format_postfix(
92
+ node: &Node,
93
+ ctx: &mut FormatContext,
94
+ _registry: &RuleRegistry,
95
+ keyword: &str,
96
+ ) -> Result<Doc> {
97
+ let mut docs: Vec<Doc> = Vec::with_capacity(6);
98
+
99
+ // Leading comments
100
+ let leading = format_leading_comments(ctx, node.location.start_line);
101
+ if !leading.is_empty() {
102
+ docs.push(leading);
103
+ }
104
+
105
+ // Emit statement. When the statement contains a heredoc whose body
106
+ // spills past the opener line, the bridge extends the statement's
107
+ // end_offset to cover the terminator — and that extended slice *also*
108
+ // sweeps in the intervening `if cond` modifier text that sits between
109
+ // the opener's `)` and the heredoc body. Appending our own
110
+ // ` if <cond>` after that text would then either produce
111
+ // `... if cond if cond` (duplicated modifier) or, worse, land the
112
+ // modifier on the same line as `SQL`, breaking heredoc termination.
113
+ //
114
+ // Detect the heredoc-in-statement case and emit the slice verbatim;
115
+ // the modifier is already baked into the source text right where
116
+ // Ruby expects it.
117
+ if let Some(statements) = node.children.get(1) {
118
+ if let Some(source_text) = ctx.extract_source(statements) {
119
+ if statement_contains_heredoc_tail(source_text) {
120
+ docs.push(text(source_text.trim_end_matches('\n').to_string()));
121
+
122
+ let trailing = format_trailing_comment(ctx, node.location.end_line);
123
+ if !trailing.is_empty() {
124
+ docs.push(trailing);
125
+ }
126
+ return Ok(concat(docs));
127
+ }
128
+
129
+ docs.push(text(source_text.trim()));
130
+ }
131
+ }
132
+
133
+ docs.push(text(" "));
134
+ docs.push(text(keyword));
135
+ docs.push(text(" "));
136
+
137
+ // Emit condition
138
+ if let Some(predicate) = node.children.first() {
139
+ if let Some(source_text) = ctx.extract_source(predicate) {
140
+ docs.push(text(source_text));
141
+ }
142
+ }
143
+
144
+ // Trailing comment
145
+ let trailing = format_trailing_comment(ctx, node.location.end_line);
146
+ if !trailing.is_empty() {
147
+ docs.push(trailing);
148
+ }
149
+
150
+ Ok(concat(docs))
151
+ }
152
+
153
+ /// True if `source` looks like `<opener_with_heredoc>…\n<body>\n<TERMINATOR>`:
154
+ /// that is, line 1 contains a heredoc opening marker (`<<~`, `<<-`, `<<`) and
155
+ /// at least one subsequent line is not a chain continuation. The bridge
156
+ /// extends the node's end_offset to cover the heredoc tail, so this slice
157
+ /// already contains any `if`/`unless` modifier that was typed between the
158
+ /// opener's closing paren and the heredoc body on the opener line.
159
+ fn statement_contains_heredoc_tail(source: &str) -> bool {
160
+ let source = source.trim_end_matches('\n');
161
+ let Some((first, rest)) = source.split_once('\n') else {
162
+ return false;
163
+ };
164
+ if !first.contains("<<~") && !first.contains("<<-") && !first.contains("<<") {
165
+ return false;
166
+ }
167
+ rest.lines().any(|l| {
168
+ let t = l.trim_start();
169
+ !t.is_empty() && !t.starts_with('.') && !t.starts_with("&.")
170
+ })
171
+ }
172
+
173
+ /// Formats ternary operator: `cond ? then_expr : else_expr`
174
+ fn format_ternary(node: &Node, ctx: &mut FormatContext) -> Result<Doc> {
175
+ let mut docs: Vec<Doc> = Vec::with_capacity(8);
176
+
177
+ // Leading comments
178
+ let leading = format_leading_comments(ctx, node.location.start_line);
179
+ if !leading.is_empty() {
180
+ docs.push(leading);
181
+ }
182
+
183
+ // Emit condition
184
+ if let Some(predicate) = node.children.first() {
185
+ if let Some(source_text) = ctx.extract_source(predicate) {
186
+ docs.push(text(source_text));
187
+ }
188
+ }
189
+
190
+ docs.push(text(" ? "));
191
+
192
+ // Emit then expression
193
+ if let Some(statements) = node.children.get(1) {
194
+ if let Some(source_text) = ctx.extract_source(statements) {
195
+ docs.push(text(source_text.trim()));
196
+ }
197
+ }
198
+
199
+ docs.push(text(" : "));
200
+
201
+ // Emit else expression
202
+ if let Some(else_node) = node.children.get(2) {
203
+ if let Some(else_statements) = else_node.children.first() {
204
+ if let Some(source_text) = ctx.extract_source(else_statements) {
205
+ docs.push(text(source_text.trim()));
206
+ }
207
+ }
208
+ }
209
+
210
+ // Trailing comment
211
+ let trailing = format_trailing_comment(ctx, node.location.end_line);
212
+ if !trailing.is_empty() {
213
+ docs.push(trailing);
214
+ }
215
+
216
+ Ok(concat(docs))
217
+ }
218
+
219
+ /// Formats inline then style: `if cond then expr end`
220
+ fn format_inline_then(node: &Node, ctx: &mut FormatContext, keyword: &str) -> Result<Doc> {
221
+ let mut docs: Vec<Doc> = Vec::with_capacity(8);
222
+
223
+ // Leading comments
224
+ let leading = format_leading_comments(ctx, node.location.start_line);
225
+ if !leading.is_empty() {
226
+ docs.push(leading);
227
+ }
228
+
229
+ docs.push(text(keyword));
230
+ docs.push(text(" "));
231
+
232
+ // Emit condition
233
+ if let Some(predicate) = node.children.first() {
234
+ if let Some(source_text) = ctx.extract_source(predicate) {
235
+ docs.push(text(source_text));
236
+ }
237
+ }
238
+
239
+ docs.push(text(" then "));
240
+
241
+ // Emit statement
242
+ if let Some(statements) = node.children.get(1) {
243
+ if let Some(source_text) = ctx.extract_source(statements) {
244
+ docs.push(text(source_text.trim()));
245
+ }
246
+ }
247
+
248
+ docs.push(text(" end"));
249
+
250
+ // Trailing comment
251
+ let trailing = format_trailing_comment(ctx, node.location.end_line);
252
+ if !trailing.is_empty() {
253
+ docs.push(trailing);
254
+ }
255
+
256
+ Ok(concat(docs))
257
+ }
258
+
259
+ /// Formats normal if/unless/elsif with potential else
260
+ fn format_normal(
261
+ node: &Node,
262
+ ctx: &mut FormatContext,
263
+ registry: &RuleRegistry,
264
+ keyword: &str,
265
+ is_elsif: bool,
266
+ ) -> Result<Doc> {
267
+ let mut docs: Vec<Doc> = Vec::with_capacity(12);
268
+
269
+ // Leading comments (only for outermost if/unless)
270
+ if !is_elsif {
271
+ let leading = format_leading_comments(ctx, node.location.start_line);
272
+ if !leading.is_empty() {
273
+ docs.push(leading);
274
+ }
275
+ }
276
+
277
+ // Emit 'if'/'unless' or 'elsif' keyword
278
+ if is_elsif {
279
+ docs.push(text("elsif "));
280
+ } else {
281
+ docs.push(text(keyword));
282
+ docs.push(text(" "));
283
+ }
284
+
285
+ // Emit predicate (condition). When the predicate is something like
286
+ // `(sql = <<~SQL)`, Prism's bridge stretches the heredoc-containing
287
+ // nodes' end_offset past the terminator's newline. Leaving that
288
+ // newline in the emitted text combines with our own `hardline` before
289
+ // the then-clause to produce a spurious blank line after the
290
+ // terminator. Strip at most one trailing newline.
291
+ if let Some(predicate) = node.children.first() {
292
+ if let Some(source_text) = ctx.extract_source(predicate) {
293
+ docs.push(text(strip_one_trailing_newline(source_text).to_string()));
294
+ }
295
+ }
296
+
297
+ // Trailing comment on same line as if/unless/elsif
298
+ let trailing = format_trailing_comment(ctx, node.location.start_line);
299
+ if !trailing.is_empty() {
300
+ docs.push(trailing);
301
+ }
302
+
303
+ // Emit then clause (second child is StatementsNode)
304
+ if let Some(statements) = node.children.get(1) {
305
+ if matches!(statements.node_type, NodeType::StatementsNode) {
306
+ let body_doc = format_statements(statements, ctx, registry)?;
307
+ docs.push(indent(concat(vec![hardline(), body_doc])));
308
+ }
309
+ }
310
+
311
+ // Check for elsif/else (third child)
312
+ if let Some(consequent) = node.children.get(2) {
313
+ match &consequent.node_type {
314
+ NodeType::IfNode => {
315
+ // This is an elsif clause
316
+ docs.push(hardline());
317
+ let elsif_doc = format_if_unless(consequent, ctx, registry, "if", true)?;
318
+ docs.push(elsif_doc);
319
+ }
320
+ NodeType::ElseNode => {
321
+ // This is an else clause
322
+ docs.push(hardline());
323
+ docs.push(text("else"));
324
+
325
+ // Emit else body (first child of ElseNode)
326
+ if let Some(else_statements) = consequent.children.first() {
327
+ if matches!(else_statements.node_type, NodeType::StatementsNode) {
328
+ let body_doc = format_statements(else_statements, ctx, registry)?;
329
+ docs.push(indent(concat(vec![hardline(), body_doc])));
330
+ }
331
+ }
332
+ }
333
+ _ => {}
334
+ }
335
+ }
336
+
337
+ // Only emit 'end' for the outermost if (not for elsif)
338
+ if !is_elsif {
339
+ docs.push(hardline());
340
+ docs.push(text("end"));
341
+
342
+ // Trailing comment on end line
343
+ let end_trailing = format_trailing_comment(ctx, node.location.end_line);
344
+ if !end_trailing.is_empty() {
345
+ docs.push(end_trailing);
346
+ }
347
+ }
348
+
349
+ Ok(concat(docs))
350
+ }
351
+
352
+ #[cfg(test)]
353
+ mod tests {
354
+ use super::*;
355
+ use crate::ast::{FormattingInfo, Location};
356
+ use crate::config::Config;
357
+ use crate::doc::Printer;
358
+ use std::collections::HashMap;
359
+
360
+ fn make_if_node(
361
+ children: Vec<Node>,
362
+ metadata: HashMap<String, String>,
363
+ start_line: usize,
364
+ end_line: usize,
365
+ start_offset: usize,
366
+ end_offset: usize,
367
+ ) -> Node {
368
+ Node {
369
+ node_type: NodeType::IfNode,
370
+ location: Location::new(start_line, 0, end_line, 0, start_offset, end_offset),
371
+ children,
372
+ metadata,
373
+ comments: Vec::new(),
374
+ formatting: FormattingInfo::default(),
375
+ }
376
+ }
377
+
378
+ fn make_predicate_node(start_offset: usize, end_offset: usize, start_line: usize) -> Node {
379
+ Node {
380
+ node_type: NodeType::CallNode,
381
+ location: Location::new(start_line, 0, start_line, 0, start_offset, end_offset),
382
+ children: Vec::new(),
383
+ metadata: HashMap::new(),
384
+ comments: Vec::new(),
385
+ formatting: FormattingInfo::default(),
386
+ }
387
+ }
388
+
389
+ fn make_statements_node(
390
+ start_offset: usize,
391
+ end_offset: usize,
392
+ start_line: usize,
393
+ end_line: usize,
394
+ ) -> Node {
395
+ Node {
396
+ node_type: NodeType::StatementsNode,
397
+ location: Location::new(start_line, 0, end_line, 0, start_offset, end_offset),
398
+ children: Vec::new(),
399
+ metadata: HashMap::new(),
400
+ comments: Vec::new(),
401
+ formatting: FormattingInfo::default(),
402
+ }
403
+ }
404
+
405
+ #[test]
406
+ fn test_simple_if() {
407
+ let config = Config::default();
408
+ let source = "if true\n puts 'yes'\nend";
409
+ let mut ctx = FormatContext::new(&config, source);
410
+ let registry = RuleRegistry::default_registry();
411
+
412
+ // predicate: "true" at offset 3-7
413
+ let predicate = make_predicate_node(3, 7, 1);
414
+ // statements: "puts 'yes'" at offset 10-20
415
+ let statements = make_statements_node(10, 20, 2, 2);
416
+
417
+ let node = make_if_node(vec![predicate, statements], HashMap::new(), 1, 3, 0, 24);
418
+ ctx.collect_comments(&node);
419
+
420
+ let rule = IfRule;
421
+ let doc = rule.format(&node, &mut ctx, &registry).unwrap();
422
+
423
+ let mut printer = Printer::new(&config);
424
+ let result = printer.print(&doc);
425
+
426
+ assert!(result.contains("if true"));
427
+ assert!(result.contains("end"));
428
+ }
429
+
430
+ #[test]
431
+ fn test_postfix_if() {
432
+ let config = Config::default();
433
+ let source = "puts 'yes' if true";
434
+ let mut ctx = FormatContext::new(&config, source);
435
+ let registry = RuleRegistry::default_registry();
436
+
437
+ // For postfix: statements come before predicate in source
438
+ // predicate: "true" at offset 14-18
439
+ let predicate = make_predicate_node(14, 18, 1);
440
+ // statements: "puts 'yes'" at offset 0-10
441
+ let statements = make_statements_node(0, 10, 1, 1);
442
+
443
+ let node = make_if_node(vec![predicate, statements], HashMap::new(), 1, 1, 0, 18);
444
+ ctx.collect_comments(&node);
445
+
446
+ let rule = IfRule;
447
+ let doc = rule.format(&node, &mut ctx, &registry).unwrap();
448
+
449
+ let mut printer = Printer::new(&config);
450
+ let result = printer.print(&doc);
451
+
452
+ assert!(result.contains("puts 'yes' if true"));
453
+ }
454
+ }