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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/Cargo.lock +1 -1
- data/README.md +22 -18
- data/ext/rfmt/Cargo.toml +4 -1
- data/ext/rfmt/src/doc/builders.rs +528 -0
- data/ext/rfmt/src/doc/mod.rs +220 -0
- data/ext/rfmt/src/doc/printer.rs +721 -0
- data/ext/rfmt/src/format/context.rs +448 -0
- data/ext/rfmt/src/format/formatter.rs +250 -0
- data/ext/rfmt/src/format/mod.rs +35 -0
- data/ext/rfmt/src/format/registry.rs +195 -0
- data/ext/rfmt/src/format/rule.rs +726 -0
- data/ext/rfmt/src/format/rules/begin.rs +434 -0
- data/ext/rfmt/src/format/rules/body_end.rs +233 -0
- data/ext/rfmt/src/format/rules/call.rs +448 -0
- data/ext/rfmt/src/format/rules/case.rs +359 -0
- data/ext/rfmt/src/format/rules/class.rs +160 -0
- data/ext/rfmt/src/format/rules/def.rs +216 -0
- data/ext/rfmt/src/format/rules/fallback.rs +130 -0
- data/ext/rfmt/src/format/rules/if_unless.rs +454 -0
- data/ext/rfmt/src/format/rules/loops.rs +325 -0
- data/ext/rfmt/src/format/rules/mod.rs +31 -0
- data/ext/rfmt/src/format/rules/module.rs +150 -0
- data/ext/rfmt/src/format/rules/singleton_class.rs +202 -0
- data/ext/rfmt/src/format/rules/statements.rs +122 -0
- data/ext/rfmt/src/format/rules/variable_write.rs +314 -0
- data/ext/rfmt/src/lib.rs +8 -5
- data/ext/rfmt/src/parser/prism_adapter.rs +157 -2
- data/lib/rfmt/prism_bridge.rb +43 -12
- data/lib/rfmt/version.rb +1 -1
- data/lib/ruby_lsp/rfmt/formatter_runner.rb +2 -0
- metadata +23 -2
- data/ext/rfmt/src/emitter/mod.rs +0 -1844
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
//! FormatRule trait and helper functions
|
|
2
|
+
//!
|
|
3
|
+
//! This module defines the core FormatRule trait that all formatting rules implement,
|
|
4
|
+
//! along with shared helper functions for common formatting patterns.
|
|
5
|
+
|
|
6
|
+
use crate::ast::{CommentType, Node};
|
|
7
|
+
use crate::doc::{concat, hardline, leading_comment, literalline, text, trailing_comment, Doc};
|
|
8
|
+
use crate::error::Result;
|
|
9
|
+
|
|
10
|
+
use super::context::FormatContext;
|
|
11
|
+
use super::registry::RuleRegistry;
|
|
12
|
+
|
|
13
|
+
/// Trait for formatting rules.
|
|
14
|
+
///
|
|
15
|
+
/// Each rule handles a specific node type (or set of node types) and produces
|
|
16
|
+
/// a Doc IR representation of that node.
|
|
17
|
+
///
|
|
18
|
+
/// Rules are stateless and can be shared across multiple formatting contexts.
|
|
19
|
+
pub trait FormatRule: Send + Sync {
|
|
20
|
+
/// Formats a node and returns the Doc IR.
|
|
21
|
+
///
|
|
22
|
+
/// # Arguments
|
|
23
|
+
/// * `node` - The AST node to format
|
|
24
|
+
/// * `ctx` - The formatting context with source, config, and comment tracking
|
|
25
|
+
/// * `registry` - The rule registry for recursive formatting
|
|
26
|
+
///
|
|
27
|
+
/// # Returns
|
|
28
|
+
/// A Doc IR representing the formatted node
|
|
29
|
+
fn format(&self, node: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) -> Result<Doc>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Formats a child node by dispatching to the appropriate rule.
|
|
33
|
+
///
|
|
34
|
+
/// This is the primary way to recursively format child nodes within rules.
|
|
35
|
+
pub fn format_child(child: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) -> Result<Doc> {
|
|
36
|
+
let rule = registry.get_rule(&child.node_type);
|
|
37
|
+
rule.format(child, ctx, registry)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Boxed rule type for dynamic dispatch.
|
|
41
|
+
pub type BoxedRule = Box<dyn FormatRule>;
|
|
42
|
+
|
|
43
|
+
/// Lightweight comment reference using index instead of cloning.
|
|
44
|
+
#[derive(Clone, Copy)]
|
|
45
|
+
struct CommentRef {
|
|
46
|
+
idx: usize,
|
|
47
|
+
start_line: usize,
|
|
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
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Formats leading comments before a given line.
|
|
79
|
+
///
|
|
80
|
+
/// This helper collects all unemitted comments that appear before the given line,
|
|
81
|
+
/// formats them with hardlines, and marks them as emitted.
|
|
82
|
+
///
|
|
83
|
+
/// # Arguments
|
|
84
|
+
/// * `ctx` - The formatting context
|
|
85
|
+
/// * `line` - The line number to get comments before
|
|
86
|
+
///
|
|
87
|
+
/// # Returns
|
|
88
|
+
/// A Doc containing all leading comments with proper line breaks
|
|
89
|
+
pub fn format_leading_comments(ctx: &mut FormatContext, line: usize) -> Doc {
|
|
90
|
+
// Collect lightweight refs while borrowing immutably
|
|
91
|
+
let comment_refs: Vec<CommentRef> = ctx
|
|
92
|
+
.get_comment_indices_before(line)
|
|
93
|
+
.filter_map(|idx| {
|
|
94
|
+
ctx.get_comment(idx).map(|c| CommentRef {
|
|
95
|
+
idx,
|
|
96
|
+
start_line: c.location.start_line,
|
|
97
|
+
end_line: c.location.end_line,
|
|
98
|
+
is_block: matches!(c.comment_type, CommentType::Block),
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
.collect();
|
|
102
|
+
|
|
103
|
+
if comment_refs.is_empty() {
|
|
104
|
+
return Doc::Empty;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let mut docs: Vec<Doc> = Vec::with_capacity(comment_refs.len() * 2);
|
|
108
|
+
let mut last_end_line: Option<usize> = None;
|
|
109
|
+
let mut indices_to_mark: Vec<usize> = Vec::with_capacity(comment_refs.len());
|
|
110
|
+
|
|
111
|
+
for cref in &comment_refs {
|
|
112
|
+
// Preserve blank lines between comments
|
|
113
|
+
if let Some(prev_end) = last_end_line {
|
|
114
|
+
let gap = cref.start_line.saturating_sub(prev_end);
|
|
115
|
+
for _ in 1..gap {
|
|
116
|
+
docs.push(hardline());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if let Some(comment) = ctx.get_comment(cref.idx) {
|
|
121
|
+
push_leading_comment(&mut docs, &comment.text, cref.is_block);
|
|
122
|
+
}
|
|
123
|
+
last_end_line = Some(cref.end_line);
|
|
124
|
+
indices_to_mark.push(cref.idx);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Mark comments as emitted in batch
|
|
128
|
+
ctx.mark_comments_emitted(indices_to_mark);
|
|
129
|
+
|
|
130
|
+
// Add blank line after comments if there's a gap before the node
|
|
131
|
+
if let Some(last_end) = last_end_line {
|
|
132
|
+
if line > last_end + 1 {
|
|
133
|
+
docs.push(hardline());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
concat(docs)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Formats a trailing comment on the same line.
|
|
141
|
+
///
|
|
142
|
+
/// # Arguments
|
|
143
|
+
/// * `ctx` - The formatting context
|
|
144
|
+
/// * `line` - The line number to get trailing comments for
|
|
145
|
+
///
|
|
146
|
+
/// # Returns
|
|
147
|
+
/// A Doc containing the trailing comment, or Empty if none
|
|
148
|
+
pub fn format_trailing_comment(ctx: &mut FormatContext, line: usize) -> Doc {
|
|
149
|
+
// Collect indices while borrowing immutably
|
|
150
|
+
let indices: Vec<usize> = ctx.get_trailing_comment_indices(line).collect();
|
|
151
|
+
|
|
152
|
+
if indices.is_empty() {
|
|
153
|
+
return Doc::Empty;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let mut docs: Vec<Doc> = Vec::with_capacity(indices.len());
|
|
157
|
+
|
|
158
|
+
for &idx in &indices {
|
|
159
|
+
if let Some(comment) = ctx.get_comment(idx) {
|
|
160
|
+
docs.push(trailing_comment(&comment.text));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Mark comments as emitted in batch
|
|
165
|
+
ctx.mark_comments_emitted(indices);
|
|
166
|
+
|
|
167
|
+
concat(docs)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Formats comments that appear before the `end` keyword of a construct.
|
|
171
|
+
///
|
|
172
|
+
/// This is used for comments inside class/module/def bodies that appear
|
|
173
|
+
/// on standalone lines before the closing `end`.
|
|
174
|
+
///
|
|
175
|
+
/// # Arguments
|
|
176
|
+
/// * `ctx` - The formatting context
|
|
177
|
+
/// * `start_line` - The start line of the construct
|
|
178
|
+
/// * `end_line` - The end line of the construct (where `end` appears)
|
|
179
|
+
///
|
|
180
|
+
/// # Returns
|
|
181
|
+
/// A Doc containing the formatted comments
|
|
182
|
+
pub fn format_comments_before_end(
|
|
183
|
+
ctx: &mut FormatContext,
|
|
184
|
+
start_line: usize,
|
|
185
|
+
end_line: usize,
|
|
186
|
+
) -> Doc {
|
|
187
|
+
// Collect indices for comments in range
|
|
188
|
+
let indices: Vec<usize> = ctx
|
|
189
|
+
.get_comment_indices_in_range(start_line + 1, end_line)
|
|
190
|
+
.collect();
|
|
191
|
+
|
|
192
|
+
if indices.is_empty() {
|
|
193
|
+
return Doc::Empty;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Filter to only standalone comments
|
|
197
|
+
let standalone_refs: Vec<CommentRef> = indices
|
|
198
|
+
.iter()
|
|
199
|
+
.filter_map(|&idx| {
|
|
200
|
+
ctx.get_comment(idx).and_then(|c| {
|
|
201
|
+
if ctx.is_standalone_comment(c) && c.location.end_line < end_line {
|
|
202
|
+
Some(CommentRef {
|
|
203
|
+
idx,
|
|
204
|
+
start_line: c.location.start_line,
|
|
205
|
+
end_line: c.location.end_line,
|
|
206
|
+
is_block: matches!(c.comment_type, CommentType::Block),
|
|
207
|
+
})
|
|
208
|
+
} else {
|
|
209
|
+
None
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
.collect();
|
|
214
|
+
|
|
215
|
+
if standalone_refs.is_empty() {
|
|
216
|
+
return Doc::Empty;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let mut docs: Vec<Doc> = vec![hardline()];
|
|
220
|
+
let mut last_end_line: Option<usize> = None;
|
|
221
|
+
let mut indices_to_mark: Vec<usize> = Vec::with_capacity(standalone_refs.len());
|
|
222
|
+
|
|
223
|
+
let last_idx = standalone_refs.len().saturating_sub(1);
|
|
224
|
+
for (i, cref) in standalone_refs.iter().enumerate() {
|
|
225
|
+
// Preserve blank lines between comments
|
|
226
|
+
if let Some(prev_end) = last_end_line {
|
|
227
|
+
let gap = cref.start_line.saturating_sub(prev_end);
|
|
228
|
+
for _ in 1..gap {
|
|
229
|
+
docs.push(hardline());
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if let Some(comment) = ctx.get_comment(cref.idx) {
|
|
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
|
+
}
|
|
254
|
+
}
|
|
255
|
+
last_end_line = Some(cref.end_line);
|
|
256
|
+
indices_to_mark.push(cref.idx);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Mark comments as emitted in batch
|
|
260
|
+
ctx.mark_comments_emitted(indices_to_mark);
|
|
261
|
+
|
|
262
|
+
concat(docs)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/// Formats remaining comments at the end of the file.
|
|
266
|
+
///
|
|
267
|
+
/// This should be called after all nodes have been formatted to emit
|
|
268
|
+
/// any comments that weren't attached to specific nodes.
|
|
269
|
+
///
|
|
270
|
+
/// # Arguments
|
|
271
|
+
/// * `ctx` - The formatting context
|
|
272
|
+
/// * `last_code_line` - The last line of code in the file
|
|
273
|
+
///
|
|
274
|
+
/// # Returns
|
|
275
|
+
/// A Doc containing all remaining comments
|
|
276
|
+
pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize) -> Doc {
|
|
277
|
+
// Collect remaining comment indices and their line info
|
|
278
|
+
let comment_refs: Vec<CommentRef> = ctx
|
|
279
|
+
.get_remaining_comment_indices()
|
|
280
|
+
.filter_map(|idx| {
|
|
281
|
+
ctx.get_comment(idx).map(|c| CommentRef {
|
|
282
|
+
idx,
|
|
283
|
+
start_line: c.location.start_line,
|
|
284
|
+
end_line: c.location.end_line,
|
|
285
|
+
is_block: matches!(c.comment_type, CommentType::Block),
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
.collect();
|
|
289
|
+
|
|
290
|
+
if comment_refs.is_empty() {
|
|
291
|
+
return Doc::Empty;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let mut docs: Vec<Doc> = Vec::with_capacity(comment_refs.len() * 2);
|
|
295
|
+
let mut last_end_line = last_code_line;
|
|
296
|
+
let mut is_first = true;
|
|
297
|
+
let mut indices_to_mark: Vec<usize> = Vec::with_capacity(comment_refs.len());
|
|
298
|
+
|
|
299
|
+
for cref in &comment_refs {
|
|
300
|
+
// Preserve blank lines
|
|
301
|
+
let gap = cref.start_line.saturating_sub(last_end_line);
|
|
302
|
+
|
|
303
|
+
// Only add newlines if not the first comment or if there's a gap
|
|
304
|
+
if !is_first || gap > 0 {
|
|
305
|
+
for _ in 0..gap.max(1) {
|
|
306
|
+
docs.push(hardline());
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if let Some(comment) = ctx.get_comment(cref.idx) {
|
|
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
|
+
}
|
|
323
|
+
}
|
|
324
|
+
last_end_line = cref.end_line;
|
|
325
|
+
is_first = false;
|
|
326
|
+
indices_to_mark.push(cref.idx);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Mark comments as emitted in batch
|
|
330
|
+
ctx.mark_comments_emitted(indices_to_mark);
|
|
331
|
+
|
|
332
|
+
concat(docs)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/// Formats a statements node as a sequence of children with proper line spacing.
|
|
336
|
+
///
|
|
337
|
+
/// This is a shared helper used by multiple formatting rules (if_unless, case,
|
|
338
|
+
/// begin, call, loops) to format StatementsNode children consistently.
|
|
339
|
+
///
|
|
340
|
+
/// # Arguments
|
|
341
|
+
/// * `node` - The StatementsNode to format
|
|
342
|
+
/// * `ctx` - The formatting context
|
|
343
|
+
/// * `registry` - The rule registry for recursive formatting
|
|
344
|
+
///
|
|
345
|
+
/// # Returns
|
|
346
|
+
/// A Doc containing all statements with proper line breaks between them
|
|
347
|
+
pub fn format_statements(
|
|
348
|
+
node: &Node,
|
|
349
|
+
ctx: &mut FormatContext,
|
|
350
|
+
registry: &RuleRegistry,
|
|
351
|
+
) -> Result<Doc> {
|
|
352
|
+
if node.children.is_empty() {
|
|
353
|
+
return Ok(Doc::Empty);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let mut docs: Vec<Doc> = Vec::with_capacity(node.children.len() * 2);
|
|
357
|
+
|
|
358
|
+
for (i, child) in node.children.iter().enumerate() {
|
|
359
|
+
let child_doc = format_child(child, ctx, registry)?;
|
|
360
|
+
docs.push(child_doc);
|
|
361
|
+
|
|
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.
|
|
369
|
+
if let Some(next_child) = node.children.get(i + 1) {
|
|
370
|
+
let current_end_line = child.location.end_line;
|
|
371
|
+
let next_start_line = next_child.location.start_line;
|
|
372
|
+
let line_diff = next_start_line.saturating_sub(current_end_line);
|
|
373
|
+
|
|
374
|
+
docs.push(hardline());
|
|
375
|
+
|
|
376
|
+
if line_diff > 1 {
|
|
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
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
Ok(concat(docs))
|
|
407
|
+
}
|
|
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
|
+
|
|
425
|
+
/// Reformats multiline method chain text with indented style.
|
|
426
|
+
///
|
|
427
|
+
/// Converts aligned method chains to indented style:
|
|
428
|
+
/// - First line is kept as-is (trimmed at end)
|
|
429
|
+
/// - Subsequent lines starting with `.` or `&.` are re-indented to
|
|
430
|
+
/// `base_indent + indent_width` spaces
|
|
431
|
+
///
|
|
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.
|
|
435
|
+
///
|
|
436
|
+
/// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
|
|
437
|
+
///
|
|
438
|
+
/// # Example
|
|
439
|
+
/// ```text
|
|
440
|
+
/// Input (base_indent=4, indent_width=2):
|
|
441
|
+
/// "foo.bar\n .baz"
|
|
442
|
+
/// Output:
|
|
443
|
+
/// "foo.bar\n .baz"
|
|
444
|
+
/// ```
|
|
445
|
+
pub fn reformat_chain_lines(
|
|
446
|
+
source_text: &str,
|
|
447
|
+
base_indent: usize,
|
|
448
|
+
indent_width: usize,
|
|
449
|
+
) -> std::borrow::Cow<'_, str> {
|
|
450
|
+
use std::borrow::Cow;
|
|
451
|
+
|
|
452
|
+
let lines: Vec<&str> = source_text.lines().collect();
|
|
453
|
+
if lines.len() <= 1 {
|
|
454
|
+
return Cow::Borrowed(source_text);
|
|
455
|
+
}
|
|
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
|
+
|
|
472
|
+
// Check if there are actual chain continuation lines (. or &.)
|
|
473
|
+
let has_chain = lines[1..].iter().any(|l| {
|
|
474
|
+
let t = l.trim_start();
|
|
475
|
+
t.starts_with('.') || t.starts_with("&.")
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if !has_chain {
|
|
479
|
+
return Cow::Borrowed(source_text);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Build the indented chain with pre-allocated capacity
|
|
483
|
+
let chain_indent = " ".repeat(base_indent + indent_width);
|
|
484
|
+
let mut result = String::with_capacity(source_text.len());
|
|
485
|
+
result.push_str(lines[0].trim_end());
|
|
486
|
+
|
|
487
|
+
for line in &lines[1..] {
|
|
488
|
+
result.push('\n');
|
|
489
|
+
let trimmed = line.trim();
|
|
490
|
+
if trimmed.starts_with('.') || trimmed.starts_with("&.") {
|
|
491
|
+
result.push_str(&chain_indent);
|
|
492
|
+
result.push_str(trimmed);
|
|
493
|
+
} else {
|
|
494
|
+
// Non-chain continuation (e.g., heredoc content): preserve as-is
|
|
495
|
+
result.push_str(line);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
Cow::Owned(result)
|
|
500
|
+
}
|
|
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
|
+
|
|
520
|
+
/// Marks comments within a line range as emitted.
|
|
521
|
+
///
|
|
522
|
+
/// This is used when source text is extracted directly, as any comments
|
|
523
|
+
/// within the extracted range are included in the output.
|
|
524
|
+
///
|
|
525
|
+
/// # Arguments
|
|
526
|
+
/// * `ctx` - The formatting context
|
|
527
|
+
/// * `start_line` - The start line of the range
|
|
528
|
+
/// * `end_line` - The end line of the range
|
|
529
|
+
pub fn mark_comments_in_range_emitted(ctx: &mut FormatContext, start_line: usize, end_line: usize) {
|
|
530
|
+
let indices: Vec<usize> = ctx
|
|
531
|
+
.get_comment_indices_in_range(start_line, end_line)
|
|
532
|
+
.collect();
|
|
533
|
+
ctx.mark_comments_emitted(indices);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// Checks if a node is a structural node (part of definition syntax, not body).
|
|
537
|
+
///
|
|
538
|
+
/// Structural nodes are parts of class/module/method definitions that should
|
|
539
|
+
/// not be emitted as body content (e.g., constant names, parameter nodes).
|
|
540
|
+
pub fn is_structural_node(node: &Node) -> bool {
|
|
541
|
+
use crate::ast::NodeType;
|
|
542
|
+
|
|
543
|
+
matches!(
|
|
544
|
+
node.node_type,
|
|
545
|
+
NodeType::ConstantReadNode
|
|
546
|
+
| NodeType::ConstantWriteNode
|
|
547
|
+
| NodeType::ConstantPathNode
|
|
548
|
+
| NodeType::RequiredParameterNode
|
|
549
|
+
| NodeType::OptionalParameterNode
|
|
550
|
+
| NodeType::RestParameterNode
|
|
551
|
+
| NodeType::KeywordParameterNode
|
|
552
|
+
| NodeType::RequiredKeywordParameterNode
|
|
553
|
+
| NodeType::OptionalKeywordParameterNode
|
|
554
|
+
| NodeType::KeywordRestParameterNode
|
|
555
|
+
| NodeType::BlockParameterNode
|
|
556
|
+
| NodeType::ForwardingParameterNode
|
|
557
|
+
| NodeType::NoKeywordsParameterNode
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
#[cfg(test)]
|
|
562
|
+
mod tests {
|
|
563
|
+
use super::*;
|
|
564
|
+
use crate::ast::{
|
|
565
|
+
Comment, CommentPosition, CommentType, FormattingInfo, Location, Node, NodeType,
|
|
566
|
+
};
|
|
567
|
+
use crate::config::Config;
|
|
568
|
+
use std::collections::HashMap;
|
|
569
|
+
|
|
570
|
+
fn make_comment(text: &str, line: usize, start_offset: usize) -> Comment {
|
|
571
|
+
Comment {
|
|
572
|
+
text: text.to_string(),
|
|
573
|
+
location: Location::new(
|
|
574
|
+
line,
|
|
575
|
+
0,
|
|
576
|
+
line,
|
|
577
|
+
text.len(),
|
|
578
|
+
start_offset,
|
|
579
|
+
start_offset + text.len(),
|
|
580
|
+
),
|
|
581
|
+
comment_type: CommentType::Line,
|
|
582
|
+
position: CommentPosition::Leading,
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
fn make_node_with_comments(comments: Vec<Comment>) -> Node {
|
|
587
|
+
Node {
|
|
588
|
+
node_type: NodeType::ProgramNode,
|
|
589
|
+
location: Location::new(1, 0, 10, 0, 0, 100),
|
|
590
|
+
children: Vec::new(),
|
|
591
|
+
metadata: HashMap::new(),
|
|
592
|
+
comments,
|
|
593
|
+
formatting: FormattingInfo::default(),
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
#[test]
|
|
598
|
+
fn test_format_leading_comments() {
|
|
599
|
+
let config = Config::default();
|
|
600
|
+
let source = "# comment\nclass Foo\nend";
|
|
601
|
+
let mut ctx = FormatContext::new(&config, source);
|
|
602
|
+
|
|
603
|
+
let comment = make_comment("# comment", 1, 0);
|
|
604
|
+
let node = make_node_with_comments(vec![comment]);
|
|
605
|
+
ctx.collect_comments(&node);
|
|
606
|
+
|
|
607
|
+
let doc = format_leading_comments(&mut ctx, 5);
|
|
608
|
+
assert!(!matches!(doc, Doc::Empty));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
#[test]
|
|
612
|
+
fn test_format_trailing_comment() {
|
|
613
|
+
let config = Config::default();
|
|
614
|
+
let source = "code # trailing";
|
|
615
|
+
let mut ctx = FormatContext::new(&config, source);
|
|
616
|
+
|
|
617
|
+
let comment = Comment {
|
|
618
|
+
text: "# trailing".to_string(),
|
|
619
|
+
location: Location::new(1, 5, 1, 15, 5, 15),
|
|
620
|
+
comment_type: CommentType::Line,
|
|
621
|
+
position: CommentPosition::Trailing,
|
|
622
|
+
};
|
|
623
|
+
let node = make_node_with_comments(vec![comment]);
|
|
624
|
+
ctx.collect_comments(&node);
|
|
625
|
+
|
|
626
|
+
let doc = format_trailing_comment(&mut ctx, 1);
|
|
627
|
+
assert!(!matches!(doc, Doc::Empty));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
#[test]
|
|
631
|
+
fn test_is_structural_node() {
|
|
632
|
+
let structural_node = Node {
|
|
633
|
+
node_type: NodeType::ConstantReadNode,
|
|
634
|
+
location: Location::new(1, 0, 1, 3, 0, 3),
|
|
635
|
+
children: Vec::new(),
|
|
636
|
+
metadata: HashMap::new(),
|
|
637
|
+
comments: Vec::new(),
|
|
638
|
+
formatting: FormattingInfo::default(),
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
let non_structural_node = Node {
|
|
642
|
+
node_type: NodeType::CallNode,
|
|
643
|
+
location: Location::new(1, 0, 1, 10, 0, 10),
|
|
644
|
+
children: Vec::new(),
|
|
645
|
+
metadata: HashMap::new(),
|
|
646
|
+
comments: Vec::new(),
|
|
647
|
+
formatting: FormattingInfo::default(),
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
assert!(is_structural_node(&structural_node));
|
|
651
|
+
assert!(!is_structural_node(&non_structural_node));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
#[test]
|
|
655
|
+
fn test_format_child() {
|
|
656
|
+
let config = Config::default();
|
|
657
|
+
let source = "puts 'hello'";
|
|
658
|
+
let mut ctx = FormatContext::new(&config, source);
|
|
659
|
+
let registry = RuleRegistry::default_registry();
|
|
660
|
+
|
|
661
|
+
let node = Node {
|
|
662
|
+
node_type: NodeType::CallNode,
|
|
663
|
+
location: Location::new(1, 0, 1, 12, 0, 12),
|
|
664
|
+
children: Vec::new(),
|
|
665
|
+
metadata: HashMap::new(),
|
|
666
|
+
comments: Vec::new(),
|
|
667
|
+
formatting: FormattingInfo::default(),
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
ctx.collect_comments(&node);
|
|
671
|
+
|
|
672
|
+
let doc = format_child(&node, &mut ctx, ®istry).unwrap();
|
|
673
|
+
assert!(!matches!(doc, Doc::Empty));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
#[test]
|
|
677
|
+
fn test_reformat_chain_lines_single_line() {
|
|
678
|
+
let input = "foo.bar.baz";
|
|
679
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
680
|
+
assert_eq!(result, "foo.bar.baz");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
#[test]
|
|
684
|
+
fn test_reformat_chain_lines_multiline_chain() {
|
|
685
|
+
let input = "foo.bar\n .baz\n .qux";
|
|
686
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
687
|
+
assert_eq!(result, "foo.bar\n .baz\n .qux");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
#[test]
|
|
691
|
+
fn test_reformat_chain_lines_safe_navigation() {
|
|
692
|
+
let input = "foo&.bar\n &.baz";
|
|
693
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
694
|
+
assert_eq!(result, "foo&.bar\n &.baz");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
#[test]
|
|
698
|
+
fn test_reformat_chain_lines_no_chain() {
|
|
699
|
+
let input = "foo(\n arg1,\n arg2\n)";
|
|
700
|
+
let result = reformat_chain_lines(input, 0, 2);
|
|
701
|
+
assert_eq!(result, input);
|
|
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
|
+
}
|
|
726
|
+
}
|