rubydex 0.2.5 → 0.2.7

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -16
  3. data/THIRD_PARTY_LICENSES.html +45 -12
  4. data/exe/rdx +2 -0
  5. data/ext/rubydex/declaration.c +115 -106
  6. data/ext/rubydex/definition.c +123 -72
  7. data/ext/rubydex/diagnostic.c +5 -0
  8. data/ext/rubydex/document.c +51 -23
  9. data/ext/rubydex/extconf.rb +1 -1
  10. data/ext/rubydex/graph.c +253 -66
  11. data/ext/rubydex/handle.h +34 -5
  12. data/ext/rubydex/location.c +5 -0
  13. data/ext/rubydex/reference.c +51 -46
  14. data/ext/rubydex/rubydex.c +5 -0
  15. data/ext/rubydex/signature.c +5 -0
  16. data/ext/rubydex/utils.c +13 -0
  17. data/ext/rubydex/utils.h +4 -0
  18. data/lib/rubydex/bin/rubydex_mcp.exe +0 -0
  19. data/lib/rubydex/errors.rb +11 -0
  20. data/lib/rubydex/graph.rb +9 -28
  21. data/lib/rubydex/location.rb +24 -0
  22. data/lib/rubydex/version.rb +1 -1
  23. data/lib/rubydex.rb +1 -0
  24. data/rbi/rubydex.rbi +40 -15
  25. data/rust/Cargo.lock +122 -17
  26. data/rust/rubydex/Cargo.toml +26 -2
  27. data/rust/rubydex/benches/graph_memory.rs +46 -0
  28. data/rust/rubydex/src/config.rs +275 -0
  29. data/rust/rubydex/src/dot.rs +609 -0
  30. data/rust/rubydex/src/errors.rs +2 -0
  31. data/rust/rubydex/src/indexing/rbs_indexer.rs +19 -1
  32. data/rust/rubydex/src/indexing/ruby_indexer.rs +4 -0
  33. data/rust/rubydex/src/lib.rs +8 -1
  34. data/rust/rubydex/src/main.rs +8 -5
  35. data/rust/rubydex/src/model/built_in.rs +5 -2
  36. data/rust/rubydex/src/model/comment.rs +2 -0
  37. data/rust/rubydex/src/model/declaration.rs +13 -51
  38. data/rust/rubydex/src/model/definitions.rs +13 -1
  39. data/rust/rubydex/src/model/document.rs +2 -0
  40. data/rust/rubydex/src/model/encoding.rs +2 -0
  41. data/rust/rubydex/src/model/graph.rs +88 -27
  42. data/rust/rubydex/src/model/identity_maps.rs +3 -0
  43. data/rust/rubydex/src/model/keywords.rs +3 -0
  44. data/rust/rubydex/src/model/name.rs +2 -0
  45. data/rust/rubydex/src/model/string_ref.rs +2 -0
  46. data/rust/rubydex/src/model/visibility.rs +3 -0
  47. data/rust/rubydex/src/operation/applier.rs +1 -0
  48. data/rust/rubydex/src/operation/mod.rs +1 -0
  49. data/rust/rubydex/src/operation/ruby_builder.rs +4 -0
  50. data/rust/rubydex/src/query.rs +114 -33
  51. data/rust/rubydex/src/resolution.rs +18 -20
  52. data/rust/rubydex/src/resolution_tests.rs +132 -0
  53. data/rust/rubydex/tests/cli.rs +17 -61
  54. data/rust/rubydex-mcp/Cargo.toml +9 -3
  55. data/rust/rubydex-sys/Cargo.toml +9 -2
  56. data/rust/rubydex-sys/src/definition_api.rs +72 -2
  57. data/rust/rubydex-sys/src/document_api.rs +28 -0
  58. data/rust/rubydex-sys/src/graph_api.rs +74 -6
  59. metadata +6 -4
  60. data/rust/rubydex/src/visualization/dot.rs +0 -192
  61. data/rust/rubydex/src/visualization.rs +0 -6
data/rust/Cargo.lock CHANGED
@@ -180,7 +180,7 @@ dependencies = [
180
180
  "serde_json",
181
181
  "syn",
182
182
  "tempfile",
183
- "toml",
183
+ "toml 0.8.23",
184
184
  ]
185
185
 
186
186
  [[package]]
@@ -536,9 +536,9 @@ checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
536
536
 
537
537
  [[package]]
538
538
  name = "hashbrown"
539
- version = "0.15.4"
539
+ version = "0.17.1"
540
540
  source = "registry+https://github.com/rust-lang/crates.io-index"
541
- checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
541
+ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
542
542
 
543
543
  [[package]]
544
544
  name = "heck"
@@ -685,9 +685,9 @@ dependencies = [
685
685
 
686
686
  [[package]]
687
687
  name = "indexmap"
688
- version = "2.10.0"
688
+ version = "2.14.0"
689
689
  source = "registry+https://github.com/rust-lang/crates.io-index"
690
- checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
690
+ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
691
691
  dependencies = [
692
692
  "equivalent",
693
693
  "hashbrown",
@@ -823,6 +823,12 @@ version = "1.70.1"
823
823
  source = "registry+https://github.com/rust-lang/crates.io-index"
824
824
  checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
825
825
 
826
+ [[package]]
827
+ name = "paste"
828
+ version = "1.0.15"
829
+ source = "registry+https://github.com/rust-lang/crates.io-index"
830
+ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
831
+
826
832
  [[package]]
827
833
  name = "pastey"
828
834
  version = "0.2.1"
@@ -1048,7 +1054,7 @@ dependencies = [
1048
1054
 
1049
1055
  [[package]]
1050
1056
  name = "rubydex"
1051
- version = "0.1.0"
1057
+ version = "0.2.6"
1052
1058
  dependencies = [
1053
1059
  "assert_cmd",
1054
1060
  "bitflags",
@@ -1065,14 +1071,18 @@ dependencies = [
1065
1071
  "ruby-prism",
1066
1072
  "ruby-rbs",
1067
1073
  "rubydex",
1074
+ "serde",
1068
1075
  "tempfile",
1076
+ "tikv-jemalloc-ctl",
1077
+ "tikv-jemallocator",
1078
+ "toml 1.1.2+spec-1.1.0",
1069
1079
  "url",
1070
1080
  "xxhash-rust",
1071
1081
  ]
1072
1082
 
1073
1083
  [[package]]
1074
1084
  name = "rubydex-mcp"
1075
- version = "0.1.0"
1085
+ version = "0.2.6"
1076
1086
  dependencies = [
1077
1087
  "assert_cmd",
1078
1088
  "clap",
@@ -1087,7 +1097,7 @@ dependencies = [
1087
1097
 
1088
1098
  [[package]]
1089
1099
  name = "rubydex-sys"
1090
- version = "0.1.0"
1100
+ version = "0.2.6"
1091
1101
  dependencies = [
1092
1102
  "cbindgen",
1093
1103
  "libc",
@@ -1155,18 +1165,28 @@ dependencies = [
1155
1165
 
1156
1166
  [[package]]
1157
1167
  name = "serde"
1158
- version = "1.0.219"
1168
+ version = "1.0.228"
1159
1169
  source = "registry+https://github.com/rust-lang/crates.io-index"
1160
- checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
1170
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1171
+ dependencies = [
1172
+ "serde_core",
1173
+ "serde_derive",
1174
+ ]
1175
+
1176
+ [[package]]
1177
+ name = "serde_core"
1178
+ version = "1.0.228"
1179
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1180
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1161
1181
  dependencies = [
1162
1182
  "serde_derive",
1163
1183
  ]
1164
1184
 
1165
1185
  [[package]]
1166
1186
  name = "serde_derive"
1167
- version = "1.0.219"
1187
+ version = "1.0.228"
1168
1188
  source = "registry+https://github.com/rust-lang/crates.io-index"
1169
- checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
1189
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1170
1190
  dependencies = [
1171
1191
  "proc-macro2",
1172
1192
  "quote",
@@ -1205,6 +1225,15 @@ dependencies = [
1205
1225
  "serde",
1206
1226
  ]
1207
1227
 
1228
+ [[package]]
1229
+ name = "serde_spanned"
1230
+ version = "1.1.1"
1231
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1232
+ checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
1233
+ dependencies = [
1234
+ "serde_core",
1235
+ ]
1236
+
1208
1237
  [[package]]
1209
1238
  name = "serde_yaml"
1210
1239
  version = "0.9.34+deprecated"
@@ -1315,6 +1344,37 @@ dependencies = [
1315
1344
  "syn",
1316
1345
  ]
1317
1346
 
1347
+ [[package]]
1348
+ name = "tikv-jemalloc-ctl"
1349
+ version = "0.7.0"
1350
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1351
+ checksum = "3a184c43b8ab2f41df2733b55556e3f5f632f4aeaa205b1bb018f574b7f5f142"
1352
+ dependencies = [
1353
+ "libc",
1354
+ "paste",
1355
+ "tikv-jemalloc-sys",
1356
+ ]
1357
+
1358
+ [[package]]
1359
+ name = "tikv-jemalloc-sys"
1360
+ version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8"
1361
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1362
+ checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0"
1363
+ dependencies = [
1364
+ "cc",
1365
+ "libc",
1366
+ ]
1367
+
1368
+ [[package]]
1369
+ name = "tikv-jemallocator"
1370
+ version = "0.7.0"
1371
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1372
+ checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd"
1373
+ dependencies = [
1374
+ "libc",
1375
+ "tikv-jemalloc-sys",
1376
+ ]
1377
+
1318
1378
  [[package]]
1319
1379
  name = "tinystr"
1320
1380
  version = "0.8.1"
@@ -1367,11 +1427,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1367
1427
  checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
1368
1428
  dependencies = [
1369
1429
  "serde",
1370
- "serde_spanned",
1371
- "toml_datetime",
1430
+ "serde_spanned 0.6.9",
1431
+ "toml_datetime 0.6.11",
1372
1432
  "toml_edit",
1373
1433
  ]
1374
1434
 
1435
+ [[package]]
1436
+ name = "toml"
1437
+ version = "1.1.2+spec-1.1.0"
1438
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1439
+ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
1440
+ dependencies = [
1441
+ "indexmap",
1442
+ "serde_core",
1443
+ "serde_spanned 1.1.1",
1444
+ "toml_datetime 1.1.1+spec-1.1.0",
1445
+ "toml_parser",
1446
+ "toml_writer",
1447
+ "winnow 1.0.3",
1448
+ ]
1449
+
1375
1450
  [[package]]
1376
1451
  name = "toml_datetime"
1377
1452
  version = "0.6.11"
@@ -1381,6 +1456,15 @@ dependencies = [
1381
1456
  "serde",
1382
1457
  ]
1383
1458
 
1459
+ [[package]]
1460
+ name = "toml_datetime"
1461
+ version = "1.1.1+spec-1.1.0"
1462
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1463
+ checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
1464
+ dependencies = [
1465
+ "serde_core",
1466
+ ]
1467
+
1384
1468
  [[package]]
1385
1469
  name = "toml_edit"
1386
1470
  version = "0.22.27"
@@ -1389,10 +1473,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
1389
1473
  dependencies = [
1390
1474
  "indexmap",
1391
1475
  "serde",
1392
- "serde_spanned",
1393
- "toml_datetime",
1476
+ "serde_spanned 0.6.9",
1477
+ "toml_datetime 0.6.11",
1394
1478
  "toml_write",
1395
- "winnow",
1479
+ "winnow 0.7.12",
1480
+ ]
1481
+
1482
+ [[package]]
1483
+ name = "toml_parser"
1484
+ version = "1.1.2+spec-1.1.0"
1485
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1486
+ checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
1487
+ dependencies = [
1488
+ "winnow 1.0.3",
1396
1489
  ]
1397
1490
 
1398
1491
  [[package]]
@@ -1401,6 +1494,12 @@ version = "0.1.2"
1401
1494
  source = "registry+https://github.com/rust-lang/crates.io-index"
1402
1495
  checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
1403
1496
 
1497
+ [[package]]
1498
+ name = "toml_writer"
1499
+ version = "1.1.1+spec-1.1.0"
1500
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1501
+ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
1502
+
1404
1503
  [[package]]
1405
1504
  name = "tracing"
1406
1505
  version = "0.1.44"
@@ -1751,6 +1850,12 @@ dependencies = [
1751
1850
  "memchr",
1752
1851
  ]
1753
1852
 
1853
+ [[package]]
1854
+ name = "winnow"
1855
+ version = "1.0.3"
1856
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1857
+ checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
1858
+
1754
1859
  [[package]]
1755
1860
  name = "wit-bindgen-rt"
1756
1861
  version = "0.39.0"
@@ -1,9 +1,15 @@
1
1
  [package]
2
2
  name = "rubydex"
3
- version = "0.1.0"
3
+ version = "0.2.6"
4
4
  edition = "2024"
5
5
  rust-version = "1.89.0"
6
6
  license = "MIT"
7
+ description = "High-performance Ruby code indexing and static analysis library."
8
+ homepage = "https://github.com/Shopify/rubydex"
9
+ repository = "https://github.com/Shopify/rubydex"
10
+ readme = "README.md"
11
+ keywords = ["ruby", "indexing", "static-analysis"]
12
+ categories = ["development-tools"]
7
13
 
8
14
  [[bin]]
9
15
  name = "rubydex_cli"
@@ -13,6 +19,11 @@ path = "src/main.rs"
13
19
  crate-type = ["rlib"]
14
20
 
15
21
  [features]
22
+ jemalloc = ["dep:tikv-jemallocator"]
23
+ # The Ruby native extension uses a dlopen to load the rubydex shared object. In these scenarios, jemalloc must be linked
24
+ # dynamically or else the process crashes. This has a small performance cost, which we don't need to pay when using the
25
+ # Rust crate by itself, so we gate it with a feature
26
+ jemalloc_dylib = ["jemalloc", "tikv-jemallocator/disable_initial_exec_tls"]
16
27
  test_utils = ["dep:tempfile"]
17
28
 
18
29
  [dependencies]
@@ -26,17 +37,30 @@ bitflags = "2.9"
26
37
  bytecount = "0.6.9"
27
38
  libc = "0.2"
28
39
  line-index = "0.1.2"
40
+ serde = { version = "1.0", features = ["derive"] }
29
41
  tempfile = { version = "3.0", optional = true }
30
42
  crossbeam-deque = "0.8"
31
43
  crossbeam-utils = "0.8"
32
44
  crossbeam-channel = "0.5"
45
+ toml = "1.1.2"
46
+
47
+ [target.'cfg(not(target_os = "windows"))'.dependencies]
48
+ tikv-jemallocator = { version = "0.7.0", optional = true }
33
49
 
34
50
  [dev-dependencies]
35
- rubydex = { path = ".", features = ["test_utils"] }
51
+ rubydex = { path = ".", features = ["test_utils", "jemalloc"] }
36
52
  tempfile = "3.0"
37
53
  assert_cmd = "2.0"
38
54
  predicates = "3.1"
39
55
  regex = "1.10"
40
56
 
57
+ [target.'cfg(not(target_os = "windows"))'.dev-dependencies]
58
+ tikv-jemallocator = { version = "0.7.0", features = ["stats"] }
59
+ tikv-jemalloc-ctl = { version = "0.7.0", features = ["stats"] }
60
+
61
+ [[bench]]
62
+ name = "graph_memory"
63
+ harness = false
64
+
41
65
  [lints]
42
66
  workspace = true
@@ -0,0 +1,46 @@
1
+ #[cfg(all(feature = "jemalloc", not(target_os = "windows")))]
2
+ mod imp {
3
+ use std::collections::HashSet;
4
+
5
+ use rubydex::{
6
+ indexing::{self, IndexerBackend},
7
+ listing,
8
+ model::graph::Graph,
9
+ resolution::Resolver,
10
+ };
11
+ use tikv_jemalloc_ctl::{epoch, stats};
12
+
13
+ /// Advance the jemalloc epoch (stats are cached between epochs) and read the
14
+ /// number of bytes currently allocated by the application.
15
+ fn allocated_bytes() -> usize {
16
+ epoch::advance().expect("failed to advance jemalloc epoch");
17
+ stats::allocated::read().expect("failed to read stats.allocated (is the `stats` feature on?)")
18
+ }
19
+
20
+ pub fn run() {
21
+ let paths: Vec<String> = std::env::args().skip(1).collect();
22
+ let paths = if paths.is_empty() { vec![".".to_string()] } else { paths };
23
+ let (file_paths, _) = listing::collect_file_paths(paths, &HashSet::new());
24
+
25
+ let mut graph = Graph::new();
26
+ let _ = indexing::index_files(&mut graph, file_paths, IndexerBackend::RubyIndexer);
27
+ Resolver::new(&mut graph).resolve();
28
+
29
+ // Compare the total memory used in the allocator before and after dropping the graph
30
+ let before_drop = allocated_bytes();
31
+ drop(graph);
32
+ let after_drop = allocated_bytes();
33
+
34
+ let total_graph_memory = before_drop.saturating_sub(after_drop);
35
+
36
+ #[allow(clippy::cast_precision_loss)]
37
+ let mega_bytes = total_graph_memory as f64 / 1024.0 / 1024.0;
38
+
39
+ println!("Total graph memory: {mega_bytes:.2} MB");
40
+ }
41
+ }
42
+
43
+ fn main() {
44
+ #[cfg(all(feature = "jemalloc", not(target_os = "windows")))]
45
+ imp::run();
46
+ }
@@ -0,0 +1,275 @@
1
+ use crate::assert_mem_size;
2
+ use crate::errors::Errors;
3
+ use serde::Deserialize;
4
+ use std::collections::HashSet;
5
+ use std::fs;
6
+ use std::io::ErrorKind;
7
+ use std::path::{Path, PathBuf};
8
+
9
+ pub const DEFAULT_EXCLUDED_DIRECTORIES: &[&str] = &[
10
+ ".bundle",
11
+ ".claude",
12
+ ".git",
13
+ ".github",
14
+ ".ruby-lsp",
15
+ ".vscode",
16
+ "log",
17
+ "node_modules",
18
+ "tmp",
19
+ ];
20
+
21
+ /// Configuration coming from a config file
22
+ #[derive(Debug, Default, Deserialize)]
23
+ struct ConfigFile {
24
+ /// Paths to exclude from file discovery during indexing.
25
+ #[serde(default)]
26
+ exclude: Vec<PathBuf>,
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
+ /// Paths to exclude from file discovery during indexing.
35
+ excluded_paths: HashSet<PathBuf>,
36
+ }
37
+ assert_mem_size!(Config, 64);
38
+
39
+ impl Default for Config {
40
+ fn default() -> Self {
41
+ Self::new()
42
+ }
43
+ }
44
+
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_paths: DEFAULT_EXCLUDED_DIRECTORIES.iter().map(PathBuf::from).collect(),
54
+ }
55
+ }
56
+
57
+ /// Returns the root directory of the workspace being analyzed.
58
+ #[must_use]
59
+ pub fn workspace_path(&self) -> &Path {
60
+ &self.workspace_path
61
+ }
62
+
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();
66
+ }
67
+
68
+ /// Adds paths to exclude from file discovery during indexing. Excluded directories will be skipped entirely during
69
+ /// directory traversal.
70
+ pub fn exclude_paths(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
71
+ self.excluded_paths.extend(paths);
72
+ }
73
+
74
+ /// Returns the set of paths excluded from file discovery
75
+ #[must_use]
76
+ pub fn excluded_paths(&self) -> HashSet<PathBuf> {
77
+ self.excluded_paths
78
+ .iter()
79
+ .map(|path| self.workspace_path.join(path))
80
+ .collect()
81
+ }
82
+
83
+ /// Merges the default `rubydex.toml` configuration file from the workspace root into this config, if present.
84
+ ///
85
+ /// The default config file is optional, so a missing `rubydex.toml` is silently ignored. Any other failure (an
86
+ /// unreadable or malformed file) is still reported.
87
+ ///
88
+ /// # Errors
89
+ ///
90
+ /// Will error if the config file exists but cannot be read or has invalid syntax.
91
+ pub fn load_default(&mut self) -> Result<(), Errors> {
92
+ let config_path = self.workspace_path.join("rubydex.toml");
93
+
94
+ match self.load_file(&config_path) {
95
+ Err(Errors::ConfigNotFound(_)) => Ok(()),
96
+ other => other,
97
+ }
98
+ }
99
+
100
+ /// Merges the configuration at `config_path` into this config
101
+ ///
102
+ /// # Errors
103
+ ///
104
+ /// Returns [`Errors::ConfigNotFound`] if the file does not exist or [`Errors::ConfigError`] if it cannot otherwise
105
+ /// be read or has invalid syntax.
106
+ pub fn load_file(&mut self, config_path: &Path) -> Result<(), Errors> {
107
+ let content = match fs::read_to_string(config_path) {
108
+ Ok(content) => content,
109
+ Err(error) if error.kind() == ErrorKind::NotFound => {
110
+ return Err(Errors::ConfigNotFound(format!(
111
+ "Config file `{}` does not exist",
112
+ config_path.display()
113
+ )));
114
+ }
115
+ Err(error) => {
116
+ return Err(Errors::ConfigError(format!(
117
+ "Failed to read config file `{}`: {error}",
118
+ config_path.display()
119
+ )));
120
+ }
121
+ };
122
+
123
+ let parsed = Self::parse(&content).map_err(|error| {
124
+ Errors::ConfigError(format!("Invalid config file `{}`: {error}", config_path.display()))
125
+ })?;
126
+
127
+ self.excluded_paths.extend(parsed.exclude);
128
+ Ok(())
129
+ }
130
+
131
+ /// Parses the content into a [`ConfigFile`]
132
+ fn parse(content: &str) -> Result<ConfigFile, toml::de::Error> {
133
+ toml::from_str(content)
134
+ }
135
+ }
136
+
137
+ #[cfg(test)]
138
+ mod tests {
139
+ use super::*;
140
+ use std::path::Path;
141
+
142
+ #[test]
143
+ fn excluded_paths_are_resolved_against_the_workspace_path() {
144
+ let mut config = Config::new();
145
+ config.set_workspace_path(PathBuf::from("/workspace"));
146
+ config.exclude_paths([PathBuf::from("vendor"), PathBuf::from("/absolute/path")]);
147
+
148
+ let excluded = config.excluded_paths();
149
+
150
+ // Relative entries (including the defaults) are joined with the workspace path.
151
+ assert!(excluded.contains(Path::new("/workspace/vendor")));
152
+ assert!(excluded.contains(Path::new("/workspace/.git")));
153
+ // Absolute entries pass through unchanged.
154
+ assert!(excluded.contains(Path::new("/absolute/path")));
155
+ }
156
+
157
+ #[test]
158
+ fn new_seeds_the_default_excluded_directories() {
159
+ let config = Config::new();
160
+
161
+ for default in DEFAULT_EXCLUDED_DIRECTORIES {
162
+ assert!(
163
+ config.excluded_paths.contains(Path::new(default)),
164
+ "expected `{default}` to be excluded by default"
165
+ );
166
+ }
167
+ }
168
+
169
+ #[test]
170
+ fn load_file_merges_excluded_paths_and_leaves_the_workspace_path_untouched() {
171
+ let dir = tempfile::tempdir().expect("failed to create temp dir");
172
+ let config_path = dir.path().join("rubydex.toml");
173
+ fs::write(&config_path, "exclude = [\"vendor\", \"generated\"]\n").unwrap();
174
+
175
+ let mut config = Config::new();
176
+ config.set_workspace_path(PathBuf::from("/workspace"));
177
+
178
+ config
179
+ .load_file(&config_path)
180
+ .expect("expected the config file to load");
181
+
182
+ let excluded = config.excluded_paths();
183
+ // Entries from the file are merged in and resolved against the workspace path.
184
+ assert!(excluded.contains(Path::new("/workspace/vendor")));
185
+ assert!(excluded.contains(Path::new("/workspace/generated")));
186
+ // Defaults seeded at construction survive the merge.
187
+ assert!(excluded.contains(Path::new("/workspace/node_modules")));
188
+ // A config file cannot override the programmatically-set workspace path.
189
+ assert_eq!(config.workspace_path(), Path::new("/workspace"));
190
+ }
191
+
192
+ #[test]
193
+ fn load_file_accumulates_exclusions_across_multiple_loads() {
194
+ let dir = tempfile::tempdir().expect("failed to create temp dir");
195
+ fs::write(dir.path().join("a.toml"), "exclude = [\"vendor\"]\n").unwrap();
196
+ fs::write(dir.path().join("b.toml"), "exclude = [\"generated\"]\n").unwrap();
197
+
198
+ let mut config = Config::new();
199
+ config.set_workspace_path(PathBuf::from("/workspace"));
200
+ config
201
+ .load_file(&dir.path().join("a.toml"))
202
+ .expect("expected the first file to load");
203
+ config
204
+ .load_file(&dir.path().join("b.toml"))
205
+ .expect("expected the second file to load");
206
+
207
+ let excluded = config.excluded_paths();
208
+ assert!(excluded.contains(Path::new("/workspace/vendor")));
209
+ assert!(excluded.contains(Path::new("/workspace/generated")));
210
+ }
211
+
212
+ #[test]
213
+ fn load_file_errors_when_the_file_is_missing() {
214
+ let dir = tempfile::tempdir().expect("failed to create temp dir");
215
+ let mut config = Config::new();
216
+
217
+ let error = config
218
+ .load_file(&dir.path().join("does_not_exist.toml"))
219
+ .expect_err("an explicitly requested missing file must be an error");
220
+
221
+ assert!(
222
+ matches!(error, Errors::ConfigNotFound(_)),
223
+ "unexpected error: {error:?}"
224
+ );
225
+ }
226
+
227
+ #[test]
228
+ fn load_default_ignores_a_missing_config_file() {
229
+ let dir = tempfile::tempdir().expect("failed to create temp dir");
230
+ let mut config = Config::new();
231
+ config.set_workspace_path(dir.path().to_path_buf());
232
+
233
+ config
234
+ .load_default()
235
+ .expect("a missing rubydex.toml must not be an error");
236
+ }
237
+
238
+ #[test]
239
+ fn load_default_loads_an_existing_config_file() {
240
+ let dir = tempfile::tempdir().expect("failed to create temp dir");
241
+ fs::write(dir.path().join("rubydex.toml"), "exclude = [\"vendor\"]\n").unwrap();
242
+
243
+ let mut config = Config::new();
244
+ config.set_workspace_path(dir.path().to_path_buf());
245
+ config.load_default().expect("expected rubydex.toml to load");
246
+
247
+ assert!(config.excluded_paths().contains(&dir.path().join("vendor")));
248
+ }
249
+
250
+ #[test]
251
+ fn load_default_propagates_malformed_config_errors() {
252
+ let dir = tempfile::tempdir().expect("failed to create temp dir");
253
+ fs::write(dir.path().join("rubydex.toml"), "exclude = [\n").unwrap();
254
+
255
+ let mut config = Config::new();
256
+ config.set_workspace_path(dir.path().to_path_buf());
257
+
258
+ let error = config
259
+ .load_default()
260
+ .expect_err("a malformed default config must still be an error");
261
+
262
+ assert!(matches!(error, Errors::ConfigError(_)), "unexpected error: {error:?}");
263
+ }
264
+
265
+ #[test]
266
+ fn parse_defaults_the_excluded_paths_to_empty_when_the_key_is_absent() {
267
+ let file = Config::parse("").expect("an empty config is valid");
268
+ assert!(file.exclude.is_empty());
269
+ }
270
+
271
+ #[test]
272
+ fn parse_rejects_an_exclude_value_of_the_wrong_type() {
273
+ Config::parse("exclude = \"vendor\"").expect_err("exclude must be an array of strings, not a string");
274
+ }
275
+ }