rubydex 0.4.0 → 0.4.1

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -20
  3. data/ext/rubydex/definition.c +34 -0
  4. data/ext/rubydex/definition.h +3 -0
  5. data/ext/rubydex/diagnostic.c +2 -49
  6. data/ext/rubydex/extconf.rb +4 -2
  7. data/ext/rubydex/graph.c +59 -11
  8. data/lib/rubydex/cli/command/lint/explain.rb +8 -6
  9. data/lib/rubydex/cli/command/lint.rb +10 -4
  10. data/lib/rubydex/linter/rule_loader.rb +1 -1
  11. data/lib/rubydex/rules/dynamic_ancestor.rb +29 -0
  12. data/lib/rubydex/rules/dynamic_constant_reference.rb +25 -0
  13. data/lib/rubydex/rules/dynamic_singleton_definition.rb +30 -0
  14. data/lib/rubydex/rules/invalid_constant_visibility.rb +28 -0
  15. data/lib/rubydex/rules/invalid_method_visibility.rb +28 -0
  16. data/lib/rubydex/rules/parse_error.rb +25 -0
  17. data/lib/rubydex/rules/parse_warning.rb +28 -0
  18. data/lib/rubydex/rules/top_level_mixin_self.rb +27 -0
  19. data/lib/rubydex/rules/undefined_constant_visibility_target.rb +27 -0
  20. data/lib/rubydex/rules/undefined_method_visibility_target.rb +27 -0
  21. data/lib/rubydex/skill.rb +1 -0
  22. data/lib/rubydex/version.rb +1 -1
  23. data/lib/rubydex.rb +2 -0
  24. data/lib/rubydex_linter/rules/rule_structure.rb +19 -4
  25. data/rbi/rubydex.rbi +32 -130
  26. data/rust/rubydex/Cargo.toml +4 -0
  27. data/rust/rubydex/src/bin/generate_ruby_rules.rs +94 -0
  28. data/rust/rubydex/src/diagnostic.rs +142 -51
  29. data/rust/rubydex/src/query.rs +191 -1
  30. data/rust/rubydex-sys/src/definition_api.rs +68 -1
  31. data/rust/rubydex-sys/src/diagnostic_api.rs +0 -35
  32. data/rust/rubydex-sys/src/graph_api.rs +112 -11
  33. data/rust/rubydex-sys/src/name_api.rs +47 -11
  34. metadata +13 -2
@@ -5,7 +5,7 @@ use std::thread;
5
5
 
6
6
  use url::Url;
7
7
 
8
- use crate::model::built_in::OBJECT_ID;
8
+ use crate::model::built_in::{BUILT_IN_URI_ID, OBJECT_ID};
9
9
  use crate::model::declaration::{Ancestor, Declaration, Namespace};
10
10
  use crate::model::definitions::{Definition, Parameter};
11
11
  use crate::model::graph::Graph;
@@ -833,6 +833,63 @@ pub fn follow_method_alias(graph: &Graph, alias_id: DefinitionId) -> Result<Decl
833
833
  }
834
834
  }
835
835
 
836
+ /// Returns `true` when any definition of `declaration` was seeded by rubydex rather than read from indexed source.
837
+ ///
838
+ /// Dead code candidates are reported at declaration granularity, so a seeded definition disqualifies the declaration
839
+ /// even when user code also reopens it.
840
+ fn is_built_in(graph: &Graph, declaration: &Declaration) -> bool {
841
+ declaration.definitions().iter().any(|definition_id| {
842
+ graph
843
+ .definitions()
844
+ .get(definition_id)
845
+ .is_some_and(|definition| definition.uri_id() == &*BUILT_IN_URI_ID)
846
+ })
847
+ }
848
+
849
+ /// Returns a list of declarations that may be unused in the codebase, which means
850
+ /// that the analysis could not find any references to them. This misses
851
+ /// meta-programming or untyped code, which is why the term candidates is used.
852
+ ///
853
+ /// Currently, only supports constants.
854
+ ///
855
+ /// # Panics
856
+ ///
857
+ /// Will panic if any of the threads panic
858
+ pub fn dead_code_candidates(graph: &Graph) -> Vec<DeclarationId> {
859
+ let num_threads = thread::available_parallelism().map_or(4, std::num::NonZero::get);
860
+ let declarations = graph.declarations();
861
+
862
+ let ids: Vec<DeclarationId> = declarations.keys().copied().collect();
863
+ let chunk_size = ids.len().div_ceil(num_threads);
864
+
865
+ if chunk_size == 0 {
866
+ return Vec::new();
867
+ }
868
+
869
+ thread::scope(|s| {
870
+ let handles: Vec<_> = ids
871
+ .chunks(chunk_size)
872
+ .map(|chunk| {
873
+ s.spawn(|| {
874
+ chunk
875
+ .iter()
876
+ .filter(|id| {
877
+ let decl = declarations.get(id).unwrap();
878
+ decl.constant_references().is_some_and(HashSet::is_empty)
879
+ && !matches!(decl, Declaration::Namespace(Namespace::SingletonClass(_)))
880
+ && !decl.has_no_definitions()
881
+ && !is_built_in(graph, decl)
882
+ })
883
+ .copied()
884
+ .collect::<Vec<_>>()
885
+ })
886
+ })
887
+ .collect();
888
+
889
+ handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
890
+ })
891
+ }
892
+
836
893
  #[cfg(test)]
837
894
  mod tests {
838
895
  use std::str::FromStr;
@@ -920,6 +977,21 @@ mod tests {
920
977
  };
921
978
  }
922
979
 
980
+ macro_rules! assert_dead_code_candidates {
981
+ ($context:expr, [$($expected:expr),* $(,)?]) => {
982
+ let mut actual: Vec<String> = dead_code_candidates($context.graph())
983
+ .iter()
984
+ .map(|id| $context.graph().declarations().get(id).unwrap().name().to_string())
985
+ .collect();
986
+ actual.sort();
987
+
988
+ let mut expected: Vec<String> = vec![$(String::from($expected)),*];
989
+ expected.sort();
990
+
991
+ assert_eq!(expected, actual);
992
+ };
993
+ }
994
+
923
995
  #[test]
924
996
  fn fuzzy_search_returns_partial_matches() {
925
997
  let mut context = GraphTest::new();
@@ -3982,4 +4054,122 @@ mod tests {
3982
4054
  Err(FindMemberError::DeclarationNotFound),
3983
4055
  );
3984
4056
  }
4057
+
4058
+ #[test]
4059
+ fn dead_code_candidates_returns_unreferenced_constants() {
4060
+ let mut context = GraphTest::new();
4061
+ context.index_uri(
4062
+ "file:///foo.rb",
4063
+ r"
4064
+ class UnusedClass; end
4065
+ module UnusedModule; end
4066
+ UNUSED_CONSTANT = 1
4067
+ ",
4068
+ );
4069
+ context.resolve();
4070
+
4071
+ assert_dead_code_candidates!(context, ["UnusedClass", "UnusedModule", "UNUSED_CONSTANT"]);
4072
+ }
4073
+
4074
+ #[test]
4075
+ fn dead_code_candidates_excludes_referenced_constants() {
4076
+ let mut context = GraphTest::new();
4077
+ context.index_uri(
4078
+ "file:///foo.rb",
4079
+ r"
4080
+ class UsedClass; end
4081
+ USED_CONSTANT = 1
4082
+ ",
4083
+ );
4084
+ context.index_uri(
4085
+ "file:///bar.rb",
4086
+ r"
4087
+ UsedClass
4088
+ USED_CONSTANT
4089
+ ",
4090
+ );
4091
+ context.resolve();
4092
+
4093
+ assert_dead_code_candidates!(context, []);
4094
+ }
4095
+
4096
+ #[test]
4097
+ fn dead_code_candidates_excludes_methods_and_variables() {
4098
+ let mut context = GraphTest::new();
4099
+ context.index_uri(
4100
+ "file:///foo.rb",
4101
+ r"
4102
+ $global_var = 1
4103
+
4104
+ class Holder
4105
+ @@class_var = 1
4106
+
4107
+ def initialize
4108
+ @instance_var = 1
4109
+ end
4110
+
4111
+ def never_called; end
4112
+ end
4113
+ ",
4114
+ );
4115
+ context.resolve();
4116
+
4117
+ assert_dead_code_candidates!(context, ["Holder"]);
4118
+ }
4119
+
4120
+ #[test]
4121
+ fn dead_code_candidates_counts_references_rather_than_reachability() {
4122
+ let mut context = GraphTest::new();
4123
+ context.index_uri(
4124
+ "file:///foo.rb",
4125
+ r"
4126
+ class Target; end
4127
+ AliasName = Target
4128
+ ",
4129
+ );
4130
+ context.resolve();
4131
+
4132
+ // `AliasName` is itself dead, but it still references `Target`, giving `Target` a non-zero count.
4133
+ // A single pass only peels the outermost layer of a dead subgraph.
4134
+ assert_dead_code_candidates!(context, ["AliasName"]);
4135
+ }
4136
+
4137
+ #[test]
4138
+ fn dead_code_candidates_excludes_built_ins_and_definitionless_declarations() {
4139
+ let mut context = GraphTest::new();
4140
+ context.index_uri(
4141
+ "file:///foo.rb",
4142
+ r"
4143
+ class Unused
4144
+ def self.class_method; end
4145
+ end
4146
+
4147
+ class Class
4148
+ end
4149
+ ",
4150
+ );
4151
+ context.resolve();
4152
+
4153
+ // A reopened built-in remains excluded regardless of definition order.
4154
+ assert_dead_code_candidates!(context, ["Unused"]);
4155
+ }
4156
+
4157
+ #[test]
4158
+ fn dead_code_candidates_excludes_singleton_classes() {
4159
+ let mut context = GraphTest::new();
4160
+ context.index_uri(
4161
+ "file:///foo.rb",
4162
+ r"
4163
+ class Unused; end
4164
+ class Attached; end
4165
+
4166
+ class << Attached
4167
+ def never_called; end
4168
+ end
4169
+ ",
4170
+ );
4171
+ context.resolve();
4172
+
4173
+ assert_dead_code_candidates!(context, ["Unused"]);
4174
+ }
3985
4175
  }
@@ -6,7 +6,9 @@ use crate::location_api::{Location, create_location_for_uri_and_offset};
6
6
  use crate::reference_api::CConstantReference;
7
7
  use libc::c_char;
8
8
  use rubydex::model::definitions::{Definition, Mixin};
9
- use rubydex::model::ids::DefinitionId;
9
+ use rubydex::model::graph::Graph;
10
+ use rubydex::model::ids::{DefinitionId, NameId};
11
+ use rubydex::model::name::ParentScope;
10
12
  use rubydex::query::AliasResolutionError;
11
13
  use std::ffi::CString;
12
14
  use std::ptr;
@@ -110,6 +112,71 @@ pub unsafe extern "C" fn rdx_definition_name(pointer: GraphPointer, definition_i
110
112
  })
111
113
  }
112
114
 
115
+ fn raw_name_for_name(graph: &Graph, name_id: NameId) -> String {
116
+ let mut raw_name = String::new();
117
+ append_raw_name(graph, name_id, &mut raw_name);
118
+ raw_name
119
+ }
120
+
121
+ fn append_raw_name(graph: &Graph, name_id: NameId, raw_name: &mut String) {
122
+ let name = graph
123
+ .names()
124
+ .get(&name_id)
125
+ .expect("name should exist while building raw_name");
126
+ let simple_name = graph
127
+ .strings()
128
+ .get(name.str())
129
+ .expect("string should exist while building raw_name")
130
+ .as_str();
131
+
132
+ match name.parent_scope() {
133
+ ParentScope::None => {}
134
+ ParentScope::TopLevel => raw_name.push_str("::"),
135
+ ParentScope::Some(parent_id) | ParentScope::Attached(parent_id) => {
136
+ append_raw_name(graph, *parent_id, raw_name);
137
+ raw_name.push_str("::");
138
+ }
139
+ }
140
+
141
+ raw_name.push_str(simple_name);
142
+ }
143
+
144
+ fn raw_name_id(definition: &Definition) -> Option<&NameId> {
145
+ match definition {
146
+ Definition::Class(_) | Definition::Module(_) | Definition::Constant(_) | Definition::ConstantAlias(_) => {
147
+ definition.name_id()
148
+ }
149
+ _ => None,
150
+ }
151
+ }
152
+
153
+ /// Returns the UTF-8 raw name string for a class, module, constant, or constant-alias definition id.
154
+ ///
155
+ /// The raw name is reconstructed from the definition's `Name`, preserving explicit parent scopes like `Foo::Bar` and
156
+ /// rooted paths like `::Bar`. Returns null if the definition cannot be found or does not support raw names. Caller must
157
+ /// free a non-null result with `free_c_string`.
158
+ ///
159
+ /// # Panics
160
+ ///
161
+ /// Panics if the graph contains an invalid name or string reference, or if the name contains an embedded null byte.
162
+ ///
163
+ /// # Safety
164
+ ///
165
+ /// Assumes pointer is valid.
166
+ ///
167
+ #[unsafe(no_mangle)]
168
+ pub unsafe extern "C" fn rdx_definition_raw_name(pointer: GraphPointer, definition_id: u64) -> *const c_char {
169
+ with_graph(pointer, |graph| {
170
+ let def_id = DefinitionId::new(definition_id);
171
+ let Some(name_id) = graph.definitions().get(&def_id).and_then(raw_name_id) else {
172
+ return ptr::null();
173
+ };
174
+
175
+ let name = raw_name_for_name(graph, *name_id);
176
+ CString::new(name).unwrap().into_raw().cast_const()
177
+ })
178
+ }
179
+
113
180
  /// Shared iterator over definition (id, kind) pairs
114
181
  #[derive(Debug)]
115
182
  pub struct DefinitionsIter {
@@ -32,7 +32,6 @@ impl From<Severity> for DiagnosticSeverity {
32
32
  pub struct CRule {
33
33
  pub name: *const c_char,
34
34
  pub name_length: usize,
35
- pub default_severity: DiagnosticSeverity,
36
35
  }
37
36
 
38
37
  impl From<Rule> for CRule {
@@ -42,44 +41,10 @@ impl From<Rule> for CRule {
42
41
  Self {
43
42
  name: name.as_ptr().cast::<c_char>(),
44
43
  name_length: name.len(),
45
- default_severity: DiagnosticSeverity::from(rule.default_severity()),
46
44
  }
47
45
  }
48
46
  }
49
47
 
50
- #[repr(C)]
51
- pub struct CRuleArray {
52
- pub items: *mut CRule,
53
- pub len: usize,
54
- }
55
-
56
- /// Returns every rule the graph can report. Caller must free it with `rdx_rules_free`.
57
- #[unsafe(no_mangle)]
58
- pub extern "C" fn rdx_rules() -> CRuleArray {
59
- let items = Rule::all().iter().copied().map(CRule::from).collect::<Box<[CRule]>>();
60
-
61
- CRuleArray {
62
- len: items.len(),
63
- items: Box::into_raw(items).cast::<CRule>(),
64
- }
65
- }
66
-
67
- /// Frees an array previously returned by `rdx_rules`.
68
- ///
69
- /// # Safety
70
- ///
71
- /// - `rules` must have been returned by `rdx_rules` and must not be used afterwards.
72
- #[unsafe(no_mangle)]
73
- pub unsafe extern "C" fn rdx_rules_free(rules: CRuleArray) {
74
- if rules.items.is_null() {
75
- return;
76
- }
77
-
78
- unsafe {
79
- let _ = Box::from_raw(ptr::slice_from_raw_parts_mut(rules.items, rules.len));
80
- }
81
- }
82
-
83
48
  /// C-compatible struct representing a diagnostic entry.
84
49
  #[repr(C)]
85
50
  pub struct DiagnosticEntry {
@@ -10,9 +10,10 @@ use crate::{name_api, utils};
10
10
  use libc::{c_char, c_void};
11
11
  use rubydex::config::Config;
12
12
  use rubydex::indexing::LanguageId;
13
+ use rubydex::model::definitions::Definition;
13
14
  use rubydex::model::encoding::Encoding;
14
15
  use rubydex::model::graph::Graph;
15
- use rubydex::model::ids::{DeclarationId, NameId, UriId, declaration_id_from_lookup_name};
16
+ use rubydex::model::ids::{DeclarationId, DefinitionId, NameId, UriId, declaration_id_from_lookup_name};
16
17
  use rubydex::model::keywords;
17
18
  use rubydex::model::name::NameRef;
18
19
  use rubydex::model::visibility::Visibility;
@@ -133,6 +134,27 @@ pub unsafe extern "C" fn rdx_graph_declarations_fuzzy_search(
133
134
  DeclarationsIter::new(entries)
134
135
  }
135
136
 
137
+ /// Returns an iterator over all dead code candidates in the graph.
138
+ ///
139
+ /// # Safety
140
+ ///
141
+ /// Expects `pointer` to be a valid graph. The returned iterator must be freed with `rdx_graph_declarations_iter_free`.
142
+ #[unsafe(no_mangle)]
143
+ pub unsafe extern "C" fn rdx_graph_dead_code_candidates(pointer: GraphPointer) -> *mut DeclarationsIter {
144
+ let entries = with_graph(pointer, |graph| {
145
+ query::dead_code_candidates(graph)
146
+ .into_iter()
147
+ .filter_map(|id| {
148
+ let decl = graph.declarations().get(&id)?;
149
+ Some(CDeclaration::from_declaration(id, decl))
150
+ })
151
+ .collect::<Vec<CDeclaration>>()
152
+ .into_boxed_slice()
153
+ });
154
+
155
+ DeclarationsIter::new(entries)
156
+ }
157
+
136
158
  /// # Panics
137
159
  ///
138
160
  /// Will panic if the nesting cannot be transformed into a vector of strings
@@ -155,24 +177,103 @@ pub unsafe extern "C" fn rdx_graph_resolve_constant(
155
177
  return ptr::null();
156
178
  };
157
179
 
158
- let mut resolver = Resolver::new(graph);
180
+ resolve_constant_name(graph, name_id, names_to_untrack)
181
+ })
182
+ }
183
+
184
+ /// Result of resolving a constant using a namespace definition as its lexical context.
185
+ #[repr(u8)]
186
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
187
+ pub enum CDefinitionConstantResolution {
188
+ Resolved = 0,
189
+ NotFound = 1,
190
+ DefinitionNotFound = 2,
191
+ InvalidDefinitionKind = 3,
192
+ }
159
193
 
160
- let declaration = match resolver.resolve_constant(name_id) {
161
- Some(id) => {
162
- let decl = graph.declarations().get(&id).unwrap();
163
- Box::into_raw(Box::new(CDeclaration::from_declaration(id, decl))).cast_const()
194
+ #[repr(C)]
195
+ #[derive(Debug)]
196
+ pub struct CDefinitionConstantResolutionResult {
197
+ pub status: CDefinitionConstantResolution,
198
+ pub declaration: *const CDeclaration,
199
+ }
200
+
201
+ /// Resolves a constant using a namespace definition as its lexical context.
202
+ ///
203
+ /// # Panics
204
+ ///
205
+ /// Panics if `const_name` cannot be converted to a string.
206
+ ///
207
+ /// # Safety
208
+ ///
209
+ /// Assumes that `pointer` and `const_name` are valid.
210
+ #[unsafe(no_mangle)]
211
+ pub unsafe extern "C" fn rdx_graph_resolve_constant_from_definition(
212
+ pointer: GraphPointer,
213
+ const_name: *const c_char,
214
+ definition_id: u64,
215
+ ) -> CDefinitionConstantResolutionResult {
216
+ with_mut_graph(pointer, |graph| {
217
+ let const_name: String = unsafe { utils::convert_char_ptr_to_string(const_name).unwrap() };
218
+ let nesting = match class_or_module_definition_name_id(graph, DefinitionId::new(definition_id)) {
219
+ Ok(nesting) => nesting,
220
+ Err(status) => {
221
+ return CDefinitionConstantResolutionResult {
222
+ status,
223
+ declaration: ptr::null(),
224
+ };
164
225
  }
165
- None => ptr::null(),
166
226
  };
167
227
 
168
- for name_id in names_to_untrack {
169
- graph.untrack_name(name_id);
170
- }
228
+ let Some((name_id, names_to_untrack)) = name_api::name_in_nesting_to_name_id(graph, &const_name, Some(nesting))
229
+ else {
230
+ return CDefinitionConstantResolutionResult {
231
+ status: CDefinitionConstantResolution::NotFound,
232
+ declaration: ptr::null(),
233
+ };
234
+ };
171
235
 
172
- declaration
236
+ let declaration = resolve_constant_name(graph, name_id, names_to_untrack);
237
+ let status = if declaration.is_null() {
238
+ CDefinitionConstantResolution::NotFound
239
+ } else {
240
+ CDefinitionConstantResolution::Resolved
241
+ };
242
+
243
+ CDefinitionConstantResolutionResult { status, declaration }
173
244
  })
174
245
  }
175
246
 
247
+ fn class_or_module_definition_name_id(
248
+ graph: &Graph,
249
+ definition_id: DefinitionId,
250
+ ) -> Result<NameId, CDefinitionConstantResolution> {
251
+ let Some(definition) = graph.definitions().get(&definition_id) else {
252
+ return Err(CDefinitionConstantResolution::DefinitionNotFound);
253
+ };
254
+
255
+ match definition {
256
+ Definition::Class(definition) => Ok(*definition.name_id()),
257
+ Definition::SingletonClass(definition) => Ok(*definition.name_id()),
258
+ Definition::Module(definition) => Ok(*definition.name_id()),
259
+ _ => Err(CDefinitionConstantResolution::InvalidDefinitionKind),
260
+ }
261
+ }
262
+
263
+ fn resolve_constant_name(graph: &mut Graph, name_id: NameId, names_to_untrack: Vec<NameId>) -> *const CDeclaration {
264
+ let declaration_id = Resolver::new(graph).resolve_constant(name_id);
265
+ let declaration = declaration_id.map_or(ptr::null(), |id| {
266
+ let declaration = graph.declarations().get(&id).unwrap();
267
+ Box::into_raw(Box::new(CDeclaration::from_declaration(id, declaration))).cast_const()
268
+ });
269
+
270
+ for name_id in names_to_untrack {
271
+ graph.untrack_name(name_id);
272
+ }
273
+
274
+ declaration
275
+ }
276
+
176
277
  /// Adds glob patterns to exclude from file discovery during indexing.
177
278
  ///
178
279
  /// # Panics
@@ -13,13 +13,7 @@ pub fn nesting_stack_to_name_id(
13
13
  let mut names_to_untrack = Vec::new();
14
14
 
15
15
  for entry in nesting {
16
- process_qualified_name(
17
- graph,
18
- &entry,
19
- &mut current_name,
20
- &mut current_nesting,
21
- &mut names_to_untrack,
22
- );
16
+ process_qualified_name(graph, &entry, current_nesting, &mut current_name, &mut names_to_untrack);
23
17
  current_nesting = current_name.as_ref().copied();
24
18
  current_name = ParentScope::None;
25
19
  }
@@ -27,12 +21,39 @@ pub fn nesting_stack_to_name_id(
27
21
  process_qualified_name(
28
22
  graph,
29
23
  const_name,
24
+ current_nesting,
30
25
  &mut current_name,
31
- &mut current_nesting,
32
26
  &mut names_to_untrack,
33
27
  );
34
28
 
35
29
  let (ParentScope::Some(name_id) | ParentScope::Attached(name_id)) = current_name else {
30
+ // Invalid names may leave temporary parts in the graph (e.g. `Foo` from `Foo::`).
31
+ for name_id in names_to_untrack {
32
+ graph.untrack_name(name_id);
33
+ }
34
+ return None;
35
+ };
36
+
37
+ Some((name_id, names_to_untrack))
38
+ }
39
+
40
+ /// Takes a constant name and an existing lexical nesting and transforms it into a `NameId`, registering each required
41
+ /// part in the graph. Returns the `NameId` and a list of name ids that need to be untracked afterwards.
42
+ pub fn name_in_nesting_to_name_id(
43
+ graph: &mut Graph,
44
+ const_name: &str,
45
+ nesting: Option<NameId>,
46
+ ) -> Option<(NameId, Vec<NameId>)> {
47
+ let mut current_name = ParentScope::None;
48
+ let mut names_to_untrack = Vec::new();
49
+
50
+ process_qualified_name(graph, const_name, nesting, &mut current_name, &mut names_to_untrack);
51
+
52
+ let (ParentScope::Some(name_id) | ParentScope::Attached(name_id)) = current_name else {
53
+ // Invalid names may leave temporary parts in the graph (e.g. `Foo` from `Foo::`).
54
+ for name_id in names_to_untrack {
55
+ graph.untrack_name(name_id);
56
+ }
36
57
  return None;
37
58
  };
38
59
 
@@ -47,8 +68,8 @@ pub fn nesting_stack_to_name_id(
47
68
  fn process_qualified_name(
48
69
  graph: &mut Graph,
49
70
  qualified_name: &str,
71
+ current_nesting: Option<NameId>,
50
72
  current_name: &mut ParentScope,
51
- current_nesting: &mut Option<NameId>,
52
73
  names_to_untrack: &mut Vec<NameId>,
53
74
  ) {
54
75
  for part in qualified_name.split("::") {
@@ -60,13 +81,13 @@ fn process_qualified_name(
60
81
  let (parent_scope, nesting_for_part) = if part.starts_with('<') {
61
82
  let attached_id = match *current_name {
62
83
  ParentScope::Some(id) | ParentScope::Attached(id) => Some(id),
63
- _ => *current_nesting,
84
+ _ => current_nesting,
64
85
  };
65
86
 
66
87
  let attached = attached_id.map_or(ParentScope::None, ParentScope::Attached);
67
88
  (attached, attached_id)
68
89
  } else {
69
- (*current_name, *current_nesting)
90
+ (*current_name, current_nesting)
70
91
  };
71
92
 
72
93
  let str_id = graph.intern_string(part.to_owned());
@@ -182,4 +203,19 @@ mod tests {
182
203
  assert!(foo_name.parent_scope().is_none());
183
204
  assert!(foo_name.nesting().is_none());
184
205
  }
206
+
207
+ #[test]
208
+ fn invalid_names_are_untracked() {
209
+ let mut graph = Graph::new();
210
+ let name_count = graph.names().len();
211
+ let string_count = graph.strings().len();
212
+
213
+ assert!(nesting_stack_to_name_id(&mut graph, "Foo::", vec!["Bar".into()]).is_none());
214
+ assert_eq!(name_count, graph.names().len());
215
+ assert_eq!(string_count, graph.strings().len());
216
+
217
+ assert!(name_in_nesting_to_name_id(&mut graph, "Foo::", None).is_none());
218
+ assert_eq!(name_count, graph.names().len());
219
+ assert_eq!(string_count, graph.strings().len());
220
+ }
185
221
  }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubydex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-20 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A high-performance static analysis suite for Ruby, built in Rust with
14
14
  Ruby APIs
@@ -89,6 +89,16 @@ files:
89
89
  - lib/rubydex/reference.rb
90
90
  - lib/rubydex/related_information.rb
91
91
  - lib/rubydex/rule.rb
92
+ - lib/rubydex/rules/dynamic_ancestor.rb
93
+ - lib/rubydex/rules/dynamic_constant_reference.rb
94
+ - lib/rubydex/rules/dynamic_singleton_definition.rb
95
+ - lib/rubydex/rules/invalid_constant_visibility.rb
96
+ - lib/rubydex/rules/invalid_method_visibility.rb
97
+ - lib/rubydex/rules/parse_error.rb
98
+ - lib/rubydex/rules/parse_warning.rb
99
+ - lib/rubydex/rules/top_level_mixin_self.rb
100
+ - lib/rubydex/rules/undefined_constant_visibility_target.rb
101
+ - lib/rubydex/rules/undefined_method_visibility_target.rb
92
102
  - lib/rubydex/severity.rb
93
103
  - lib/rubydex/signature.rb
94
104
  - lib/rubydex/skill.rb
@@ -119,6 +129,7 @@ files:
119
129
  - rust/rubydex-sys/src/utils.rs
120
130
  - rust/rubydex/Cargo.toml
121
131
  - rust/rubydex/benches/graph_memory.rs
132
+ - rust/rubydex/src/bin/generate_ruby_rules.rs
122
133
  - rust/rubydex/src/compile_assertions.rs
123
134
  - rust/rubydex/src/config.rs
124
135
  - rust/rubydex/src/diagnostic.rs