rfmt 1.3.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c46c2c5129ef89717e76c7d187a0739389814d2ee01a1c50a5b64173d9b728f4
4
- data.tar.gz: 63982b7a966f9a64b9b01ea9d5d22e55e3c020fa7f76c4d63333bb7114591a6c
3
+ metadata.gz: 79f5e6c24f82f6552874aefda2a6b97d8f4612419810d76ebd9381e862edded4
4
+ data.tar.gz: f193dab59e9a02f62d3e599c8f0349ebbf6e7fda070ba55990e9a8d399990f72
5
5
  SHA512:
6
- metadata.gz: a1f3c26b649ea73a7a01c41c8d06e8d00137eb1b48946e8043ee632d9f5ea6465649999558a1c101779638d0383dadbbd610174de34c68ffcefb6a095c214b01
7
- data.tar.gz: 8c6c55859f132cd0cf5cfedf8b3af6b785cbd52f73f9f6ae9d8493717068724357abec0ad82c64ba31fefd99d798154f0df6d9136f52ffff675f848aa57f7500
6
+ metadata.gz: 6ebfd2c04793445912e1edc28db74e5cfa218fcd3fc28f6e3b42a88b1794fe0bfc6643890fa34477291c7ad8624696f3bb7207436ecfeb6f756439be5d9065ae
7
+ data.tar.gz: e209cf0a8671786af5e1560979f70f5fe8bb4e2de3912613a59e6d6269effe54f123407ca8989b50578f330d16e8a0d2fa7a1b47c8c07d5731e893664892fe1a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.2] - 2026-01-09
4
+
5
+ ### Added
6
+ - Implement `std::str::FromStr` trait for `NodeType`
7
+ - Unit tests for validation module
8
+
9
+ ### Changed
10
+ - Use serde enum for comment type deserialization (type-safe JSON parsing)
11
+ - Convert recursive `find_last_code_line` to iterative approach (prevent stack overflow)
12
+ - Use BTreeMap index for comment lookup in `emit_statements` (O(n) → O(log n))
13
+
14
+ ### Fixed
15
+ - Remove panic-prone `unwrap()` on Mutex lock in logger (prevent Ruby VM crash)
16
+
17
+ ## [1.3.1] - 2026-01-08
18
+
19
+ ### Added
20
+ - Dedicated emitters for SingletonClassNode and pattern matching (CaseMatchNode, InNode)
21
+ - Literal and pattern match node support
22
+ - NoKeywordsParameterNode support
23
+
24
+ ### Changed
25
+ - Performance: Comment lookup optimized with BTreeMap index (O(n) → O(log n))
26
+ - Performance: HashSet for comment tracking (O(n) → O(1) contains check)
27
+ - Performance: Cached indent strings to avoid repeated allocations
28
+
29
+ ### Fixed
30
+ - DefNode parameter handling and missing node types
31
+ - Deep nest JSON parse error (max_nesting: false)
32
+
3
33
  ## [1.3.0] - 2026-01-07
4
34
 
5
35
  ### Added
data/Cargo.lock CHANGED
@@ -1214,7 +1214,7 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
1214
1214
 
1215
1215
  [[package]]
1216
1216
  name = "rfmt"
1217
- version = "1.3.0"
1217
+ version = "1.3.2"
1218
1218
  dependencies = [
1219
1219
  "anyhow",
1220
1220
  "clap",
data/ext/rfmt/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rfmt"
3
- version = "1.3.0"
3
+ version = "1.3.2"
4
4
  edition = "2021"
5
5
  authors = ["fujitani sora <fujitanisora0414@gmail.com>"]
6
6
  license = "MIT"
@@ -180,12 +180,66 @@ pub enum NodeType {
180
180
  KeywordRestParameterNode,
181
181
  BlockParameterNode,
182
182
 
183
+ // Source metadata nodes
184
+ SourceFileNode,
185
+ SourceLineNode,
186
+ SourceEncodingNode,
187
+
188
+ // Pre/Post execution
189
+ PreExecutionNode,
190
+ PostExecutionNode,
191
+
192
+ // Numeric literals
193
+ RationalNode,
194
+ ImaginaryNode,
195
+
196
+ // String interpolation
197
+ EmbeddedVariableNode,
198
+
199
+ // Pattern matching patterns
200
+ ArrayPatternNode,
201
+ HashPatternNode,
202
+ FindPatternNode,
203
+ CapturePatternNode,
204
+ AlternationPatternNode,
205
+ PinnedExpressionNode,
206
+ PinnedVariableNode,
207
+
208
+ // Forwarding
209
+ ForwardingArgumentsNode,
210
+ ForwardingParameterNode,
211
+ NoKeywordsParameterNode,
212
+
213
+ // References
214
+ BackReferenceReadNode,
215
+ NumberedReferenceReadNode,
216
+
217
+ // Call/Index compound assignment
218
+ CallAndWriteNode,
219
+ CallOrWriteNode,
220
+ CallOperatorWriteNode,
221
+ IndexAndWriteNode,
222
+ IndexOrWriteNode,
223
+ IndexOperatorWriteNode,
224
+
225
+ // Match
226
+ MatchWriteNode,
227
+ MatchLastLineNode,
228
+ InterpolatedMatchLastLineNode,
229
+
230
+ // Other
231
+ FlipFlopNode,
232
+ ImplicitNode,
233
+ ImplicitRestNode,
234
+
183
235
  Unknown(String),
184
236
  }
185
237
 
186
- impl NodeType {
187
- pub fn from_str(s: &str) -> Self {
188
- match s {
238
+ impl std::str::FromStr for NodeType {
239
+ type Err = std::convert::Infallible;
240
+
241
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
242
+ Ok(match s {
189
243
  "program_node" => Self::ProgramNode,
190
244
  "statements_node" => Self::StatementsNode,
191
245
  "class_node" => Self::ClassNode,
@@ -290,8 +344,55 @@ impl NodeType {
290
344
  "block_argument_node" => Self::BlockArgumentNode,
291
345
  "multi_write_node" => Self::MultiWriteNode,
292
346
  "multi_target_node" => Self::MultiTargetNode,
347
+ "source_file_node" => Self::SourceFileNode,
348
+ "source_line_node" => Self::SourceLineNode,
349
+ "source_encoding_node" => Self::SourceEncodingNode,
350
+ "pre_execution_node" => Self::PreExecutionNode,
351
+ "post_execution_node" => Self::PostExecutionNode,
352
+ // Numeric literals
353
+ "rational_node" => Self::RationalNode,
354
+ "imaginary_node" => Self::ImaginaryNode,
355
+ // String interpolation
356
+ "embedded_variable_node" => Self::EmbeddedVariableNode,
357
+ // Pattern matching patterns
358
+ "array_pattern_node" => Self::ArrayPatternNode,
359
+ "hash_pattern_node" => Self::HashPatternNode,
360
+ "find_pattern_node" => Self::FindPatternNode,
361
+ "capture_pattern_node" => Self::CapturePatternNode,
362
+ "alternation_pattern_node" => Self::AlternationPatternNode,
363
+ "pinned_expression_node" => Self::PinnedExpressionNode,
364
+ "pinned_variable_node" => Self::PinnedVariableNode,
365
+ // Forwarding
366
+ "forwarding_arguments_node" => Self::ForwardingArgumentsNode,
367
+ "forwarding_parameter_node" => Self::ForwardingParameterNode,
368
+ "no_keywords_parameter_node" => Self::NoKeywordsParameterNode,
369
+ // References
370
+ "back_reference_read_node" => Self::BackReferenceReadNode,
371
+ "numbered_reference_read_node" => Self::NumberedReferenceReadNode,
372
+ // Call/Index compound assignment
373
+ "call_and_write_node" => Self::CallAndWriteNode,
374
+ "call_or_write_node" => Self::CallOrWriteNode,
375
+ "call_operator_write_node" => Self::CallOperatorWriteNode,
376
+ "index_and_write_node" => Self::IndexAndWriteNode,
377
+ "index_or_write_node" => Self::IndexOrWriteNode,
378
+ "index_operator_write_node" => Self::IndexOperatorWriteNode,
379
+ // Match
380
+ "match_write_node" => Self::MatchWriteNode,
381
+ "match_last_line_node" => Self::MatchLastLineNode,
382
+ "interpolated_match_last_line_node" => Self::InterpolatedMatchLastLineNode,
383
+ // Other
384
+ "flip_flop_node" => Self::FlipFlopNode,
385
+ "implicit_node" => Self::ImplicitNode,
386
+ "implicit_rest_node" => Self::ImplicitRestNode,
293
387
  _ => Self::Unknown(s.to_string()),
294
- }
388
+ })
389
+ }
390
+ }
391
+
392
+ impl NodeType {
393
+ /// Parse a node type from a string (convenience wrapper for `FromStr`)
394
+ pub fn from_str(s: &str) -> Self {
395
+ s.parse().unwrap()
295
396
  }
296
397
 
297
398
  /// Check if this node type is a definition (class, module, or method)
@@ -1,6 +1,7 @@
1
1
  use crate::ast::{Comment, Node, NodeType};
2
2
  use crate::config::{Config, IndentStyle};
3
3
  use crate::error::Result;
4
+ use std::collections::{BTreeMap, HashSet};
4
5
  use std::fmt::Write;
5
6
 
6
7
  /// Block style for Ruby blocks
@@ -16,7 +17,12 @@ pub struct Emitter {
16
17
  source: String,
17
18
  buffer: String,
18
19
  all_comments: Vec<Comment>,
19
- emitted_comment_indices: Vec<usize>,
20
+ emitted_comment_indices: HashSet<usize>,
21
+ /// Cached indent strings by level (index = level, value = indent string)
22
+ indent_cache: Vec<String>,
23
+ /// Index of comment indices by start line for O(log n) lookup
24
+ /// Key: start_line, Value: Vec of comment indices that start on that line
25
+ comments_by_line: BTreeMap<usize, Vec<usize>>,
20
26
  }
21
27
 
22
28
  impl Emitter {
@@ -26,7 +32,9 @@ impl Emitter {
26
32
  source: String::new(),
27
33
  buffer: String::new(),
28
34
  all_comments: Vec::new(),
29
- emitted_comment_indices: Vec::new(),
35
+ emitted_comment_indices: HashSet::new(),
36
+ indent_cache: Vec::new(),
37
+ comments_by_line: BTreeMap::new(),
30
38
  }
31
39
  }
32
40
 
@@ -37,7 +45,9 @@ impl Emitter {
37
45
  source,
38
46
  buffer: String::new(),
39
47
  all_comments: Vec::new(),
40
- emitted_comment_indices: Vec::new(),
48
+ emitted_comment_indices: HashSet::new(),
49
+ indent_cache: Vec::new(),
50
+ comments_by_line: BTreeMap::new(),
41
51
  }
42
52
  }
43
53
 
@@ -45,8 +55,10 @@ impl Emitter {
45
55
  pub fn emit(&mut self, ast: &Node) -> Result<String> {
46
56
  self.buffer.clear();
47
57
  self.emitted_comment_indices.clear();
58
+ self.comments_by_line.clear();
48
59
 
49
60
  self.collect_comments(ast);
61
+ self.build_comment_index();
50
62
 
51
63
  self.emit_node(ast, 0)?;
52
64
 
@@ -61,18 +73,19 @@ impl Emitter {
61
73
  self.buffer.push('\n');
62
74
  }
63
75
 
64
- Ok(self.buffer.clone())
76
+ Ok(std::mem::take(&mut self.buffer))
65
77
  }
66
78
 
67
79
  /// Find the last line of code in the AST (excluding comments)
68
80
  fn find_last_code_line(ast: &Node) -> usize {
69
81
  let mut max_line = ast.location.end_line;
70
- for child in &ast.children {
71
- let child_end = Self::find_last_code_line(child);
72
- if child_end > max_line {
73
- max_line = child_end;
74
- }
82
+ let mut stack = vec![ast];
83
+
84
+ while let Some(node) = stack.pop() {
85
+ max_line = max_line.max(node.location.end_line);
86
+ stack.extend(node.children.iter());
75
87
  }
88
+
76
89
  max_line
77
90
  }
78
91
 
@@ -107,7 +120,7 @@ impl Emitter {
107
120
  }
108
121
 
109
122
  writeln!(self.buffer, "{}", comment.text)?;
110
- self.emitted_comment_indices.push(idx);
123
+ self.emitted_comment_indices.insert(idx);
111
124
  last_end_line = Some(comment.location.end_line);
112
125
  is_first_comment = false;
113
126
  }
@@ -122,28 +135,75 @@ impl Emitter {
122
135
  }
123
136
  }
124
137
 
138
+ /// Build the comment index by start line for O(log n) range lookups
139
+ fn build_comment_index(&mut self) {
140
+ for (idx, comment) in self.all_comments.iter().enumerate() {
141
+ self.comments_by_line
142
+ .entry(comment.location.start_line)
143
+ .or_default()
144
+ .push(idx);
145
+ }
146
+ }
147
+
148
+ /// Get comment indices in the given line range [start_line, end_line)
149
+ /// Uses BTreeMap range for O(log n) lookup instead of O(n) iteration
150
+ fn get_comment_indices_in_range(&self, start_line: usize, end_line: usize) -> Vec<usize> {
151
+ self.comments_by_line
152
+ .range(start_line..end_line)
153
+ .flat_map(|(_, indices)| indices.iter().copied())
154
+ .filter(|&idx| !self.emitted_comment_indices.contains(&idx))
155
+ .collect()
156
+ }
157
+
158
+ /// Get comment indices before a given line (exclusive)
159
+ /// Uses BTreeMap range for O(log n) lookup
160
+ fn get_comment_indices_before(&self, line: usize) -> Vec<usize> {
161
+ self.comments_by_line
162
+ .range(..line)
163
+ .flat_map(|(_, indices)| indices.iter().copied())
164
+ .filter(|&idx| {
165
+ !self.emitted_comment_indices.contains(&idx)
166
+ && self.all_comments[idx].location.end_line < line
167
+ })
168
+ .collect()
169
+ }
170
+
171
+ /// Get comment indices on a specific line (for trailing comments)
172
+ /// Uses BTreeMap get for O(log n) lookup
173
+ fn get_comment_indices_on_line(&self, line: usize) -> Vec<usize> {
174
+ self.comments_by_line
175
+ .get(&line)
176
+ .map(|indices| {
177
+ indices
178
+ .iter()
179
+ .copied()
180
+ .filter(|&idx| !self.emitted_comment_indices.contains(&idx))
181
+ .collect()
182
+ })
183
+ .unwrap_or_default()
184
+ }
185
+
125
186
  /// Emit comments that appear before a given line
187
+ /// Uses BTreeMap index for O(log n) lookup instead of O(n) iteration
126
188
  fn emit_comments_before(&mut self, line: usize, indent_level: usize) -> Result<()> {
127
- let indent_str = match self.config.formatting.indent_style {
128
- IndentStyle::Spaces => " ".repeat(self.config.formatting.indent_width * indent_level),
129
- IndentStyle::Tabs => "\t".repeat(indent_level),
130
- };
189
+ let indent_str = self.get_indent(indent_level).to_string();
131
190
 
132
- let mut comments_to_emit = Vec::new();
133
- for (idx, comment) in self.all_comments.iter().enumerate() {
134
- if self.emitted_comment_indices.contains(&idx) {
135
- continue;
136
- }
191
+ // Use indexed lookup instead of iterating all comments
192
+ let indices = self.get_comment_indices_before(line);
137
193
 
138
- if comment.location.end_line < line {
139
- comments_to_emit.push((
194
+ // Build list of comments to emit with their data
195
+ let mut comments_to_emit: Vec<_> = indices
196
+ .into_iter()
197
+ .map(|idx| {
198
+ let comment = &self.all_comments[idx];
199
+ (
140
200
  idx,
141
201
  comment.text.clone(),
142
202
  comment.location.start_line,
143
203
  comment.location.end_line,
144
- ));
145
- }
146
- }
204
+ )
205
+ })
206
+ .collect();
147
207
 
148
208
  // Sort by start_line to emit in order
149
209
  comments_to_emit.sort_by_key(|(_, _, start, _)| *start);
@@ -163,7 +223,7 @@ impl Emitter {
163
223
  }
164
224
 
165
225
  writeln!(self.buffer, "{}{}", indent_str, text)?;
166
- self.emitted_comment_indices.push(idx);
226
+ self.emitted_comment_indices.insert(idx);
167
227
  last_comment_end_line = Some(comment_end_line);
168
228
 
169
229
  // Add blank line after the LAST comment if there was a gap to the code
@@ -176,41 +236,44 @@ impl Emitter {
176
236
  }
177
237
 
178
238
  /// Check if there are any unemitted comments in the given line range
239
+ /// Uses BTreeMap index for O(log n) lookup instead of O(n) iteration
179
240
  fn has_comments_in_range(&self, start_line: usize, end_line: usize) -> bool {
180
- self.all_comments.iter().enumerate().any(|(idx, comment)| {
181
- !self.emitted_comment_indices.contains(&idx)
182
- && comment.location.start_line >= start_line
183
- && comment.location.end_line < end_line
184
- })
241
+ self.comments_by_line
242
+ .range(start_line..end_line)
243
+ .flat_map(|(_, indices)| indices.iter())
244
+ .any(|&idx| {
245
+ !self.emitted_comment_indices.contains(&idx)
246
+ && self.all_comments[idx].location.end_line < end_line
247
+ })
185
248
  }
186
249
 
187
250
  /// Emit comments that are within a given line range (exclusive of end_line)
251
+ /// Uses BTreeMap index for O(log n) lookup instead of O(n) iteration
188
252
  fn emit_comments_in_range(
189
253
  &mut self,
190
254
  start_line: usize,
191
255
  end_line: usize,
192
256
  indent_level: usize,
193
257
  ) -> Result<()> {
194
- let indent_str = match self.config.formatting.indent_style {
195
- IndentStyle::Spaces => " ".repeat(self.config.formatting.indent_width * indent_level),
196
- IndentStyle::Tabs => "\t".repeat(indent_level),
197
- };
198
-
199
- let mut comments_to_emit = Vec::new();
200
- for (idx, comment) in self.all_comments.iter().enumerate() {
201
- if self.emitted_comment_indices.contains(&idx) {
202
- continue;
203
- }
204
-
205
- if comment.location.start_line >= start_line && comment.location.end_line < end_line {
206
- comments_to_emit.push((
258
+ let indent_str = self.get_indent(indent_level).to_string();
259
+
260
+ // Use indexed lookup instead of iterating all comments
261
+ let indices = self.get_comment_indices_in_range(start_line, end_line);
262
+
263
+ // Build list of comments to emit, filtering by end_line
264
+ let mut comments_to_emit: Vec<_> = indices
265
+ .into_iter()
266
+ .filter(|&idx| self.all_comments[idx].location.end_line < end_line)
267
+ .map(|idx| {
268
+ let comment = &self.all_comments[idx];
269
+ (
207
270
  idx,
208
271
  comment.text.clone(),
209
272
  comment.location.start_line,
210
273
  comment.location.end_line,
211
- ));
212
- }
213
- }
274
+ )
275
+ })
276
+ .collect();
214
277
 
215
278
  // Sort by start_line to emit in order
216
279
  comments_to_emit.sort_by_key(|(_, _, start, _)| *start);
@@ -227,7 +290,7 @@ impl Emitter {
227
290
  }
228
291
 
229
292
  writeln!(self.buffer, "{}{}", indent_str, text)?;
230
- self.emitted_comment_indices.push(idx);
293
+ self.emitted_comment_indices.insert(idx);
231
294
  last_comment_end_line = Some(comment_end_line);
232
295
  }
233
296
 
@@ -235,6 +298,7 @@ impl Emitter {
235
298
  }
236
299
 
237
300
  /// Emit comments that are within a given line range, preserving blank lines from prev_line
301
+ /// Uses BTreeMap index for O(log n) lookup instead of O(n) iteration
238
302
  fn emit_comments_in_range_with_prev_line(
239
303
  &mut self,
240
304
  start_line: usize,
@@ -242,26 +306,25 @@ impl Emitter {
242
306
  indent_level: usize,
243
307
  prev_line: usize,
244
308
  ) -> Result<()> {
245
- let indent_str = match self.config.formatting.indent_style {
246
- IndentStyle::Spaces => " ".repeat(self.config.formatting.indent_width * indent_level),
247
- IndentStyle::Tabs => "\t".repeat(indent_level),
248
- };
249
-
250
- let mut comments_to_emit = Vec::new();
251
- for (idx, comment) in self.all_comments.iter().enumerate() {
252
- if self.emitted_comment_indices.contains(&idx) {
253
- continue;
254
- }
255
-
256
- if comment.location.start_line >= start_line && comment.location.end_line < end_line {
257
- comments_to_emit.push((
309
+ let indent_str = self.get_indent(indent_level).to_string();
310
+
311
+ // Use indexed lookup instead of iterating all comments
312
+ let indices = self.get_comment_indices_in_range(start_line, end_line);
313
+
314
+ // Build list of comments to emit, filtering by end_line
315
+ let mut comments_to_emit: Vec<_> = indices
316
+ .into_iter()
317
+ .filter(|&idx| self.all_comments[idx].location.end_line < end_line)
318
+ .map(|idx| {
319
+ let comment = &self.all_comments[idx];
320
+ (
258
321
  idx,
259
322
  comment.text.clone(),
260
323
  comment.location.start_line,
261
324
  comment.location.end_line,
262
- ));
263
- }
264
- }
325
+ )
326
+ })
327
+ .collect();
265
328
 
266
329
  // Sort by start_line to emit in order
267
330
  comments_to_emit.sort_by_key(|(_, _, start, _)| *start);
@@ -276,7 +339,7 @@ impl Emitter {
276
339
  }
277
340
 
278
341
  writeln!(self.buffer, "{}{}", indent_str, text)?;
279
- self.emitted_comment_indices.push(idx);
342
+ self.emitted_comment_indices.insert(idx);
280
343
  last_end_line = comment_end_line;
281
344
  }
282
345
 
@@ -284,23 +347,21 @@ impl Emitter {
284
347
  }
285
348
 
286
349
  /// Emit comments that appear on the same line (trailing comments)
350
+ /// Uses BTreeMap index for O(log n) lookup instead of O(n) iteration
287
351
  fn emit_trailing_comments(&mut self, line: usize) -> Result<()> {
288
- let mut indices_to_emit = Vec::new();
289
- for (idx, comment) in self.all_comments.iter().enumerate() {
290
- if self.emitted_comment_indices.contains(&idx) {
291
- continue;
292
- }
352
+ // Use indexed lookup for O(log n) access
353
+ let indices = self.get_comment_indices_on_line(line);
293
354
 
294
- // Collect comments on the same line (trailing)
295
- if comment.location.start_line == line {
296
- indices_to_emit.push((idx, comment.text.clone()));
297
- }
298
- }
355
+ // Build list of comments to emit
356
+ let indices_to_emit: Vec<_> = indices
357
+ .into_iter()
358
+ .map(|idx| (idx, self.all_comments[idx].text.clone()))
359
+ .collect();
299
360
 
300
361
  // Now emit the collected comments
301
362
  for (idx, text) in indices_to_emit {
302
363
  write!(self.buffer, " {}", text)?;
303
- self.emitted_comment_indices.push(idx);
364
+ self.emitted_comment_indices.insert(idx);
304
365
  }
305
366
 
306
367
  Ok(())
@@ -326,6 +387,9 @@ impl Emitter {
326
387
  NodeType::WhileNode => self.emit_while_until(node, indent_level, "while")?,
327
388
  NodeType::UntilNode => self.emit_while_until(node, indent_level, "until")?,
328
389
  NodeType::ForNode => self.emit_for(node, indent_level)?,
390
+ NodeType::SingletonClassNode => self.emit_singleton_class(node, indent_level)?,
391
+ NodeType::CaseMatchNode => self.emit_case_match(node, indent_level)?,
392
+ NodeType::InNode => self.emit_in(node, indent_level)?,
329
393
  _ => self.emit_generic(node, indent_level)?,
330
394
  }
331
395
  Ok(())
@@ -363,15 +427,12 @@ impl Emitter {
363
427
  let next_start_line = next_child.location.start_line;
364
428
 
365
429
  // Find the first comment between current and next node (if any)
430
+ // Uses BTreeMap range for O(log n) lookup instead of O(n) iteration
366
431
  let first_comment_line = self
367
- .all_comments
368
- .iter()
369
- .filter(|c| {
370
- c.location.start_line > current_end_line
371
- && c.location.end_line < next_start_line
372
- })
373
- .map(|c| c.location.start_line)
374
- .min();
432
+ .comments_by_line
433
+ .range((current_end_line + 1)..next_start_line)
434
+ .next()
435
+ .map(|(line, _)| *line);
375
436
 
376
437
  // Calculate line diff based on whether there's a comment
377
438
  let effective_next_line = first_comment_line.unwrap_or(next_start_line);
@@ -508,31 +569,17 @@ impl Emitter {
508
569
  write!(self.buffer, "{}", name)?;
509
570
  }
510
571
 
511
- // TODO: Handle parameters properly
512
- // For now, extract from source if method has parameters
513
- if node
514
- .metadata
515
- .get("parameters_count")
516
- .and_then(|s| s.parse::<usize>().ok())
517
- .unwrap_or(0)
518
- > 0
519
- {
520
- // Extract parameter part from source
521
- if !self.source.is_empty() && node.location.end_offset <= self.source.len() {
522
- if let Some(source_text) = self
523
- .source
524
- .get(node.location.start_offset..node.location.end_offset)
525
- {
526
- // Find parameters in source (between def name and \n or ;)
527
- if let Some(def_line) = source_text.lines().next() {
528
- if let Some(params_start) = def_line.find('(') {
529
- if let Some(params_end) = def_line.find(')') {
530
- let params = &def_line[params_start..=params_end];
531
- write!(self.buffer, "{}", params)?;
532
- }
533
- }
534
- }
535
- }
572
+ // Emit parameters using metadata from prism_bridge
573
+ if let Some(params_text) = node.metadata.get("parameters_text") {
574
+ let has_parens = node
575
+ .metadata
576
+ .get("has_parens")
577
+ .map(|v| v == "true")
578
+ .unwrap_or(false);
579
+ if has_parens {
580
+ write!(self.buffer, "({})", params_text)?;
581
+ } else {
582
+ write!(self.buffer, " {}", params_text)?;
536
583
  }
537
584
  }
538
585
 
@@ -1221,7 +1268,7 @@ impl Emitter {
1221
1268
  && comment.location.start_line >= node.location.start_line
1222
1269
  && comment.location.end_line < node.location.end_line
1223
1270
  {
1224
- self.emitted_comment_indices.push(idx);
1271
+ self.emitted_comment_indices.insert(idx);
1225
1272
  }
1226
1273
  }
1227
1274
 
@@ -1254,7 +1301,7 @@ impl Emitter {
1254
1301
  && comment.location.start_line >= node.location.start_line
1255
1302
  && comment.location.end_line < node.location.end_line
1256
1303
  {
1257
- self.emitted_comment_indices.push(idx);
1304
+ self.emitted_comment_indices.insert(idx);
1258
1305
  }
1259
1306
  }
1260
1307
 
@@ -1264,13 +1311,23 @@ impl Emitter {
1264
1311
  Ok(())
1265
1312
  }
1266
1313
 
1314
+ /// Get cached indent string for a given level
1315
+ fn get_indent(&mut self, level: usize) -> &str {
1316
+ // Extend cache if needed
1317
+ while self.indent_cache.len() <= level {
1318
+ let len = self.indent_cache.len();
1319
+ let indent = match self.config.formatting.indent_style {
1320
+ IndentStyle::Spaces => " ".repeat(self.config.formatting.indent_width * len),
1321
+ IndentStyle::Tabs => "\t".repeat(len),
1322
+ };
1323
+ self.indent_cache.push(indent);
1324
+ }
1325
+ &self.indent_cache[level]
1326
+ }
1327
+
1267
1328
  /// Emit indentation
1268
1329
  fn emit_indent(&mut self, level: usize) -> Result<()> {
1269
- let indent_str = match self.config.formatting.indent_style {
1270
- IndentStyle::Spaces => " ".repeat(self.config.formatting.indent_width * level),
1271
- IndentStyle::Tabs => "\t".repeat(level),
1272
- };
1273
-
1330
+ let indent_str = self.get_indent(level).to_string();
1274
1331
  write!(self.buffer, "{}", indent_str)?;
1275
1332
  Ok(())
1276
1333
  }
@@ -1382,6 +1439,148 @@ impl Emitter {
1382
1439
  Ok(())
1383
1440
  }
1384
1441
 
1442
+ /// Emit singleton class definition (class << self / class << object)
1443
+ fn emit_singleton_class(&mut self, node: &Node, indent_level: usize) -> Result<()> {
1444
+ self.emit_comments_before(node.location.start_line, indent_level)?;
1445
+ self.emit_indent(indent_level)?;
1446
+
1447
+ write!(self.buffer, "class << ")?;
1448
+
1449
+ // First child is the expression (self or an object)
1450
+ if let Some(expression) = node.children.first() {
1451
+ if !self.source.is_empty() {
1452
+ let start = expression.location.start_offset;
1453
+ let end = expression.location.end_offset;
1454
+ if let Some(text) = self.source.get(start..end) {
1455
+ write!(self.buffer, "{}", text)?;
1456
+ }
1457
+ }
1458
+ }
1459
+
1460
+ // Emit trailing comments on the class << line
1461
+ self.emit_trailing_comments(node.location.start_line)?;
1462
+ self.buffer.push('\n');
1463
+
1464
+ let class_start_line = node.location.start_line;
1465
+ let class_end_line = node.location.end_line;
1466
+ let mut has_body_content = false;
1467
+
1468
+ // Emit body (skip the first child which is the expression)
1469
+ for (i, child) in node.children.iter().enumerate() {
1470
+ if i == 0 {
1471
+ // Skip the expression (self or object)
1472
+ continue;
1473
+ }
1474
+ if matches!(child.node_type, NodeType::StatementsNode) {
1475
+ has_body_content = true;
1476
+ self.emit_statements(child, indent_level + 1)?;
1477
+ } else if !self.is_structural_node(&child.node_type) {
1478
+ has_body_content = true;
1479
+ self.emit_node(child, indent_level + 1)?;
1480
+ }
1481
+ }
1482
+
1483
+ // Emit comments inside the singleton class body
1484
+ self.emit_comments_in_range(class_start_line + 1, class_end_line, indent_level + 1)?;
1485
+
1486
+ // Add newline before end if there was body content
1487
+ if (has_body_content || self.has_comments_in_range(class_start_line + 1, class_end_line))
1488
+ && !self.buffer.ends_with('\n')
1489
+ {
1490
+ self.buffer.push('\n');
1491
+ }
1492
+
1493
+ self.emit_indent(indent_level)?;
1494
+ write!(self.buffer, "end")?;
1495
+ self.emit_trailing_comments(node.location.end_line)?;
1496
+
1497
+ Ok(())
1498
+ }
1499
+
1500
+ /// Emit case match (Ruby 3.0+ pattern matching with case...in)
1501
+ fn emit_case_match(&mut self, node: &Node, indent_level: usize) -> Result<()> {
1502
+ self.emit_comments_before(node.location.start_line, indent_level)?;
1503
+ self.emit_indent(indent_level)?;
1504
+
1505
+ // Write "case" keyword
1506
+ write!(self.buffer, "case")?;
1507
+
1508
+ // Find predicate (first child that isn't InNode or ElseNode)
1509
+ let mut in_start_idx = 0;
1510
+ if let Some(first_child) = node.children.first() {
1511
+ if !matches!(first_child.node_type, NodeType::InNode | NodeType::ElseNode) {
1512
+ // This is the predicate - extract from source
1513
+ let start = first_child.location.start_offset;
1514
+ let end = first_child.location.end_offset;
1515
+ if let Some(text) = self.source.get(start..end) {
1516
+ write!(self.buffer, " {}", text)?;
1517
+ }
1518
+ in_start_idx = 1;
1519
+ }
1520
+ }
1521
+
1522
+ self.buffer.push('\n');
1523
+
1524
+ // Emit in clauses and else
1525
+ for child in node.children.iter().skip(in_start_idx) {
1526
+ match &child.node_type {
1527
+ NodeType::InNode => {
1528
+ self.emit_in(child, indent_level)?;
1529
+ self.buffer.push('\n');
1530
+ }
1531
+ NodeType::ElseNode => {
1532
+ self.emit_indent(indent_level)?;
1533
+ writeln!(self.buffer, "else")?;
1534
+ // Emit else body
1535
+ for else_child in &child.children {
1536
+ if matches!(else_child.node_type, NodeType::StatementsNode) {
1537
+ self.emit_statements(else_child, indent_level + 1)?;
1538
+ } else {
1539
+ self.emit_node(else_child, indent_level + 1)?;
1540
+ }
1541
+ }
1542
+ self.buffer.push('\n');
1543
+ }
1544
+ _ => {}
1545
+ }
1546
+ }
1547
+
1548
+ // Emit "end" keyword
1549
+ self.emit_indent(indent_level)?;
1550
+ write!(self.buffer, "end")?;
1551
+ self.emit_trailing_comments(node.location.end_line)?;
1552
+
1553
+ Ok(())
1554
+ }
1555
+
1556
+ /// Emit in node (pattern matching clause)
1557
+ fn emit_in(&mut self, node: &Node, indent_level: usize) -> Result<()> {
1558
+ self.emit_comments_before(node.location.start_line, indent_level)?;
1559
+ self.emit_indent(indent_level)?;
1560
+
1561
+ write!(self.buffer, "in ")?;
1562
+
1563
+ // First child is the pattern
1564
+ if let Some(pattern) = node.children.first() {
1565
+ let start = pattern.location.start_offset;
1566
+ let end = pattern.location.end_offset;
1567
+ if let Some(text) = self.source.get(start..end) {
1568
+ write!(self.buffer, "{}", text)?;
1569
+ }
1570
+ }
1571
+
1572
+ self.buffer.push('\n');
1573
+
1574
+ // Second child is the statements body
1575
+ if let Some(statements) = node.children.get(1) {
1576
+ if matches!(statements.node_type, NodeType::StatementsNode) {
1577
+ self.emit_statements(statements, indent_level + 1)?;
1578
+ }
1579
+ }
1580
+
1581
+ Ok(())
1582
+ }
1583
+
1385
1584
  /// Check if node is structural (part of definition syntax, not body)
1386
1585
  /// These nodes are part of class/module/method definitions and should not be emitted as body
1387
1586
  fn is_structural_node(&self, node_type: &NodeType) -> bool {
@@ -1398,6 +1597,8 @@ impl Emitter {
1398
1597
  | NodeType::OptionalKeywordParameterNode
1399
1598
  | NodeType::KeywordRestParameterNode
1400
1599
  | NodeType::BlockParameterNode
1600
+ | NodeType::ForwardingParameterNode
1601
+ | NodeType::NoKeywordsParameterNode
1401
1602
  )
1402
1603
  }
1403
1604
  }
@@ -54,7 +54,12 @@ impl Log for RfmtLogger {
54
54
  return;
55
55
  }
56
56
 
57
- let mut output = self.output.lock().unwrap();
57
+ // Use unwrap_or_else to recover from poisoned mutex.
58
+ // Logging should never cause a panic, even if another thread panicked while holding the lock.
59
+ let mut output = self
60
+ .output
61
+ .lock()
62
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
58
63
 
59
64
  writeln!(
60
65
  output,
@@ -67,7 +72,11 @@ impl Log for RfmtLogger {
67
72
  }
68
73
 
69
74
  fn flush(&self) {
70
- let mut output = self.output.lock().unwrap();
75
+ // Recover from poisoned mutex - flushing should not panic
76
+ let mut output = self
77
+ .output
78
+ .lock()
79
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
71
80
  output.flush().ok();
72
81
  }
73
82
  }
@@ -13,8 +13,8 @@ impl PrismAdapter {
13
13
  Self
14
14
  }
15
15
 
16
- /// Parse JSON from Ruby's PrismBridge
17
- fn parse_json(&self, json: &str) -> Result<(PrismNode, Vec<PrismComment>)> {
16
+ /// Parse JSON from Ruby's `PrismBridge`
17
+ fn parse_json(json: &str) -> Result<(PrismNode, Vec<PrismComment>)> {
18
18
  // Try to parse as new format with comments first
19
19
  if let Ok(wrapper) = serde_json::from_str::<PrismWrapper>(json) {
20
20
  return Ok((wrapper.ast, wrapper.comments));
@@ -26,8 +26,8 @@ impl PrismAdapter {
26
26
  Ok((node, Vec::new()))
27
27
  }
28
28
 
29
- /// Convert PrismNode to internal Node representation
30
- fn convert_node(&self, prism_node: &PrismNode) -> Result<Node> {
29
+ /// Convert `PrismNode` to internal `Node` representation
30
+ fn convert_node(prism_node: &PrismNode) -> Result<Node> {
31
31
  // Convert node type (always succeeds, returns Unknown for unsupported types)
32
32
  let node_type = NodeType::from_str(&prism_node.node_type);
33
33
 
@@ -42,18 +42,15 @@ impl PrismAdapter {
42
42
  );
43
43
 
44
44
  // Convert children recursively
45
- let children: Result<Vec<Node>> = prism_node
46
- .children
47
- .iter()
48
- .map(|child| self.convert_node(child))
49
- .collect();
45
+ let children: Result<Vec<Node>> =
46
+ prism_node.children.iter().map(Self::convert_node).collect();
50
47
  let children = children?;
51
48
 
52
49
  // Convert comments
53
50
  let comments: Vec<Comment> = prism_node
54
51
  .comments
55
52
  .iter()
56
- .map(|c| self.convert_comment(c))
53
+ .map(Self::convert_comment)
57
54
  .collect();
58
55
 
59
56
  // Convert formatting info
@@ -76,21 +73,8 @@ impl PrismAdapter {
76
73
  })
77
74
  }
78
75
 
79
- /// Convert PrismComment to internal Comment
80
- fn convert_comment(&self, comment: &PrismComment) -> Comment {
81
- let comment_type = match comment.comment_type.as_str() {
82
- "line" => CommentType::Line,
83
- "block" => CommentType::Block,
84
- _ => CommentType::Line, // default to line comment
85
- };
86
-
87
- let position = match comment.position.as_str() {
88
- "leading" => CommentPosition::Leading,
89
- "trailing" => CommentPosition::Trailing,
90
- "inner" => CommentPosition::Inner,
91
- _ => CommentPosition::Leading, // default to leading
92
- };
93
-
76
+ /// Convert `PrismComment` to internal `Comment`
77
+ fn convert_comment(comment: &PrismComment) -> Comment {
94
78
  Comment {
95
79
  text: comment.text.clone(),
96
80
  location: Location::new(
@@ -101,21 +85,21 @@ impl PrismAdapter {
101
85
  comment.location.start_offset,
102
86
  comment.location.end_offset,
103
87
  ),
104
- comment_type,
105
- position,
88
+ comment_type: comment.comment_type.into(),
89
+ position: comment.position.into(),
106
90
  }
107
91
  }
108
92
  }
109
93
 
110
94
  impl RubyParser for PrismAdapter {
111
95
  fn parse(&self, json: &str) -> Result<Node> {
112
- let (prism_ast, top_level_comments) = self.parse_json(json)?;
113
- let mut node = self.convert_node(&prism_ast)?;
96
+ let (prism_ast, top_level_comments) = Self::parse_json(json)?;
97
+ let mut node = Self::convert_node(&prism_ast)?;
114
98
 
115
99
  // Attach top-level comments to the root node
116
100
  if !top_level_comments.is_empty() {
117
101
  node.comments
118
- .extend(top_level_comments.iter().map(|c| self.convert_comment(c)));
102
+ .extend(top_level_comments.iter().map(Self::convert_comment));
119
103
  }
120
104
 
121
105
  Ok(node)
@@ -160,8 +144,46 @@ pub struct PrismLocation {
160
144
  pub struct PrismComment {
161
145
  pub text: String,
162
146
  pub location: PrismLocation,
163
- pub comment_type: String,
164
- pub position: String,
147
+ #[serde(rename = "type", default)]
148
+ pub comment_type: PrismCommentType,
149
+ #[serde(default)]
150
+ pub position: PrismCommentPosition,
151
+ }
152
+
153
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
154
+ #[serde(rename_all = "lowercase")]
155
+ pub enum PrismCommentType {
156
+ #[default]
157
+ Line,
158
+ Block,
159
+ }
160
+
161
+ impl From<PrismCommentType> for CommentType {
162
+ fn from(t: PrismCommentType) -> Self {
163
+ match t {
164
+ PrismCommentType::Line => CommentType::Line,
165
+ PrismCommentType::Block => CommentType::Block,
166
+ }
167
+ }
168
+ }
169
+
170
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
171
+ #[serde(rename_all = "lowercase")]
172
+ pub enum PrismCommentPosition {
173
+ #[default]
174
+ Leading,
175
+ Trailing,
176
+ Inner,
177
+ }
178
+
179
+ impl From<PrismCommentPosition> for CommentPosition {
180
+ fn from(p: PrismCommentPosition) -> Self {
181
+ match p {
182
+ PrismCommentPosition::Leading => CommentPosition::Leading,
183
+ PrismCommentPosition::Trailing => CommentPosition::Trailing,
184
+ PrismCommentPosition::Inner => CommentPosition::Inner,
185
+ }
186
+ }
165
187
  }
166
188
 
167
189
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -16,3 +16,38 @@ pub fn validate_source_size(source: &str, max_size: u64) -> Result<()> {
16
16
 
17
17
  Ok(())
18
18
  }
19
+
20
+ #[cfg(test)]
21
+ mod tests {
22
+ use super::*;
23
+
24
+ #[test]
25
+ fn test_validate_source_size_ok() {
26
+ assert!(validate_source_size("small", 1000).is_ok());
27
+ }
28
+
29
+ #[test]
30
+ fn test_validate_source_size_at_limit() {
31
+ let source = "a".repeat(1000);
32
+ assert!(validate_source_size(&source, 1000).is_ok());
33
+ }
34
+
35
+ #[test]
36
+ fn test_validate_source_size_exceeds_limit() {
37
+ let source = "a".repeat(1001);
38
+ assert!(validate_source_size(&source, 1000).is_err());
39
+ }
40
+
41
+ #[test]
42
+ fn test_validate_source_size_empty() {
43
+ assert!(validate_source_size("", 1000).is_ok());
44
+ }
45
+
46
+ #[test]
47
+ fn test_validate_source_size_unicode() {
48
+ // "日本語" = 9 bytes in UTF-8
49
+ let source = "日本語";
50
+ assert!(validate_source_size(source, 9).is_ok());
51
+ assert!(validate_source_size(source, 8).is_err());
52
+ }
53
+ }
@@ -56,7 +56,7 @@ module Rfmt
56
56
 
57
57
  # Serialize the Prism AST to JSON
58
58
  def self.serialize_ast(node)
59
- JSON.generate(convert_node(node))
59
+ JSON.generate(convert_node(node), max_nesting: false)
60
60
  end
61
61
 
62
62
  # Serialize the Prism AST with comments to JSON
@@ -80,7 +80,7 @@ module Rfmt
80
80
  JSON.generate({
81
81
  ast: convert_node(result.value),
82
82
  comments: comments
83
- })
83
+ }, max_nesting: false)
84
84
  end
85
85
 
86
86
  # Convert a Prism node to our internal representation
@@ -241,8 +241,6 @@ module Rfmt
241
241
  [node.expression].compact
242
242
  when Prism::AndNode
243
243
  [node.left, node.right].compact
244
- when Prism::NotNode
245
- [node.expression].compact
246
244
  when Prism::InterpolatedRegularExpressionNode, Prism::InterpolatedSymbolNode,
247
245
  Prism::InterpolatedXStringNode
248
246
  node.parts || []
@@ -293,6 +291,55 @@ module Rfmt
293
291
  [*node.lefts, node.rest, *node.rights, node.value].compact
294
292
  when Prism::MultiTargetNode
295
293
  [*node.lefts, node.rest, *node.rights].compact
294
+ when Prism::SourceFileNode, Prism::SourceLineNode, Prism::SourceEncodingNode
295
+ []
296
+ when Prism::PreExecutionNode, Prism::PostExecutionNode
297
+ [node.statements].compact
298
+ # Numeric literals
299
+ when Prism::RationalNode, Prism::ImaginaryNode
300
+ [node.numeric].compact
301
+ # String interpolation
302
+ when Prism::EmbeddedVariableNode
303
+ [node.variable].compact
304
+ # Pattern matching patterns
305
+ when Prism::ArrayPatternNode
306
+ [*node.requireds, node.rest, *node.posts].compact
307
+ when Prism::HashPatternNode
308
+ [*node.elements, node.rest].compact
309
+ when Prism::FindPatternNode
310
+ [node.left, *node.requireds, node.right].compact
311
+ when Prism::CapturePatternNode
312
+ [node.value, node.target].compact
313
+ when Prism::AlternationPatternNode
314
+ [node.left, node.right].compact
315
+ when Prism::PinnedExpressionNode
316
+ [node.expression].compact
317
+ when Prism::PinnedVariableNode
318
+ [node.variable].compact
319
+ # Forwarding and special parameters
320
+ when Prism::ForwardingArgumentsNode, Prism::ForwardingParameterNode,
321
+ Prism::NoKeywordsParameterNode
322
+ []
323
+ # References
324
+ when Prism::BackReferenceReadNode, Prism::NumberedReferenceReadNode
325
+ []
326
+ # Call/Index compound assignment
327
+ when Prism::CallAndWriteNode, Prism::CallOrWriteNode, Prism::CallOperatorWriteNode
328
+ [node.receiver, node.value].compact
329
+ when Prism::IndexAndWriteNode, Prism::IndexOrWriteNode, Prism::IndexOperatorWriteNode
330
+ [node.receiver, node.arguments, node.value].compact
331
+ # Match
332
+ when Prism::MatchWriteNode
333
+ [node.call, *node.targets].compact
334
+ when Prism::MatchLastLineNode, Prism::InterpolatedMatchLastLineNode
335
+ []
336
+ # Other
337
+ when Prism::FlipFlopNode
338
+ [node.left, node.right].compact
339
+ when Prism::ImplicitNode
340
+ [node.value].compact
341
+ when Prism::ImplicitRestNode
342
+ []
296
343
  else
297
344
  # For unknown types, try to get child nodes if they exist
298
345
  []
@@ -327,6 +374,11 @@ module Rfmt
327
374
  metadata['name'] = name
328
375
  end
329
376
  metadata['parameters_count'] = extract_parameter_count(node).to_s
377
+ # Extract parameters text directly from source
378
+ if node.parameters
379
+ metadata['parameters_text'] = node.parameters.location.slice
380
+ metadata['has_parens'] = (!node.lparen_loc.nil?).to_s
381
+ end
330
382
  # Check if this is a class method (def self.method_name)
331
383
  if node.respond_to?(:receiver) && node.receiver
332
384
  receiver = node.receiver
data/lib/rfmt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rfmt
4
- VERSION = '1.3.0'
4
+ VERSION = '1.3.2'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rfmt
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - fujitani sora
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-06 00:00:00.000000000 Z
11
+ date: 2026-01-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys