rubydex 0.2.6 → 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.
@@ -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
+ }
@@ -25,4 +25,6 @@ macro_rules! errors {
25
25
 
26
26
  errors!(
27
27
  FileError;
28
+ ConfigError;
29
+ ConfigNotFound;
28
30
  );
@@ -1,4 +1,11 @@
1
+ // Setting the global allocator needs to happen during linking and consumers may not always want to change the global
2
+ // allocator. We gate the usage of jemalloc with a cargo feature, so that consumers can decide if they want to use it
3
+ #[cfg(all(feature = "jemalloc", not(target_os = "windows")))]
4
+ #[global_allocator]
5
+ static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
6
+
1
7
  pub mod compile_assertions;
8
+ pub mod config;
2
9
  pub mod diagnostic;
3
10
  pub mod dot;
4
11
  pub mod errors;
@@ -1,5 +1,4 @@
1
1
  use crate::assert_mem_size;
2
- use crate::diagnostic::Diagnostic;
3
2
  use crate::model::ids::{
4
3
  ClassVariableReferenceId, GlobalVariableReferenceId, InstanceVariableReferenceId, MethodReferenceId,
5
4
  };
@@ -111,8 +110,6 @@ macro_rules! namespace_declaration {
111
110
  descendants: IdentityHashSet<DeclarationId>,
112
111
  /// The singleton class associated with this declaration
113
112
  singleton_class_id: Option<DeclarationId>,
114
- /// Diagnostics associated with this declaration
115
- diagnostics: Vec<Diagnostic>,
116
113
  }
117
114
 
118
115
  impl $name {
@@ -127,13 +124,11 @@ macro_rules! namespace_declaration {
127
124
  ancestors: Ancestors::Partial(Vec::new()),
128
125
  descendants: IdentityHashSet::default(),
129
126
  singleton_class_id: None,
130
- diagnostics: Vec::new(),
131
127
  }
132
128
  }
133
129
 
134
- pub fn extend(&mut self, mut other: Declaration) {
130
+ pub fn extend(&mut self, other: Declaration) {
135
131
  self.definition_ids.extend(other.definitions());
136
- self.diagnostics.extend(other.take_diagnostics());
137
132
 
138
133
  match other {
139
134
  Declaration::Namespace(namespace) => {
@@ -243,8 +238,6 @@ macro_rules! simple_declaration {
243
238
  references: IdentityHashSet<$reference_type>,
244
239
  /// The ID of the owner of this declaration
245
240
  owner_id: DeclarationId,
246
- /// Diagnostics associated with this declaration
247
- diagnostics: Vec<Diagnostic>,
248
241
  }
249
242
 
250
243
  impl $name {
@@ -255,13 +248,11 @@ macro_rules! simple_declaration {
255
248
  definition_ids: Vec::new(),
256
249
  references: IdentityHashSet::default(),
257
250
  owner_id,
258
- diagnostics: Vec::new(),
259
251
  }
260
252
  }
261
253
 
262
- pub fn extend(&mut self, mut other: $name) {
254
+ pub fn extend(&mut self, other: $name) {
263
255
  self.definition_ids.extend(other.definitions());
264
- self.diagnostics.extend(other.take_diagnostics());
265
256
  self.references.extend(other.references());
266
257
  }
267
258
 
@@ -278,10 +269,6 @@ macro_rules! simple_declaration {
278
269
  self.references.remove(reference_id);
279
270
  }
280
271
 
281
- pub fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
282
- std::mem::take(&mut self.diagnostics)
283
- }
284
-
285
272
  #[must_use]
286
273
  pub fn definitions(&self) -> &[DefinitionId] {
287
274
  &self.definition_ids
@@ -436,23 +423,6 @@ impl Declaration {
436
423
  })
437
424
  }
438
425
 
439
- #[must_use]
440
- pub fn diagnostics(&self) -> &[Diagnostic] {
441
- all_declarations!(self, it => &it.diagnostics)
442
- }
443
-
444
- pub fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
445
- all_declarations!(self, it => std::mem::take(&mut it.diagnostics))
446
- }
447
-
448
- pub fn add_diagnostic(&mut self, diagnostic: Diagnostic) {
449
- all_declarations!(self, it => it.diagnostics.push(diagnostic));
450
- }
451
-
452
- pub fn clear_diagnostics(&mut self) {
453
- all_declarations!(self, it => it.diagnostics.clear());
454
- }
455
-
456
426
  #[must_use]
457
427
  pub fn reference_count(&self) -> usize {
458
428
  all_declarations!(self, it => it.references.len())
@@ -546,15 +516,6 @@ impl Namespace {
546
516
  all_namespaces!(self, it => &it.members)
547
517
  }
548
518
 
549
- #[must_use]
550
- pub fn diagnostics(&self) -> &[Diagnostic] {
551
- all_namespaces!(self, it => &it.diagnostics)
552
- }
553
-
554
- pub fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
555
- all_namespaces!(self, it => std::mem::take(&mut it.diagnostics))
556
- }
557
-
558
519
  pub fn extend(&mut self, other: Declaration) {
559
520
  all_namespaces!(self, it => it.extend(other));
560
521
  }
@@ -647,25 +608,25 @@ impl Namespace {
647
608
  }
648
609
 
649
610
  namespace_declaration!(Class, ClassDeclaration);
650
- assert_mem_size!(ClassDeclaration, 216);
611
+ assert_mem_size!(ClassDeclaration, 192);
651
612
  namespace_declaration!(Module, ModuleDeclaration);
652
- assert_mem_size!(ModuleDeclaration, 216);
613
+ assert_mem_size!(ModuleDeclaration, 192);
653
614
  namespace_declaration!(SingletonClass, SingletonClassDeclaration);
654
- assert_mem_size!(SingletonClassDeclaration, 216);
615
+ assert_mem_size!(SingletonClassDeclaration, 192);
655
616
  namespace_declaration!(Todo, TodoDeclaration);
656
- assert_mem_size!(TodoDeclaration, 216);
617
+ assert_mem_size!(TodoDeclaration, 192);
657
618
  simple_declaration!(ConstantDeclaration, ConstantReferenceId);
658
- assert_mem_size!(ConstantDeclaration, 112);
619
+ assert_mem_size!(ConstantDeclaration, 88);
659
620
  simple_declaration!(MethodDeclaration, MethodReferenceId);
660
- assert_mem_size!(MethodDeclaration, 112);
621
+ assert_mem_size!(MethodDeclaration, 88);
661
622
  simple_declaration!(GlobalVariableDeclaration, GlobalVariableReferenceId);
662
- assert_mem_size!(GlobalVariableDeclaration, 112);
623
+ assert_mem_size!(GlobalVariableDeclaration, 88);
663
624
  simple_declaration!(InstanceVariableDeclaration, InstanceVariableReferenceId);
664
- assert_mem_size!(InstanceVariableDeclaration, 112);
625
+ assert_mem_size!(InstanceVariableDeclaration, 88);
665
626
  simple_declaration!(ClassVariableDeclaration, ClassVariableReferenceId);
666
- assert_mem_size!(ClassVariableDeclaration, 112);
627
+ assert_mem_size!(ClassVariableDeclaration, 88);
667
628
  simple_declaration!(ConstantAliasDeclaration, ConstantReferenceId);
668
- assert_mem_size!(ConstantAliasDeclaration, 112);
629
+ assert_mem_size!(ConstantAliasDeclaration, 88);
669
630
 
670
631
  #[cfg(test)]
671
632
  mod tests {
@@ -1,9 +1,11 @@
1
1
  use std::collections::HashSet;
2
2
  use std::collections::hash_map::Entry;
3
- use std::path::PathBuf;
3
+ use std::path::{Path, PathBuf};
4
4
 
5
5
  use crate::assert_mem_size;
6
+ use crate::config::Config;
6
7
  use crate::diagnostic::Diagnostic;
8
+ use crate::errors::Errors;
7
9
  use crate::indexing::local_graph::LocalGraph;
8
10
  use crate::model::built_in::{OBJECT_ID, add_built_in_data};
9
11
  use crate::model::declaration::{Ancestor, Declaration, Namespace};
@@ -85,10 +87,10 @@ pub struct Graph {
85
87
  /// Drained by `take_pending_work()` before resolution.
86
88
  pending_work: Vec<Unit>,
87
89
 
88
- /// Paths to exclude from file discovery during indexing.
89
- excluded_paths: HashSet<PathBuf>,
90
+ /// Project configuration
91
+ config: Config,
90
92
  }
91
- assert_mem_size!(Graph, 336);
93
+ assert_mem_size!(Graph, 352);
92
94
 
93
95
  impl Graph {
94
96
  #[must_use]
@@ -104,7 +106,7 @@ impl Graph {
104
106
  position_encoding: Encoding::default(),
105
107
  name_dependents: IdentityHashMap::default(),
106
108
  pending_work: Vec::default(),
107
- excluded_paths: HashSet::new(),
109
+ config: Config::new(),
108
110
  };
109
111
 
110
112
  add_built_in_data(&mut graph);
@@ -126,13 +128,40 @@ impl Graph {
126
128
  /// Adds paths to exclude from file discovery during indexing. Excluded directories will be skipped entirely during
127
129
  /// directory traversal.
128
130
  pub fn exclude_paths(&mut self, paths: Vec<PathBuf>) {
129
- self.excluded_paths.extend(paths);
131
+ self.config.exclude_paths(paths);
130
132
  }
131
133
 
132
134
  /// Returns the set of paths excluded from file discovery.
133
135
  #[must_use]
134
- pub fn excluded_paths(&self) -> &HashSet<PathBuf> {
135
- &self.excluded_paths
136
+ pub fn excluded_paths(&self) -> HashSet<PathBuf> {
137
+ self.config.excluded_paths()
138
+ }
139
+
140
+ /// Returns the root directory of the workspace being indexed.
141
+ #[must_use]
142
+ pub fn workspace_path(&self) -> &Path {
143
+ self.config.workspace_path()
144
+ }
145
+
146
+ /// Sets the root directory of the workspace being indexed.
147
+ pub fn set_workspace_path(&mut self, workspace_path: PathBuf) {
148
+ self.config.set_workspace_path(workspace_path);
149
+ }
150
+
151
+ /// Loads a configuration file. Pass `None` to load the default `rubydex.toml` configuration file if it exists
152
+ ///
153
+ /// # Errors
154
+ ///
155
+ /// Returns an [`Errors::ConfigNotFound`] if an explicitly requested file does not exist or an
156
+ /// [`Errors::ConfigError`] if a file cannot otherwise be read or its contents are malformed.
157
+ pub fn load_config(&mut self, config_path: Option<&Path>) -> Result<(), Errors> {
158
+ match config_path {
159
+ Some(path) => {
160
+ let path = self.config.workspace_path().join(path);
161
+ self.config.load_file(&path)
162
+ }
163
+ None => self.config.load_default(),
164
+ }
136
165
  }
137
166
 
138
167
  /// # Panics
@@ -486,10 +515,7 @@ impl Graph {
486
515
 
487
516
  #[must_use]
488
517
  pub fn all_diagnostics(&self) -> Vec<&Diagnostic> {
489
- let document_diagnostics = self.documents.values().flat_map(Document::diagnostics);
490
- let declaration_diagnostics = self.declarations.values().flat_map(Declaration::diagnostics);
491
-
492
- document_diagnostics.chain(declaration_diagnostics).collect()
518
+ self.documents.values().flat_map(Document::diagnostics).collect()
493
519
  }
494
520
 
495
521
  /// Interns a string in the graph unless already interned. This method is only used to back the
@@ -1201,9 +1227,6 @@ impl Graph {
1201
1227
  for def_id in detach_def_ids {
1202
1228
  decl.remove_definition(def_id);
1203
1229
  }
1204
- if !detach_def_ids.is_empty() {
1205
- decl.clear_diagnostics();
1206
- }
1207
1230
  }
1208
1231
 
1209
1232
  let Some(decl) = self.declarations.get(&decl_id) else {
@@ -710,18 +710,8 @@ impl<'a> Resolver<'a> {
710
710
  offset,
711
711
  format!("undefined method `{owner_name}#{method_name}` for visibility change"),
712
712
  );
713
- if is_singleton {
714
- // Document-scoped: the singleton class may be synthetic (created by this
715
- // visibility resolution) and won't be cleaned up on file delete, so attaching
716
- // the diagnostic to the declaration would leave it orphaned.
717
- self.graph.add_document_diagnostic(uri_id, diagnostic);
718
- } else {
719
- self.graph
720
- .declarations_mut()
721
- .get_mut(&owner_id)
722
- .unwrap()
723
- .add_diagnostic(diagnostic);
724
- }
713
+
714
+ self.graph.add_document_diagnostic(uri_id, diagnostic);
725
715
  }
726
716
  }
727
717
 
@@ -7,6 +7,7 @@ use crate::document_api::DocumentsIter;
7
7
  use crate::reference_api::{CConstantReference, CMethodReference, ConstantReferencesIter, MethodReferencesIter};
8
8
  use crate::{name_api, utils};
9
9
  use libc::{c_char, c_void};
10
+ use rubydex::errors::Errors;
10
11
  use rubydex::indexing::LanguageId;
11
12
  use rubydex::model::encoding::Encoding;
12
13
  use rubydex::model::graph::Graph;
@@ -18,7 +19,7 @@ use rubydex::query::{CompletionCandidate, CompletionContext, CompletionReceiver}
18
19
  use rubydex::resolution::Resolver;
19
20
  use rubydex::{indexing, integrity, listing, query};
20
21
  use std::ffi::CString;
21
- use std::path::PathBuf;
22
+ use std::path::{Path, PathBuf};
22
23
  use std::{mem, ptr};
23
24
 
24
25
  pub type GraphPointer = *mut c_void;
@@ -197,7 +198,13 @@ pub unsafe extern "C" fn rdx_graph_excluded_paths(
197
198
  let c_strings: Vec<*const c_char> = excluded
198
199
  .iter()
199
200
  .filter_map(|path| {
200
- CString::new(path.to_string_lossy().as_ref())
201
+ // Normalize all paths to use forward slashes. Otherwise, you get mixed backslashes and forward slashes
202
+ // on Windows if a configuration file is using forward slashes. For example:
203
+ //
204
+ // C:\project/vendor/bundle
205
+ let normalized = path.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
206
+
207
+ CString::new(normalized)
201
208
  .ok()
202
209
  .map(|c_string| c_string.into_raw().cast_const())
203
210
  })
@@ -210,6 +217,69 @@ pub unsafe extern "C" fn rdx_graph_excluded_paths(
210
217
  })
211
218
  }
212
219
 
220
+ /// Sets the workspace path used as the root directory for indexing and relative path resolution. Silently ignores the
221
+ /// call if the given path is not valid UTF-8, leaving the existing workspace path untouched (mirrors
222
+ /// `rdx_graph_set_encoding`). This avoids unwinding across the FFI boundary on malformed input.
223
+ ///
224
+ /// # Safety
225
+ ///
226
+ /// - `pointer` must be a valid `GraphPointer` previously returned by this crate.
227
+ /// - `path` must be a valid, null-terminated string.
228
+ #[unsafe(no_mangle)]
229
+ pub unsafe extern "C" fn rdx_graph_set_workspace_path(pointer: GraphPointer, path: *const c_char) {
230
+ let Ok(path) = (unsafe { utils::convert_char_ptr_to_string(path) }) else {
231
+ return;
232
+ };
233
+
234
+ with_mut_graph(pointer, |graph| graph.set_workspace_path(PathBuf::from(path)));
235
+ }
236
+
237
+ /// Returns the workspace path as a C string. Caller must free with `free_c_string`.
238
+ ///
239
+ /// # Safety
240
+ ///
241
+ /// - `pointer` must be a valid `GraphPointer` previously returned by this crate.
242
+ #[unsafe(no_mangle)]
243
+ pub unsafe extern "C" fn rdx_graph_workspace_path(pointer: GraphPointer) -> *const c_char {
244
+ with_graph(pointer, |graph| {
245
+ CString::new(graph.workspace_path().to_string_lossy().as_ref())
246
+ .map_or(ptr::null(), |c_string| c_string.into_raw().cast_const())
247
+ })
248
+ }
249
+
250
+ /// Loads configuration into the graph. A null `config_path` attempts to load the default configuration file.
251
+ ///
252
+ /// Returns NULL on success. On failure returns an owned, null-terminated error message that the caller must free with
253
+ /// `free_c_string`.
254
+ ///
255
+ /// A `config_path` that is not valid UTF-8 is reported as an error message.
256
+ ///
257
+ /// # Safety
258
+ ///
259
+ /// - `pointer` must be a valid `GraphPointer` previously returned by this crate.
260
+ /// - `config_path` must either be NULL or a valid, null-terminated string.
261
+ #[unsafe(no_mangle)]
262
+ pub unsafe extern "C" fn rdx_graph_load_config(pointer: GraphPointer, config_path: *const c_char) -> *const c_char {
263
+ let result = with_mut_graph(pointer, |graph| {
264
+ if config_path.is_null() {
265
+ graph.load_config(None)
266
+ } else {
267
+ match unsafe { utils::convert_char_ptr_to_string(config_path) } {
268
+ Ok(config_path) => graph.load_config(Some(Path::new(&config_path))),
269
+ Err(_) => Err(Errors::ConfigError("config file path is not valid UTF-8".to_string())),
270
+ }
271
+ }
272
+ });
273
+
274
+ match result {
275
+ Ok(()) => ptr::null(),
276
+ Err(error) => CString::new(error.to_string())
277
+ .unwrap_or_default()
278
+ .into_raw()
279
+ .cast_const(),
280
+ }
281
+ }
282
+
213
283
  /// Indexes all given file paths in parallel using the provided Graph pointer.
214
284
  /// Returns an array of error message strings and writes the count to `out_error_count`.
215
285
  /// Returns NULL if there are no errors. Caller must free with `free_c_string_array`.
@@ -232,7 +302,7 @@ pub unsafe extern "C" fn rdx_index_all(
232
302
  let file_paths: Vec<String> = unsafe { utils::convert_double_pointer_to_vec(file_paths, count).unwrap() };
233
303
 
234
304
  with_mut_graph(pointer, |graph| {
235
- let (file_paths, listing_errors) = listing::collect_file_paths(file_paths, graph.excluded_paths());
305
+ let (file_paths, listing_errors) = listing::collect_file_paths(file_paths, &graph.excluded_paths());
236
306
  let indexing_errors = indexing::index_files(graph, file_paths, indexing::IndexerBackend::RubyIndexer);
237
307
 
238
308
  let all_errors: Vec<String> = listing_errors