rubydex 0.4.0 → 0.4.2

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -14
  3. data/THIRD_PARTY_LICENSES.html +13 -24
  4. data/ext/rubydex/definition.c +34 -0
  5. data/ext/rubydex/definition.h +3 -0
  6. data/ext/rubydex/diagnostic.c +2 -49
  7. data/ext/rubydex/extconf.rb +4 -2
  8. data/ext/rubydex/graph.c +59 -11
  9. data/lib/rubydex/cli/command/dead_code.rb +59 -0
  10. data/lib/rubydex/cli/command/lint/explain.rb +8 -6
  11. data/lib/rubydex/cli/command/lint.rb +10 -4
  12. data/lib/rubydex/linter/rule_loader.rb +1 -1
  13. data/lib/rubydex/mcp_server/tools/codebase_stats_tool.rb +1 -1
  14. data/lib/rubydex/mcp_server/tools/find_constant_references_tool.rb +4 -4
  15. data/lib/rubydex/mcp_server/tools/find_dead_code_candidates_tool.rb +33 -0
  16. data/lib/rubydex/mcp_server/tools/get_declaration_tool.rb +2 -2
  17. data/lib/rubydex/mcp_server/tools/get_descendants_tool.rb +4 -4
  18. data/lib/rubydex/mcp_server/tools/get_file_declarations_tool.rb +2 -2
  19. data/lib/rubydex/mcp_server/tools/search_declarations_tool.rb +6 -6
  20. data/lib/rubydex/mcp_server.rb +24 -12
  21. data/lib/rubydex/rules/dynamic_ancestor.rb +29 -0
  22. data/lib/rubydex/rules/dynamic_constant_reference.rb +25 -0
  23. data/lib/rubydex/rules/dynamic_singleton_definition.rb +30 -0
  24. data/lib/rubydex/rules/invalid_constant_visibility.rb +28 -0
  25. data/lib/rubydex/rules/invalid_method_visibility.rb +28 -0
  26. data/lib/rubydex/rules/parse_error.rb +25 -0
  27. data/lib/rubydex/rules/parse_warning.rb +28 -0
  28. data/lib/rubydex/rules/top_level_mixin_self.rb +27 -0
  29. data/lib/rubydex/rules/undefined_constant_visibility_target.rb +27 -0
  30. data/lib/rubydex/rules/undefined_method_visibility_target.rb +27 -0
  31. data/lib/rubydex/skill.rb +1 -0
  32. data/lib/rubydex/version.rb +1 -1
  33. data/lib/rubydex.rb +2 -0
  34. data/lib/rubydex_linter/rules/rule_structure.rb +19 -4
  35. data/rbi/rubydex.rbi +32 -130
  36. data/rust/Cargo.lock +17 -11
  37. data/rust/Cargo.toml +3 -0
  38. data/rust/rubydex/Cargo.toml +6 -2
  39. data/rust/rubydex/src/bin/generate_ruby_rules.rs +94 -0
  40. data/rust/rubydex/src/compile_assertions.rs +2 -0
  41. data/rust/rubydex/src/config.rs +1 -1
  42. data/rust/rubydex/src/diagnostic.rs +142 -51
  43. data/rust/rubydex/src/indexing/rbs_indexer.rs +4 -4
  44. data/rust/rubydex/src/indexing/ruby_indexer.rs +26 -18
  45. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +42 -2
  46. data/rust/rubydex/src/indexing.rs +6 -6
  47. data/rust/rubydex/src/listing.rs +12 -12
  48. data/rust/rubydex/src/model/built_in.rs +1 -1
  49. data/rust/rubydex/src/model/comment.rs +4 -4
  50. data/rust/rubydex/src/model/declaration.rs +25 -17
  51. data/rust/rubydex/src/model/document.rs +11 -11
  52. data/rust/rubydex/src/model/graph.rs +3 -3
  53. data/rust/rubydex/src/offset.rs +1 -13
  54. data/rust/rubydex/src/operation/ruby_builder.rs +29 -19
  55. data/rust/rubydex/src/query.rs +193 -2
  56. data/rust/rubydex/src/test_utils/graph_test.rs +2 -2
  57. data/rust/rubydex/src/test_utils/local_graph_test.rs +13 -6
  58. data/rust/rubydex-sys/Cargo.toml +2 -2
  59. data/rust/rubydex-sys/src/definition_api.rs +69 -2
  60. data/rust/rubydex-sys/src/diagnostic_api.rs +0 -35
  61. data/rust/rubydex-sys/src/graph_api.rs +193 -45
  62. data/rust/rubydex-sys/src/name_api.rs +219 -48
  63. metadata +191 -180
@@ -5,12 +5,12 @@ 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;
12
12
  use crate::model::identity_maps::IdentityHashSet;
13
- use crate::model::ids::{DeclarationId, DefinitionId, NameId, StringId, UriId};
13
+ use crate::model::ids::{ConstantReferenceId, DeclarationId, DefinitionId, NameId, StringId, UriId};
14
14
  use crate::model::keywords::{self, Keyword};
15
15
  use crate::model::name::NameRef;
16
16
  use crate::model::visibility::Visibility;
@@ -833,6 +833,64 @@ 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()
879
+ .is_some_and(<[ConstantReferenceId]>::is_empty)
880
+ && !matches!(decl, Declaration::Namespace(Namespace::SingletonClass(_)))
881
+ && !decl.has_no_definitions()
882
+ && !is_built_in(graph, decl)
883
+ })
884
+ .copied()
885
+ .collect::<Vec<_>>()
886
+ })
887
+ })
888
+ .collect();
889
+
890
+ handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
891
+ })
892
+ }
893
+
836
894
  #[cfg(test)]
837
895
  mod tests {
838
896
  use std::str::FromStr;
@@ -920,6 +978,21 @@ mod tests {
920
978
  };
921
979
  }
922
980
 
981
+ macro_rules! assert_dead_code_candidates {
982
+ ($context:expr, [$($expected:expr),* $(,)?]) => {
983
+ let mut actual: Vec<String> = dead_code_candidates($context.graph())
984
+ .iter()
985
+ .map(|id| $context.graph().declarations().get(id).unwrap().name().to_string())
986
+ .collect();
987
+ actual.sort();
988
+
989
+ let mut expected: Vec<String> = vec![$(String::from($expected)),*];
990
+ expected.sort();
991
+
992
+ assert_eq!(expected, actual);
993
+ };
994
+ }
995
+
923
996
  #[test]
924
997
  fn fuzzy_search_returns_partial_matches() {
925
998
  let mut context = GraphTest::new();
@@ -3982,4 +4055,122 @@ mod tests {
3982
4055
  Err(FindMemberError::DeclarationNotFound),
3983
4056
  );
3984
4057
  }
4058
+
4059
+ #[test]
4060
+ fn dead_code_candidates_returns_unreferenced_constants() {
4061
+ let mut context = GraphTest::new();
4062
+ context.index_uri(
4063
+ "file:///foo.rb",
4064
+ r"
4065
+ class UnusedClass; end
4066
+ module UnusedModule; end
4067
+ UNUSED_CONSTANT = 1
4068
+ ",
4069
+ );
4070
+ context.resolve();
4071
+
4072
+ assert_dead_code_candidates!(context, ["UnusedClass", "UnusedModule", "UNUSED_CONSTANT"]);
4073
+ }
4074
+
4075
+ #[test]
4076
+ fn dead_code_candidates_excludes_referenced_constants() {
4077
+ let mut context = GraphTest::new();
4078
+ context.index_uri(
4079
+ "file:///foo.rb",
4080
+ r"
4081
+ class UsedClass; end
4082
+ USED_CONSTANT = 1
4083
+ ",
4084
+ );
4085
+ context.index_uri(
4086
+ "file:///bar.rb",
4087
+ r"
4088
+ UsedClass
4089
+ USED_CONSTANT
4090
+ ",
4091
+ );
4092
+ context.resolve();
4093
+
4094
+ assert_dead_code_candidates!(context, []);
4095
+ }
4096
+
4097
+ #[test]
4098
+ fn dead_code_candidates_excludes_methods_and_variables() {
4099
+ let mut context = GraphTest::new();
4100
+ context.index_uri(
4101
+ "file:///foo.rb",
4102
+ r"
4103
+ $global_var = 1
4104
+
4105
+ class Holder
4106
+ @@class_var = 1
4107
+
4108
+ def initialize
4109
+ @instance_var = 1
4110
+ end
4111
+
4112
+ def never_called; end
4113
+ end
4114
+ ",
4115
+ );
4116
+ context.resolve();
4117
+
4118
+ assert_dead_code_candidates!(context, ["Holder"]);
4119
+ }
4120
+
4121
+ #[test]
4122
+ fn dead_code_candidates_counts_references_rather_than_reachability() {
4123
+ let mut context = GraphTest::new();
4124
+ context.index_uri(
4125
+ "file:///foo.rb",
4126
+ r"
4127
+ class Target; end
4128
+ AliasName = Target
4129
+ ",
4130
+ );
4131
+ context.resolve();
4132
+
4133
+ // `AliasName` is itself dead, but it still references `Target`, giving `Target` a non-zero count.
4134
+ // A single pass only peels the outermost layer of a dead subgraph.
4135
+ assert_dead_code_candidates!(context, ["AliasName"]);
4136
+ }
4137
+
4138
+ #[test]
4139
+ fn dead_code_candidates_excludes_built_ins_and_definitionless_declarations() {
4140
+ let mut context = GraphTest::new();
4141
+ context.index_uri(
4142
+ "file:///foo.rb",
4143
+ r"
4144
+ class Unused
4145
+ def self.class_method; end
4146
+ end
4147
+
4148
+ class Class
4149
+ end
4150
+ ",
4151
+ );
4152
+ context.resolve();
4153
+
4154
+ // A reopened built-in remains excluded regardless of definition order.
4155
+ assert_dead_code_candidates!(context, ["Unused"]);
4156
+ }
4157
+
4158
+ #[test]
4159
+ fn dead_code_candidates_excludes_singleton_classes() {
4160
+ let mut context = GraphTest::new();
4161
+ context.index_uri(
4162
+ "file:///foo.rb",
4163
+ r"
4164
+ class Unused; end
4165
+ class Attached; end
4166
+
4167
+ class << Attached
4168
+ def never_called; end
4169
+ end
4170
+ ",
4171
+ );
4172
+ context.resolve();
4173
+
4174
+ assert_dead_code_candidates!(context, ["Unused"]);
4175
+ }
3985
4176
  }
@@ -44,14 +44,14 @@ impl GraphTest {
44
44
  /// Indexes a Ruby source
45
45
  pub fn index_uri(&mut self, uri: &str, source: &str) {
46
46
  let source = normalize_indentation(source);
47
- let local_graph = indexing::build_local_graph(uri.to_string(), &source, &LanguageId::Ruby, self.backend);
47
+ let local_graph = indexing::build_local_graph(uri.into(), &source, &LanguageId::Ruby, self.backend);
48
48
  self.graph.consume_document_changes(local_graph);
49
49
  }
50
50
 
51
51
  /// Indexes an RBS source
52
52
  pub fn index_rbs_uri(&mut self, uri: &str, source: &str) {
53
53
  let source = normalize_indentation(source);
54
- indexing::index_source(&mut self.graph, uri, &source, &LanguageId::Rbs);
54
+ indexing::index_source(&mut self.graph, uri.into(), &source, &LanguageId::Rbs);
55
55
  }
56
56
 
57
57
  pub fn delete_uri(&mut self, uri: &str) {
@@ -24,22 +24,29 @@ impl LocalGraphTest {
24
24
 
25
25
  #[must_use]
26
26
  pub fn new_with_backend(uri: &str, source: &str, backend: IndexerBackend) -> Self {
27
- let uri = uri.to_string();
28
27
  let source = normalize_indentation(source);
29
- let graph = build_local_graph(uri.clone(), &source, &LanguageId::Ruby, backend);
30
- Self { uri, source, graph }
28
+ let graph = build_local_graph(uri.into(), &source, &LanguageId::Ruby, backend);
29
+
30
+ Self {
31
+ uri: uri.to_string(),
32
+ source,
33
+ graph,
34
+ }
31
35
  }
32
36
 
33
37
  #[must_use]
34
38
  pub fn new_rbs(uri: &str, source: &str) -> Self {
35
- let uri = uri.to_string();
36
39
  let source = normalize_indentation(source);
37
40
 
38
- let mut indexer = RBSIndexer::new(uri.clone(), &source);
41
+ let mut indexer = RBSIndexer::new(uri.into(), &source);
39
42
  indexer.index();
40
43
  let graph = indexer.local_graph();
41
44
 
42
- Self { uri, source, graph }
45
+ Self {
46
+ uri: uri.to_string(),
47
+ source,
48
+ graph,
49
+ }
43
50
  }
44
51
 
45
52
  #[must_use]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rubydex-sys"
3
- version = "0.2.6"
3
+ version.workspace = true
4
4
  edition = "2024"
5
5
  rust-version = "1.89.0"
6
6
  license = "MIT"
@@ -15,7 +15,7 @@ categories = ["development-tools", "external-ffi-bindings"]
15
15
  crate-type = ["cdylib", "staticlib"]
16
16
 
17
17
  [dependencies]
18
- rubydex = { version = "0.2.6", path = "../rubydex" }
18
+ rubydex = { version = "=0.4.2", path = "../rubydex" }
19
19
  libc = "0.2.174"
20
20
  url = "2.5.4"
21
21
  line-index = "0.1.2"
@@ -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 {
@@ -181,7 +248,7 @@ pub unsafe extern "C" fn rdx_definition_comments(pointer: GraphPointer, definiti
181
248
  .comments()
182
249
  .iter()
183
250
  .map(|c| CommentEntry {
184
- string: CString::new(c.string().as_str()).unwrap().into_raw().cast_const(),
251
+ string: CString::new(c.string()).unwrap().into_raw().cast_const(),
185
252
  location: create_location_for_uri_and_offset(graph, document, c.offset()),
186
253
  })
187
254
  .collect::<Vec<CommentEntry>>()
@@ -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 {