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
data/Cargo.toml CHANGED
@@ -5,3 +5,9 @@
5
5
  [workspace]
6
6
  members = ["./ext/rfmt"]
7
7
  resolver = "2"
8
+
9
+ # panic = "abort" must not be set here: magnus relies on unwinding to convert
10
+ # Rust panics into Ruby exceptions instead of killing the host process.
11
+ [profile.release]
12
+ lto = "thin"
13
+ codegen-units = 1
data/README.md CHANGED
@@ -30,7 +30,7 @@ A Ruby code formatter written in Rust
30
30
  - **Opinionated**: Minimal configuration with consistent output
31
31
  - **Idempotent**: Running multiple times produces identical results
32
32
  - **Comment preservation**: Maintains existing comment placement
33
- - **Rust implementation**: Core formatter implemented in Rust
33
+ - **Rust implementation**: Parsing and formatting both run natively in Rust (via the [ruby-prism](https://crates.io/crates/ruby-prism) crate); Ruby provides the CLI and LSP shell
34
34
 
35
35
  ## Features
36
36
 
@@ -49,35 +49,28 @@ Enforces code style rules:
49
49
 
50
50
  ## Performance
51
51
 
52
- rfmt delivers consistent, fast formatting across projects of any size:
52
+ Parsing and formatting both run natively in Rust (the [ruby-prism](https://crates.io/crates/ruby-prism) crate, with prism statically linked), so the per-file cost is well under a millisecond:
53
53
 
54
- | Project Size | Files | Execution Time | Throughput |
55
- |-------------|-------|----------------|------------|
56
- | Small | 9 files | ~105ms | 85 files/sec |
57
- | Medium | 35 files | ~110ms | 315 files/sec |
58
- | Large | 151 files | ~100ms | 1,560 files/sec |
54
+ | Pipeline | In-process format time |
55
+ |----------|------------------------|
56
+ | Before native parsing (Ruby Prism parse + JSON handoff to Rust; historical, not reproducible from this checkout) | 4.28 ms/file |
57
+ | Now (parsing and formatting in Rust) | 0.19 ms/file |
59
58
 
60
- **Key Performance Characteristics:**
59
+ Measured with `scripts/bench_format.rb` over rfmt's own `lib/` corpus on arm64 macOS, Ruby 3.4. Reproduce with:
61
60
 
62
- - **Constant Time**: Execution time stays around 100ms regardless of project size
63
- - **Parallel Processing**: Automatic scaling with available CPU cores
64
- - **High Throughput**: Up to 1,500+ files per second on large projects
65
- - **Low Overhead**: Minimal startup time and memory usage
66
-
67
- **Test Environment:**
68
- - CPU: Apple Silicon (arm64)
69
- - Ruby: 3.4.8
70
- - Average of 5 runs per test
61
+ ```bash
62
+ bundle exec ruby scripts/bench_format.rb
63
+ ```
71
64
 
72
- *Built with Rust for optimal performance and memory efficiency.*
65
+ A cold CLI invocation (`rfmt --check FILE`) takes roughly 0.1-0.25 s of wall-clock time; that is Ruby VM startup, not formatting.
73
66
 
74
- For detailed performance comparisons and benchmarks, see [Performance Benchmarks](docs/benchmark.md).
67
+ For more detail and a historical comparison against RuboCop, see [Performance Benchmarks](docs/benchmark.md).
75
68
 
76
69
  ## Installation
77
70
 
78
71
  ### Requirements
79
72
 
80
- - Ruby 3.0 or higher
73
+ - Ruby 3.3 or higher
81
74
  - Rust 1.70 or higher (for building from source)
82
75
 
83
76
  ### From RubyGems
@@ -379,18 +372,29 @@ gem install rfmt
379
372
  rfmt-lsp
380
373
  ```
381
374
 
382
- Example Helix configuration:
375
+ Example Neovim configuration (with `nvim-lspconfig`):
383
376
 
384
- ```toml
385
- [language-server.rfmt]
386
- command = "rfmt-lsp"
377
+ ```lua
378
+ local configs = require("lspconfig.configs")
379
+ local lspconfig = require("lspconfig")
380
+
381
+ if not configs.rfmt then
382
+ configs.rfmt = {
383
+ default_config = {
384
+ cmd = { "rfmt-lsp" },
385
+ filetypes = { "ruby" },
386
+ root_dir = lspconfig.util.root_pattern(".rfmt.yml", ".git"),
387
+ single_file_support = true,
388
+ },
389
+ }
390
+ end
387
391
 
388
- [[language]]
389
- name = "ruby"
390
- language-servers = ["rfmt"]
391
- auto-format = true
392
+ lspconfig.rfmt.setup({})
392
393
  ```
393
394
 
395
+ Helix, Emacs, and Zed configurations are covered in the
396
+ [Editor Integration Guide](docs/editors.md).
397
+
394
398
  ### VSCode (Quick Start)
395
399
 
396
400
  1. Install [Ruby LSP extension](https://marketplace.visualstudio.com/items?itemName=Shopify.ruby-lsp)
data/ext/rfmt/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rfmt"
3
- version = "1.7.0"
3
+ version = "2.0.0-beta1"
4
4
  edition = "2021"
5
5
  authors = ["fujitani sora <fujitanisora0414@gmail.com>"]
6
6
  license = "MIT"
@@ -8,7 +8,11 @@ publish = false
8
8
 
9
9
  [lib]
10
10
  name = "rfmt"
11
- crate-type = ["cdylib"]
11
+ # rlib in addition to cdylib so integration tests (tests/) can link the crate
12
+ crate-type = ["cdylib", "rlib"]
13
+ # The doc examples are illustrative and were never compiled while the crate
14
+ # was cdylib-only; keep them out of the test suite
15
+ doctest = false
12
16
 
13
17
  [dependencies]
14
18
  # Ruby FFI
@@ -19,44 +23,21 @@ rb-sys = "0.9.124"
19
23
  serde = { version = "1.0", features = ["derive"] }
20
24
  serde_json = "1.0"
21
25
  serde_yaml = "0.9"
22
- rmp-serde = "1.3"
23
26
 
24
27
  # Error handling
25
28
  thiserror = "1.0"
26
- anyhow = "1.0"
27
-
28
- # Parallel processing
29
- rayon = "1.8"
30
-
31
- # Caching
32
- lru = "0.12"
33
29
 
34
30
  # Logging
35
- log = "0.4"
36
- env_logger = "0.11"
31
+ # "std" was previously enabled transitively via env_logger; set_boxed_logger needs it
32
+ log = { version = "0.4", features = ["std"] }
37
33
 
38
34
  # Configuration
39
- config = "0.14"
40
- toml = "0.8"
41
35
  dirs = "5.0"
42
36
  globset = "0.4"
43
-
44
- # Security and resource management
45
- num_cpus = "1.16"
46
-
47
- # CLI (optional)
48
- clap = { version = "4.4", features = ["derive"], optional = true }
37
+ ruby-prism = "1.9"
49
38
 
50
39
  [dev-dependencies]
51
- # Testing
52
- proptest = "1.4"
53
- insta = "1.34"
54
- criterion = "0.5"
55
40
  tempfile = "3.8"
56
41
 
57
- [features]
58
- default = ["cli"]
59
- cli = ["clap"]
60
-
61
42
  # Note: Benchmarks are included as part of the test suite due to Ruby FFI linking constraints.
62
43
  # Run performance tests with: cargo test --release perf_
@@ -391,6 +391,8 @@ impl std::str::FromStr for NodeType {
391
391
 
392
392
  impl NodeType {
393
393
  /// Parse a node type from a string (convenience wrapper for `FromStr`)
394
+ // Infallible unlike the trait method; flagged only since the module went pub
395
+ #[allow(clippy::should_implement_trait)]
394
396
  pub fn from_str(s: &str) -> Self {
395
397
  s.parse().unwrap()
396
398
  }
@@ -1,4 +1,7 @@
1
1
  use serde::{Deserialize, Serialize};
2
+ use std::path::{Path, PathBuf};
3
+ use std::sync::Mutex;
4
+ use std::time::SystemTime;
2
5
 
3
6
  /// Complete configuration structure matching .rfmt.yml format
4
7
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -98,43 +101,114 @@ pub enum TrailingComma {
98
101
  Multiline,
99
102
  }
100
103
 
104
+ /// Search order within each directory: rfmt.yml, rfmt.yaml, .rfmt.yml, .rfmt.yaml
105
+ const CONFIG_FILE_NAMES: [&str; 4] = ["rfmt.yml", "rfmt.yaml", ".rfmt.yml", ".rfmt.yaml"];
106
+
107
+ /// Discovery result cached per process so repeated format calls (CLI batch,
108
+ /// long-lived LSP) skip the cwd-to-root-to-home filesystem walk.
109
+ struct DiscoveryCache {
110
+ cwd: PathBuf,
111
+ /// Discovered file and its mtime; `None` when nothing was found.
112
+ found: Option<(PathBuf, SystemTime)>,
113
+ config: Config,
114
+ }
115
+
116
+ static DISCOVERY_CACHE: Mutex<Option<DiscoveryCache>> = Mutex::new(None);
117
+
118
+ /// Explicit-path loads cached separately, keyed by canonical path + mtime,
119
+ /// so a CLI batch does not re-parse the same YAML once per file.
120
+ static EXPLICIT_CACHE: Mutex<Option<(PathBuf, SystemTime, Config)>> = Mutex::new(None);
121
+
101
122
  impl Config {
102
- /// Discover configuration file in current directory or parent directories
103
- /// Searches in order: rfmt.yml, rfmt.yaml, .rfmt.yml, .rfmt.yaml
104
- pub fn discover() -> crate::error::Result<Self> {
105
- let config_files = ["rfmt.yml", "rfmt.yaml", ".rfmt.yml", ".rfmt.yaml"];
106
-
107
- // Try current directory and parent directories
108
- if let Ok(mut current_dir) = std::env::current_dir() {
109
- loop {
110
- for filename in &config_files {
111
- let config_path = current_dir.join(filename);
112
- if config_path.exists() {
113
- log::info!("Found config file: {:?}", config_path);
114
- return Self::load_file(&config_path);
115
- }
116
- }
117
-
118
- // Move to parent directory
119
- if !current_dir.pop() {
120
- break;
121
- }
123
+ /// Resolve the effective configuration.
124
+ ///
125
+ /// Error handling differs by intent: an explicit path was asked for by
126
+ /// name, so load failures must surface loudly; discovery merely stumbles
127
+ /// on files, so a broken discovered file logs a warning and falls back
128
+ /// to defaults (an LSP mid-edit of .rfmt.yml must not break formatting).
129
+ pub fn resolve(explicit_path: Option<&Path>) -> crate::error::Result<Self> {
130
+ match explicit_path {
131
+ Some(path) => Self::load_explicit_cached(path),
132
+ None => {
133
+ let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
134
+ Ok(Self::discover_cached_from(&cwd))
135
+ }
136
+ }
137
+ }
138
+
139
+ fn load_explicit_cached(path: &Path) -> crate::error::Result<Self> {
140
+ // Canonicalize so a relative path is not confused across cwd changes.
141
+ let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
142
+ // mtime read before the load: a racing write can only make the cache
143
+ // entry look older than the content, forcing a reload, never staleness.
144
+ let mtime = file_mtime(&key);
145
+
146
+ let mut guard = EXPLICIT_CACHE
147
+ .lock()
148
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
149
+
150
+ if let (Some((cached_path, cached_mtime, config)), Some(mtime)) = (guard.as_ref(), mtime) {
151
+ if *cached_path == key && *cached_mtime == mtime {
152
+ return Ok(config.clone());
153
+ }
154
+ }
155
+
156
+ let config = Self::load_file(path)?;
157
+ if let Some(mtime) = mtime {
158
+ *guard = Some((key, mtime, config.clone()));
159
+ }
160
+ Ok(config)
161
+ }
162
+
163
+ fn discover_cached_from(cwd: &Path) -> Self {
164
+ let mut guard = DISCOVERY_CACHE
165
+ .lock()
166
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
167
+
168
+ if let Some(cache) = guard.as_ref() {
169
+ if cache.cwd == cwd && cache_is_fresh(cwd, cache) {
170
+ return cache.config.clone();
122
171
  }
123
172
  }
124
173
 
125
- // Try home directory
126
- if let Some(home_dir) = dirs::home_dir() {
127
- for filename in &config_files {
128
- let config_path = home_dir.join(filename);
129
- if config_path.exists() {
130
- log::debug!("Found config file in home: {:?}", config_path);
131
- return Self::load_file(&config_path);
132
- }
174
+ let (config, found) = Self::discover_from(cwd);
175
+ *guard = Some(DiscoveryCache {
176
+ cwd: cwd.to_path_buf(),
177
+ found,
178
+ config: config.clone(),
179
+ });
180
+ config
181
+ }
182
+
183
+ /// Walk `start` up to the root, then the home directory.
184
+ fn find_config_file(start: &Path) -> Option<PathBuf> {
185
+ let mut current_dir = start.to_path_buf();
186
+ loop {
187
+ if let Some(path) = first_candidate_in(&current_dir) {
188
+ return Some(path);
189
+ }
190
+ if !current_dir.pop() {
191
+ break;
133
192
  }
134
193
  }
135
194
 
136
- log::info!("No config file found, using defaults");
137
- Ok(Config::default())
195
+ dirs::home_dir().and_then(|home| first_candidate_in(&home))
196
+ }
197
+
198
+ fn discover_from(start: &Path) -> (Self, Option<(PathBuf, SystemTime)>) {
199
+ let Some(path) = Self::find_config_file(start) else {
200
+ log::info!("No config file found, using defaults");
201
+ return (Config::default(), None);
202
+ };
203
+
204
+ // A broken file is still cached with its mtime so fixing it triggers a reload.
205
+ let config = Self::load_file(&path).unwrap_or_else(|e| {
206
+ log::warn!("Ignoring config file {:?}: {}", path, e);
207
+ Config::default()
208
+ });
209
+ let mtime = file_mtime(&path).unwrap_or(SystemTime::UNIX_EPOCH);
210
+ log::info!("Found config file: {:?}", path);
211
+ (config, Some((path, mtime)))
138
212
  }
139
213
 
140
214
  /// Load configuration from a YAML file
@@ -226,6 +300,35 @@ impl Config {
226
300
  }
227
301
  }
228
302
 
303
+ fn first_candidate_in(dir: &Path) -> Option<PathBuf> {
304
+ CONFIG_FILE_NAMES
305
+ .iter()
306
+ .map(|name| dir.join(name))
307
+ .find(|path| path.exists())
308
+ }
309
+
310
+ fn file_mtime(path: &Path) -> Option<SystemTime> {
311
+ std::fs::metadata(path).and_then(|m| m.modified()).ok()
312
+ }
313
+
314
+ /// Cheap per-call re-validation replacing the full walk: stat the cwd
315
+ /// candidates (catches a config newly created where formatting runs) and the
316
+ /// discovered file's mtime (catches edits; a vanished file forces a re-walk).
317
+ /// A config newly created outside cwd (parent directory or home) is not
318
+ /// detected until something else invalidates the cache.
319
+ fn cache_is_fresh(cwd: &Path, cache: &DiscoveryCache) -> bool {
320
+ let cwd_candidate = first_candidate_in(cwd);
321
+ match &cache.found {
322
+ Some((path, mtime)) => {
323
+ if cwd_candidate.is_some_and(|candidate| candidate != *path) {
324
+ return false;
325
+ }
326
+ file_mtime(path) == Some(*mtime)
327
+ }
328
+ None => cwd_candidate.is_none(),
329
+ }
330
+ }
331
+
229
332
  impl Default for Config {
230
333
  fn default() -> Self {
231
334
  Self {
@@ -417,6 +520,140 @@ formatting:
417
520
  }
418
521
  }
419
522
 
523
+ // The discovery cache is process-global; serialize the tests that touch it.
524
+ static CACHE_TEST_LOCK: Mutex<()> = Mutex::new(());
525
+
526
+ fn write_indent_config(path: &Path, indent_width: usize) {
527
+ std::fs::write(
528
+ path,
529
+ format!("formatting:\n indent_width: {indent_width}\n"),
530
+ )
531
+ .unwrap();
532
+ }
533
+
534
+ #[test]
535
+ fn test_resolve_explicit_missing_path_errors_loudly() {
536
+ let result = Config::resolve(Some(Path::new("/nonexistent/rfmt.yml")));
537
+ assert!(result.is_err());
538
+ }
539
+
540
+ #[test]
541
+ fn test_resolve_explicit_broken_file_errors_loudly() {
542
+ let mut file = NamedTempFile::new().unwrap();
543
+ file.write_all(b"formatting:\n line_length: not_a_number\n")
544
+ .unwrap();
545
+ file.flush().unwrap();
546
+
547
+ assert!(Config::resolve(Some(file.path())).is_err());
548
+ }
549
+
550
+ #[test]
551
+ fn test_resolve_explicit_path_cached_and_reloaded_on_change() {
552
+ let _lock = CACHE_TEST_LOCK.lock().unwrap();
553
+ let dir = tempfile::tempdir().unwrap();
554
+ let path = dir.path().join("custom.yml");
555
+
556
+ write_indent_config(&path, 4);
557
+ assert_eq!(
558
+ Config::resolve(Some(&path))
559
+ .unwrap()
560
+ .formatting
561
+ .indent_width,
562
+ 4
563
+ );
564
+
565
+ std::thread::sleep(std::time::Duration::from_millis(20));
566
+ write_indent_config(&path, 3);
567
+ assert_eq!(
568
+ Config::resolve(Some(&path))
569
+ .unwrap()
570
+ .formatting
571
+ .indent_width,
572
+ 3
573
+ );
574
+ }
575
+
576
+ #[test]
577
+ fn test_discovered_broken_file_falls_back_to_defaults() {
578
+ let _lock = CACHE_TEST_LOCK.lock().unwrap();
579
+ let dir = tempfile::tempdir().unwrap();
580
+ std::fs::write(
581
+ dir.path().join("rfmt.yml"),
582
+ "formatting:\n indent_width: 99\n",
583
+ )
584
+ .unwrap();
585
+
586
+ // Unlike an explicit path, discovery swallows load errors.
587
+ let config = Config::discover_cached_from(dir.path());
588
+ assert_eq!(config.formatting.indent_width, 2);
589
+ }
590
+
591
+ #[test]
592
+ fn test_discovery_cache_reloads_on_mtime_change() {
593
+ let _lock = CACHE_TEST_LOCK.lock().unwrap();
594
+ let dir = tempfile::tempdir().unwrap();
595
+ let path = dir.path().join("rfmt.yml");
596
+
597
+ write_indent_config(&path, 4);
598
+ assert_eq!(
599
+ Config::discover_cached_from(dir.path())
600
+ .formatting
601
+ .indent_width,
602
+ 4
603
+ );
604
+
605
+ std::thread::sleep(std::time::Duration::from_millis(20));
606
+ write_indent_config(&path, 3);
607
+ assert_eq!(
608
+ Config::discover_cached_from(dir.path())
609
+ .formatting
610
+ .indent_width,
611
+ 3
612
+ );
613
+ }
614
+
615
+ #[test]
616
+ fn test_discovery_cache_missing_file_falls_back_to_walk() {
617
+ let _lock = CACHE_TEST_LOCK.lock().unwrap();
618
+ let dir = tempfile::tempdir().unwrap();
619
+ let sub = dir.path().join("sub");
620
+ std::fs::create_dir(&sub).unwrap();
621
+ let path = dir.path().join("rfmt.yml");
622
+
623
+ write_indent_config(&path, 4);
624
+ assert_eq!(
625
+ Config::discover_cached_from(&sub).formatting.indent_width,
626
+ 4
627
+ );
628
+
629
+ std::fs::remove_file(&path).unwrap();
630
+ assert_eq!(
631
+ Config::discover_cached_from(&sub).formatting.indent_width,
632
+ 2
633
+ );
634
+ }
635
+
636
+ #[test]
637
+ fn test_discovery_nothing_found_cached_then_new_config_in_cwd() {
638
+ let _lock = CACHE_TEST_LOCK.lock().unwrap();
639
+ let dir = tempfile::tempdir().unwrap();
640
+
641
+ assert_eq!(
642
+ Config::discover_cached_from(dir.path())
643
+ .formatting
644
+ .indent_width,
645
+ 2
646
+ );
647
+
648
+ write_indent_config(&dir.path().join("rfmt.yml"), 5);
649
+ assert_eq!(
650
+ Config::discover_cached_from(dir.path())
651
+ .formatting
652
+ .indent_width,
653
+ 5
654
+ );
655
+ }
656
+
420
657
  #[test]
421
658
  fn test_partial_config_uses_defaults() {
422
659
  let yaml = r#"
@@ -8,6 +8,18 @@ pub enum RfmtError {
8
8
  #[error("Prism integration error: {0}")]
9
9
  PrismError(String),
10
10
 
11
+ // Message-only kinds: lib/rfmt.rb rewrites these into its public
12
+ // exception classes by their [Rfmt::...] prefix, so Display must not
13
+ // add any wrapper text around the message.
14
+ #[error("{0}")]
15
+ ParseError(String),
16
+
17
+ #[error("{0}")]
18
+ ValidationError(String),
19
+
20
+ #[error("{message}")]
21
+ ConfigError { message: String },
22
+
11
23
  #[error("Format error: {0}")]
12
24
  FormatError(String),
13
25
 
@@ -16,9 +28,6 @@ pub enum RfmtError {
16
28
  feature: String,
17
29
  explanation: String,
18
30
  },
19
-
20
- #[error("Configuration error: {message}")]
21
- ConfigError { message: String },
22
31
  }
23
32
 
24
33
  // Implement From for std::fmt::Error
@@ -33,6 +42,8 @@ impl RfmtError {
33
42
  pub fn to_magnus_error(&self, ruby: &Ruby) -> MagnusError {
34
43
  let exception_class = match self {
35
44
  RfmtError::PrismError(_) => "PrismError",
45
+ RfmtError::ParseError(_) => "ParseError",
46
+ RfmtError::ValidationError(_) => "ValidationError",
36
47
  RfmtError::FormatError(_) => "FormatError",
37
48
  RfmtError::UnsupportedFeature { .. } => "UnsupportedFeature",
38
49
  RfmtError::ConfigError { .. } => "ConfigError",
@@ -23,8 +23,8 @@ use super::rule::format_remaining_comments;
23
23
  pub struct Formatter {
24
24
  /// Configuration for formatting
25
25
  config: Config,
26
- /// Registry of formatting rules
27
- registry: RuleRegistry,
26
+ /// Registry of formatting rules (shared: rules are stateless)
27
+ registry: &'static RuleRegistry,
28
28
  }
29
29
 
30
30
  impl Formatter {
@@ -32,15 +32,10 @@ impl Formatter {
32
32
  pub fn new(config: Config) -> Self {
33
33
  Self {
34
34
  config,
35
- registry: RuleRegistry::default_registry(),
35
+ registry: RuleRegistry::shared(),
36
36
  }
37
37
  }
38
38
 
39
- /// Creates a new formatter with a custom registry.
40
- pub fn with_registry(config: Config, registry: RuleRegistry) -> Self {
41
- Self { config, registry }
42
- }
43
-
44
39
  /// Formats Ruby source code.
45
40
  ///
46
41
  /// # Arguments
@@ -71,7 +66,23 @@ impl Formatter {
71
66
 
72
67
  // 5. Print to string
73
68
  let mut printer = Printer::new(&self.config);
74
- let result = printer.print(&final_doc);
69
+ let mut result = printer.print(&final_doc);
70
+
71
+ // 6. Re-append the `__END__` data section, which the AST excludes.
72
+ // Appended after printing (and its trailing-whitespace strip) so the
73
+ // data content survives byte-for-byte.
74
+ if let Some(data) = ast
75
+ .metadata
76
+ .get("data_start_offset")
77
+ .and_then(|offset| offset.parse::<usize>().ok())
78
+ .and_then(|offset| source.get(offset..))
79
+ {
80
+ if !result.is_empty() {
81
+ result.truncate(result.trim_end_matches('\n').len());
82
+ result.push('\n');
83
+ }
84
+ result.push_str(data);
85
+ }
75
86
 
76
87
  Ok(result)
77
88
  }
@@ -84,14 +95,14 @@ impl Formatter {
84
95
  _ => {
85
96
  // Use the rule registry for specific node types
86
97
  let rule = self.registry.get_rule(&node.node_type);
87
- rule.format(node, ctx, &self.registry)
98
+ rule.format(node, ctx, self.registry)
88
99
  }
89
100
  }
90
101
  }
91
102
 
92
103
  /// Returns a reference to the registry for recursive formatting.
93
104
  pub fn registry(&self) -> &RuleRegistry {
94
- &self.registry
105
+ self.registry
95
106
  }
96
107
 
97
108
  /// Formats the program node (root).
@@ -6,6 +6,7 @@
6
6
  use crate::ast::NodeType;
7
7
  use std::borrow::Cow;
8
8
  use std::collections::HashMap;
9
+ use std::sync::OnceLock;
9
10
 
10
11
  use super::rule::{BoxedRule, FormatRule};
11
12
  use super::rules::{
@@ -104,6 +105,13 @@ impl RuleRegistry {
104
105
  .unwrap_or(self.fallback.as_ref())
105
106
  }
106
107
 
108
+ /// Rules are stateless, so one registry serves every format call;
109
+ /// building ~23 boxed rules per call was pure waste.
110
+ pub fn shared() -> &'static Self {
111
+ static SHARED: OnceLock<RuleRegistry> = OnceLock::new();
112
+ SHARED.get_or_init(Self::default_registry)
113
+ }
114
+
107
115
  pub fn default_registry() -> Self {
108
116
  Self::new()
109
117
  .add(NodeType::StatementsNode, StatementsRule)