rfmt 1.4.0 → 1.5.0

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: cb4f41a4ba34f1bc106746712bf144fe6f5ee8cf5a80cf26efaecc8450645d89
4
- data.tar.gz: 94f896c28015d9592d959ef3a42b4db80e309ad664ef97d5f28b0e8479605892
3
+ metadata.gz: 2df39db6eb525d4ca6bc42d55e01057e5a12772f0b61a7a8e3101e3a8fff76ec
4
+ data.tar.gz: b34bbf251457c8eb0d9174497c124f71d6a7b098ad506712acaec54812bcc674
5
5
  SHA512:
6
- metadata.gz: 0aef4d71101fd621bcecf87b0160e437ec9f7836e12759dbb28cd56416d1428bb3b54aabae888566548dc11557d1179e519aa5c971e613c3c37c775590eef981
7
- data.tar.gz: 3ee34d6c250fdd9263822a8b361dc3fdde5005c36570a5a6a0be346f717090c581822c90cd08bee43674495c97a15fb22677558bd5be5b85f9ccd4c1d4fd9e51
6
+ metadata.gz: 1d341d108c8875f683a2ef3654e6e540b1fe9840a4f908abc75725da544c0b8be2b27cf5534cdf634eb2e9eaaa36bed0df9500f978a6fffc95ec7379e2485cf3
7
+ data.tar.gz: 9cbce53637c758998c79c77af60d10130dd293dac67c4a7918d882e816d848ef525400bd6fcebc030523a2407e08140dc1bc9227cbd9c1cfc3f579f0e5b477be
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.5.0] - 2026-01-25
4
+
5
+ ### Added
6
+ - Docker Compose test environment setup (#84)
7
+ - Support for `then` expression emission (#80)
8
+ - CI support for Ruby 3.4 and Ruby 4 (#82)
9
+
10
+ ### Changed
11
+ - Upgrade Magnus (Rust-Ruby FFI library) (#83)
12
+ - Optimize Docker build with multi-stage and caching (#84)
13
+ - Upgrade unicode-emoji dependency
14
+
15
+ ### Fixed
16
+ - Preserve heredoc content and closing identifier (#81)
17
+ - Fix `rescue`/`ensure` clauses being deleted inside `do...end` blocks (#78)
18
+ - Fix inline comment node handling (#77)
19
+ - Fix BTreeMap range error (Issue #71)
20
+
21
+ ## [1.4.1] - 2026-01-17
22
+
23
+ ### Fixed
24
+ - Fixed comment positioning issue where standalone comments before `end` statements were incorrectly attached to previous code lines
25
+ - Improved comment semantic preservation to maintain developer's original placement intent
26
+ - Enhanced standalone comment detection logic to distinguish between inline and independent comments
27
+
3
28
  ## [1.4.0] - 2026-01-17
4
29
 
5
30
  ### Added
data/Cargo.lock CHANGED
@@ -838,9 +838,9 @@ dependencies = [
838
838
 
839
839
  [[package]]
840
840
  name = "magnus"
841
- version = "0.6.4"
841
+ version = "0.8.2"
842
842
  source = "registry+https://github.com/rust-lang/crates.io-index"
843
- checksum = "b1597ef40aa8c36be098249e82c9a20cf7199278ac1c1a1a995eeead6a184479"
843
+ checksum = "3b36a5b126bbe97eb0d02d07acfeb327036c6319fd816139a49824a83b7f9012"
844
844
  dependencies = [
845
845
  "magnus-macros",
846
846
  "rb-sys",
@@ -850,9 +850,9 @@ dependencies = [
850
850
 
851
851
  [[package]]
852
852
  name = "magnus-macros"
853
- version = "0.6.0"
853
+ version = "0.8.0"
854
854
  source = "registry+https://github.com/rust-lang/crates.io-index"
855
- checksum = "5968c820e2960565f647819f5928a42d6e874551cab9d88d75e3e0660d7f71e3"
855
+ checksum = "47607461fd8e1513cb4f2076c197d8092d921a1ea75bd08af97398f593751892"
856
856
  dependencies = [
857
857
  "proc-macro2",
858
858
  "quote",
@@ -1168,9 +1168,9 @@ dependencies = [
1168
1168
 
1169
1169
  [[package]]
1170
1170
  name = "rb-sys-env"
1171
- version = "0.1.2"
1171
+ version = "0.2.3"
1172
1172
  source = "registry+https://github.com/rust-lang/crates.io-index"
1173
- checksum = "a35802679f07360454b418a5d1735c89716bde01d35b1560fc953c1415a0b3bb"
1173
+ checksum = "cca7ad6a7e21e72151d56fe2495a259b5670e204c3adac41ee7ef676ea08117a"
1174
1174
 
1175
1175
  [[package]]
1176
1176
  name = "redox_users"
@@ -1214,7 +1214,7 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
1214
1214
 
1215
1215
  [[package]]
1216
1216
  name = "rfmt"
1217
- version = "1.4.0"
1217
+ version = "1.5.0"
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.4.0"
3
+ version = "1.5.0"
4
4
  edition = "2021"
5
5
  authors = ["fujitani sora <fujitanisora0414@gmail.com>"]
6
6
  license = "MIT"
@@ -12,8 +12,8 @@ crate-type = ["cdylib"]
12
12
 
13
13
  [dependencies]
14
14
  # Ruby FFI
15
- magnus = { version = "0.6.2" }
16
- rb-sys = "0.9.117"
15
+ magnus = { version = "0.8.2" }
16
+ rb-sys = "0.9.124"
17
17
 
18
18
  # Serialization
19
19
  serde = { version = "1.0", features = ["derive"] }
@@ -144,6 +144,10 @@ impl Emitter {
144
144
  /// Get comment indices in the given line range [start_line, end_line)
145
145
  /// Uses BTreeMap range for O(log n) lookup instead of O(n) iteration
146
146
  fn get_comment_indices_in_range(&self, start_line: usize, end_line: usize) -> Vec<usize> {
147
+ // Guard against invalid range (e.g., endless methods where start_line >= end_line)
148
+ if start_line >= end_line {
149
+ return Vec::new();
150
+ }
147
151
  self.comments_by_line
148
152
  .range(start_line..end_line)
149
153
  .flat_map(|(_, indices)| indices.iter().copied())
@@ -228,6 +232,10 @@ impl Emitter {
228
232
  /// Check if there are any unemitted comments in the given line range
229
233
  /// Uses BTreeMap index for O(log n) lookup instead of O(n) iteration
230
234
  fn has_comments_in_range(&self, start_line: usize, end_line: usize) -> bool {
235
+ // Guard against invalid range (e.g., endless methods where start_line >= end_line)
236
+ if start_line >= end_line {
237
+ return false;
238
+ }
231
239
  self.comments_by_line
232
240
  .range(start_line..end_line)
233
241
  .flat_map(|(_, indices)| indices.iter())
@@ -237,34 +245,58 @@ impl Emitter {
237
245
  })
238
246
  }
239
247
 
240
- /// Emit comments that are within a given line range (exclusive of end_line)
241
- /// Uses BTreeMap index for O(log n) lookup instead of O(n) iteration
242
- fn emit_comments_in_range(
248
+ /// Emit comments that appear immediately before the end statement while preserving their position
249
+ /// This is crucial for maintaining semantic relationships between comments and the code they precede
250
+ fn emit_comments_before_end(
243
251
  &mut self,
244
- start_line: usize,
245
- end_line: usize,
252
+ construct_start_line: usize,
253
+ construct_end_line: usize,
246
254
  indent_level: usize,
247
255
  ) -> Result<()> {
248
256
  self.ensure_indent_cache(indent_level);
249
257
 
250
- let indices = self.get_comment_indices_in_range(start_line, end_line);
258
+ // Implement proper comment positioning logic
259
+ // Only emit standalone comments that appear on their own lines
260
+ // This prevents comments from being incorrectly attached to code statements
261
+
262
+ // Find comments that are between the construct and the end line
263
+ // Only emit comments that haven't been emitted yet AND are on their own lines
264
+ let indices =
265
+ self.get_comment_indices_in_range(construct_start_line + 1, construct_end_line);
251
266
 
252
267
  let mut comments_to_emit: Vec<_> = indices
253
268
  .into_iter()
254
- .filter(|&idx| self.all_comments[idx].location.end_line < end_line)
269
+ .filter(|&idx| {
270
+ let comment = &self.all_comments[idx];
271
+ // Only emit if: not already emitted, before end line, and is standalone
272
+ !self.emitted_comment_indices.contains(&idx)
273
+ && comment.location.end_line < construct_end_line
274
+ && self.is_standalone_comment(comment)
275
+ })
255
276
  .map(|idx| {
256
277
  let comment = &self.all_comments[idx];
257
278
  (idx, comment.location.start_line, comment.location.end_line)
258
279
  })
259
280
  .collect();
260
281
 
282
+ if comments_to_emit.is_empty() {
283
+ return Ok(());
284
+ }
285
+
261
286
  comments_to_emit.sort_by_key(|(_, start, _)| *start);
262
287
 
263
- let mut last_comment_end_line: Option<usize> = None;
288
+ // Ensure newline before first comment if buffer doesn't end with one
289
+ if !self.buffer.ends_with('\n') {
290
+ self.buffer.push('\n');
291
+ }
292
+
293
+ let mut last_emitted_line: Option<usize> = None;
264
294
 
295
+ // Emit comments while preserving their exact line positioning
265
296
  for (idx, comment_start_line, comment_end_line) in comments_to_emit {
266
- if let Some(prev_end) = last_comment_end_line {
267
- let gap = comment_start_line.saturating_sub(prev_end);
297
+ // Preserve blank lines between comments
298
+ if let Some(prev_line) = last_emitted_line {
299
+ let gap = comment_start_line.saturating_sub(prev_line);
268
300
  for _ in 1..gap {
269
301
  self.buffer.push('\n');
270
302
  }
@@ -276,12 +308,75 @@ impl Emitter {
276
308
  &self.indent_cache[indent_level], &self.all_comments[idx].text
277
309
  )?;
278
310
  self.emitted_comment_indices.insert(idx);
279
- last_comment_end_line = Some(comment_end_line);
311
+ last_emitted_line = Some(comment_end_line);
280
312
  }
281
313
 
282
314
  Ok(())
283
315
  }
284
316
 
317
+ /// Check if a comment should be treated as standalone
318
+ /// A standalone comment is one that should appear on its own line,
319
+ /// not attached to the end of a code statement
320
+ fn is_standalone_comment(&self, comment: &Comment) -> bool {
321
+ let comment_line = comment.location.start_line;
322
+ let _comment_start_offset = comment.location.start_offset;
323
+
324
+ // Get the source lines to analyze the comment's position
325
+ let lines: Vec<&str> = self.source.lines().collect();
326
+
327
+ // Check if we have a valid line number (1-indexed to 0-indexed)
328
+ if comment_line == 0 || comment_line > lines.len() {
329
+ return false;
330
+ }
331
+
332
+ let line = lines[comment_line - 1]; // Convert to 0-indexed
333
+
334
+ // Find where the comment starts within the line
335
+ let comment_text = &comment.text;
336
+
337
+ // Look for the comment marker (#) in the line
338
+ if let Some(hash_pos) = line.find('#') {
339
+ // Check if there's only whitespace before the comment
340
+ let before_comment = &line[..hash_pos];
341
+ let is_only_whitespace = before_comment.trim().is_empty();
342
+
343
+ // Also verify this is actually our comment by checking the text matches
344
+ let line_comment_text = &line[hash_pos..];
345
+ let is_same_comment = line_comment_text.trim_end() == comment_text.trim_end();
346
+
347
+ return is_only_whitespace && is_same_comment;
348
+ }
349
+
350
+ // If we can't find the comment marker, assume it's standalone
351
+ // This is a fallback for edge cases
352
+ false
353
+ }
354
+
355
+ /// Check if the node spans only a single line
356
+ fn is_single_line(&self, node: &Node) -> bool {
357
+ node.location.start_line == node.location.end_line
358
+ }
359
+
360
+ /// Extract and write source text for a node
361
+ fn write_source_text(&mut self, node: &Node) -> Result<()> {
362
+ let start = node.location.start_offset;
363
+ let end = node.location.end_offset;
364
+ if let Some(text) = self.source.get(start..end) {
365
+ write!(self.buffer, "{}", text)?;
366
+ }
367
+ Ok(())
368
+ }
369
+
370
+ /// Extract and write trimmed source text for a node
371
+ fn write_source_text_trimmed(&mut self, node: &Node) -> Result<()> {
372
+ let start = node.location.start_offset;
373
+ let end = node.location.end_offset;
374
+ if let Some(text) = self.source.get(start..end) {
375
+ write!(self.buffer, "{}", text.trim())?;
376
+ }
377
+ Ok(())
378
+ }
379
+
285
380
  /// Emit comments that are within a given line range, preserving blank lines from prev_line
286
381
  /// Uses BTreeMap index for O(log n) lookup instead of O(n) iteration
287
382
  fn emit_comments_in_range_with_prev_line(
@@ -464,9 +559,8 @@ impl Emitter {
464
559
  self.emit_node(child, indent_level + 1)?;
465
560
  }
466
561
 
467
- // Emit comments that are inside the class body but not attached to any node
468
- // These are comments between class_start_line and class_end_line
469
- self.emit_comments_in_range(class_start_line + 1, class_end_line, indent_level + 1)?;
562
+ // Emit comments that appear before the end statement while preserving their position
563
+ self.emit_comments_before_end(class_start_line, class_end_line, indent_level + 1)?;
470
564
 
471
565
  // Add newline before end if there was body content or internal comments
472
566
  if (has_body_content || self.has_comments_in_range(class_start_line + 1, class_end_line))
@@ -512,8 +606,8 @@ impl Emitter {
512
606
  self.emit_node(child, indent_level + 1)?;
513
607
  }
514
608
 
515
- // Emit comments that are inside the module body but not attached to any node
516
- self.emit_comments_in_range(module_start_line + 1, module_end_line, indent_level + 1)?;
609
+ // Emit comments that appear before the end statement while preserving their position
610
+ self.emit_comments_before_end(module_start_line, module_end_line, indent_level + 1)?;
517
611
 
518
612
  // Add newline before end if there was body content or internal comments
519
613
  if (has_body_content || self.has_comments_in_range(module_start_line + 1, module_end_line))
@@ -573,6 +667,13 @@ impl Emitter {
573
667
  self.emit_node(child, indent_level + 1)?;
574
668
  }
575
669
 
670
+ // Emit comments that appear before the end statement while preserving their position
671
+ self.emit_comments_before_end(
672
+ node.location.start_line,
673
+ node.location.end_line,
674
+ indent_level + 1,
675
+ )?;
676
+
576
677
  // Add newline before end if there was body content
577
678
  if node
578
679
  .children
@@ -831,25 +932,30 @@ impl Emitter {
831
932
 
832
933
  // Emit conditions with comma separator
833
934
  for (i, cond) in conditions.iter().enumerate() {
834
- let start = cond.location.start_offset;
835
- let end = cond.location.end_offset;
836
- if let Some(text) = self.source.get(start..end) {
837
- write!(self.buffer, "{}", text)?;
838
- }
935
+ self.write_source_text(cond)?;
839
936
  if i < conditions.len() - 1 {
840
937
  write!(self.buffer, ", ")?;
841
938
  }
842
939
  }
843
940
 
844
- self.buffer.push('\n');
845
-
846
- // Emit statements body
847
- if let Some(statements) = node
941
+ let statements = node
848
942
  .children
849
943
  .iter()
850
- .find(|c| matches!(c.node_type, NodeType::StatementsNode))
851
- {
852
- self.emit_statements(statements, indent_level + 1)?;
944
+ .find(|c| matches!(c.node_type, NodeType::StatementsNode));
945
+
946
+ if self.is_single_line(node) {
947
+ // Inline style: when X then Y
948
+ if let Some(statements) = statements {
949
+ write!(self.buffer, " then ")?;
950
+ self.write_source_text(statements)?;
951
+ }
952
+ } else {
953
+ // Multi-line style: when X\n Y
954
+ self.buffer.push('\n');
955
+
956
+ if let Some(statements) = statements {
957
+ self.emit_statements(statements, indent_level + 1)?;
958
+ }
853
959
  }
854
960
 
855
961
  Ok(())
@@ -883,14 +989,7 @@ impl Emitter {
883
989
  // Emit statement
884
990
  if let Some(statements) = node.children.get(1) {
885
991
  if matches!(statements.node_type, NodeType::StatementsNode) {
886
- // Extract the statement text (without extra indentation)
887
- if !self.source.is_empty() {
888
- let start = statements.location.start_offset;
889
- let end = statements.location.end_offset;
890
- if let Some(text) = self.source.get(start..end) {
891
- write!(self.buffer, "{}", text.trim())?;
892
- }
893
- }
992
+ self.write_source_text_trimmed(statements)?;
894
993
  }
895
994
  }
896
995
 
@@ -898,13 +997,7 @@ impl Emitter {
898
997
 
899
998
  // Emit condition
900
999
  if let Some(predicate) = node.children.first() {
901
- if !self.source.is_empty() {
902
- let start = predicate.location.start_offset;
903
- let end = predicate.location.end_offset;
904
- if let Some(text) = self.source.get(start..end) {
905
- write!(self.buffer, "{}", text)?;
906
- }
907
- }
1000
+ self.write_source_text(predicate)?;
908
1001
  }
909
1002
 
910
1003
  return Ok(());
@@ -923,26 +1016,14 @@ impl Emitter {
923
1016
 
924
1017
  // Emit condition
925
1018
  if let Some(predicate) = node.children.first() {
926
- if !self.source.is_empty() {
927
- let start = predicate.location.start_offset;
928
- let end = predicate.location.end_offset;
929
- if let Some(text) = self.source.get(start..end) {
930
- write!(self.buffer, "{}", text)?;
931
- }
932
- }
1019
+ self.write_source_text(predicate)?;
933
1020
  }
934
1021
 
935
1022
  write!(self.buffer, " ? ")?;
936
1023
 
937
1024
  // Emit then expression
938
1025
  if let Some(statements) = node.children.get(1) {
939
- if !self.source.is_empty() {
940
- let start = statements.location.start_offset;
941
- let end = statements.location.end_offset;
942
- if let Some(text) = self.source.get(start..end) {
943
- write!(self.buffer, "{}", text.trim())?;
944
- }
945
- }
1026
+ self.write_source_text_trimmed(statements)?;
946
1027
  }
947
1028
 
948
1029
  write!(self.buffer, " : ")?;
@@ -950,19 +1031,40 @@ impl Emitter {
950
1031
  // Emit else expression
951
1032
  if let Some(else_node) = node.children.get(2) {
952
1033
  if let Some(else_statements) = else_node.children.first() {
953
- if !self.source.is_empty() {
954
- let start = else_statements.location.start_offset;
955
- let end = else_statements.location.end_offset;
956
- if let Some(text) = self.source.get(start..end) {
957
- write!(self.buffer, "{}", text.trim())?;
958
- }
959
- }
1034
+ self.write_source_text_trimmed(else_statements)?;
960
1035
  }
961
1036
  }
962
1037
 
963
1038
  return Ok(());
964
1039
  }
965
1040
 
1041
+ // Check for inline then style: "if true then 1 end"
1042
+ // Single line, not postfix, not ternary, no else clause
1043
+ let is_inline_then =
1044
+ !is_elsif && self.is_single_line(node) && node.children.get(2).is_none();
1045
+
1046
+ if is_inline_then {
1047
+ self.emit_comments_before(node.location.start_line, indent_level)?;
1048
+ self.emit_indent(indent_level)?;
1049
+ write!(self.buffer, "{} ", keyword)?;
1050
+
1051
+ // Emit condition
1052
+ if let Some(predicate) = node.children.first() {
1053
+ self.write_source_text(predicate)?;
1054
+ }
1055
+
1056
+ write!(self.buffer, " then ")?;
1057
+
1058
+ // Emit statement
1059
+ if let Some(statements) = node.children.get(1) {
1060
+ self.write_source_text_trimmed(statements)?;
1061
+ }
1062
+
1063
+ write!(self.buffer, " end")?;
1064
+ self.emit_trailing_comments(node.location.end_line)?;
1065
+ return Ok(());
1066
+ }
1067
+
966
1068
  // Normal if/unless/elsif
967
1069
  if !is_elsif {
968
1070
  self.emit_comments_before(node.location.start_line, indent_level)?;
@@ -978,14 +1080,7 @@ impl Emitter {
978
1080
 
979
1081
  // Emit predicate (condition) - first child
980
1082
  if let Some(predicate) = node.children.first() {
981
- // Extract predicate from source
982
- if !self.source.is_empty() {
983
- let start = predicate.location.start_offset;
984
- let end = predicate.location.end_offset;
985
- if let Some(text) = self.source.get(start..end) {
986
- write!(self.buffer, "{}", text)?;
987
- }
988
- }
1083
+ self.write_source_text(predicate)?;
989
1084
  }
990
1085
 
991
1086
  // Emit trailing comment on same line as if/unless/elsif
@@ -1125,14 +1220,27 @@ impl Emitter {
1125
1220
  let mut last_stmt_end_line = block_start_line;
1126
1221
 
1127
1222
  for child in &block_node.children {
1128
- if matches!(child.node_type, NodeType::StatementsNode) {
1129
- self.emit_statements(child, indent_level + 1)?;
1130
- // Track the last statement's end line for blank line preservation
1131
- if let Some(last_child) = child.children.last() {
1132
- last_stmt_end_line = last_child.location.end_line;
1223
+ match &child.node_type {
1224
+ NodeType::StatementsNode => {
1225
+ self.emit_statements(child, indent_level + 1)?;
1226
+ // Track the last statement's end line for blank line preservation
1227
+ if let Some(last_child) = child.children.last() {
1228
+ last_stmt_end_line = last_child.location.end_line;
1229
+ }
1230
+ self.buffer.push('\n');
1231
+ break;
1232
+ }
1233
+ NodeType::BeginNode => {
1234
+ // Block with rescue/ensure/else - delegate to emit_begin
1235
+ // which handles implicit begin (no "begin" keyword)
1236
+ self.emit_begin(child, indent_level + 1)?;
1237
+ self.buffer.push('\n');
1238
+ last_stmt_end_line = child.location.end_line;
1239
+ break;
1240
+ }
1241
+ _ => {
1242
+ // Skip parameter nodes
1133
1243
  }
1134
- self.buffer.push('\n');
1135
- break;
1136
1244
  }
1137
1245
  }
1138
1246
 
@@ -1168,6 +1276,7 @@ impl Emitter {
1168
1276
  fn emit_brace_block(&mut self, block_node: &Node, indent_level: usize) -> Result<()> {
1169
1277
  // Determine if block should be inline or multiline
1170
1278
  let is_multiline = block_node.location.start_line != block_node.location.end_line;
1279
+ let block_end_line = block_node.location.end_line;
1171
1280
 
1172
1281
  if is_multiline {
1173
1282
  // Multiline brace block
@@ -1186,6 +1295,7 @@ impl Emitter {
1186
1295
 
1187
1296
  self.emit_indent(indent_level)?;
1188
1297
  write!(self.buffer, "}}")?;
1298
+ self.emit_trailing_comments(block_end_line)?;
1189
1299
  } else {
1190
1300
  // Inline brace block - extract from source to preserve spacing
1191
1301
  write!(self.buffer, " ")?;
@@ -1195,6 +1305,7 @@ impl Emitter {
1195
1305
  {
1196
1306
  write!(self.buffer, "{}", text)?;
1197
1307
  }
1308
+ self.emit_trailing_comments(block_end_line)?;
1198
1309
  }
1199
1310
 
1200
1311
  Ok(())
@@ -1457,8 +1568,8 @@ impl Emitter {
1457
1568
  }
1458
1569
  }
1459
1570
 
1460
- // Emit comments inside the singleton class body
1461
- self.emit_comments_in_range(class_start_line + 1, class_end_line, indent_level + 1)?;
1571
+ // Emit comments that appear before the end statement while preserving their position
1572
+ self.emit_comments_before_end(class_start_line, class_end_line, indent_level + 1)?;
1462
1573
 
1463
1574
  // Add newline before end if there was body content
1464
1575
  if (has_body_content || self.has_comments_in_range(class_start_line + 1, class_end_line))
@@ -1503,7 +1614,6 @@ impl Emitter {
1503
1614
  match &child.node_type {
1504
1615
  NodeType::InNode => {
1505
1616
  self.emit_in(child, indent_level)?;
1506
- self.buffer.push('\n');
1507
1617
  }
1508
1618
  NodeType::ElseNode => {
1509
1619
  self.emit_indent(indent_level)?;
@@ -1539,19 +1649,25 @@ impl Emitter {
1539
1649
 
1540
1650
  // First child is the pattern
1541
1651
  if let Some(pattern) = node.children.first() {
1542
- let start = pattern.location.start_offset;
1543
- let end = pattern.location.end_offset;
1544
- if let Some(text) = self.source.get(start..end) {
1545
- write!(self.buffer, "{}", text)?;
1546
- }
1652
+ self.write_source_text(pattern)?;
1547
1653
  }
1548
1654
 
1549
- self.buffer.push('\n');
1655
+ if self.is_single_line(node) {
1656
+ // Inline style: in X then Y
1657
+ if let Some(statements) = node.children.get(1) {
1658
+ write!(self.buffer, " then ")?;
1659
+ self.write_source_text(statements)?;
1660
+ }
1661
+ self.buffer.push('\n');
1662
+ } else {
1663
+ // Multi-line style: in X\n Y
1664
+ self.buffer.push('\n');
1550
1665
 
1551
- // Second child is the statements body
1552
- if let Some(statements) = node.children.get(1) {
1553
- if matches!(statements.node_type, NodeType::StatementsNode) {
1554
- self.emit_statements(statements, indent_level + 1)?;
1666
+ // Second child is the statements body
1667
+ if let Some(statements) = node.children.get(1) {
1668
+ if matches!(statements.node_type, NodeType::StatementsNode) {
1669
+ self.emit_statements(statements, indent_level + 1)?;
1670
+ }
1555
1671
  }
1556
1672
  }
1557
1673
 
data/ext/rfmt/src/lib.rs CHANGED
@@ -10,7 +10,7 @@ use policy::SecurityPolicy;
10
10
 
11
11
  use config::Config;
12
12
  use emitter::Emitter;
13
- use magnus::{define_module, function, prelude::*, Error, Ruby};
13
+ use magnus::{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> {
@@ -45,10 +45,10 @@ fn rust_version() -> String {
45
45
  }
46
46
 
47
47
  #[magnus::init]
48
- fn init(_ruby: &Ruby) -> Result<(), Error> {
48
+ fn init(ruby: &Ruby) -> Result<(), Error> {
49
49
  logging::RfmtLogger::init();
50
50
 
51
- let module = define_module("Rfmt")?;
51
+ let module = ruby.define_module("Rfmt")?;
52
52
 
53
53
  module.define_singleton_method("format_code", function!(format_ruby_code, 2))?;
54
54
  module.define_singleton_method("parse_to_json", function!(parse_to_json, 1))?;
@@ -108,13 +108,42 @@ module Rfmt
108
108
  # Extract location information from node
109
109
  def self.extract_location(node)
110
110
  loc = node.location
111
+
112
+ # For heredoc nodes, the location only covers the opening tag (<<~CSV)
113
+ # We need to find the maximum end_offset including closing_loc
114
+ end_offset = loc.end_offset
115
+ end_line = loc.end_line
116
+ end_column = loc.end_column
117
+
118
+ # Check this node's closing_loc
119
+ if node.respond_to?(:closing_loc) && node.closing_loc
120
+ closing = node.closing_loc
121
+ if closing.end_offset > end_offset
122
+ end_offset = closing.end_offset
123
+ end_line = closing.end_line
124
+ end_column = closing.end_column
125
+ end
126
+ end
127
+
128
+ # Check child nodes for heredoc (e.g., LocalVariableWriteNode containing StringNode)
129
+ node.child_nodes.compact.each do |child|
130
+ next unless child.respond_to?(:closing_loc) && child.closing_loc
131
+
132
+ closing = child.closing_loc
133
+ next unless closing.end_offset > end_offset
134
+
135
+ end_offset = closing.end_offset
136
+ end_line = closing.end_line
137
+ end_column = closing.end_column
138
+ end
139
+
111
140
  {
112
141
  start_line: loc.start_line,
113
142
  start_column: loc.start_column,
114
- end_line: loc.end_line,
115
- end_column: loc.end_column,
143
+ end_line: end_line,
144
+ end_column: end_column,
116
145
  start_offset: loc.start_offset,
117
- end_offset: loc.end_offset
146
+ end_offset: end_offset
118
147
  }
119
148
  end
120
149
 
@@ -176,6 +205,7 @@ module Rfmt
176
205
  [
177
206
  node.statements,
178
207
  node.rescue_clause,
208
+ node.else_clause,
179
209
  node.ensure_clause
180
210
  ].compact
181
211
  when Prism::EnsureNode
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.4.0'
4
+ VERSION = '1.5.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rfmt
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - fujitani sora
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-01-17 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: diff-lcs
@@ -113,7 +112,6 @@ metadata:
113
112
  source_code_uri: https://github.com/fs0414/rfmt
114
113
  changelog_uri: https://github.com/fs0414/rfmt/releases
115
114
  ruby_lsp_addon: 'true'
116
- post_install_message:
117
115
  rdoc_options: []
118
116
  require_paths:
119
117
  - lib
@@ -128,8 +126,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
126
  - !ruby/object:Gem::Version
129
127
  version: 3.0.0
130
128
  requirements: []
131
- rubygems_version: 3.5.22
132
- signing_key:
129
+ rubygems_version: 3.6.9
133
130
  specification_version: 4
134
131
  summary: Ruby Formatter impl Rust lang.
135
132
  test_files: []