rfmt 1.6.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/Cargo.lock +1 -1
- data/ext/rfmt/Cargo.toml +1 -1
- data/ext/rfmt/src/doc/printer.rs +37 -0
- data/ext/rfmt/src/format/formatter.rs +28 -4
- data/ext/rfmt/src/format/rule.rs +192 -21
- data/ext/rfmt/src/format/rules/begin.rs +161 -22
- data/ext/rfmt/src/format/rules/body_end.rs +146 -22
- data/ext/rfmt/src/format/rules/call.rs +63 -24
- data/ext/rfmt/src/format/rules/fallback.rs +20 -6
- data/ext/rfmt/src/format/rules/if_unless.rs +51 -4
- data/ext/rfmt/src/format/rules/variable_write.rs +25 -7
- data/lib/rfmt/prism_bridge.rb +43 -12
- data/lib/rfmt/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 327f57f105df77a0d6b77bda9ab87c4f234f03f23fb918d74679942456c5a8ec
|
|
4
|
+
data.tar.gz: 482a5406275e422fb306970ffb47708f37be0c3115bc92485996d2adde6c9463
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 52982745650ed439f2ac7a5a1a9a26ae2dcf9e9df8015526278f58508929e15a1d72425d2d3c5963c0a4008968e20fcb6c374881122a3e99618d2cef15434eaf
|
|
7
|
+
data.tar.gz: 10e8282f05caacdffcbb2d7042e9f95a08c5d08c9b081307a12752840549fbb08b6bc557a256a9a46fd012986369fd7cfd0442a46d1d81146c2fb234d0b936f7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.6.1] - 2026-04-24
|
|
4
|
+
|
|
5
|
+
Follow-up release consolidating the 1.6.0 architecture work.
|
|
6
|
+
|
|
7
|
+
### Rule-based formatter
|
|
8
|
+
|
|
9
|
+
The rule-based `format/` pipeline (`Formatter`, `Registry`, `Rule`) is the canonical formatting path, replacing the legacy monolithic emitter. Rules covering body indentation (`StatementsRule`), singleton classes (`SingletonClassRule`), and variable writes (`VariableWriteRule`) ship as part of the default registry. The Intermediate Representation (IR) module decouples parsing from emission for composability and testability.
|
|
10
|
+
|
|
11
|
+
### Method chain reformatting
|
|
12
|
+
|
|
13
|
+
Multi-line method chains can be reformatted from aligned style (indented under the first dot) to indented style (one level beyond the receiver), preserving the source's base indent. The pass is wired into the fallback path for resilience.
|
|
14
|
+
|
|
15
|
+
### Printer optimizations
|
|
16
|
+
|
|
17
|
+
The printer carries a pre-computed indent cache and inline hints for the hot path; `reformat_chain_lines` has been deduplicated across rules and uses `Cow<str>` to avoid allocations on pass-through.
|
|
18
|
+
|
|
19
|
+
### Editor integration
|
|
20
|
+
|
|
21
|
+
Setup guides for VSCode, Neovim, Helix, Emacs, and Zed land in the repository; every editor uses the Ruby LSP addon system, so there are no editor-specific plugins to maintain. The README's Editor Integration section replaces the previous "Coming Soon" placeholder with a VSCode quick start.
|
|
22
|
+
|
|
3
23
|
## [1.6.0] - 2026-04-23
|
|
4
24
|
|
|
5
25
|
### Added
|
data/Cargo.lock
CHANGED
data/ext/rfmt/Cargo.toml
CHANGED
data/ext/rfmt/src/doc/printer.rs
CHANGED
|
@@ -108,6 +108,18 @@ impl<'a> Printer<'a> {
|
|
|
108
108
|
self.output.push('\n');
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// Strip trailing whitespace on every line.
|
|
112
|
+
//
|
|
113
|
+
// When a `hardline` lands inside an `indent(...)` region and is
|
|
114
|
+
// immediately followed by another newline (because the user wrote a
|
|
115
|
+
// blank line between statements), the printer first emits
|
|
116
|
+
// `"\n" + indent_spaces`, then the next newline runs over those
|
|
117
|
+
// spaces — leaving them on the otherwise-blank line. Stripping here
|
|
118
|
+
// is simpler and safer than threading "next is newline?" state
|
|
119
|
+
// through `Doc::Line` emission, and it also removes stray spaces
|
|
120
|
+
// that show up after inline trailing comments.
|
|
121
|
+
strip_trailing_line_whitespace(&mut self.output);
|
|
122
|
+
|
|
111
123
|
std::mem::take(&mut self.output)
|
|
112
124
|
}
|
|
113
125
|
|
|
@@ -388,6 +400,31 @@ impl<'a> Printer<'a> {
|
|
|
388
400
|
}
|
|
389
401
|
}
|
|
390
402
|
|
|
403
|
+
/// Strips trailing ASCII spaces/tabs from every line in `s` in-place.
|
|
404
|
+
///
|
|
405
|
+
/// Heredoc content embedded in `Doc::Text` also passes through this pass;
|
|
406
|
+
/// trailing whitespace inside a heredoc body is extremely rare in real Ruby
|
|
407
|
+
/// code and Rails projects universally run with `Layout/TrailingWhitespace`,
|
|
408
|
+
/// so trimming unconditionally matches project conventions.
|
|
409
|
+
fn strip_trailing_line_whitespace(buf: &mut String) {
|
|
410
|
+
if !buf.bytes().any(|b| b == b' ' || b == b'\t') {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let mut out = String::with_capacity(buf.len());
|
|
415
|
+
for line in buf.split_inclusive('\n') {
|
|
416
|
+
// `split_inclusive` keeps the trailing `\n` attached to the line.
|
|
417
|
+
if let Some(stripped) = line.strip_suffix('\n') {
|
|
418
|
+
out.push_str(stripped.trim_end_matches([' ', '\t']));
|
|
419
|
+
out.push('\n');
|
|
420
|
+
} else {
|
|
421
|
+
// Final line without a trailing newline.
|
|
422
|
+
out.push_str(line.trim_end_matches([' ', '\t']));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
*buf = out;
|
|
426
|
+
}
|
|
427
|
+
|
|
391
428
|
#[cfg(test)]
|
|
392
429
|
mod tests {
|
|
393
430
|
use super::*;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//! 3. Apply rules to generate Doc IR
|
|
7
7
|
//! 4. Print Doc IR to string using Printer
|
|
8
8
|
|
|
9
|
-
use crate::ast::{Node, NodeType};
|
|
9
|
+
use crate::ast::{CommentType, Node, NodeType};
|
|
10
10
|
use crate::config::Config;
|
|
11
11
|
use crate::doc::{concat, hardline, Doc, Printer};
|
|
12
12
|
use crate::error::Result;
|
|
@@ -120,16 +120,40 @@ impl Formatter {
|
|
|
120
120
|
let child_doc = self.format_node(child, ctx)?;
|
|
121
121
|
docs.push(child_doc);
|
|
122
122
|
|
|
123
|
-
// Add newlines between statements
|
|
123
|
+
// Add newlines between statements. Mirrors the logic in
|
|
124
|
+
// `format_statements` so top-level programs and node bodies
|
|
125
|
+
// agree on how comments participate in blank-line preservation:
|
|
126
|
+
// 1) subtract comment-occupied lines from the blank-line count
|
|
127
|
+
// so a standalone comment in the gap doesn't inflate it;
|
|
128
|
+
// 2) deduct one more when the gap contains a `=begin/=end`
|
|
129
|
+
// block comment, because that comment's `literalline`
|
|
130
|
+
// emission already supplies one of the line breaks.
|
|
124
131
|
if let Some(next_child) = children.get(i + 1) {
|
|
125
132
|
let current_end_line = child.location.end_line;
|
|
126
133
|
let next_start_line = next_child.location.start_line;
|
|
127
134
|
let line_diff = next_start_line.saturating_sub(current_end_line);
|
|
128
135
|
|
|
129
|
-
// Add 1 hardline if consecutive, 2 hardlines (1 blank line) if there was a gap
|
|
130
136
|
docs.push(hardline());
|
|
137
|
+
|
|
131
138
|
if line_diff > 1 {
|
|
132
|
-
|
|
139
|
+
let (comment_lines_in_gap, gap_has_block): (usize, bool) = ctx
|
|
140
|
+
.get_comment_indices_in_range(current_end_line + 1, next_start_line)
|
|
141
|
+
.filter_map(|idx| ctx.get_comment(idx).cloned())
|
|
142
|
+
.fold((0usize, false), |(lines, had_block), c| {
|
|
143
|
+
let span =
|
|
144
|
+
c.location.end_line.saturating_sub(c.location.start_line) + 1;
|
|
145
|
+
let is_block = matches!(c.comment_type, CommentType::Block);
|
|
146
|
+
(lines + span, had_block || is_block)
|
|
147
|
+
});
|
|
148
|
+
let mut blank_lines = line_diff
|
|
149
|
+
.saturating_sub(1)
|
|
150
|
+
.saturating_sub(comment_lines_in_gap);
|
|
151
|
+
if gap_has_block && blank_lines > 0 {
|
|
152
|
+
blank_lines -= 1;
|
|
153
|
+
}
|
|
154
|
+
if blank_lines >= 1 {
|
|
155
|
+
docs.push(hardline());
|
|
156
|
+
}
|
|
133
157
|
}
|
|
134
158
|
}
|
|
135
159
|
}
|
data/ext/rfmt/src/format/rule.rs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
//! This module defines the core FormatRule trait that all formatting rules implement,
|
|
4
4
|
//! along with shared helper functions for common formatting patterns.
|
|
5
5
|
|
|
6
|
-
use crate::ast::Node;
|
|
7
|
-
use crate::doc::{concat, hardline, leading_comment, trailing_comment, Doc};
|
|
6
|
+
use crate::ast::{CommentType, Node};
|
|
7
|
+
use crate::doc::{concat, hardline, leading_comment, literalline, text, trailing_comment, Doc};
|
|
8
8
|
use crate::error::Result;
|
|
9
9
|
|
|
10
10
|
use super::context::FormatContext;
|
|
@@ -46,6 +46,33 @@ struct CommentRef {
|
|
|
46
46
|
idx: usize,
|
|
47
47
|
start_line: usize,
|
|
48
48
|
end_line: usize,
|
|
49
|
+
is_block: bool,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Emits a single leading comment at the configured indent context.
|
|
53
|
+
///
|
|
54
|
+
/// `=begin ... =end` (Prism's `EmbDocComment`) must start in column 0;
|
|
55
|
+
/// nesting it under an `indent(...)` wrapper would emit `=begin` at the
|
|
56
|
+
/// body indent, which is a *syntax error*. Use `literalline` to break out
|
|
57
|
+
/// of the current indent so the `=begin` and `=end` markers — and every
|
|
58
|
+
/// intermediate line — land at column 0. The following `hardline` then
|
|
59
|
+
/// restores the normal indented flow for the code that comes after.
|
|
60
|
+
///
|
|
61
|
+
/// Prism's location slice for an embdoc includes the trailing newline that
|
|
62
|
+
/// sits after `=end`; strip it so that our own `hardline` doesn't produce
|
|
63
|
+
/// a double line break.
|
|
64
|
+
fn push_leading_comment(docs: &mut Vec<Doc>, comment_text: &str, is_block: bool) {
|
|
65
|
+
if is_block {
|
|
66
|
+
let body = comment_text
|
|
67
|
+
.strip_suffix('\n')
|
|
68
|
+
.and_then(|s| s.strip_suffix('\r').or(Some(s)))
|
|
69
|
+
.unwrap_or(comment_text);
|
|
70
|
+
docs.push(literalline());
|
|
71
|
+
docs.push(text(body.to_string()));
|
|
72
|
+
docs.push(hardline());
|
|
73
|
+
} else {
|
|
74
|
+
docs.push(leading_comment(comment_text, true));
|
|
75
|
+
}
|
|
49
76
|
}
|
|
50
77
|
|
|
51
78
|
/// Formats leading comments before a given line.
|
|
@@ -68,6 +95,7 @@ pub fn format_leading_comments(ctx: &mut FormatContext, line: usize) -> Doc {
|
|
|
68
95
|
idx,
|
|
69
96
|
start_line: c.location.start_line,
|
|
70
97
|
end_line: c.location.end_line,
|
|
98
|
+
is_block: matches!(c.comment_type, CommentType::Block),
|
|
71
99
|
})
|
|
72
100
|
})
|
|
73
101
|
.collect();
|
|
@@ -90,7 +118,7 @@ pub fn format_leading_comments(ctx: &mut FormatContext, line: usize) -> Doc {
|
|
|
90
118
|
}
|
|
91
119
|
|
|
92
120
|
if let Some(comment) = ctx.get_comment(cref.idx) {
|
|
93
|
-
|
|
121
|
+
push_leading_comment(&mut docs, &comment.text, cref.is_block);
|
|
94
122
|
}
|
|
95
123
|
last_end_line = Some(cref.end_line);
|
|
96
124
|
indices_to_mark.push(cref.idx);
|
|
@@ -175,6 +203,7 @@ pub fn format_comments_before_end(
|
|
|
175
203
|
idx,
|
|
176
204
|
start_line: c.location.start_line,
|
|
177
205
|
end_line: c.location.end_line,
|
|
206
|
+
is_block: matches!(c.comment_type, CommentType::Block),
|
|
178
207
|
})
|
|
179
208
|
} else {
|
|
180
209
|
None
|
|
@@ -191,7 +220,8 @@ pub fn format_comments_before_end(
|
|
|
191
220
|
let mut last_end_line: Option<usize> = None;
|
|
192
221
|
let mut indices_to_mark: Vec<usize> = Vec::with_capacity(standalone_refs.len());
|
|
193
222
|
|
|
194
|
-
|
|
223
|
+
let last_idx = standalone_refs.len().saturating_sub(1);
|
|
224
|
+
for (i, cref) in standalone_refs.iter().enumerate() {
|
|
195
225
|
// Preserve blank lines between comments
|
|
196
226
|
if let Some(prev_end) = last_end_line {
|
|
197
227
|
let gap = cref.start_line.saturating_sub(prev_end);
|
|
@@ -201,7 +231,26 @@ pub fn format_comments_before_end(
|
|
|
201
231
|
}
|
|
202
232
|
|
|
203
233
|
if let Some(comment) = ctx.get_comment(cref.idx) {
|
|
204
|
-
|
|
234
|
+
// The caller always emits its own `hardline + "end"` right after
|
|
235
|
+
// us, so the last comment must *not* also emit its own trailing
|
|
236
|
+
// newline — doing so produces a spurious blank line between the
|
|
237
|
+
// comment and the `end` keyword.
|
|
238
|
+
let hard_line_after = i != last_idx;
|
|
239
|
+
if cref.is_block {
|
|
240
|
+
let body = comment
|
|
241
|
+
.text
|
|
242
|
+
.strip_suffix('\n')
|
|
243
|
+
.and_then(|s| s.strip_suffix('\r').or(Some(s)))
|
|
244
|
+
.unwrap_or(&comment.text)
|
|
245
|
+
.to_string();
|
|
246
|
+
docs.push(literalline());
|
|
247
|
+
docs.push(text(body));
|
|
248
|
+
if hard_line_after {
|
|
249
|
+
docs.push(hardline());
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
docs.push(leading_comment(&comment.text, hard_line_after));
|
|
253
|
+
}
|
|
205
254
|
}
|
|
206
255
|
last_end_line = Some(cref.end_line);
|
|
207
256
|
indices_to_mark.push(cref.idx);
|
|
@@ -233,6 +282,7 @@ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize)
|
|
|
233
282
|
idx,
|
|
234
283
|
start_line: c.location.start_line,
|
|
235
284
|
end_line: c.location.end_line,
|
|
285
|
+
is_block: matches!(c.comment_type, CommentType::Block),
|
|
236
286
|
})
|
|
237
287
|
})
|
|
238
288
|
.collect();
|
|
@@ -258,7 +308,18 @@ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize)
|
|
|
258
308
|
}
|
|
259
309
|
|
|
260
310
|
if let Some(comment) = ctx.get_comment(cref.idx) {
|
|
261
|
-
|
|
311
|
+
if cref.is_block {
|
|
312
|
+
let body = comment
|
|
313
|
+
.text
|
|
314
|
+
.strip_suffix('\n')
|
|
315
|
+
.and_then(|s| s.strip_suffix('\r').or(Some(s)))
|
|
316
|
+
.unwrap_or(&comment.text)
|
|
317
|
+
.to_string();
|
|
318
|
+
docs.push(literalline());
|
|
319
|
+
docs.push(text(body));
|
|
320
|
+
} else {
|
|
321
|
+
docs.push(leading_comment(&comment.text, false));
|
|
322
|
+
}
|
|
262
323
|
}
|
|
263
324
|
last_end_line = cref.end_line;
|
|
264
325
|
is_first = false;
|
|
@@ -298,15 +359,46 @@ pub fn format_statements(
|
|
|
298
359
|
let child_doc = format_child(child, ctx, registry)?;
|
|
299
360
|
docs.push(child_doc);
|
|
300
361
|
|
|
301
|
-
// Add newlines between statements
|
|
362
|
+
// Add newlines between statements. A pure line-number diff would add
|
|
363
|
+
// a blank-line hardline whenever two consecutive statements sit on
|
|
364
|
+
// lines that are more than 1 apart — but that gap may be occupied
|
|
365
|
+
// by one or more standalone comments, each of which gets emitted as
|
|
366
|
+
// a leading comment of the next statement and already supplies its
|
|
367
|
+
// own line break. Subtract the lines consumed by comments so we
|
|
368
|
+
// only preserve *actually* blank lines between statements.
|
|
302
369
|
if let Some(next_child) = node.children.get(i + 1) {
|
|
303
370
|
let current_end_line = child.location.end_line;
|
|
304
371
|
let next_start_line = next_child.location.start_line;
|
|
305
372
|
let line_diff = next_start_line.saturating_sub(current_end_line);
|
|
306
373
|
|
|
307
374
|
docs.push(hardline());
|
|
375
|
+
|
|
308
376
|
if line_diff > 1 {
|
|
309
|
-
|
|
377
|
+
let (comment_lines_in_gap, gap_has_block): (usize, bool) = ctx
|
|
378
|
+
.get_comment_indices_in_range(current_end_line + 1, next_start_line)
|
|
379
|
+
.filter_map(|idx| ctx.get_comment(idx).cloned())
|
|
380
|
+
.fold((0usize, false), |(lines, had_block), c| {
|
|
381
|
+
let span = c.location.end_line.saturating_sub(c.location.start_line) + 1;
|
|
382
|
+
let is_block = matches!(c.comment_type, CommentType::Block);
|
|
383
|
+
(lines + span, had_block || is_block)
|
|
384
|
+
});
|
|
385
|
+
// `line_diff - 1` is the count of lines strictly between the
|
|
386
|
+
// two statements. Subtract comment-occupied lines to get the
|
|
387
|
+
// count of truly blank lines.
|
|
388
|
+
let mut blank_lines = line_diff
|
|
389
|
+
.saturating_sub(1)
|
|
390
|
+
.saturating_sub(comment_lines_in_gap);
|
|
391
|
+
// A block comment (`=begin/=end`) is emitted via
|
|
392
|
+
// `literalline + text + hardline`. The leading `literalline`
|
|
393
|
+
// already supplies one line break, so the normal
|
|
394
|
+
// blank-line hardline added here would produce one extra
|
|
395
|
+
// blank line above `=begin`. Deduct one.
|
|
396
|
+
if gap_has_block && blank_lines > 0 {
|
|
397
|
+
blank_lines -= 1;
|
|
398
|
+
}
|
|
399
|
+
if blank_lines >= 1 {
|
|
400
|
+
docs.push(hardline());
|
|
401
|
+
}
|
|
310
402
|
}
|
|
311
403
|
}
|
|
312
404
|
}
|
|
@@ -314,24 +406,47 @@ pub fn format_statements(
|
|
|
314
406
|
Ok(concat(docs))
|
|
315
407
|
}
|
|
316
408
|
|
|
409
|
+
/// Returns the number of leading space/tab characters on the line containing `offset`.
|
|
410
|
+
///
|
|
411
|
+
/// The source text extracted by `FormatContext::extract_source` starts at the node's
|
|
412
|
+
/// offset and does not include the whitespace that precedes the first line in the
|
|
413
|
+
/// original source. `Doc::Text` is printed verbatim without re-indenting embedded
|
|
414
|
+
/// newlines, so any reformatting that emits a multi-line string must include the
|
|
415
|
+
/// original leading indent itself.
|
|
416
|
+
pub fn line_leading_indent(source: &str, offset: usize) -> usize {
|
|
417
|
+
let offset = offset.min(source.len());
|
|
418
|
+
let line_start = source[..offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
|
|
419
|
+
source.as_bytes()[line_start..offset]
|
|
420
|
+
.iter()
|
|
421
|
+
.take_while(|&&b| b == b' ' || b == b'\t')
|
|
422
|
+
.count()
|
|
423
|
+
}
|
|
424
|
+
|
|
317
425
|
/// Reformats multiline method chain text with indented style.
|
|
318
426
|
///
|
|
319
427
|
/// Converts aligned method chains to indented style:
|
|
320
428
|
/// - First line is kept as-is (trimmed at end)
|
|
321
|
-
/// - Subsequent lines starting with `.` or `&.` are re-indented
|
|
429
|
+
/// - Subsequent lines starting with `.` or `&.` are re-indented to
|
|
430
|
+
/// `base_indent + indent_width` spaces
|
|
322
431
|
///
|
|
323
|
-
///
|
|
432
|
+
/// `base_indent` is the column at which the first line starts in the original source
|
|
433
|
+
/// (obtain via `line_leading_indent`). Because `Doc::Text` is printed verbatim without
|
|
434
|
+
/// re-indenting embedded newlines, this indent must be included in the returned string.
|
|
324
435
|
///
|
|
325
|
-
///
|
|
326
|
-
/// * `source_text` - The source text containing a method chain
|
|
327
|
-
/// * `indent_width` - The number of spaces for one level of indentation
|
|
436
|
+
/// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
|
|
328
437
|
///
|
|
329
438
|
/// # Example
|
|
330
439
|
/// ```text
|
|
331
|
-
/// Input (indent_width=2):
|
|
332
|
-
///
|
|
440
|
+
/// Input (base_indent=4, indent_width=2):
|
|
441
|
+
/// "foo.bar\n .baz"
|
|
442
|
+
/// Output:
|
|
443
|
+
/// "foo.bar\n .baz"
|
|
333
444
|
/// ```
|
|
334
|
-
pub fn reformat_chain_lines(
|
|
445
|
+
pub fn reformat_chain_lines(
|
|
446
|
+
source_text: &str,
|
|
447
|
+
base_indent: usize,
|
|
448
|
+
indent_width: usize,
|
|
449
|
+
) -> std::borrow::Cow<'_, str> {
|
|
335
450
|
use std::borrow::Cow;
|
|
336
451
|
|
|
337
452
|
let lines: Vec<&str> = source_text.lines().collect();
|
|
@@ -339,6 +454,21 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
|
|
|
339
454
|
return Cow::Borrowed(source_text);
|
|
340
455
|
}
|
|
341
456
|
|
|
457
|
+
// Skip reformatting when the first line opens a new scope (a `{` brace
|
|
458
|
+
// lambda, a `do` block, or a `do |params|` block). The `.method` lines
|
|
459
|
+
// that follow are chain continuations *inside the block body*, not of
|
|
460
|
+
// the outer call — re-indenting them relative to the outer
|
|
461
|
+
// `base_indent` collapses the nested chain one level to the left and
|
|
462
|
+
// breaks the visual structure of the block body.
|
|
463
|
+
//
|
|
464
|
+
// This deliberately keeps the reformat conservative: for a top-level
|
|
465
|
+
// `User.active.where(...)` chain, line 1 ends with an identifier so
|
|
466
|
+
// we still rewrite aligned → indented as PR #100 intended.
|
|
467
|
+
let first_line = lines[0].trim_end();
|
|
468
|
+
if first_line.ends_with('{') || first_line.ends_with(" do") || first_line.ends_with('|') {
|
|
469
|
+
return Cow::Borrowed(source_text);
|
|
470
|
+
}
|
|
471
|
+
|
|
342
472
|
// Check if there are actual chain continuation lines (. or &.)
|
|
343
473
|
let has_chain = lines[1..].iter().any(|l| {
|
|
344
474
|
let t = l.trim_start();
|
|
@@ -350,7 +480,7 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
|
|
|
350
480
|
}
|
|
351
481
|
|
|
352
482
|
// Build the indented chain with pre-allocated capacity
|
|
353
|
-
let chain_indent = " ".repeat(indent_width);
|
|
483
|
+
let chain_indent = " ".repeat(base_indent + indent_width);
|
|
354
484
|
let mut result = String::with_capacity(source_text.len());
|
|
355
485
|
result.push_str(lines[0].trim_end());
|
|
356
486
|
|
|
@@ -369,6 +499,24 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
|
|
|
369
499
|
Cow::Owned(result)
|
|
370
500
|
}
|
|
371
501
|
|
|
502
|
+
/// Removes at most one trailing `\n` (optionally preceded by a single `\r`)
|
|
503
|
+
/// from `s`. Spaces, tabs, and any additional preceding newlines are
|
|
504
|
+
/// preserved.
|
|
505
|
+
///
|
|
506
|
+
/// This is used by source-extracting rules (FallbackRule, CallRule,
|
|
507
|
+
/// VariableWriteRule) to strip the terminator-line newline that Prism
|
|
508
|
+
/// includes in node extents for constructs like
|
|
509
|
+
/// `foo(<<~HEREDOC)\n…\nHEREDOC\n`. A full `trim_end` would also eat any
|
|
510
|
+
/// blank separator line that happens to fall inside the node's range,
|
|
511
|
+
/// collapsing the spacing between consecutive statements.
|
|
512
|
+
pub fn strip_one_trailing_newline(s: &str) -> &str {
|
|
513
|
+
if let Some(rest) = s.strip_suffix('\n') {
|
|
514
|
+
rest.strip_suffix('\r').unwrap_or(rest)
|
|
515
|
+
} else {
|
|
516
|
+
s
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
372
520
|
/// Marks comments within a line range as emitted.
|
|
373
521
|
///
|
|
374
522
|
/// This is used when source text is extracted directly, as any comments
|
|
@@ -528,28 +676,51 @@ mod tests {
|
|
|
528
676
|
#[test]
|
|
529
677
|
fn test_reformat_chain_lines_single_line() {
|
|
530
678
|
let input = "foo.bar.baz";
|
|
531
|
-
let result = reformat_chain_lines(input, 2);
|
|
679
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
532
680
|
assert_eq!(result, "foo.bar.baz");
|
|
533
681
|
}
|
|
534
682
|
|
|
535
683
|
#[test]
|
|
536
684
|
fn test_reformat_chain_lines_multiline_chain() {
|
|
537
685
|
let input = "foo.bar\n .baz\n .qux";
|
|
538
|
-
let result = reformat_chain_lines(input, 2);
|
|
686
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
539
687
|
assert_eq!(result, "foo.bar\n .baz\n .qux");
|
|
540
688
|
}
|
|
541
689
|
|
|
542
690
|
#[test]
|
|
543
691
|
fn test_reformat_chain_lines_safe_navigation() {
|
|
544
692
|
let input = "foo&.bar\n &.baz";
|
|
545
|
-
let result = reformat_chain_lines(input, 2);
|
|
693
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
546
694
|
assert_eq!(result, "foo&.bar\n &.baz");
|
|
547
695
|
}
|
|
548
696
|
|
|
549
697
|
#[test]
|
|
550
698
|
fn test_reformat_chain_lines_no_chain() {
|
|
551
699
|
let input = "foo(\n arg1,\n arg2\n)";
|
|
552
|
-
let result = reformat_chain_lines(input, 2);
|
|
700
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
553
701
|
assert_eq!(result, input);
|
|
554
702
|
}
|
|
703
|
+
|
|
704
|
+
#[test]
|
|
705
|
+
fn test_reformat_chain_lines_preserves_base_indent() {
|
|
706
|
+
// Simulates a chain inside a 4-space-indented method body:
|
|
707
|
+
// the caller must include base_indent so the printed continuation
|
|
708
|
+
// lines up with `base_indent + indent_width` columns.
|
|
709
|
+
let input = "foo.bar\n .baz\n .qux";
|
|
710
|
+
let result = reformat_chain_lines(input, 4, 2);
|
|
711
|
+
assert_eq!(result, "foo.bar\n .baz\n .qux");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
#[test]
|
|
715
|
+
fn test_line_leading_indent_counts_spaces_and_tabs() {
|
|
716
|
+
let source = "def foo\n bar\n\tbaz\nqux\n";
|
|
717
|
+
let bar = source.find("bar").unwrap();
|
|
718
|
+
let baz = source.find("baz").unwrap();
|
|
719
|
+
let qux = source.find("qux").unwrap();
|
|
720
|
+
assert_eq!(line_leading_indent(source, bar), 4);
|
|
721
|
+
assert_eq!(line_leading_indent(source, baz), 1);
|
|
722
|
+
assert_eq!(line_leading_indent(source, qux), 0);
|
|
723
|
+
// Out-of-range offset is clamped.
|
|
724
|
+
assert_eq!(line_leading_indent(source, usize::MAX), 0);
|
|
725
|
+
}
|
|
555
726
|
}
|