rfmt 1.7.0 → 2.0.0.beta1

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Cargo.lock +90 -1225
  4. data/Cargo.toml +6 -0
  5. data/README.md +32 -28
  6. data/ext/rfmt/Cargo.toml +9 -28
  7. data/ext/rfmt/src/ast/mod.rs +2 -0
  8. data/ext/rfmt/src/config/mod.rs +267 -30
  9. data/ext/rfmt/src/error/mod.rs +14 -3
  10. data/ext/rfmt/src/format/formatter.rs +22 -11
  11. data/ext/rfmt/src/format/registry.rs +8 -0
  12. data/ext/rfmt/src/format/rule.rs +208 -136
  13. data/ext/rfmt/src/format/rules/call.rs +14 -22
  14. data/ext/rfmt/src/format/rules/fallback.rs +4 -11
  15. data/ext/rfmt/src/format/rules/if_unless.rs +25 -3
  16. data/ext/rfmt/src/format/rules/loops.rs +3 -1
  17. data/ext/rfmt/src/format/rules/variable_write.rs +16 -9
  18. data/ext/rfmt/src/lib.rs +45 -12
  19. data/ext/rfmt/src/parser/mod.rs +2 -0
  20. data/ext/rfmt/src/parser/native_adapter.rs +2735 -0
  21. data/ext/rfmt/src/parser/prism_adapter.rs +5 -0
  22. data/ext/rfmt/src/validation.rs +69 -0
  23. data/ext/rfmt/tests/fixtures/parity/comments_mixed.rb +9 -0
  24. data/ext/rfmt/tests/fixtures/parity/constructs.rb +50 -0
  25. data/ext/rfmt/tests/fixtures/parity/embdoc.rb +12 -0
  26. data/ext/rfmt/tests/fixtures/parity/heredoc_assign.rb +15 -0
  27. data/ext/rfmt/tests/fixtures/parity/heredoc_call_args.rb +17 -0
  28. data/ext/rfmt/tests/fixtures/parity/metadata_classes.rb +30 -0
  29. data/ext/rfmt/tests/fixtures/parity/metadata_conditionals.rb +14 -0
  30. data/ext/rfmt/tests/fixtures/parity/metadata_defs.rb +33 -0
  31. data/ext/rfmt/tests/fixtures/parity/multibyte.rb +14 -0
  32. data/ext/rfmt/tests/fixtures/parity/numeric.rb +6 -0
  33. data/ext/rfmt/tests/fixtures/parity/plain.rb +31 -0
  34. data/ext/rfmt/tests/native_parity.rs +245 -0
  35. data/ext/rfmt/tests/ruby_prism_smoke.rs +66 -0
  36. data/lib/rfmt/cli.rb +37 -7
  37. data/lib/rfmt/configuration.rb +2 -20
  38. data/lib/rfmt/version.rb +1 -1
  39. data/lib/rfmt.rb +47 -19
  40. metadata +17 -18
  41. data/lib/rfmt/prism_bridge.rb +0 -529
  42. data/lib/rfmt/prism_node_extractor.rb +0 -115
@@ -1,3 +1,8 @@
1
+ //! Test support only: no production caller since the phase 7 legacy-path
2
+ //! deletion. Deserializes the frozen golden JSON fixtures under
3
+ //! tests/fixtures/parity/ so native_parity.rs can pin NativeAdapter's output
4
+ //! against future ruby-prism crate bumps.
5
+
1
6
  use crate::ast::{Comment, CommentPosition, CommentType, FormattingInfo, Location, Node, NodeType};
2
7
  use crate::error::{Result, RfmtError};
3
8
  use crate::parser::RubyParser;
@@ -0,0 +1,69 @@
1
+ //! Output guard: never return syntactically invalid Ruby to the caller.
2
+ //! Moved here from lib/rfmt.rb's validate_output! at the phase-6 switchover.
3
+
4
+ use crate::error::{Result, RfmtError};
5
+
6
+ pub fn validate_output(formatted: &str) -> Result<()> {
7
+ let bytes = formatted.as_bytes();
8
+ let parse_result = ruby_prism::parse(bytes);
9
+
10
+ let Some(error) = parse_result.errors().next() else {
11
+ return Ok(());
12
+ };
13
+
14
+ let line = 1 + bytes[..error.location().start_offset()]
15
+ .iter()
16
+ .filter(|&&b| b == b'\n')
17
+ .count();
18
+ Err(RfmtError::ValidationError(format!(
19
+ "Formatter produced syntactically invalid output (this is a bug in rfmt, not in your code): {} at line {}",
20
+ error.message(),
21
+ line
22
+ )))
23
+ }
24
+
25
+ #[cfg(test)]
26
+ mod tests {
27
+ use super::*;
28
+
29
+ #[test]
30
+ fn accepts_valid_ruby() {
31
+ assert!(validate_output("x = 1\n").is_ok());
32
+ }
33
+
34
+ #[test]
35
+ fn rejects_invalid_ruby_with_validation_error() {
36
+ let err = validate_output("def broken(\n").unwrap_err();
37
+
38
+ match err {
39
+ RfmtError::ValidationError(message) => {
40
+ assert!(
41
+ message.starts_with(
42
+ "Formatter produced syntactically invalid output (this is a bug in rfmt, not in your code): "
43
+ ),
44
+ "unexpected message: {message}"
45
+ );
46
+ assert!(
47
+ message.contains(" at line 1"),
48
+ "unexpected message: {message}"
49
+ );
50
+ }
51
+ other => panic!("expected ValidationError, got {other:?}"),
52
+ }
53
+ }
54
+
55
+ #[test]
56
+ fn reports_the_line_of_the_first_error() {
57
+ let err = validate_output("x = 1\ny = 2\ndef broken(\n").unwrap_err();
58
+
59
+ match err {
60
+ RfmtError::ValidationError(message) => {
61
+ assert!(
62
+ message.contains(" at line 3"),
63
+ "unexpected message: {message}"
64
+ );
65
+ }
66
+ other => panic!("expected ValidationError, got {other:?}"),
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,9 @@
1
+ # leading comment
2
+ x = 1 # trailing comment
3
+
4
+ =begin
5
+ embdoc line one
6
+ line two
7
+ =end
8
+ y = 2
9
+ # final comment
@@ -0,0 +1,50 @@
1
+ expected = :ready
2
+
3
+ case payload
4
+ in [Integer => first, *rest]
5
+ first + rest.sum
6
+ in { status: String => status, code: 200 | 201 }
7
+ status
8
+ in ^expected
9
+ :pinned
10
+ else
11
+ :unmatched
12
+ end
13
+
14
+ begin
15
+ risky!
16
+ rescue KeyError => e
17
+ recover(e)
18
+ rescue StandardError
19
+ retry_count += 1
20
+ retry if retry_count < 3
21
+ else
22
+ celebrate
23
+ ensure
24
+ cleanup
25
+ end
26
+
27
+ counters = Hash.new(0)
28
+ counters[:hits] ||= 0
29
+ counters[:hits] += 1
30
+ @cache &&= @cache.compact
31
+ $verbose ||= false
32
+ CONFIG = { a: 3r, b: 3.14r, c: 2i, d: 2ri }
33
+
34
+ class << self
35
+ def singleton_helper = :ok
36
+ end
37
+
38
+ alias aka singleton_helper
39
+ alias $err $stderr
40
+ undef :aka
41
+
42
+ flip = items.select { |l| (l == first)..(l == last) }
43
+
44
+ lines = ->(input) { input.each_line.to_a }
45
+ x, *middle, z = lines.call(<<~TXT)
46
+ alpha
47
+ beta
48
+ gamma
49
+ TXT
50
+ puts x, middle, z
@@ -0,0 +1,12 @@
1
+ =begin
2
+ Block documentation spanning
3
+ multiple lines.
4
+ =end
5
+ value = 42
6
+
7
+ =begin
8
+ Another embedded document right before a method.
9
+ =end
10
+ def answer
11
+ value
12
+ end
@@ -0,0 +1,15 @@
1
+ sql = <<~SQL
2
+ SELECT *
3
+ FROM users
4
+ WHERE active = true
5
+ SQL
6
+
7
+ body = <<-TEXT
8
+ indented terminator keeps its column
9
+ TEXT
10
+
11
+ plain = <<HTML
12
+ <p>no squiggly</p>
13
+ HTML
14
+
15
+ after = sql.length + body.length
@@ -0,0 +1,17 @@
1
+ result = execute(<<~SQL, timeout: 5)
2
+ SELECT id
3
+ FROM events
4
+ SQL
5
+
6
+ logger.info(prefix, <<~MSG)
7
+ first line
8
+ second line
9
+ MSG
10
+
11
+ wrap(inner(<<~ONE), <<~TWO)
12
+ one body
13
+ ONE
14
+ two body
15
+ TWO
16
+
17
+ puts result
@@ -0,0 +1,30 @@
1
+ class Foo::Bar < Baz::Qux
2
+ def change
3
+ x = compute(1)
4
+ @count = x
5
+ end
6
+ end
7
+
8
+ class AddUsers < ActiveRecord::Migration[8.1]
9
+ def change
10
+ create_table :users
11
+ end
12
+ end
13
+
14
+ class Simple < Base
15
+ end
16
+
17
+ class Plain
18
+ end
19
+
20
+ module Alpha::Beta
21
+ end
22
+
23
+ module Gamma
24
+ end
25
+
26
+ class ::TopScoped < ::Deep::Nested::Base
27
+ end
28
+
29
+ module Alpha::Beta::Gamma
30
+ end
@@ -0,0 +1,14 @@
1
+ result = flag ? 1 : 2
2
+
3
+ if flag
4
+ a = 1
5
+ else
6
+ a = 2
7
+ end
8
+
9
+ unless flag
10
+ b = 3
11
+ end
12
+
13
+ c = 4 if flag
14
+ d = 5 unless flag
@@ -0,0 +1,33 @@
1
+ def !@
2
+ true
3
+ end
4
+
5
+ def +@
6
+ self
7
+ end
8
+
9
+ def []=(key, value)
10
+ store(key, value)
11
+ end
12
+
13
+ def self.build(name)
14
+ new(name)
15
+ end
16
+
17
+ obj = Object.new
18
+
19
+ def obj.run
20
+ 1
21
+ end
22
+
23
+ def bare arg
24
+ arg
25
+ end
26
+
27
+ def none
28
+ 0
29
+ end
30
+
31
+ def empty_parens()
32
+ nil
33
+ end
@@ -0,0 +1,14 @@
1
+ greeting = "こんにちは、世界"
2
+ farewell = 'さようなら'; short = "あ"
3
+
4
+ def label(polite: false)
5
+ polite ? "です" : "だ"
6
+ end
7
+
8
+ mixed = "emoji 🎉 と 日本語"
9
+ message = <<~TEXT
10
+ 一行目の本文
11
+ 二行目の本文
12
+ TEXT
13
+
14
+ combined = "#{greeting} #{mixed}".freeze
@@ -0,0 +1,6 @@
1
+ a = 3r
2
+ b = 3.14r
3
+ c = 2i
4
+ d = 2ri
5
+ e = 6.02e2i
6
+ f = a + b * c - d / e
@@ -0,0 +1,31 @@
1
+ class Greeter
2
+ attr_reader :name
3
+
4
+ def initialize(name)
5
+ @name = name
6
+ end
7
+
8
+ def greet(loud: false)
9
+ message = "hello, #{name}"
10
+ if loud
11
+ message.upcase
12
+ else
13
+ message
14
+ end
15
+ end
16
+ end
17
+
18
+ module Registry
19
+ DEFAULT = Greeter.new("world")
20
+
21
+ def self.lookup(key)
22
+ entries.fetch(key) { DEFAULT }
23
+ end
24
+ end
25
+
26
+ items = [1, 2.5, :three, nil, true]
27
+ totals = { "sum" => items.compact.size, count: items.length }
28
+ items.each_with_index do |item, index|
29
+ puts "#{index}: #{item}" unless item.nil?
30
+ end
31
+ result = Registry.lookup(:default)&.greet(loud: totals[:count] > 3)
@@ -0,0 +1,245 @@
1
+ //! Parity tests for the native ruby-prism converter.
2
+ //!
3
+ //! Each fixture under tests/fixtures/parity/ exists as a .rb source and the
4
+ //! frozen golden JSON the (now deleted) Ruby PrismBridge produced for it.
5
+ //! The .rb goes through NativeAdapter, the .json through the test-support
6
+ //! PrismAdapter, and the trees must agree on node types, all six location
7
+ //! fields, children, formatting.multiline, metadata, and comments. This pins
8
+ //! NativeAdapter's output shape against future ruby-prism crate bumps; only
9
+ //! `bundle exec ruby scripts/gen_parity_fixtures.rb` may regenerate the JSON.
10
+
11
+ use rfmt::ast::{Location, Node};
12
+ use rfmt::parser::{NativeAdapter, PrismAdapter, RubyParser};
13
+ use std::collections::HashMap;
14
+ use std::fs;
15
+ use std::path::{Path, PathBuf};
16
+
17
+ /// Keys the bridge still emits but nothing downstream reads; the native
18
+ /// converter intentionally drops them, so they are stripped from the JSON
19
+ /// side before the exact metadata comparison.
20
+ const DEAD_METADATA_KEYS: [&str; 4] = ["parameters_count", "message", "content", "value"];
21
+
22
+ fn fixtures_dir() -> PathBuf {
23
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/parity")
24
+ }
25
+
26
+ fn push_diff<T: std::fmt::Debug + PartialEq>(
27
+ diffs: &mut Vec<String>,
28
+ path: &str,
29
+ field: &str,
30
+ json: &T,
31
+ native: &T,
32
+ ) {
33
+ if json != native {
34
+ diffs.push(format!(
35
+ "{}.{}: json={:?} native={:?}",
36
+ path, field, json, native
37
+ ));
38
+ }
39
+ }
40
+
41
+ fn live_metadata(metadata: &HashMap<String, String>) -> HashMap<String, String> {
42
+ metadata
43
+ .iter()
44
+ .filter(|(key, _)| !DEAD_METADATA_KEYS.contains(&key.as_str()))
45
+ .map(|(key, value)| (key.clone(), value.clone()))
46
+ .collect()
47
+ }
48
+
49
+ fn compare_locations(path: &str, json: &Location, native: &Location, diffs: &mut Vec<String>) {
50
+ push_diff(
51
+ diffs,
52
+ path,
53
+ "start_line",
54
+ &json.start_line,
55
+ &native.start_line,
56
+ );
57
+ push_diff(
58
+ diffs,
59
+ path,
60
+ "start_column",
61
+ &json.start_column,
62
+ &native.start_column,
63
+ );
64
+ push_diff(diffs, path, "end_line", &json.end_line, &native.end_line);
65
+ push_diff(
66
+ diffs,
67
+ path,
68
+ "end_column",
69
+ &json.end_column,
70
+ &native.end_column,
71
+ );
72
+ push_diff(
73
+ diffs,
74
+ path,
75
+ "start_offset",
76
+ &json.start_offset,
77
+ &native.start_offset,
78
+ );
79
+ push_diff(
80
+ diffs,
81
+ path,
82
+ "end_offset",
83
+ &json.end_offset,
84
+ &native.end_offset,
85
+ );
86
+ }
87
+
88
+ fn compare_nodes(path: &str, json: &Node, native: &Node, diffs: &mut Vec<String>) {
89
+ push_diff(diffs, path, "node_type", &json.node_type, &native.node_type);
90
+
91
+ compare_locations(
92
+ &format!("{}.location", path),
93
+ &json.location,
94
+ &native.location,
95
+ diffs,
96
+ );
97
+
98
+ push_diff(
99
+ diffs,
100
+ path,
101
+ "formatting.multiline",
102
+ &json.formatting.multiline,
103
+ &native.formatting.multiline,
104
+ );
105
+
106
+ let json_metadata = live_metadata(&json.metadata);
107
+ push_diff(diffs, path, "metadata", &json_metadata, &native.metadata);
108
+
109
+ push_diff(
110
+ diffs,
111
+ path,
112
+ "comments.len",
113
+ &json.comments.len(),
114
+ &native.comments.len(),
115
+ );
116
+ for (i, (jc, nc)) in json.comments.iter().zip(native.comments.iter()).enumerate() {
117
+ let cpath = format!("{}.comments[{}]", path, i);
118
+ push_diff(diffs, &cpath, "text", &jc.text, &nc.text);
119
+ push_diff(
120
+ diffs,
121
+ &cpath,
122
+ "comment_type",
123
+ &jc.comment_type,
124
+ &nc.comment_type,
125
+ );
126
+ push_diff(diffs, &cpath, "position", &jc.position, &nc.position);
127
+ compare_locations(
128
+ &format!("{}.location", cpath),
129
+ &jc.location,
130
+ &nc.location,
131
+ diffs,
132
+ );
133
+ }
134
+
135
+ push_diff(
136
+ diffs,
137
+ path,
138
+ "children.len",
139
+ &json.children.len(),
140
+ &native.children.len(),
141
+ );
142
+ for (i, (jc, nc)) in json.children.iter().zip(native.children.iter()).enumerate() {
143
+ compare_nodes(&format!("{}.children[{}]", path, i), jc, nc, diffs);
144
+ }
145
+ }
146
+
147
+ #[test]
148
+ fn native_conversion_matches_ruby_bridge() {
149
+ let dir = fixtures_dir();
150
+ let mut names: Vec<PathBuf> = fs::read_dir(&dir)
151
+ .expect("fixtures dir missing; add fixtures under tests/fixtures/parity")
152
+ .map(|e| e.unwrap().path())
153
+ .filter(|p| p.extension().is_some_and(|ext| ext == "rb"))
154
+ .collect();
155
+ names.sort();
156
+ assert!(
157
+ names.len() >= 11,
158
+ "expected the full parity fixture set, found {:?}",
159
+ names
160
+ );
161
+
162
+ let mut failures = Vec::new();
163
+ for rb_path in &names {
164
+ let name = rb_path.file_stem().unwrap().to_string_lossy().into_owned();
165
+ let source = fs::read_to_string(rb_path).unwrap();
166
+ let json = fs::read_to_string(rb_path.with_extension("json")).unwrap_or_else(|_| {
167
+ panic!(
168
+ "{}.json missing; run `bundle exec ruby scripts/gen_parity_fixtures.rb`",
169
+ name
170
+ )
171
+ });
172
+
173
+ let json_tree = PrismAdapter::new().parse(&json).expect("bridge JSON parse");
174
+ let native_tree = NativeAdapter::new().parse(&source).expect("native parse");
175
+
176
+ let mut diffs = Vec::new();
177
+ compare_nodes("root", &json_tree, &native_tree, &mut diffs);
178
+ if !diffs.is_empty() {
179
+ failures.push(format!("[{}]\n{}", name, diffs.join("\n")));
180
+ }
181
+ }
182
+
183
+ assert!(failures.is_empty(), "\n{}", failures.join("\n\n"));
184
+ }
185
+
186
+ #[test]
187
+ fn comparator_detects_a_mutated_tree() {
188
+ let source = fs::read_to_string(fixtures_dir().join("plain.rb")).unwrap();
189
+ let reference = NativeAdapter::new().parse(&source).unwrap();
190
+ let mut mutated = reference.clone();
191
+ let first = mutated.children.first_mut().expect("fixture has children");
192
+ first.location.end_line += 1;
193
+
194
+ let mut diffs = Vec::new();
195
+ compare_nodes("root", &reference, &mutated, &mut diffs);
196
+ assert_eq!(diffs.len(), 1, "{diffs:?}");
197
+ assert!(
198
+ diffs[0].starts_with("root.children[0].location.end_line: "),
199
+ "{diffs:?}"
200
+ );
201
+ }
202
+
203
+ #[test]
204
+ fn comparator_detects_a_mutated_metadata_value() {
205
+ let reference = NativeAdapter::new()
206
+ .parse("def foo(a)\n a\nend\n")
207
+ .unwrap();
208
+ let mut mutated = reference.clone();
209
+ let def = mutated.children.first_mut().expect("def child");
210
+ assert_eq!(def.metadata.get("name").map(String::as_str), Some("foo"));
211
+ def.metadata.insert("name".to_string(), "bar".to_string());
212
+
213
+ let mut diffs = Vec::new();
214
+ compare_nodes("root", &reference, &mutated, &mut diffs);
215
+ assert_eq!(diffs.len(), 1, "{diffs:?}");
216
+ assert!(
217
+ diffs[0].starts_with("root.children[0].metadata: "),
218
+ "{diffs:?}"
219
+ );
220
+ }
221
+
222
+ #[test]
223
+ fn comparator_detects_a_mutated_comment() {
224
+ let reference = NativeAdapter::new().parse("# hello\nx = 1\n").unwrap();
225
+ let mut mutated = reference.clone();
226
+ let comment = mutated.comments.first_mut().expect("root comment");
227
+ assert_eq!(comment.text, "# hello");
228
+ comment.text = "# tampered".to_string();
229
+
230
+ let mut diffs = Vec::new();
231
+ compare_nodes("root", &reference, &mutated, &mut diffs);
232
+ assert_eq!(diffs.len(), 1, "{diffs:?}");
233
+ assert!(diffs[0].starts_with("root.comments[0].text: "), "{diffs:?}");
234
+ }
235
+
236
+ #[test]
237
+ fn native_adapter_reports_parse_errors_with_position() {
238
+ let err = NativeAdapter::new()
239
+ .parse("x = 1\ndef broken(")
240
+ .unwrap_err();
241
+ let message = err.to_string();
242
+ assert!(message.contains("Parse error"), "{}", message);
243
+ // Same line:column shape the Ruby bridge raises (line 2, byte column)
244
+ assert!(message.contains("2:"), "{}", message);
245
+ }
@@ -0,0 +1,66 @@
1
+ //! Spike gate for the ruby-prism migration (#110 Phase 3): proves the crate
2
+ //! parses with the byte-offset and comment semantics the formatter relies on.
3
+
4
+ #[test]
5
+ fn parses_heredoc_comments_and_safe_navigation() {
6
+ let source = r#"result = fetch&.body
7
+ sql = <<~SQL
8
+ SELECT 1
9
+ SQL
10
+ # leading comment
11
+ value = compute(sql) # trailing comment
12
+ "#;
13
+
14
+ let result = ruby_prism::parse(source.as_bytes());
15
+
16
+ assert!(result.errors().next().is_none());
17
+ assert_eq!(result.comments().count(), 2);
18
+
19
+ let comment_offsets: Vec<(usize, usize)> = result
20
+ .comments()
21
+ .map(|c| {
22
+ let loc = c.location();
23
+ (loc.start_offset(), loc.end_offset())
24
+ })
25
+ .collect();
26
+ let leading = &source[comment_offsets[0].0..comment_offsets[0].1];
27
+ let trailing = &source[comment_offsets[1].0..comment_offsets[1].1];
28
+ assert_eq!(leading, "# leading comment");
29
+ assert_eq!(trailing, "# trailing comment");
30
+
31
+ let program = result.node();
32
+ let program = program.as_program_node().expect("program node");
33
+ let statements = program.statements();
34
+ assert_eq!(statements.body().iter().count(), 3);
35
+
36
+ let first = statements.body().iter().next().expect("first statement");
37
+ let loc = first.location();
38
+ assert_eq!(
39
+ &source[loc.start_offset()..loc.end_offset()],
40
+ "result = fetch&.body"
41
+ );
42
+ }
43
+
44
+ #[test]
45
+ fn reports_errors_for_invalid_source() {
46
+ let result = ruby_prism::parse(b"def broken(");
47
+ assert!(result.errors().next().is_some());
48
+ }
49
+
50
+ #[test]
51
+ fn multibyte_source_keeps_byte_offsets() {
52
+ let source = "x = \"あいう\"\ny = 1\n";
53
+ let result = ruby_prism::parse(source.as_bytes());
54
+ assert!(result.errors().next().is_none());
55
+
56
+ let program = result.node();
57
+ let program = program.as_program_node().expect("program node");
58
+ let second = program
59
+ .statements()
60
+ .body()
61
+ .iter()
62
+ .nth(1)
63
+ .expect("second statement");
64
+ let loc = second.location();
65
+ assert_eq!(&source[loc.start_offset()..loc.end_offset()], "y = 1");
66
+ }