rubydex 0.3.0 → 0.4.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 +4 -4
- data/README.md +45 -7
- data/THIRD_PARTY_LICENSES.html +238 -2
- data/exe/rdx +2 -149
- data/ext/rubydex/config.c +140 -0
- data/ext/rubydex/config.h +16 -0
- data/ext/rubydex/diagnostic.c +75 -1
- data/ext/rubydex/diagnostic.h +2 -0
- data/ext/rubydex/graph.c +13 -45
- data/ext/rubydex/graph.h +6 -0
- data/ext/rubydex/query.c +398 -16
- data/ext/rubydex/rubydex.c +2 -0
- data/ext/rubydex/utils.c +11 -4
- data/lib/ruby_lsp/rubydex/addon.rb +211 -0
- data/lib/rubydex/cli/command/console.rb +55 -0
- data/lib/rubydex/cli/command/lint/explain.rb +74 -0
- data/lib/rubydex/cli/command/lint.rb +202 -0
- data/lib/rubydex/cli/command/mcp.rb +30 -0
- data/lib/rubydex/cli/command/query.rb +70 -0
- data/lib/rubydex/cli/command/skill.rb +69 -0
- data/lib/rubydex/cli/command.rb +168 -0
- data/lib/rubydex/cli.rb +93 -0
- data/lib/rubydex/config.rb +59 -0
- data/lib/rubydex/diagnostic.rb +12 -3
- data/lib/rubydex/errors.rb +42 -1
- data/lib/rubydex/graph.rb +10 -3
- data/lib/rubydex/linter/custom_rule.rb +97 -0
- data/lib/rubydex/linter/helpers/path_helpers.rb +78 -0
- data/lib/rubydex/linter/helpers/source_access_helpers.rb +31 -0
- data/lib/rubydex/linter/rule_loader.rb +36 -0
- data/lib/rubydex/linter/rule_test_case.rb +343 -0
- data/lib/rubydex/linter/runner.rb +56 -0
- data/lib/rubydex/linter.rb +19 -0
- data/lib/rubydex/location.rb +3 -0
- data/lib/rubydex/mcp_server.rb +1 -2
- data/lib/rubydex/related_information.rb +17 -0
- data/lib/rubydex/rule.rb +33 -0
- data/lib/rubydex/severity.rb +70 -0
- data/lib/rubydex/skill.rb +88 -0
- data/lib/rubydex/skill_registry.rb +62 -0
- data/lib/rubydex/version.rb +1 -1
- data/lib/rubydex.rb +6 -0
- data/lib/rubydex_linter/rules/rule_structure.rb +125 -0
- data/rbi/rubydex.rbi +558 -17
- data/rust/Cargo.lock +2 -2
- data/rust/rubydex/Cargo.toml +1 -1
- data/rust/rubydex/benches/graph_memory.rs +3 -5
- data/rust/rubydex/src/config.rs +538 -157
- data/rust/rubydex/src/diagnostic.rs +66 -40
- data/rust/rubydex/src/errors.rs +0 -1
- data/rust/rubydex/src/indexing/local_graph.rs +6 -5
- data/rust/rubydex/src/indexing/rbs_indexer.rs +270 -6
- data/rust/rubydex/src/indexing/ruby_indexer.rs +10 -12
- data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +134 -81
- data/rust/rubydex/src/lib.rs +1 -0
- data/rust/rubydex/src/listing.rs +26 -1
- data/rust/rubydex/src/main.rs +7 -4
- data/rust/rubydex/src/model/declaration.rs +302 -219
- data/rust/rubydex/src/model/graph.rs +27 -40
- data/rust/rubydex/src/model/name.rs +58 -17
- data/rust/rubydex/src/operation/ruby_builder.rs +30 -45
- data/rust/rubydex/src/path_helpers.rs +77 -0
- data/rust/rubydex/src/query/cypher/schema.rs +63 -0
- data/rust/rubydex/src/query/cypher/tests.rs +26 -1
- data/rust/rubydex/src/query/cypher.rs +8 -11
- data/rust/rubydex/src/query.rs +123 -43
- data/rust/rubydex/src/resolution.rs +139 -187
- data/rust/rubydex/src/resolution_tests.rs +247 -19
- data/rust/rubydex/src/test_utils/context.rs +2 -1
- data/rust/rubydex/src/test_utils/graph_test.rs +26 -12
- data/rust/rubydex/src/test_utils/local_graph_test.rs +19 -0
- data/rust/rubydex/tests/cli.rs +4 -4
- data/rust/rubydex-sys/src/config_api.rs +205 -0
- data/rust/rubydex-sys/src/cypher_api.rs +791 -0
- data/rust/rubydex-sys/src/diagnostic_api.rs +77 -8
- data/rust/rubydex-sys/src/graph_api.rs +13 -194
- data/rust/rubydex-sys/src/lib.rs +2 -0
- data/rust/rubydex-sys/src/name_api.rs +2 -6
- data/rust/rubydex-sys/src/utils.rs +37 -0
- data/skills/send-private-method/SKILL.md +133 -0
- metadata +31 -2
data/rust/rubydex/src/config.rs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
use crate::assert_mem_size;
|
|
2
|
+
use crate::diagnostic::Severity;
|
|
2
3
|
use crate::errors::Errors;
|
|
3
|
-
use
|
|
4
|
+
use crate::path_helpers;
|
|
4
5
|
use std::collections::HashSet;
|
|
5
6
|
use std::fs;
|
|
6
7
|
use std::io::ErrorKind;
|
|
7
|
-
use std::path::{Path, PathBuf};
|
|
8
|
+
use std::path::{MAIN_SEPARATOR, Path, PathBuf};
|
|
9
|
+
use toml::{Table, Value};
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
const DEFAULT_EXCLUDED_DIRECTORIES: &[&str] = &[
|
|
10
12
|
".bundle",
|
|
11
13
|
".claude",
|
|
12
14
|
".git",
|
|
@@ -18,106 +20,227 @@ pub const DEFAULT_EXCLUDED_DIRECTORIES: &[&str] = &[
|
|
|
18
20
|
"tmp",
|
|
19
21
|
];
|
|
20
22
|
|
|
21
|
-
///
|
|
22
|
-
#[derive(Debug,
|
|
23
|
-
struct
|
|
24
|
-
/// Patterns to exclude from file discovery during indexing.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/// Project configuration
|
|
30
|
-
#[derive(Debug)]
|
|
31
|
-
pub struct Config {
|
|
32
|
-
/// Path to the workspace being analyzed
|
|
33
|
-
workspace_path: Box<Path>,
|
|
34
|
-
/// Patterns to exclude from file discovery during indexing.
|
|
23
|
+
/// The graph's settings, read from the `[graph]` section of the configuration file
|
|
24
|
+
#[derive(Debug, Clone)]
|
|
25
|
+
pub struct GraphSettings {
|
|
26
|
+
/// Patterns to exclude from file discovery during indexing, on top of the built-in defaults. Stored as written and
|
|
27
|
+
/// only joined with the workspace path when read, so that a pattern cannot outlive the configuration it came from
|
|
28
|
+
/// and be silently re-rooted under another workspace.
|
|
35
29
|
excluded_patterns: HashSet<Box<str>>,
|
|
36
30
|
}
|
|
37
|
-
assert_mem_size!(Config, 64);
|
|
38
31
|
|
|
39
|
-
impl Default for
|
|
32
|
+
impl Default for GraphSettings {
|
|
33
|
+
/// The settings of a workspace that configures nothing: the built-in exclusions and no more
|
|
40
34
|
fn default() -> Self {
|
|
41
|
-
Self
|
|
35
|
+
Self {
|
|
36
|
+
excluded_patterns: DEFAULT_EXCLUDED_DIRECTORIES.iter().map(|&dir| Box::from(dir)).collect(),
|
|
37
|
+
}
|
|
42
38
|
}
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
impl
|
|
46
|
-
///
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
|
|
41
|
+
impl GraphSettings {
|
|
42
|
+
/// Parses the `[graph]` section on top of the default exclusions
|
|
43
|
+
fn parse(mut table: Table) -> Result<Self, String> {
|
|
44
|
+
let exclude = match table.remove("exclude") {
|
|
45
|
+
None => Vec::new(),
|
|
46
|
+
Some(value) => value
|
|
47
|
+
.try_into::<Vec<Box<str>>>()
|
|
48
|
+
.map_err(|error| format!("invalid `graph.exclude` setting: {error}"))?,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if let Some(key) = table.keys().next() {
|
|
52
|
+
return Err(format!("unknown setting `graph.{key}`"));
|
|
54
53
|
}
|
|
54
|
+
|
|
55
|
+
let mut settings = Self::default();
|
|
56
|
+
settings.exclude_patterns(exclude);
|
|
57
|
+
Ok(settings)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Adds patterns to exclude from file discovery during indexing
|
|
61
|
+
fn exclude_patterns(&mut self, patterns: impl IntoIterator<Item = Box<str>>) {
|
|
62
|
+
self.excluded_patterns.extend(patterns);
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
/// Returns the
|
|
65
|
+
/// Returns the exclusion patterns as written, without resolving them against any workspace
|
|
66
|
+
fn excluded_patterns(&self) -> impl Iterator<Item = &str> {
|
|
67
|
+
self.excluded_patterns.iter().map(|pattern| &**pattern)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// The setting of a single linter rule, read from a `[linter.rules.RuleName]` table
|
|
72
|
+
#[derive(Debug, Clone)]
|
|
73
|
+
pub struct Rule {
|
|
74
|
+
name: Box<str>,
|
|
75
|
+
enabled: bool,
|
|
76
|
+
exclude_patterns: Box<[Box<str>]>,
|
|
77
|
+
severity: Option<Severity>,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
impl Rule {
|
|
58
81
|
#[must_use]
|
|
59
|
-
pub fn
|
|
60
|
-
&self.
|
|
82
|
+
pub fn name(&self) -> &str {
|
|
83
|
+
&self.name
|
|
61
84
|
}
|
|
62
85
|
|
|
63
|
-
|
|
64
|
-
pub fn
|
|
65
|
-
self.
|
|
86
|
+
#[must_use]
|
|
87
|
+
pub fn enabled(&self) -> bool {
|
|
88
|
+
self.enabled
|
|
66
89
|
}
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
self.excluded_patterns.extend(patterns);
|
|
91
|
+
#[must_use]
|
|
92
|
+
pub fn exclude_patterns(&self) -> &[Box<str>] {
|
|
93
|
+
&self.exclude_patterns
|
|
72
94
|
}
|
|
73
95
|
|
|
74
|
-
/// Returns the set of exclusion patterns resolved against the workspace path.
|
|
75
96
|
#[must_use]
|
|
76
|
-
pub fn
|
|
77
|
-
self.
|
|
78
|
-
.iter()
|
|
79
|
-
.map(|entry| {
|
|
80
|
-
self.workspace_path
|
|
81
|
-
.join(&**entry)
|
|
82
|
-
.to_string_lossy()
|
|
83
|
-
.into_owned()
|
|
84
|
-
.into_boxed_str()
|
|
85
|
-
})
|
|
86
|
-
.collect()
|
|
97
|
+
pub fn severity(&self) -> Option<&Severity> {
|
|
98
|
+
self.severity.as_ref()
|
|
87
99
|
}
|
|
88
100
|
|
|
89
|
-
///
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
/// Parses a single `[linter.rules.{name}]` table
|
|
102
|
+
fn parse(name: &str, value: Value) -> Result<Self, String> {
|
|
103
|
+
let Value::Table(mut table) = value else {
|
|
104
|
+
return Err(format!("invalid `linter.rules.{name}` setting: expected a table"));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
let enabled = match table.remove("enabled") {
|
|
108
|
+
Some(Value::Boolean(enabled)) => enabled,
|
|
109
|
+
Some(_) => {
|
|
110
|
+
return Err(format!(
|
|
111
|
+
"invalid `linter.rules.{name}.enabled` setting: expected a boolean"
|
|
112
|
+
));
|
|
113
|
+
}
|
|
114
|
+
None => true,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
let exclude_patterns = match table.remove("exclude") {
|
|
118
|
+
Some(value) => value
|
|
119
|
+
.try_into::<Vec<Box<str>>>()
|
|
120
|
+
.map_err(|error| format!("invalid `linter.rules.{name}.exclude` setting: {error}"))?
|
|
121
|
+
.into_boxed_slice(),
|
|
122
|
+
None => Box::default(),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
let severity = match table.remove("severity") {
|
|
126
|
+
Some(value) => Some(
|
|
127
|
+
value
|
|
128
|
+
.try_into::<Severity>()
|
|
129
|
+
.map_err(|error| format!("invalid `linter.rules.{name}.severity` setting: {error}"))?,
|
|
130
|
+
),
|
|
131
|
+
None => None,
|
|
132
|
+
};
|
|
99
133
|
|
|
100
|
-
|
|
101
|
-
Err(
|
|
102
|
-
other => other,
|
|
134
|
+
if let Some(key) = table.keys().next() {
|
|
135
|
+
return Err(format!("unknown setting `linter.rules.{name}.{key}`"));
|
|
103
136
|
}
|
|
137
|
+
|
|
138
|
+
Ok(Self {
|
|
139
|
+
name: Box::from(name),
|
|
140
|
+
enabled,
|
|
141
|
+
exclude_patterns,
|
|
142
|
+
severity,
|
|
143
|
+
})
|
|
104
144
|
}
|
|
145
|
+
}
|
|
105
146
|
|
|
106
|
-
|
|
147
|
+
/// The linter's settings, read from the `[linter]` section of the configuration file
|
|
148
|
+
#[derive(Debug, Clone, Default)]
|
|
149
|
+
pub struct LinterSettings {
|
|
150
|
+
rules: Box<[Rule]>,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
impl LinterSettings {
|
|
154
|
+
#[must_use]
|
|
155
|
+
pub fn rules(&self) -> &[Rule] {
|
|
156
|
+
&self.rules
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Parses the `[linter]` section
|
|
160
|
+
fn parse(mut table: Table) -> Result<Self, String> {
|
|
161
|
+
let rules = match table.remove("rules") {
|
|
162
|
+
None => Box::default(),
|
|
163
|
+
Some(Value::Table(rules)) => rules
|
|
164
|
+
.into_iter()
|
|
165
|
+
.map(|(name, value)| Rule::parse(&name, value))
|
|
166
|
+
.collect::<Result<_, _>>()?,
|
|
167
|
+
Some(_) => {
|
|
168
|
+
return Err(String::from(
|
|
169
|
+
"invalid `linter.rules` setting: expected a table of rules",
|
|
170
|
+
));
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if let Some(key) = table.keys().next() {
|
|
175
|
+
return Err(format!("unknown setting `linter.{key}`"));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
Ok(Self { rules })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// The configuration of a workspace, parsed from its `rubydex.toml` and shared by all built-in tools. It carries both
|
|
183
|
+
/// the settings that are global to every tool, such as the workspace being analyzed, and the typed settings of each
|
|
184
|
+
/// tool's own section (e.g. `[graph]`). Every section is parsed eagerly, so that all validation happens at load time and
|
|
185
|
+
/// unknown sections or settings are rejected. Load the file once and hand the configuration to each consumer, so that
|
|
186
|
+
/// none of them has to read it again.
|
|
187
|
+
#[derive(Debug, Clone)]
|
|
188
|
+
pub struct Config {
|
|
189
|
+
/// Root directory of the workspace being analyzed, which is where its configuration file lives. Global, and not
|
|
190
|
+
/// configurable through the file itself, since it is what says where that file is.
|
|
191
|
+
workspace_path: Box<Path>,
|
|
192
|
+
graph: GraphSettings,
|
|
193
|
+
linter: LinterSettings,
|
|
194
|
+
}
|
|
195
|
+
assert_mem_size!(Config, 80);
|
|
196
|
+
|
|
197
|
+
impl Default for Config {
|
|
198
|
+
/// The configuration of the current working directory, with the default settings of every section, which is what a
|
|
199
|
+
/// graph is configured by until one is loaded for it. Guessing a root here rather than leaving it empty is what
|
|
200
|
+
/// keeps `Graph::new` infallible, since patterns are resolved against it; every other configuration comes from
|
|
201
|
+
/// [`Config::load`]. Cannot be derived, as `Box<Path>` has no default.
|
|
202
|
+
fn default() -> Self {
|
|
203
|
+
Self {
|
|
204
|
+
workspace_path: std::env::current_dir()
|
|
205
|
+
.unwrap_or_else(|_| PathBuf::from("."))
|
|
206
|
+
.into_boxed_path(),
|
|
207
|
+
graph: GraphSettings::default(),
|
|
208
|
+
linter: LinterSettings::default(),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
impl Config {
|
|
214
|
+
/// Loads the configuration of the workspace rooted at `workspace_path`, which is where its `rubydex.toml` is
|
|
215
|
+
/// expected to be. The configuration file itself is optional: a workspace without one gets the default settings.
|
|
107
216
|
///
|
|
108
217
|
/// # Errors
|
|
109
218
|
///
|
|
110
|
-
/// Returns [`Errors::
|
|
111
|
-
/// be read or
|
|
112
|
-
pub fn
|
|
113
|
-
let
|
|
219
|
+
/// Returns [`Errors::ConfigError`] if `workspace_path` is not a directory, or if the configuration file exists but
|
|
220
|
+
/// cannot be read or is invalid.
|
|
221
|
+
pub fn load(workspace_path: &Path) -> Result<Self, Errors> {
|
|
222
|
+
let workspace_path = path_helpers::resolved(workspace_path).map_err(|error| {
|
|
223
|
+
Errors::ConfigError(format!(
|
|
224
|
+
"Failed to resolve workspace path `{}`: {error}",
|
|
225
|
+
workspace_path.display()
|
|
226
|
+
))
|
|
227
|
+
})?;
|
|
228
|
+
|
|
229
|
+
// Since loading a configuration is how a workspace is chosen, a root that cannot be analyzed has to be rejected
|
|
230
|
+
// here. Otherwise the mistake is only reported much later, by whatever first tries to walk it.
|
|
231
|
+
if !workspace_path.is_dir() {
|
|
232
|
+
return Err(Errors::ConfigError(format!(
|
|
233
|
+
"Workspace `{}` is not a directory",
|
|
234
|
+
workspace_path.display()
|
|
235
|
+
)));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let config_path = workspace_path.join("rubydex.toml");
|
|
239
|
+
|
|
240
|
+
let content = match fs::read_to_string(&config_path) {
|
|
114
241
|
Ok(content) => content,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"Config file `{}` does not exist",
|
|
118
|
-
config_path.display()
|
|
119
|
-
)));
|
|
120
|
-
}
|
|
242
|
+
// Configuring a workspace is optional, so a missing file is the same as an empty one
|
|
243
|
+
Err(error) if error.kind() == ErrorKind::NotFound => String::new(),
|
|
121
244
|
Err(error) => {
|
|
122
245
|
return Err(Errors::ConfigError(format!(
|
|
123
246
|
"Failed to read config file `{}`: {error}",
|
|
@@ -126,33 +249,114 @@ impl Config {
|
|
|
126
249
|
}
|
|
127
250
|
};
|
|
128
251
|
|
|
129
|
-
|
|
130
|
-
Errors::ConfigError(format!("Invalid config file `{}`: {error}", config_path.display()))
|
|
131
|
-
|
|
252
|
+
Self::parse(workspace_path, &content)
|
|
253
|
+
.map_err(|error| Errors::ConfigError(format!("Invalid config file `{}`: {error}", config_path.display())))
|
|
254
|
+
}
|
|
132
255
|
|
|
133
|
-
|
|
134
|
-
|
|
256
|
+
/// Returns the root directory of the workspace this configuration belongs to
|
|
257
|
+
#[must_use]
|
|
258
|
+
pub fn workspace_path(&self) -> &Path {
|
|
259
|
+
&self.workspace_path
|
|
135
260
|
}
|
|
136
261
|
|
|
137
|
-
///
|
|
138
|
-
|
|
139
|
-
|
|
262
|
+
/// Adds patterns to exclude from file discovery during indexing. Excluded directories will be skipped entirely
|
|
263
|
+
/// during directory traversal.
|
|
264
|
+
pub fn exclude_patterns(&mut self, patterns: impl IntoIterator<Item = Box<str>>) {
|
|
265
|
+
self.graph.exclude_patterns(patterns);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Returns the set of exclusion patterns resolved against the workspace path. Resolving is the one thing that needs
|
|
269
|
+
/// both halves of the configuration, which is why it lives here rather than on the settings of the section the
|
|
270
|
+
/// patterns come from.
|
|
271
|
+
#[must_use]
|
|
272
|
+
pub fn excluded_patterns(&self) -> HashSet<Box<str>> {
|
|
273
|
+
self.graph
|
|
274
|
+
.excluded_patterns()
|
|
275
|
+
.map(|pattern| {
|
|
276
|
+
// We must replace the separator on Windows for forward slash to use in glob patterns.
|
|
277
|
+
self.workspace_path
|
|
278
|
+
.join(pattern)
|
|
279
|
+
.to_string_lossy()
|
|
280
|
+
.replace(MAIN_SEPARATOR, "/")
|
|
281
|
+
.into_boxed_str()
|
|
282
|
+
})
|
|
283
|
+
.collect()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[must_use]
|
|
287
|
+
pub fn linter(&self) -> &LinterSettings {
|
|
288
|
+
&self.linter
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// Parses the content of the configuration file of the workspace rooted at `workspace_path` into the typed
|
|
292
|
+
/// settings of each section
|
|
293
|
+
fn parse(workspace_path: PathBuf, content: &str) -> Result<Self, String> {
|
|
294
|
+
let mut sections: Table = toml::from_str(content).map_err(|error| error.to_string())?;
|
|
295
|
+
|
|
296
|
+
// Every top-level entry must be a section table. A non-table entry is most likely a typo, and an array of
|
|
297
|
+
// tables comes from `[[section]]` syntax, which is a section shape mistake rather than an unknown setting.
|
|
298
|
+
if let Some((key, value)) = sections.iter().find(|(_, value)| !value.is_table()) {
|
|
299
|
+
if value
|
|
300
|
+
.as_array()
|
|
301
|
+
.is_some_and(|array| !array.is_empty() && array.iter().all(Value::is_table))
|
|
302
|
+
{
|
|
303
|
+
return Err(format!(
|
|
304
|
+
"section `{key}` must be a table; use `[{key}]` instead of `[[{key}]]`"
|
|
305
|
+
));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return Err(format!("unknown setting `{key}`"));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Non-table entries were rejected above, so every present section is always a table.
|
|
312
|
+
let graph = match sections.remove("graph") {
|
|
313
|
+
Some(Value::Table(table)) => GraphSettings::parse(table)?,
|
|
314
|
+
_ => GraphSettings::default(),
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
let linter = match sections.remove("linter") {
|
|
318
|
+
Some(Value::Table(table)) => LinterSettings::parse(table)?,
|
|
319
|
+
_ => LinterSettings::default(),
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// Every section must be backed by a typed settings struct, so any leftover section is unknown.
|
|
323
|
+
if let Some(key) = sections.keys().next() {
|
|
324
|
+
return Err(format!("unknown section `{key}`"));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
Ok(Self {
|
|
328
|
+
workspace_path: Box::from(workspace_path),
|
|
329
|
+
graph,
|
|
330
|
+
linter,
|
|
331
|
+
})
|
|
140
332
|
}
|
|
141
333
|
}
|
|
142
334
|
|
|
143
335
|
#[cfg(test)]
|
|
144
336
|
mod tests {
|
|
145
337
|
use super::*;
|
|
146
|
-
|
|
338
|
+
|
|
339
|
+
/// The pattern the exclusion `entry` resolves to under `workspace_path`, spelled the way exclusions are
|
|
340
|
+
fn exclusion(workspace_path: impl AsRef<Path>, entry: &str) -> String {
|
|
341
|
+
workspace_path
|
|
342
|
+
.as_ref()
|
|
343
|
+
.join(entry)
|
|
344
|
+
.to_string_lossy()
|
|
345
|
+
.replace(MAIN_SEPARATOR, "/")
|
|
346
|
+
}
|
|
147
347
|
|
|
148
348
|
fn workspace_exclusion(entry: &str) -> String {
|
|
149
|
-
|
|
349
|
+
exclusion("/workspace", entry)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/// Parses configuration content for an arbitrary workspace, for the tests that only care about the settings
|
|
353
|
+
fn parse(content: &str) -> Result<Config, String> {
|
|
354
|
+
Config::parse(PathBuf::from("/workspace"), content)
|
|
150
355
|
}
|
|
151
356
|
|
|
152
357
|
#[test]
|
|
153
358
|
fn excluded_patterns_resolves_patterns_against_the_workspace_path() {
|
|
154
|
-
let mut config =
|
|
155
|
-
config.set_workspace_path(PathBuf::from("/workspace"));
|
|
359
|
+
let mut config = parse("").expect("an empty config is valid");
|
|
156
360
|
config.exclude_patterns([
|
|
157
361
|
Box::from("vendor"),
|
|
158
362
|
Box::from("**/fixtures"),
|
|
@@ -174,122 +378,299 @@ mod tests {
|
|
|
174
378
|
}
|
|
175
379
|
|
|
176
380
|
#[test]
|
|
177
|
-
fn
|
|
178
|
-
|
|
381
|
+
fn excluded_patterns_are_separated_by_forward_slashes() {
|
|
382
|
+
// Exclusions are glob patterns, and a glob pattern is written with forward slashes whatever the platform's own
|
|
383
|
+
// separator is. Joining them against the workspace path is what would otherwise introduce a backslash, so the
|
|
384
|
+
// invariant is asserted on the joined result. Vacuous where the separator already is a forward slash.
|
|
385
|
+
let mut config = parse("").expect("an empty config is valid");
|
|
386
|
+
config.exclude_patterns([Box::from("vendor/bundle")]);
|
|
387
|
+
|
|
388
|
+
let excluded = config.excluded_patterns();
|
|
389
|
+
|
|
390
|
+
assert!(!excluded.is_empty(), "expected the defaults at least");
|
|
391
|
+
assert!(
|
|
392
|
+
excluded.iter().all(|pattern| !pattern.contains('\\')),
|
|
393
|
+
"unexpected backslash in {excluded:?}"
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#[test]
|
|
398
|
+
fn load_parses_the_settings_of_every_section() {
|
|
399
|
+
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
|
400
|
+
fs::write(
|
|
401
|
+
dir.path().join("rubydex.toml"),
|
|
402
|
+
"[graph]\nexclude = [\"vendor\"]\n\n[linter.rules.Something]\nenabled = true\n",
|
|
403
|
+
)
|
|
404
|
+
.unwrap();
|
|
405
|
+
|
|
406
|
+
let config = Config::load(dir.path()).expect("expected the config file to load");
|
|
407
|
+
let excluded = config.excluded_patterns();
|
|
408
|
+
|
|
409
|
+
let path = path_helpers::resolved(dir.path()).unwrap();
|
|
410
|
+
assert!(excluded.contains(exclusion(&path, "vendor").as_str()));
|
|
411
|
+
|
|
412
|
+
let rules = config.linter().rules();
|
|
413
|
+
assert_eq!(rules.len(), 1);
|
|
414
|
+
assert_eq!(rules[0].name(), "Something");
|
|
415
|
+
assert!(rules[0].enabled());
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#[test]
|
|
419
|
+
fn parse_parses_every_linter_rule() {
|
|
420
|
+
let config = parse(
|
|
421
|
+
"[linter.rules.Something]\nseverity = \"warning\"\nexclude = [\"components/legacy/**\"]\n\n\
|
|
422
|
+
[linter.rules.Other]\nenabled = false\n",
|
|
423
|
+
)
|
|
424
|
+
.expect("expected the config to parse");
|
|
425
|
+
|
|
426
|
+
let rules = config.linter().rules();
|
|
427
|
+
assert_eq!(rules.len(), 2);
|
|
428
|
+
|
|
429
|
+
let something = rules.iter().find(|rule| rule.name() == "Something").unwrap();
|
|
430
|
+
assert!(something.enabled());
|
|
431
|
+
assert_eq!(something.exclude_patterns(), [Box::from("components/legacy/**")]);
|
|
432
|
+
assert_eq!(something.severity(), Some(&Severity::Warning));
|
|
433
|
+
|
|
434
|
+
let other = rules.iter().find(|rule| rule.name() == "Other").unwrap();
|
|
435
|
+
assert!(!other.enabled());
|
|
436
|
+
assert!(other.exclude_patterns().is_empty());
|
|
437
|
+
assert_eq!(other.severity(), None);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#[test]
|
|
441
|
+
fn parse_accepts_an_empty_linter_section() {
|
|
442
|
+
let config = parse("[linter]\n").expect("an empty linter section is valid");
|
|
443
|
+
assert!(config.linter().rules().is_empty());
|
|
444
|
+
|
|
445
|
+
let config = parse("[linter.rules]\n").expect("an empty rules table is valid");
|
|
446
|
+
assert!(config.linter().rules().is_empty());
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#[test]
|
|
450
|
+
fn parse_rejects_an_unknown_section() {
|
|
451
|
+
let error = parse("[lintr]\n").expect_err("every section must be backed by typed settings structs");
|
|
452
|
+
assert!(error.contains("unknown section `lintr`"), "unexpected error: {error}");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#[test]
|
|
456
|
+
fn parse_rejects_an_unknown_linter_setting() {
|
|
457
|
+
let error = parse("[linter]\nparallel = true\n").expect_err("typos inside the linter section must be rejected");
|
|
458
|
+
|
|
459
|
+
assert!(
|
|
460
|
+
error.contains("unknown setting `linter.parallel`"),
|
|
461
|
+
"unexpected error: {error}"
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#[test]
|
|
466
|
+
fn parse_rejects_a_rules_setting_that_is_not_a_table() {
|
|
467
|
+
let error = parse("[linter]\nrules = true\n").expect_err("rules must be a table of rule tables");
|
|
468
|
+
|
|
469
|
+
assert!(
|
|
470
|
+
error.contains("invalid `linter.rules` setting"),
|
|
471
|
+
"unexpected error: {error}"
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#[test]
|
|
476
|
+
fn parse_rejects_a_rule_that_is_not_a_table() {
|
|
477
|
+
let error = parse("[linter.rules]\nSomething = true\n").expect_err("every rule setting must be a table");
|
|
478
|
+
|
|
479
|
+
assert!(
|
|
480
|
+
error.contains("invalid `linter.rules.Something` setting"),
|
|
481
|
+
"unexpected error: {error}"
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#[test]
|
|
486
|
+
fn parse_defaults_a_rule_to_enabled() {
|
|
487
|
+
let config = parse("[linter.rules.Something]\n").expect("enabled defaults to true");
|
|
488
|
+
assert!(config.linter().rules()[0].enabled());
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#[test]
|
|
492
|
+
fn parse_rejects_a_non_boolean_enabled_setting() {
|
|
493
|
+
let error = parse("[linter.rules.Something]\nenabled = \"yes\"\n").expect_err("enabled only accepts a boolean");
|
|
494
|
+
|
|
495
|
+
assert!(
|
|
496
|
+
error.contains("invalid `linter.rules.Something.enabled` setting"),
|
|
497
|
+
"unexpected error: {error}"
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#[test]
|
|
502
|
+
fn parse_rejects_an_unknown_rule_setting() {
|
|
503
|
+
let error =
|
|
504
|
+
parse("[linter.rules.Something]\nparallel = true\n").expect_err("unknown rule settings must be rejected");
|
|
505
|
+
|
|
506
|
+
assert!(
|
|
507
|
+
error.contains("unknown setting `linter.rules.Something.parallel`"),
|
|
508
|
+
"unexpected error: {error}"
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#[test]
|
|
513
|
+
fn parse_rejects_an_invalid_rule_exclude_setting() {
|
|
514
|
+
let error = parse("[linter.rules.Something]\nexclude = \"components/legacy/**\"\n")
|
|
515
|
+
.expect_err("exclude must be an array of strings");
|
|
516
|
+
|
|
517
|
+
assert!(
|
|
518
|
+
error.contains("invalid `linter.rules.Something.exclude` setting"),
|
|
519
|
+
"unexpected error: {error}"
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
#[test]
|
|
524
|
+
fn parse_rejects_an_invalid_rule_severity_setting() {
|
|
525
|
+
for severity in ["\"critical\"", "1"] {
|
|
526
|
+
let error = parse(&format!("[linter.rules.Something]\nseverity = {severity}\n"))
|
|
527
|
+
.expect_err("severity must be one of the supported strings");
|
|
179
528
|
|
|
180
|
-
for default in DEFAULT_EXCLUDED_DIRECTORIES {
|
|
181
529
|
assert!(
|
|
182
|
-
|
|
183
|
-
"
|
|
530
|
+
error.contains("invalid `linter.rules.Something.severity` setting"),
|
|
531
|
+
"unexpected error: {error}"
|
|
184
532
|
);
|
|
185
533
|
}
|
|
186
534
|
}
|
|
187
535
|
|
|
188
536
|
#[test]
|
|
189
|
-
fn
|
|
537
|
+
fn parse_accepts_every_rule_severity() {
|
|
538
|
+
for (value, expected) in [
|
|
539
|
+
("error", Severity::Error),
|
|
540
|
+
("warning", Severity::Warning),
|
|
541
|
+
("information", Severity::Information),
|
|
542
|
+
("hint", Severity::Hint),
|
|
543
|
+
] {
|
|
544
|
+
let config =
|
|
545
|
+
parse(&format!("[linter.rules.Something]\nseverity = \"{value}\"\n")).expect("severity should parse");
|
|
546
|
+
|
|
547
|
+
assert_eq!(config.linter().rules()[0].severity(), Some(&expected));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#[test]
|
|
552
|
+
fn load_returns_the_default_configuration_for_a_workspace_without_a_config_file() {
|
|
190
553
|
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
|
191
|
-
let
|
|
192
|
-
fs::write(&config_path, "exclude = [\"vendor\", \"generated\"]\n").unwrap();
|
|
554
|
+
let config = Config::load(dir.path()).expect("a missing rubydex.toml must not be an error");
|
|
193
555
|
|
|
194
|
-
|
|
195
|
-
config.
|
|
556
|
+
assert_eq!(config.workspace_path(), path_helpers::resolved(dir.path()).unwrap());
|
|
557
|
+
assert_eq!(config.excluded_patterns().len(), DEFAULT_EXCLUDED_DIRECTORIES.len());
|
|
558
|
+
assert!(config.linter().rules().is_empty());
|
|
559
|
+
}
|
|
196
560
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
561
|
+
#[test]
|
|
562
|
+
fn load_reads_the_config_file_under_the_workspace_path() {
|
|
563
|
+
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
|
564
|
+
fs::write(dir.path().join("rubydex.toml"), "[graph]\nexclude = [\"vendor\"]\n").unwrap();
|
|
200
565
|
|
|
566
|
+
let config = Config::load(dir.path()).expect("expected rubydex.toml to load");
|
|
201
567
|
let excluded = config.excluded_patterns();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
assert!(excluded.contains(
|
|
207
|
-
// A config file cannot override the programmatically-set workspace path.
|
|
208
|
-
assert_eq!(config.workspace_path(), Path::new("/workspace"));
|
|
568
|
+
|
|
569
|
+
let path = path_helpers::resolved(dir.path()).unwrap();
|
|
570
|
+
assert_eq!(config.workspace_path(), path);
|
|
571
|
+
assert!(excluded.contains(exclusion(&path, "vendor").as_str()));
|
|
572
|
+
assert!(excluded.contains(exclusion(&path, ".git").as_str()));
|
|
209
573
|
}
|
|
210
574
|
|
|
211
575
|
#[test]
|
|
212
|
-
fn
|
|
576
|
+
fn load_errors_when_the_workspace_is_not_a_directory() {
|
|
213
577
|
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
let mut config = Config::new();
|
|
218
|
-
config.set_workspace_path(PathBuf::from("/workspace"));
|
|
219
|
-
config
|
|
220
|
-
.load_file(&dir.path().join("a.toml"))
|
|
221
|
-
.expect("expected the first file to load");
|
|
222
|
-
config
|
|
223
|
-
.load_file(&dir.path().join("b.toml"))
|
|
224
|
-
.expect("expected the second file to load");
|
|
578
|
+
let missing = dir.path().join("typo");
|
|
579
|
+
let file = dir.path().join("file.rb");
|
|
580
|
+
fs::write(&file, "class Foo; end").unwrap();
|
|
225
581
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
582
|
+
for workspace_path in [missing.as_path(), file.as_path()] {
|
|
583
|
+
let error = Config::load(workspace_path).expect_err("a workspace must be a directory");
|
|
584
|
+
let named = path_helpers::resolved(workspace_path).unwrap_or_else(|_| workspace_path.to_path_buf());
|
|
585
|
+
|
|
586
|
+
assert!(matches!(error, Errors::ConfigError(_)), "unexpected error: {error:?}");
|
|
587
|
+
assert!(
|
|
588
|
+
error.to_string().contains(&named.display().to_string()),
|
|
589
|
+
"expected the error to name the workspace `{}`: {error}",
|
|
590
|
+
named.display()
|
|
591
|
+
);
|
|
592
|
+
}
|
|
229
593
|
}
|
|
230
594
|
|
|
231
595
|
#[test]
|
|
232
|
-
fn
|
|
596
|
+
fn load_errors_when_the_config_file_cannot_be_read() {
|
|
233
597
|
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
|
234
|
-
|
|
598
|
+
// A `rubydex.toml` that exists but cannot be read is a broken workspace, unlike one that has no configuration
|
|
599
|
+
// at all. Making it a directory is the portable way of making it unreadable.
|
|
600
|
+
fs::create_dir(dir.path().join("rubydex.toml")).unwrap();
|
|
235
601
|
|
|
236
|
-
let error = config
|
|
237
|
-
.load_file(&dir.path().join("does_not_exist.toml"))
|
|
238
|
-
.expect_err("an explicitly requested missing file must be an error");
|
|
602
|
+
let error = Config::load(dir.path()).expect_err("an unreadable config file must be an error");
|
|
239
603
|
|
|
604
|
+
assert!(matches!(error, Errors::ConfigError(_)), "unexpected error: {error:?}");
|
|
240
605
|
assert!(
|
|
241
|
-
|
|
242
|
-
"unexpected error: {error
|
|
606
|
+
error.to_string().contains("Failed to read config file"),
|
|
607
|
+
"unexpected error: {error}"
|
|
243
608
|
);
|
|
244
609
|
}
|
|
245
610
|
|
|
246
611
|
#[test]
|
|
247
|
-
fn
|
|
612
|
+
fn load_propagates_malformed_config_errors() {
|
|
248
613
|
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
|
249
|
-
|
|
250
|
-
config.set_workspace_path(dir.path().to_path_buf());
|
|
614
|
+
fs::write(dir.path().join("rubydex.toml"), "[graph]\nexclude = [\n").unwrap();
|
|
251
615
|
|
|
252
|
-
config
|
|
253
|
-
|
|
254
|
-
|
|
616
|
+
let error = Config::load(dir.path()).expect_err("a malformed config file must be an error");
|
|
617
|
+
|
|
618
|
+
assert!(matches!(error, Errors::ConfigError(_)), "unexpected error: {error:?}");
|
|
255
619
|
}
|
|
256
620
|
|
|
257
621
|
#[test]
|
|
258
|
-
fn
|
|
259
|
-
let
|
|
260
|
-
fs::write(dir.path().join("rubydex.toml"), "exclude = [\"vendor\"]\n").unwrap();
|
|
622
|
+
fn parse_accepts_an_empty_config() {
|
|
623
|
+
let config = parse("").expect("an empty config is valid");
|
|
261
624
|
|
|
262
|
-
|
|
263
|
-
config.
|
|
264
|
-
config.
|
|
625
|
+
// Nothing is configured, so the settings of every section are the default ones.
|
|
626
|
+
assert_eq!(config.excluded_patterns().len(), DEFAULT_EXCLUDED_DIRECTORIES.len());
|
|
627
|
+
assert!(config.linter().rules().is_empty());
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
#[test]
|
|
631
|
+
fn parse_rejects_an_exclude_value_of_the_wrong_type() {
|
|
632
|
+
let error =
|
|
633
|
+
parse("[graph]\nexclude = \"vendor\"").expect_err("exclude must be an array of strings, not a string");
|
|
265
634
|
|
|
266
|
-
|
|
267
|
-
assert!(config.excluded_patterns().contains(expected.as_str()));
|
|
635
|
+
assert!(error.contains("graph.exclude"), "unexpected error: {error}");
|
|
268
636
|
}
|
|
269
637
|
|
|
270
638
|
#[test]
|
|
271
|
-
fn
|
|
272
|
-
let
|
|
273
|
-
fs::write(dir.path().join("rubydex.toml"), "exclude = [\n").unwrap();
|
|
639
|
+
fn parse_rejects_an_unknown_top_level_setting() {
|
|
640
|
+
let error = parse("excludes = [\"vendor\"]\n").expect_err("every top-level entry must be a tool section table");
|
|
274
641
|
|
|
275
|
-
|
|
276
|
-
|
|
642
|
+
assert!(
|
|
643
|
+
error.contains("unknown setting `excludes`"),
|
|
644
|
+
"unexpected error: {error}"
|
|
645
|
+
);
|
|
646
|
+
}
|
|
277
647
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
648
|
+
#[test]
|
|
649
|
+
fn parse_rejects_an_unknown_graph_setting() {
|
|
650
|
+
let error = parse("[graph]\nexcludes = [\"vendor\"]\n")
|
|
651
|
+
.expect_err("the graph section is owned by Rubydex, so typos inside it must be rejected");
|
|
281
652
|
|
|
282
|
-
assert!(
|
|
653
|
+
assert!(
|
|
654
|
+
error.contains("unknown setting `graph.excludes`"),
|
|
655
|
+
"unexpected error: {error}"
|
|
656
|
+
);
|
|
283
657
|
}
|
|
284
658
|
|
|
285
659
|
#[test]
|
|
286
|
-
fn
|
|
287
|
-
let
|
|
288
|
-
|
|
660
|
+
fn parse_rejects_a_graph_section_that_is_not_a_table() {
|
|
661
|
+
let error = parse("graph = \"yes\"\n").expect_err("the graph section must be a table");
|
|
662
|
+
|
|
663
|
+
assert!(error.contains("`graph`"), "unexpected error: {error}");
|
|
289
664
|
}
|
|
290
665
|
|
|
291
666
|
#[test]
|
|
292
|
-
fn
|
|
293
|
-
|
|
667
|
+
fn parse_rejects_an_array_of_tables_section() {
|
|
668
|
+
let error =
|
|
669
|
+
parse("[[linter]]\nparallel = true\n").expect_err("array-of-tables syntax is not a valid tool section");
|
|
670
|
+
|
|
671
|
+
assert!(
|
|
672
|
+
error.contains("use `[linter]` instead of `[[linter]]`"),
|
|
673
|
+
"unexpected error: {error}"
|
|
674
|
+
);
|
|
294
675
|
}
|
|
295
676
|
}
|