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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -7
  3. data/THIRD_PARTY_LICENSES.html +238 -2
  4. data/exe/rdx +2 -149
  5. data/ext/rubydex/config.c +140 -0
  6. data/ext/rubydex/config.h +16 -0
  7. data/ext/rubydex/diagnostic.c +75 -1
  8. data/ext/rubydex/diagnostic.h +2 -0
  9. data/ext/rubydex/graph.c +13 -45
  10. data/ext/rubydex/graph.h +6 -0
  11. data/ext/rubydex/query.c +398 -16
  12. data/ext/rubydex/rubydex.c +2 -0
  13. data/ext/rubydex/utils.c +11 -4
  14. data/lib/ruby_lsp/rubydex/addon.rb +211 -0
  15. data/lib/rubydex/cli/command/console.rb +55 -0
  16. data/lib/rubydex/cli/command/lint/explain.rb +74 -0
  17. data/lib/rubydex/cli/command/lint.rb +202 -0
  18. data/lib/rubydex/cli/command/mcp.rb +30 -0
  19. data/lib/rubydex/cli/command/query.rb +70 -0
  20. data/lib/rubydex/cli/command/skill.rb +69 -0
  21. data/lib/rubydex/cli/command.rb +168 -0
  22. data/lib/rubydex/cli.rb +93 -0
  23. data/lib/rubydex/config.rb +59 -0
  24. data/lib/rubydex/diagnostic.rb +12 -3
  25. data/lib/rubydex/errors.rb +42 -1
  26. data/lib/rubydex/graph.rb +10 -3
  27. data/lib/rubydex/linter/custom_rule.rb +97 -0
  28. data/lib/rubydex/linter/helpers/path_helpers.rb +78 -0
  29. data/lib/rubydex/linter/helpers/source_access_helpers.rb +31 -0
  30. data/lib/rubydex/linter/rule_loader.rb +36 -0
  31. data/lib/rubydex/linter/rule_test_case.rb +343 -0
  32. data/lib/rubydex/linter/runner.rb +56 -0
  33. data/lib/rubydex/linter.rb +19 -0
  34. data/lib/rubydex/location.rb +3 -0
  35. data/lib/rubydex/mcp_server.rb +1 -2
  36. data/lib/rubydex/related_information.rb +17 -0
  37. data/lib/rubydex/rule.rb +33 -0
  38. data/lib/rubydex/severity.rb +70 -0
  39. data/lib/rubydex/skill.rb +88 -0
  40. data/lib/rubydex/skill_registry.rb +62 -0
  41. data/lib/rubydex/version.rb +1 -1
  42. data/lib/rubydex.rb +6 -0
  43. data/lib/rubydex_linter/rules/rule_structure.rb +125 -0
  44. data/rbi/rubydex.rbi +558 -17
  45. data/rust/Cargo.lock +2 -2
  46. data/rust/rubydex/Cargo.toml +1 -1
  47. data/rust/rubydex/benches/graph_memory.rs +3 -5
  48. data/rust/rubydex/src/config.rs +538 -157
  49. data/rust/rubydex/src/diagnostic.rs +66 -40
  50. data/rust/rubydex/src/errors.rs +0 -1
  51. data/rust/rubydex/src/indexing/local_graph.rs +6 -5
  52. data/rust/rubydex/src/indexing/rbs_indexer.rs +270 -6
  53. data/rust/rubydex/src/indexing/ruby_indexer.rs +10 -12
  54. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +134 -81
  55. data/rust/rubydex/src/lib.rs +1 -0
  56. data/rust/rubydex/src/listing.rs +26 -1
  57. data/rust/rubydex/src/main.rs +7 -4
  58. data/rust/rubydex/src/model/declaration.rs +302 -219
  59. data/rust/rubydex/src/model/graph.rs +27 -40
  60. data/rust/rubydex/src/model/name.rs +58 -17
  61. data/rust/rubydex/src/operation/ruby_builder.rs +30 -45
  62. data/rust/rubydex/src/path_helpers.rs +77 -0
  63. data/rust/rubydex/src/query/cypher/schema.rs +63 -0
  64. data/rust/rubydex/src/query/cypher/tests.rs +26 -1
  65. data/rust/rubydex/src/query/cypher.rs +8 -11
  66. data/rust/rubydex/src/query.rs +123 -43
  67. data/rust/rubydex/src/resolution.rs +139 -187
  68. data/rust/rubydex/src/resolution_tests.rs +247 -19
  69. data/rust/rubydex/src/test_utils/context.rs +2 -1
  70. data/rust/rubydex/src/test_utils/graph_test.rs +26 -12
  71. data/rust/rubydex/src/test_utils/local_graph_test.rs +19 -0
  72. data/rust/rubydex/tests/cli.rs +4 -4
  73. data/rust/rubydex-sys/src/config_api.rs +205 -0
  74. data/rust/rubydex-sys/src/cypher_api.rs +791 -0
  75. data/rust/rubydex-sys/src/diagnostic_api.rs +77 -8
  76. data/rust/rubydex-sys/src/graph_api.rs +13 -194
  77. data/rust/rubydex-sys/src/lib.rs +2 -0
  78. data/rust/rubydex-sys/src/name_api.rs +2 -6
  79. data/rust/rubydex-sys/src/utils.rs +37 -0
  80. data/skills/send-private-method/SKILL.md +133 -0
  81. metadata +31 -2
@@ -1,12 +1,14 @@
1
1
  use crate::assert_mem_size;
2
+ use crate::diagnostic::Severity;
2
3
  use crate::errors::Errors;
3
- use serde::Deserialize;
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
- pub const DEFAULT_EXCLUDED_DIRECTORIES: &[&str] = &[
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
- /// Configuration coming from a config file
22
- #[derive(Debug, Default, Deserialize)]
23
- struct ConfigFile {
24
- /// Patterns to exclude from file discovery during indexing.
25
- #[serde(default)]
26
- exclude: Vec<Box<str>>,
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 Config {
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::new()
35
+ Self {
36
+ excluded_patterns: DEFAULT_EXCLUDED_DIRECTORIES.iter().map(|&dir| Box::from(dir)).collect(),
37
+ }
42
38
  }
43
39
  }
44
40
 
45
- impl Config {
46
- /// Creates a configuration whose workspace path defaults to the current working directory.
47
- #[must_use]
48
- pub fn new() -> Self {
49
- Self {
50
- workspace_path: std::env::current_dir()
51
- .unwrap_or_else(|_| PathBuf::from("."))
52
- .into_boxed_path(),
53
- excluded_patterns: DEFAULT_EXCLUDED_DIRECTORIES.iter().map(|&dir| Box::from(dir)).collect(),
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 root directory of the workspace being analyzed.
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 workspace_path(&self) -> &Path {
60
- &self.workspace_path
82
+ pub fn name(&self) -> &str {
83
+ &self.name
61
84
  }
62
85
 
63
- /// Sets the root directory of the workspace being analyzed.
64
- pub fn set_workspace_path(&mut self, workspace_path: PathBuf) {
65
- self.workspace_path = workspace_path.into_boxed_path();
86
+ #[must_use]
87
+ pub fn enabled(&self) -> bool {
88
+ self.enabled
66
89
  }
67
90
 
68
- /// Adds patterns to exclude from file discovery during indexing. Excluded directories will be skipped entirely during
69
- /// directory traversal.
70
- pub fn exclude_patterns(&mut self, patterns: impl IntoIterator<Item = Box<str>>) {
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 excluded_patterns(&self) -> HashSet<Box<str>> {
77
- self.excluded_patterns
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
- /// Merges the default `rubydex.toml` configuration file from the workspace root into this config, if present.
90
- ///
91
- /// The default config file is optional, so a missing `rubydex.toml` is silently ignored. Any other failure (an
92
- /// unreadable or malformed file) is still reported.
93
- ///
94
- /// # Errors
95
- ///
96
- /// Will error if the config file exists but cannot be read or has invalid syntax.
97
- pub fn load_default(&mut self) -> Result<(), Errors> {
98
- let config_path = self.workspace_path.join("rubydex.toml");
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
- match self.load_file(&config_path) {
101
- Err(Errors::ConfigNotFound(_)) => Ok(()),
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
- /// Merges the configuration at `config_path` into this config
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::ConfigNotFound`] if the file does not exist or [`Errors::ConfigError`] if it cannot otherwise
111
- /// be read or has invalid syntax.
112
- pub fn load_file(&mut self, config_path: &Path) -> Result<(), Errors> {
113
- let content = match fs::read_to_string(config_path) {
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
- Err(error) if error.kind() == ErrorKind::NotFound => {
116
- return Err(Errors::ConfigNotFound(format!(
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
- let parsed = Self::parse(&content).map_err(|error| {
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
- self.excluded_patterns.extend(parsed.exclude);
134
- Ok(())
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
- /// Parses the content into a [`ConfigFile`]
138
- fn parse(content: &str) -> Result<ConfigFile, toml::de::Error> {
139
- toml::from_str(content)
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
- use std::path::Path;
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
- PathBuf::from("/workspace").join(entry).to_string_lossy().into_owned()
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 = Config::new();
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 new_seeds_the_default_excluded_directories() {
178
- let config = Config::new();
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
- config.excluded_patterns.contains(*default),
183
- "expected `{default}` to be excluded by default"
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 load_file_merges_excluded_patterns_and_leaves_the_workspace_path_untouched() {
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 config_path = dir.path().join("rubydex.toml");
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
- let mut config = Config::new();
195
- config.set_workspace_path(PathBuf::from("/workspace"));
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
- config
198
- .load_file(&config_path)
199
- .expect("expected the config file to load");
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
- // Entries from the file are merged in and resolved against the workspace path.
203
- assert!(excluded.contains(workspace_exclusion("vendor").as_str()));
204
- assert!(excluded.contains(workspace_exclusion("generated").as_str()));
205
- // Defaults seeded at construction survive the merge.
206
- assert!(excluded.contains(workspace_exclusion("node_modules").as_str()));
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 load_file_accumulates_exclusions_across_multiple_loads() {
576
+ fn load_errors_when_the_workspace_is_not_a_directory() {
213
577
  let dir = tempfile::tempdir().expect("failed to create temp dir");
214
- fs::write(dir.path().join("a.toml"), "exclude = [\"vendor\"]\n").unwrap();
215
- fs::write(dir.path().join("b.toml"), "exclude = [\"generated\"]\n").unwrap();
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
- let excluded = config.excluded_patterns();
227
- assert!(excluded.contains(workspace_exclusion("vendor").as_str()));
228
- assert!(excluded.contains(workspace_exclusion("generated").as_str()));
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 load_file_errors_when_the_file_is_missing() {
596
+ fn load_errors_when_the_config_file_cannot_be_read() {
233
597
  let dir = tempfile::tempdir().expect("failed to create temp dir");
234
- let mut config = Config::new();
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
- matches!(error, Errors::ConfigNotFound(_)),
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 load_default_ignores_a_missing_config_file() {
612
+ fn load_propagates_malformed_config_errors() {
248
613
  let dir = tempfile::tempdir().expect("failed to create temp dir");
249
- let mut config = Config::new();
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
- .load_default()
254
- .expect("a missing rubydex.toml must not be an error");
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 load_default_loads_an_existing_config_file() {
259
- let dir = tempfile::tempdir().expect("failed to create temp dir");
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
- let mut config = Config::new();
263
- config.set_workspace_path(dir.path().to_path_buf());
264
- config.load_default().expect("expected rubydex.toml to load");
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
- let expected = dir.path().join("vendor").to_string_lossy().into_owned();
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 load_default_propagates_malformed_config_errors() {
272
- let dir = tempfile::tempdir().expect("failed to create temp dir");
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
- let mut config = Config::new();
276
- config.set_workspace_path(dir.path().to_path_buf());
642
+ assert!(
643
+ error.contains("unknown setting `excludes`"),
644
+ "unexpected error: {error}"
645
+ );
646
+ }
277
647
 
278
- let error = config
279
- .load_default()
280
- .expect_err("a malformed default config must still be an error");
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!(matches!(error, Errors::ConfigError(_)), "unexpected error: {error:?}");
653
+ assert!(
654
+ error.contains("unknown setting `graph.excludes`"),
655
+ "unexpected error: {error}"
656
+ );
283
657
  }
284
658
 
285
659
  #[test]
286
- fn parse_defaults_the_excluded_patterns_to_empty_when_the_key_is_absent() {
287
- let file = Config::parse("").expect("an empty config is valid");
288
- assert!(file.exclude.is_empty());
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 parse_rejects_an_exclude_value_of_the_wrong_type() {
293
- Config::parse("exclude = \"vendor\"").expect_err("exclude must be an array of strings, not a string");
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
  }