rfmt 1.6.0 → 1.6.2
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 +24 -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 +245 -24
- 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 +81 -25
- 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: 5f1d0fa5ba56d10568e9dd3625974abc815350c4c6c2ff03d5114d8c38aae9a1
|
|
4
|
+
data.tar.gz: 4b6256a168e7be4a70c162a26bea0983fd6397397224cd1953abbe9cc1cbd01b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a90b0b5f699c21e3e5aebd5f33ce71c152694a3cc51c812d2ffda946d2250a7714ae572ab9e7538531386e09b91210aa50f5e4de49e1d11234afd6803525ede4
|
|
7
|
+
data.tar.gz: ab5588dfe3b5035e444219ad218756b475d964f18b47fdb78311593ba964c7c4436856abca58a13fc2e799eaa7d85815e6e70be39e6d0e083d9c7951c3867553
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.6.2] - 2026-04-24
|
|
4
|
+
|
|
5
|
+
Minor stability refinements on top of the 1.6.x architecture release. See the 1.6.1 notes below for the feature set this series delivers.
|
|
6
|
+
|
|
7
|
+
## [1.6.1] - 2026-04-24
|
|
8
|
+
|
|
9
|
+
Follow-up release consolidating the 1.6.0 architecture work.
|
|
10
|
+
|
|
11
|
+
### Rule-based formatter
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
### Method chain reformatting
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
### Printer optimizations
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
### Editor integration
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
3
27
|
## [1.6.0] - 2026-04-23
|
|
4
28
|
|
|
5
29
|
### 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
|
|
@@ -188,10 +217,28 @@ pub fn format_comments_before_end(
|
|
|
188
217
|
}
|
|
189
218
|
|
|
190
219
|
let mut docs: Vec<Doc> = vec![hardline()];
|
|
220
|
+
// Preserve the blank line that separated the body's last content from
|
|
221
|
+
// the first trailing comment. Without this, a construct like
|
|
222
|
+
//
|
|
223
|
+
// def foo
|
|
224
|
+
// body
|
|
225
|
+
// <- blank line
|
|
226
|
+
// # trailing annotation
|
|
227
|
+
// end
|
|
228
|
+
//
|
|
229
|
+
// collapses to `body\n# trailing annotation\nend`. Detect the case
|
|
230
|
+
// heuristically: if the source line immediately above the first
|
|
231
|
+
// standalone comment is blank, emit an extra hardline.
|
|
232
|
+
if let Some(first) = standalone_refs.first() {
|
|
233
|
+
if first.start_line > 1 && is_line_blank(ctx.source(), first.start_line - 1) {
|
|
234
|
+
docs.push(hardline());
|
|
235
|
+
}
|
|
236
|
+
}
|
|
191
237
|
let mut last_end_line: Option<usize> = None;
|
|
192
238
|
let mut indices_to_mark: Vec<usize> = Vec::with_capacity(standalone_refs.len());
|
|
193
239
|
|
|
194
|
-
|
|
240
|
+
let last_idx = standalone_refs.len().saturating_sub(1);
|
|
241
|
+
for (i, cref) in standalone_refs.iter().enumerate() {
|
|
195
242
|
// Preserve blank lines between comments
|
|
196
243
|
if let Some(prev_end) = last_end_line {
|
|
197
244
|
let gap = cref.start_line.saturating_sub(prev_end);
|
|
@@ -201,7 +248,26 @@ pub fn format_comments_before_end(
|
|
|
201
248
|
}
|
|
202
249
|
|
|
203
250
|
if let Some(comment) = ctx.get_comment(cref.idx) {
|
|
204
|
-
|
|
251
|
+
// The caller always emits its own `hardline + "end"` right after
|
|
252
|
+
// us, so the last comment must *not* also emit its own trailing
|
|
253
|
+
// newline — doing so produces a spurious blank line between the
|
|
254
|
+
// comment and the `end` keyword.
|
|
255
|
+
let hard_line_after = i != last_idx;
|
|
256
|
+
if cref.is_block {
|
|
257
|
+
let body = comment
|
|
258
|
+
.text
|
|
259
|
+
.strip_suffix('\n')
|
|
260
|
+
.and_then(|s| s.strip_suffix('\r').or(Some(s)))
|
|
261
|
+
.unwrap_or(&comment.text)
|
|
262
|
+
.to_string();
|
|
263
|
+
docs.push(literalline());
|
|
264
|
+
docs.push(text(body));
|
|
265
|
+
if hard_line_after {
|
|
266
|
+
docs.push(hardline());
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
docs.push(leading_comment(&comment.text, hard_line_after));
|
|
270
|
+
}
|
|
205
271
|
}
|
|
206
272
|
last_end_line = Some(cref.end_line);
|
|
207
273
|
indices_to_mark.push(cref.idx);
|
|
@@ -233,6 +299,7 @@ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize)
|
|
|
233
299
|
idx,
|
|
234
300
|
start_line: c.location.start_line,
|
|
235
301
|
end_line: c.location.end_line,
|
|
302
|
+
is_block: matches!(c.comment_type, CommentType::Block),
|
|
236
303
|
})
|
|
237
304
|
})
|
|
238
305
|
.collect();
|
|
@@ -247,18 +314,41 @@ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize)
|
|
|
247
314
|
let mut indices_to_mark: Vec<usize> = Vec::with_capacity(comment_refs.len());
|
|
248
315
|
|
|
249
316
|
for cref in &comment_refs {
|
|
250
|
-
// Preserve blank lines
|
|
317
|
+
// Preserve blank lines. On the first iteration we must emit *at
|
|
318
|
+
// least one* hardline to separate the first remaining comment from
|
|
319
|
+
// the main document's last token (otherwise an orphan comment whose
|
|
320
|
+
// `start_line <= last_code_line` would concatenate onto whatever
|
|
321
|
+
// ended the output — producing e.g. `end# comment…` when a block's
|
|
322
|
+
// internal comments fall through to this tail handler). Round-tripping
|
|
323
|
+
// the already-formatted output must still be idempotent, so we
|
|
324
|
+
// cap the emission at the number of line breaks visible in the
|
|
325
|
+
// source: 1 for an adjacent comment, N for N-1 blank lines above it.
|
|
251
326
|
let gap = cref.start_line.saturating_sub(last_end_line);
|
|
252
327
|
|
|
253
|
-
|
|
254
|
-
|
|
328
|
+
if is_first {
|
|
329
|
+
let hardlines_to_emit = gap.max(1);
|
|
330
|
+
for _ in 0..hardlines_to_emit {
|
|
331
|
+
docs.push(hardline());
|
|
332
|
+
}
|
|
333
|
+
} else if gap > 0 {
|
|
255
334
|
for _ in 0..gap.max(1) {
|
|
256
335
|
docs.push(hardline());
|
|
257
336
|
}
|
|
258
337
|
}
|
|
259
338
|
|
|
260
339
|
if let Some(comment) = ctx.get_comment(cref.idx) {
|
|
261
|
-
|
|
340
|
+
if cref.is_block {
|
|
341
|
+
let body = comment
|
|
342
|
+
.text
|
|
343
|
+
.strip_suffix('\n')
|
|
344
|
+
.and_then(|s| s.strip_suffix('\r').or(Some(s)))
|
|
345
|
+
.unwrap_or(&comment.text)
|
|
346
|
+
.to_string();
|
|
347
|
+
docs.push(literalline());
|
|
348
|
+
docs.push(text(body));
|
|
349
|
+
} else {
|
|
350
|
+
docs.push(leading_comment(&comment.text, false));
|
|
351
|
+
}
|
|
262
352
|
}
|
|
263
353
|
last_end_line = cref.end_line;
|
|
264
354
|
is_first = false;
|
|
@@ -298,15 +388,46 @@ pub fn format_statements(
|
|
|
298
388
|
let child_doc = format_child(child, ctx, registry)?;
|
|
299
389
|
docs.push(child_doc);
|
|
300
390
|
|
|
301
|
-
// Add newlines between statements
|
|
391
|
+
// Add newlines between statements. A pure line-number diff would add
|
|
392
|
+
// a blank-line hardline whenever two consecutive statements sit on
|
|
393
|
+
// lines that are more than 1 apart — but that gap may be occupied
|
|
394
|
+
// by one or more standalone comments, each of which gets emitted as
|
|
395
|
+
// a leading comment of the next statement and already supplies its
|
|
396
|
+
// own line break. Subtract the lines consumed by comments so we
|
|
397
|
+
// only preserve *actually* blank lines between statements.
|
|
302
398
|
if let Some(next_child) = node.children.get(i + 1) {
|
|
303
399
|
let current_end_line = child.location.end_line;
|
|
304
400
|
let next_start_line = next_child.location.start_line;
|
|
305
401
|
let line_diff = next_start_line.saturating_sub(current_end_line);
|
|
306
402
|
|
|
307
403
|
docs.push(hardline());
|
|
404
|
+
|
|
308
405
|
if line_diff > 1 {
|
|
309
|
-
|
|
406
|
+
let (comment_lines_in_gap, gap_has_block): (usize, bool) = ctx
|
|
407
|
+
.get_comment_indices_in_range(current_end_line + 1, next_start_line)
|
|
408
|
+
.filter_map(|idx| ctx.get_comment(idx).cloned())
|
|
409
|
+
.fold((0usize, false), |(lines, had_block), c| {
|
|
410
|
+
let span = c.location.end_line.saturating_sub(c.location.start_line) + 1;
|
|
411
|
+
let is_block = matches!(c.comment_type, CommentType::Block);
|
|
412
|
+
(lines + span, had_block || is_block)
|
|
413
|
+
});
|
|
414
|
+
// `line_diff - 1` is the count of lines strictly between the
|
|
415
|
+
// two statements. Subtract comment-occupied lines to get the
|
|
416
|
+
// count of truly blank lines.
|
|
417
|
+
let mut blank_lines = line_diff
|
|
418
|
+
.saturating_sub(1)
|
|
419
|
+
.saturating_sub(comment_lines_in_gap);
|
|
420
|
+
// A block comment (`=begin/=end`) is emitted via
|
|
421
|
+
// `literalline + text + hardline`. The leading `literalline`
|
|
422
|
+
// already supplies one line break, so the normal
|
|
423
|
+
// blank-line hardline added here would produce one extra
|
|
424
|
+
// blank line above `=begin`. Deduct one.
|
|
425
|
+
if gap_has_block && blank_lines > 0 {
|
|
426
|
+
blank_lines -= 1;
|
|
427
|
+
}
|
|
428
|
+
if blank_lines >= 1 {
|
|
429
|
+
docs.push(hardline());
|
|
430
|
+
}
|
|
310
431
|
}
|
|
311
432
|
}
|
|
312
433
|
}
|
|
@@ -314,24 +435,47 @@ pub fn format_statements(
|
|
|
314
435
|
Ok(concat(docs))
|
|
315
436
|
}
|
|
316
437
|
|
|
438
|
+
/// Returns the number of leading space/tab characters on the line containing `offset`.
|
|
439
|
+
///
|
|
440
|
+
/// The source text extracted by `FormatContext::extract_source` starts at the node's
|
|
441
|
+
/// offset and does not include the whitespace that precedes the first line in the
|
|
442
|
+
/// original source. `Doc::Text` is printed verbatim without re-indenting embedded
|
|
443
|
+
/// newlines, so any reformatting that emits a multi-line string must include the
|
|
444
|
+
/// original leading indent itself.
|
|
445
|
+
pub fn line_leading_indent(source: &str, offset: usize) -> usize {
|
|
446
|
+
let offset = offset.min(source.len());
|
|
447
|
+
let line_start = source[..offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
|
|
448
|
+
source.as_bytes()[line_start..offset]
|
|
449
|
+
.iter()
|
|
450
|
+
.take_while(|&&b| b == b' ' || b == b'\t')
|
|
451
|
+
.count()
|
|
452
|
+
}
|
|
453
|
+
|
|
317
454
|
/// Reformats multiline method chain text with indented style.
|
|
318
455
|
///
|
|
319
456
|
/// Converts aligned method chains to indented style:
|
|
320
457
|
/// - First line is kept as-is (trimmed at end)
|
|
321
|
-
/// - Subsequent lines starting with `.` or `&.` are re-indented
|
|
458
|
+
/// - Subsequent lines starting with `.` or `&.` are re-indented to
|
|
459
|
+
/// `base_indent + indent_width` spaces
|
|
322
460
|
///
|
|
323
|
-
///
|
|
461
|
+
/// `base_indent` is the column at which the first line starts in the original source
|
|
462
|
+
/// (obtain via `line_leading_indent`). Because `Doc::Text` is printed verbatim without
|
|
463
|
+
/// re-indenting embedded newlines, this indent must be included in the returned string.
|
|
324
464
|
///
|
|
325
|
-
///
|
|
326
|
-
/// * `source_text` - The source text containing a method chain
|
|
327
|
-
/// * `indent_width` - The number of spaces for one level of indentation
|
|
465
|
+
/// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
|
|
328
466
|
///
|
|
329
467
|
/// # Example
|
|
330
468
|
/// ```text
|
|
331
|
-
/// Input (indent_width=2):
|
|
332
|
-
///
|
|
469
|
+
/// Input (base_indent=4, indent_width=2):
|
|
470
|
+
/// "foo.bar\n .baz"
|
|
471
|
+
/// Output:
|
|
472
|
+
/// "foo.bar\n .baz"
|
|
333
473
|
/// ```
|
|
334
|
-
pub fn reformat_chain_lines(
|
|
474
|
+
pub fn reformat_chain_lines(
|
|
475
|
+
source_text: &str,
|
|
476
|
+
base_indent: usize,
|
|
477
|
+
indent_width: usize,
|
|
478
|
+
) -> std::borrow::Cow<'_, str> {
|
|
335
479
|
use std::borrow::Cow;
|
|
336
480
|
|
|
337
481
|
let lines: Vec<&str> = source_text.lines().collect();
|
|
@@ -339,6 +483,21 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
|
|
|
339
483
|
return Cow::Borrowed(source_text);
|
|
340
484
|
}
|
|
341
485
|
|
|
486
|
+
// Skip reformatting when the first line opens a new scope (a `{` brace
|
|
487
|
+
// lambda, a `do` block, or a `do |params|` block). The `.method` lines
|
|
488
|
+
// that follow are chain continuations *inside the block body*, not of
|
|
489
|
+
// the outer call — re-indenting them relative to the outer
|
|
490
|
+
// `base_indent` collapses the nested chain one level to the left and
|
|
491
|
+
// breaks the visual structure of the block body.
|
|
492
|
+
//
|
|
493
|
+
// This deliberately keeps the reformat conservative: for a top-level
|
|
494
|
+
// `User.active.where(...)` chain, line 1 ends with an identifier so
|
|
495
|
+
// we still rewrite aligned → indented as PR #100 intended.
|
|
496
|
+
let first_line = lines[0].trim_end();
|
|
497
|
+
if first_line.ends_with('{') || first_line.ends_with(" do") || first_line.ends_with('|') {
|
|
498
|
+
return Cow::Borrowed(source_text);
|
|
499
|
+
}
|
|
500
|
+
|
|
342
501
|
// Check if there are actual chain continuation lines (. or &.)
|
|
343
502
|
let has_chain = lines[1..].iter().any(|l| {
|
|
344
503
|
let t = l.trim_start();
|
|
@@ -350,7 +509,7 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
|
|
|
350
509
|
}
|
|
351
510
|
|
|
352
511
|
// Build the indented chain with pre-allocated capacity
|
|
353
|
-
let chain_indent = " ".repeat(indent_width);
|
|
512
|
+
let chain_indent = " ".repeat(base_indent + indent_width);
|
|
354
513
|
let mut result = String::with_capacity(source_text.len());
|
|
355
514
|
result.push_str(lines[0].trim_end());
|
|
356
515
|
|
|
@@ -369,6 +528,45 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
|
|
|
369
528
|
Cow::Owned(result)
|
|
370
529
|
}
|
|
371
530
|
|
|
531
|
+
/// Returns true when the given 1-based `line` in `source` contains only
|
|
532
|
+
/// whitespace (or is empty). Returns false for any line that has code or
|
|
533
|
+
/// a comment.
|
|
534
|
+
fn is_line_blank(source: &str, line: usize) -> bool {
|
|
535
|
+
let mut current = 1usize;
|
|
536
|
+
let mut line_start = 0usize;
|
|
537
|
+
for (i, b) in source.bytes().enumerate() {
|
|
538
|
+
if current == line {
|
|
539
|
+
let end = source[i..].find('\n').map_or(source.len(), |n| i + n);
|
|
540
|
+
return source[line_start..end]
|
|
541
|
+
.bytes()
|
|
542
|
+
.all(|b| b == b' ' || b == b'\t' || b == b'\r');
|
|
543
|
+
}
|
|
544
|
+
if b == b'\n' {
|
|
545
|
+
current += 1;
|
|
546
|
+
line_start = i + 1;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
false
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/// Removes at most one trailing `\n` (optionally preceded by a single `\r`)
|
|
553
|
+
/// from `s`. Spaces, tabs, and any additional preceding newlines are
|
|
554
|
+
/// preserved.
|
|
555
|
+
///
|
|
556
|
+
/// This is used by source-extracting rules (FallbackRule, CallRule,
|
|
557
|
+
/// VariableWriteRule) to strip the terminator-line newline that Prism
|
|
558
|
+
/// includes in node extents for constructs like
|
|
559
|
+
/// `foo(<<~HEREDOC)\n…\nHEREDOC\n`. A full `trim_end` would also eat any
|
|
560
|
+
/// blank separator line that happens to fall inside the node's range,
|
|
561
|
+
/// collapsing the spacing between consecutive statements.
|
|
562
|
+
pub fn strip_one_trailing_newline(s: &str) -> &str {
|
|
563
|
+
if let Some(rest) = s.strip_suffix('\n') {
|
|
564
|
+
rest.strip_suffix('\r').unwrap_or(rest)
|
|
565
|
+
} else {
|
|
566
|
+
s
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
372
570
|
/// Marks comments within a line range as emitted.
|
|
373
571
|
///
|
|
374
572
|
/// This is used when source text is extracted directly, as any comments
|
|
@@ -528,28 +726,51 @@ mod tests {
|
|
|
528
726
|
#[test]
|
|
529
727
|
fn test_reformat_chain_lines_single_line() {
|
|
530
728
|
let input = "foo.bar.baz";
|
|
531
|
-
let result = reformat_chain_lines(input, 2);
|
|
729
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
532
730
|
assert_eq!(result, "foo.bar.baz");
|
|
533
731
|
}
|
|
534
732
|
|
|
535
733
|
#[test]
|
|
536
734
|
fn test_reformat_chain_lines_multiline_chain() {
|
|
537
735
|
let input = "foo.bar\n .baz\n .qux";
|
|
538
|
-
let result = reformat_chain_lines(input, 2);
|
|
736
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
539
737
|
assert_eq!(result, "foo.bar\n .baz\n .qux");
|
|
540
738
|
}
|
|
541
739
|
|
|
542
740
|
#[test]
|
|
543
741
|
fn test_reformat_chain_lines_safe_navigation() {
|
|
544
742
|
let input = "foo&.bar\n &.baz";
|
|
545
|
-
let result = reformat_chain_lines(input, 2);
|
|
743
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
546
744
|
assert_eq!(result, "foo&.bar\n &.baz");
|
|
547
745
|
}
|
|
548
746
|
|
|
549
747
|
#[test]
|
|
550
748
|
fn test_reformat_chain_lines_no_chain() {
|
|
551
749
|
let input = "foo(\n arg1,\n arg2\n)";
|
|
552
|
-
let result = reformat_chain_lines(input, 2);
|
|
750
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
553
751
|
assert_eq!(result, input);
|
|
554
752
|
}
|
|
753
|
+
|
|
754
|
+
#[test]
|
|
755
|
+
fn test_reformat_chain_lines_preserves_base_indent() {
|
|
756
|
+
// Simulates a chain inside a 4-space-indented method body:
|
|
757
|
+
// the caller must include base_indent so the printed continuation
|
|
758
|
+
// lines up with `base_indent + indent_width` columns.
|
|
759
|
+
let input = "foo.bar\n .baz\n .qux";
|
|
760
|
+
let result = reformat_chain_lines(input, 4, 2);
|
|
761
|
+
assert_eq!(result, "foo.bar\n .baz\n .qux");
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
#[test]
|
|
765
|
+
fn test_line_leading_indent_counts_spaces_and_tabs() {
|
|
766
|
+
let source = "def foo\n bar\n\tbaz\nqux\n";
|
|
767
|
+
let bar = source.find("bar").unwrap();
|
|
768
|
+
let baz = source.find("baz").unwrap();
|
|
769
|
+
let qux = source.find("qux").unwrap();
|
|
770
|
+
assert_eq!(line_leading_indent(source, bar), 4);
|
|
771
|
+
assert_eq!(line_leading_indent(source, baz), 1);
|
|
772
|
+
assert_eq!(line_leading_indent(source, qux), 0);
|
|
773
|
+
// Out-of-range offset is clamped.
|
|
774
|
+
assert_eq!(line_leading_indent(source, usize::MAX), 0);
|
|
775
|
+
}
|
|
555
776
|
}
|