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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/Cargo.lock +90 -1225
- data/Cargo.toml +6 -0
- data/README.md +32 -28
- data/ext/rfmt/Cargo.toml +9 -28
- data/ext/rfmt/src/ast/mod.rs +2 -0
- data/ext/rfmt/src/config/mod.rs +267 -30
- data/ext/rfmt/src/error/mod.rs +14 -3
- data/ext/rfmt/src/format/formatter.rs +22 -11
- data/ext/rfmt/src/format/registry.rs +8 -0
- data/ext/rfmt/src/format/rule.rs +208 -136
- data/ext/rfmt/src/format/rules/call.rs +14 -22
- data/ext/rfmt/src/format/rules/fallback.rs +4 -11
- data/ext/rfmt/src/format/rules/if_unless.rs +25 -3
- data/ext/rfmt/src/format/rules/loops.rs +3 -1
- data/ext/rfmt/src/format/rules/variable_write.rs +16 -9
- data/ext/rfmt/src/lib.rs +45 -12
- data/ext/rfmt/src/parser/mod.rs +2 -0
- data/ext/rfmt/src/parser/native_adapter.rs +2735 -0
- data/ext/rfmt/src/parser/prism_adapter.rs +5 -0
- data/ext/rfmt/src/validation.rs +69 -0
- data/ext/rfmt/tests/fixtures/parity/comments_mixed.rb +9 -0
- data/ext/rfmt/tests/fixtures/parity/constructs.rb +50 -0
- data/ext/rfmt/tests/fixtures/parity/embdoc.rb +12 -0
- data/ext/rfmt/tests/fixtures/parity/heredoc_assign.rb +15 -0
- data/ext/rfmt/tests/fixtures/parity/heredoc_call_args.rb +17 -0
- data/ext/rfmt/tests/fixtures/parity/metadata_classes.rb +30 -0
- data/ext/rfmt/tests/fixtures/parity/metadata_conditionals.rb +14 -0
- data/ext/rfmt/tests/fixtures/parity/metadata_defs.rb +33 -0
- data/ext/rfmt/tests/fixtures/parity/multibyte.rb +14 -0
- data/ext/rfmt/tests/fixtures/parity/numeric.rb +6 -0
- data/ext/rfmt/tests/fixtures/parity/plain.rb +31 -0
- data/ext/rfmt/tests/native_parity.rs +245 -0
- data/ext/rfmt/tests/ruby_prism_smoke.rs +66 -0
- data/lib/rfmt/cli.rb +37 -7
- data/lib/rfmt/configuration.rb +2 -20
- data/lib/rfmt/version.rb +1 -1
- data/lib/rfmt.rb +47 -19
- metadata +17 -18
- data/lib/rfmt/prism_bridge.rb +0 -529
- 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**:
|
|
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
|
-
|
|
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
|
-
|
|
|
55
|
-
|
|
56
|
-
|
|
|
57
|
-
|
|
|
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
|
-
|
|
59
|
+
Measured with `scripts/bench_format.rb` over rfmt's own `lib/` corpus on arm64 macOS, Ruby 3.4. Reproduce with:
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
375
|
+
Example Neovim configuration (with `nvim-lspconfig`):
|
|
383
376
|
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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_
|
data/ext/rfmt/src/ast/mod.rs
CHANGED
|
@@ -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
|
}
|
data/ext/rfmt/src/config/mod.rs
CHANGED
|
@@ -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
|
-
///
|
|
103
|
-
///
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(¤t_dir) {
|
|
188
|
+
return Some(path);
|
|
189
|
+
}
|
|
190
|
+
if !current_dir.pop() {
|
|
191
|
+
break;
|
|
133
192
|
}
|
|
134
193
|
}
|
|
135
194
|
|
|
136
|
-
|
|
137
|
-
|
|
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#"
|
data/ext/rfmt/src/error/mod.rs
CHANGED
|
@@ -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::
|
|
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,
|
|
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
|
-
|
|
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)
|