rfmt 1.3.1 → 1.3.3

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: 3711e11ba344ee0b9e1e76d0885fd99bd7ccbd7901ab5ba819619ae40647365f
4
- data.tar.gz: 95f1b17c4c9a542cc24d9b89df6dee44398f59d27da0446961ac320b338a6bb4
3
+ metadata.gz: 7d2092d9d53fa47024fcb8a13fb635a6da7eba5d4c51d3a8df854d551380037b
4
+ data.tar.gz: 59bb09f50cb7ee5a033328300a581e973879aaad56067d267b041c2070cac8bf
5
5
  SHA512:
6
- metadata.gz: 7bb8aff9825e2a8d44f3161786b7952a2b2e3b679d0e607eb748102971d4c8a417ca8dcaac80e171a97ef223814c229b14b63af505321309393edc9432550fe6
7
- data.tar.gz: 110a42ff056ac3dd6939d0f67b50a4784bbdafd513c4c0a1ab6e9eb8d0a040a0aa65bc39d0b37975ffb1972cf81d8396868d33e627b71ed017894cf1674d3cc4
6
+ metadata.gz: f7043e22d625b0b6a689f01950a6ed0fae7ab3fa1cfa2dcd51af544d72aadf8b0c3aa65a3465c9f9baabf2f1a000c37bddb2f2f57e2defd11344f6194ad3b68b
7
+ data.tar.gz: 407b70b3614da3ed19c74bd43d9491ee9bff1ce6cb1ef7f1e1d2d0b71068afb937bd5409668ae0c7454a6f2d12cb4764fa6df0273bef149980be30e0f9f1d7b6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.3] - 2026-01-17
4
+
5
+ ### Fixed
6
+ - Add native extension loader for Ruby 3.3+ compatibility (#65)
7
+ - Resolves LoadError on Ruby 3.3+ arm64-darwin systems
8
+ - Implements dynamic path resolution for version-specific directories
9
+
10
+ ### Changed
11
+ - Remove unnecessary String clones in comment emission (performance optimization)
12
+ - Remove debug logs and obvious comments from codebase
13
+ - Update .gitignore with development artifacts
14
+
15
+ ## [1.3.2] - 2026-01-09
16
+
17
+ ### Added
18
+ - Implement `std::str::FromStr` trait for `NodeType`
19
+ - Unit tests for validation module
20
+
21
+ ### Changed
22
+ - Use serde enum for comment type deserialization (type-safe JSON parsing)
23
+ - Convert recursive `find_last_code_line` to iterative approach (prevent stack overflow)
24
+ - Use BTreeMap index for comment lookup in `emit_statements` (O(n) → O(log n))
25
+
26
+ ### Fixed
27
+ - Remove panic-prone `unwrap()` on Mutex lock in logger (prevent Ruby VM crash)
28
+
3
29
  ## [1.3.1] - 2026-01-08
4
30
 
5
31
  ### Added
data/Cargo.lock CHANGED
@@ -1214,7 +1214,7 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
1214
1214
 
1215
1215
  [[package]]
1216
1216
  name = "rfmt"
1217
- version = "1.3.1"
1217
+ version = "1.3.3"
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.1"
3
+ version = "1.3.3"
4
4
  edition = "2021"
5
5
  authors = ["fujitani sora <fujitanisora0414@gmail.com>"]
6
6
  license = "MIT"
@@ -235,9 +235,11 @@ pub enum NodeType {
235
235
  Unknown(String),
236
236
  }
237
237
 
238
- impl NodeType {
239
- pub fn from_str(s: &str) -> Self {
240
- 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 {
241
243
  "program_node" => Self::ProgramNode,
242
244
  "statements_node" => Self::StatementsNode,
243
245
  "class_node" => Self::ClassNode,
@@ -383,7 +385,14 @@ impl NodeType {
383
385
  "implicit_node" => Self::ImplicitNode,
384
386
  "implicit_rest_node" => Self::ImplicitRestNode,
385
387
  _ => Self::Unknown(s.to_string()),
386
- }
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()
387
396
  }
388
397
 
389
398
  /// Check if this node type is a definition (class, module, or method)
@@ -62,13 +62,9 @@ impl Emitter {
62
62
 
63
63
  self.emit_node(ast, 0)?;
64
64
 
65
- // Find the last emitted code line for proper blank line handling
66
65
  let last_code_line = Self::find_last_code_line(ast);
67
-
68
- // Emit any remaining comments that weren't emitted
69
66
  self.emit_remaining_comments(last_code_line)?;
70
67
 
71
- // Ensure file ends with a newline
72
68
  if !self.buffer.ends_with('\n') {
73
69
  self.buffer.push('\n');
74
70
  }
@@ -79,12 +75,13 @@ impl Emitter {
79
75
  /// Find the last line of code in the AST (excluding comments)
80
76
  fn find_last_code_line(ast: &Node) -> usize {
81
77
  let mut max_line = ast.location.end_line;
82
- for child in &ast.children {
83
- let child_end = Self::find_last_code_line(child);
84
- if child_end > max_line {
85
- max_line = child_end;
86
- }
78
+ let mut stack = vec![ast];
79
+
80
+ while let Some(node) = stack.pop() {
81
+ max_line = max_line.max(node.location.end_line);
82
+ stack.extend(node.children.iter());
87
83
  }
84
+
88
85
  max_line
89
86
  }
90
87
 
@@ -185,35 +182,26 @@ impl Emitter {
185
182
  /// Emit comments that appear before a given line
186
183
  /// Uses BTreeMap index for O(log n) lookup instead of O(n) iteration
187
184
  fn emit_comments_before(&mut self, line: usize, indent_level: usize) -> Result<()> {
188
- let indent_str = self.get_indent(indent_level).to_string();
185
+ self.ensure_indent_cache(indent_level);
189
186
 
190
- // Use indexed lookup instead of iterating all comments
191
187
  let indices = self.get_comment_indices_before(line);
192
188
 
193
- // Build list of comments to emit with their data
194
189
  let mut comments_to_emit: Vec<_> = indices
195
190
  .into_iter()
196
191
  .map(|idx| {
197
192
  let comment = &self.all_comments[idx];
198
- (
199
- idx,
200
- comment.text.clone(),
201
- comment.location.start_line,
202
- comment.location.end_line,
203
- )
193
+ (idx, comment.location.start_line, comment.location.end_line)
204
194
  })
205
195
  .collect();
206
196
 
207
- // Sort by start_line to emit in order
208
- comments_to_emit.sort_by_key(|(_, _, start, _)| *start);
197
+ comments_to_emit.sort_by_key(|(_, start, _)| *start);
209
198
 
210
199
  let comments_count = comments_to_emit.len();
211
200
  let mut last_comment_end_line: Option<usize> = None;
212
201
 
213
- for (i, (idx, text, comment_start_line, comment_end_line)) in
202
+ for (i, (idx, comment_start_line, comment_end_line)) in
214
203
  comments_to_emit.into_iter().enumerate()
215
204
  {
216
- // Preserve blank lines between comments
217
205
  if let Some(prev_end) = last_comment_end_line {
218
206
  let gap = comment_start_line.saturating_sub(prev_end);
219
207
  for _ in 1..gap {
@@ -221,11 +209,14 @@ impl Emitter {
221
209
  }
222
210
  }
223
211
 
224
- writeln!(self.buffer, "{}{}", indent_str, text)?;
212
+ writeln!(
213
+ self.buffer,
214
+ "{}{}",
215
+ &self.indent_cache[indent_level], &self.all_comments[idx].text
216
+ )?;
225
217
  self.emitted_comment_indices.insert(idx);
226
218
  last_comment_end_line = Some(comment_end_line);
227
219
 
228
- // Add blank line after the LAST comment if there was a gap to the code
229
220
  if i == comments_count - 1 && line > comment_end_line + 1 {
230
221
  self.buffer.push('\n');
231
222
  }
@@ -254,33 +245,24 @@ impl Emitter {
254
245
  end_line: usize,
255
246
  indent_level: usize,
256
247
  ) -> Result<()> {
257
- let indent_str = self.get_indent(indent_level).to_string();
248
+ self.ensure_indent_cache(indent_level);
258
249
 
259
- // Use indexed lookup instead of iterating all comments
260
250
  let indices = self.get_comment_indices_in_range(start_line, end_line);
261
251
 
262
- // Build list of comments to emit, filtering by end_line
263
252
  let mut comments_to_emit: Vec<_> = indices
264
253
  .into_iter()
265
254
  .filter(|&idx| self.all_comments[idx].location.end_line < end_line)
266
255
  .map(|idx| {
267
256
  let comment = &self.all_comments[idx];
268
- (
269
- idx,
270
- comment.text.clone(),
271
- comment.location.start_line,
272
- comment.location.end_line,
273
- )
257
+ (idx, comment.location.start_line, comment.location.end_line)
274
258
  })
275
259
  .collect();
276
260
 
277
- // Sort by start_line to emit in order
278
- comments_to_emit.sort_by_key(|(_, _, start, _)| *start);
261
+ comments_to_emit.sort_by_key(|(_, start, _)| *start);
279
262
 
280
263
  let mut last_comment_end_line: Option<usize> = None;
281
264
 
282
- for (idx, text, comment_start_line, comment_end_line) in comments_to_emit {
283
- // Preserve blank lines between comments
265
+ for (idx, comment_start_line, comment_end_line) in comments_to_emit {
284
266
  if let Some(prev_end) = last_comment_end_line {
285
267
  let gap = comment_start_line.saturating_sub(prev_end);
286
268
  for _ in 1..gap {
@@ -288,7 +270,11 @@ impl Emitter {
288
270
  }
289
271
  }
290
272
 
291
- writeln!(self.buffer, "{}{}", indent_str, text)?;
273
+ writeln!(
274
+ self.buffer,
275
+ "{}{}",
276
+ &self.indent_cache[indent_level], &self.all_comments[idx].text
277
+ )?;
292
278
  self.emitted_comment_indices.insert(idx);
293
279
  last_comment_end_line = Some(comment_end_line);
294
280
  }
@@ -305,39 +291,35 @@ impl Emitter {
305
291
  indent_level: usize,
306
292
  prev_line: usize,
307
293
  ) -> Result<()> {
308
- let indent_str = self.get_indent(indent_level).to_string();
294
+ self.ensure_indent_cache(indent_level);
309
295
 
310
- // Use indexed lookup instead of iterating all comments
311
296
  let indices = self.get_comment_indices_in_range(start_line, end_line);
312
297
 
313
- // Build list of comments to emit, filtering by end_line
314
298
  let mut comments_to_emit: Vec<_> = indices
315
299
  .into_iter()
316
300
  .filter(|&idx| self.all_comments[idx].location.end_line < end_line)
317
301
  .map(|idx| {
318
302
  let comment = &self.all_comments[idx];
319
- (
320
- idx,
321
- comment.text.clone(),
322
- comment.location.start_line,
323
- comment.location.end_line,
324
- )
303
+ (idx, comment.location.start_line, comment.location.end_line)
325
304
  })
326
305
  .collect();
327
306
 
328
- // Sort by start_line to emit in order
329
- comments_to_emit.sort_by_key(|(_, _, start, _)| *start);
307
+ comments_to_emit.sort_by_key(|(_, start, _)| *start);
330
308
 
331
309
  let mut last_end_line: usize = prev_line;
332
310
 
333
- for (idx, text, comment_start_line, comment_end_line) in comments_to_emit {
311
+ for (idx, comment_start_line, comment_end_line) in comments_to_emit {
334
312
  // Preserve blank lines between previous content and this comment
335
313
  let gap = comment_start_line.saturating_sub(last_end_line);
336
314
  for _ in 1..gap {
337
315
  self.buffer.push('\n');
338
316
  }
339
317
 
340
- writeln!(self.buffer, "{}{}", indent_str, text)?;
318
+ writeln!(
319
+ self.buffer,
320
+ "{}{}",
321
+ &self.indent_cache[indent_level], &self.all_comments[idx].text
322
+ )?;
341
323
  self.emitted_comment_indices.insert(idx);
342
324
  last_end_line = comment_end_line;
343
325
  }
@@ -351,15 +333,12 @@ impl Emitter {
351
333
  // Use indexed lookup for O(log n) access
352
334
  let indices = self.get_comment_indices_on_line(line);
353
335
 
354
- // Build list of comments to emit
355
- let indices_to_emit: Vec<_> = indices
356
- .into_iter()
357
- .map(|idx| (idx, self.all_comments[idx].text.clone()))
358
- .collect();
336
+ // Collect indices only (no text clone needed)
337
+ let indices_to_emit: Vec<usize> = indices;
359
338
 
360
- // Now emit the collected comments
361
- for (idx, text) in indices_to_emit {
362
- write!(self.buffer, " {}", text)?;
339
+ // Now emit the collected comments by accessing text at write time
340
+ for idx in indices_to_emit {
341
+ write!(self.buffer, " {}", &self.all_comments[idx].text)?;
363
342
  self.emitted_comment_indices.insert(idx);
364
343
  }
365
344
 
@@ -426,15 +405,12 @@ impl Emitter {
426
405
  let next_start_line = next_child.location.start_line;
427
406
 
428
407
  // Find the first comment between current and next node (if any)
408
+ // Uses BTreeMap range for O(log n) lookup instead of O(n) iteration
429
409
  let first_comment_line = self
430
- .all_comments
431
- .iter()
432
- .filter(|c| {
433
- c.location.start_line > current_end_line
434
- && c.location.end_line < next_start_line
435
- })
436
- .map(|c| c.location.start_line)
437
- .min();
410
+ .comments_by_line
411
+ .range((current_end_line + 1)..next_start_line)
412
+ .next()
413
+ .map(|(line, _)| *line);
438
414
 
439
415
  // Calculate line diff based on whether there's a comment
440
416
  let effective_next_line = first_comment_line.unwrap_or(next_start_line);
@@ -1313,9 +1289,9 @@ impl Emitter {
1313
1289
  Ok(())
1314
1290
  }
1315
1291
 
1316
- /// Get cached indent string for a given level
1317
- fn get_indent(&mut self, level: usize) -> &str {
1318
- // Extend cache if needed
1292
+ /// Ensure indent cache has entries up to and including the given level
1293
+ /// This allows pre-building the cache before borrowing self.indent_cache
1294
+ fn ensure_indent_cache(&mut self, level: usize) {
1319
1295
  while self.indent_cache.len() <= level {
1320
1296
  let len = self.indent_cache.len();
1321
1297
  let indent = match self.config.formatting.indent_style {
@@ -1324,13 +1300,12 @@ impl Emitter {
1324
1300
  };
1325
1301
  self.indent_cache.push(indent);
1326
1302
  }
1327
- &self.indent_cache[level]
1328
1303
  }
1329
1304
 
1330
1305
  /// Emit indentation
1331
1306
  fn emit_indent(&mut self, level: usize) -> Result<()> {
1332
- let indent_str = self.get_indent(level).to_string();
1333
- write!(self.buffer, "{}", indent_str)?;
1307
+ self.ensure_indent_cache(level);
1308
+ write!(self.buffer, "{}", &self.indent_cache[level])?;
1334
1309
  Ok(())
1335
1310
  }
1336
1311
 
data/ext/rfmt/src/lib.rs CHANGED
@@ -14,25 +14,16 @@ use magnus::{define_module, function, prelude::*, Error, Ruby};
14
14
  use parser::{PrismAdapter, RubyParser};
15
15
 
16
16
  fn format_ruby_code(ruby: &Ruby, source: String, json: String) -> Result<String, Error> {
17
- log::info!("format_ruby_code called");
18
17
  let policy = SecurityPolicy::default();
19
18
 
20
19
  policy
21
20
  .validate_source_size(&source)
22
21
  .map_err(|e| e.to_magnus_error(ruby))?;
23
22
 
24
- log::debug!("Source code validated, size: {} bytes", source.len());
25
-
26
23
  let parser = PrismAdapter::new();
27
24
  let ast = parser.parse(&json).map_err(|e| e.to_magnus_error(ruby))?;
28
25
 
29
- // Load configuration from file or use defaults
30
- log::info!("Attempting to discover config file...");
31
26
  let config = Config::discover().map_err(|e| e.to_magnus_error(ruby))?;
32
- log::info!(
33
- "Config loaded successfully, line_length: {}",
34
- config.formatting.line_length
35
- );
36
27
  let mut emitter = Emitter::with_source(config, source);
37
28
 
38
29
  let formatted = emitter.emit(&ast).map_err(|e| e.to_magnus_error(ruby))?;
@@ -56,7 +47,6 @@ fn rust_version() -> String {
56
47
  #[magnus::init]
57
48
  fn init(_ruby: &Ruby) -> Result<(), Error> {
58
49
  logging::RfmtLogger::init();
59
- log::info!("Initializing rfmt Rust extension");
60
50
 
61
51
  let module = define_module("Rfmt")?;
62
52
 
@@ -64,6 +54,5 @@ fn init(_ruby: &Ruby) -> Result<(), Error> {
64
54
  module.define_singleton_method("parse_to_json", function!(parse_to_json, 1))?;
65
55
  module.define_singleton_method("rust_version", function!(rust_version, 0))?;
66
56
 
67
- log::info!("rfmt Rust extension initialized successfully");
68
57
  Ok(())
69
58
  }
@@ -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
+ }
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rfmt
4
+ # Handles loading of native extension across different Ruby versions
5
+ # Ruby 3.3+ places native extensions in version-specific subdirectories
6
+ module NativeExtensionLoader
7
+ class << self
8
+ # Load the native extension, trying multiple possible paths
9
+ # @return [Boolean] true if successfully loaded
10
+ # @raise [LoadError] if the extension cannot be found
11
+ def load_extension
12
+ debug_log "Loading native extension for Ruby #{RUBY_VERSION}"
13
+
14
+ possible_paths = build_possible_paths
15
+ debug_log "Trying paths: #{possible_paths.inspect}"
16
+
17
+ load_from_paths(possible_paths) || raise(LoadError, build_error_message(possible_paths))
18
+ end
19
+
20
+ private
21
+
22
+ # Try loading from multiple paths
23
+ # @param paths [Array<String>] paths to try
24
+ # @return [Boolean, nil] true if loaded, nil otherwise
25
+ def load_from_paths(paths)
26
+ paths.each do |path|
27
+ if try_load_extension(path)
28
+ debug_log "Successfully loaded from: #{path}"
29
+ return true
30
+ end
31
+ end
32
+ nil
33
+ end
34
+
35
+ # Build list of possible paths for the native extension
36
+ # @return [Array<String>] paths to try, in order of preference
37
+ def build_possible_paths
38
+ paths = []
39
+
40
+ # Ruby 3.3+ style: version-specific subdirectory
41
+ paths << version_specific_path if ruby_version >= '3.3'
42
+
43
+ # Ruby 3.0-3.2 style: might use version directory
44
+ paths << version_specific_path if ruby_version >= '3.0' && ruby_version < '3.3'
45
+
46
+ # Legacy/fallback: direct placement
47
+ paths << File.join(__dir__, 'rfmt')
48
+
49
+ # Additional fallback: check for .bundle extension explicitly
50
+ paths << File.join(__dir__, 'rfmt.bundle')
51
+
52
+ paths.uniq
53
+ end
54
+
55
+ # Get version-specific path
56
+ # @return [String] path with version directory
57
+ def version_specific_path
58
+ File.join(__dir__, ruby_version_dir, 'rfmt')
59
+ end
60
+
61
+ # Try to load extension from a specific path
62
+ # @param path [String] path to try
63
+ # @return [Boolean] true if successful, false otherwise
64
+ def try_load_extension(path)
65
+ require path
66
+ true
67
+ rescue LoadError => e
68
+ debug_log "Failed to load from #{path}: #{e.message}"
69
+ false
70
+ end
71
+
72
+ # Get Ruby version for comparison
73
+ # @return [String] Ruby version string
74
+ def ruby_version
75
+ RUBY_VERSION
76
+ end
77
+
78
+ # Get Ruby version directory name (e.g., "3.3" for Ruby 3.3.0)
79
+ # @return [String] version directory name
80
+ def ruby_version_dir
81
+ RUBY_VERSION.split('.')[0..1].join('.')
82
+ end
83
+
84
+ # Build detailed error message when extension cannot be loaded
85
+ # @param tried_paths [Array<String>] paths that were tried
86
+ # @return [String] error message
87
+ def build_error_message(tried_paths)
88
+ [
89
+ error_header,
90
+ format_tried_paths(tried_paths),
91
+ error_explanation,
92
+ workaround_instructions
93
+ ].join("\n")
94
+ end
95
+
96
+ # Error message header
97
+ # @return [String] header text
98
+ def error_header
99
+ "Unable to load rfmt native extension for Ruby #{RUBY_VERSION}.\n"
100
+ end
101
+
102
+ # Format list of tried paths
103
+ # @param paths [Array<String>] paths that were tried
104
+ # @return [String] formatted path list
105
+ def format_tried_paths(paths)
106
+ "Tried the following paths:\n#{paths.map { |p| " - #{p}" }.join("\n")}\n"
107
+ end
108
+
109
+ # Error explanation text
110
+ # @return [String] explanation
111
+ def error_explanation
112
+ "This might be a packaging issue with the gem for your Ruby version.\n"
113
+ end
114
+
115
+ # Workaround instructions
116
+ # @return [String] instructions
117
+ def workaround_instructions
118
+ <<~MSG.chomp
119
+ Workaround:
120
+ 1. Check if rfmt.bundle exists in: #{__dir__}/
121
+ 2. If it's in a subdirectory, create a symlink:
122
+ cd #{__dir__}
123
+ ln -sf <subdirectory>/rfmt.bundle rfmt.bundle
124
+
125
+ Please report this issue at: https://github.com/fs0414/rfmt/issues
126
+ MSG
127
+ end
128
+
129
+ # Log debug information if RFMT_DEBUG is set
130
+ # @param message [String] message to log
131
+ def debug_log(message)
132
+ return unless ENV['RFMT_DEBUG']
133
+
134
+ warn "[RFMT::NativeExtensionLoader] #{message}"
135
+ end
136
+ end
137
+ end
138
+ end
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.1'
4
+ VERSION = '1.3.3'
5
5
  end
data/lib/rfmt.rb CHANGED
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'rfmt/version'
4
- require_relative 'rfmt/rfmt'
4
+ require_relative 'rfmt/native_extension_loader'
5
5
  require_relative 'rfmt/prism_bridge'
6
6
 
7
+ # Load native extension with version-aware loader
8
+ Rfmt::NativeExtensionLoader.load_extension
9
+
7
10
  module Rfmt
8
11
  class Error < StandardError; end
9
12
  # Errors from Rust side
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.1
4
+ version: 1.3.3
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-07 00:00:00.000000000 Z
11
+ date: 2026-01-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -56,6 +56,7 @@ files:
56
56
  - lib/rfmt/cache.rb
57
57
  - lib/rfmt/cli.rb
58
58
  - lib/rfmt/configuration.rb
59
+ - lib/rfmt/native_extension_loader.rb
59
60
  - lib/rfmt/prism_bridge.rb
60
61
  - lib/rfmt/prism_node_extractor.rb
61
62
  - lib/rfmt/version.rb