rubydex 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -7
  3. data/THIRD_PARTY_LICENSES.html +238 -2
  4. data/exe/rdx +2 -149
  5. data/ext/rubydex/config.c +140 -0
  6. data/ext/rubydex/config.h +16 -0
  7. data/ext/rubydex/diagnostic.c +75 -1
  8. data/ext/rubydex/diagnostic.h +2 -0
  9. data/ext/rubydex/graph.c +13 -45
  10. data/ext/rubydex/graph.h +6 -0
  11. data/ext/rubydex/query.c +398 -16
  12. data/ext/rubydex/rubydex.c +2 -0
  13. data/ext/rubydex/utils.c +11 -4
  14. data/lib/ruby_lsp/rubydex/addon.rb +211 -0
  15. data/lib/rubydex/cli/command/console.rb +55 -0
  16. data/lib/rubydex/cli/command/lint/explain.rb +74 -0
  17. data/lib/rubydex/cli/command/lint.rb +202 -0
  18. data/lib/rubydex/cli/command/mcp.rb +30 -0
  19. data/lib/rubydex/cli/command/query.rb +70 -0
  20. data/lib/rubydex/cli/command/skill.rb +69 -0
  21. data/lib/rubydex/cli/command.rb +168 -0
  22. data/lib/rubydex/cli.rb +93 -0
  23. data/lib/rubydex/config.rb +59 -0
  24. data/lib/rubydex/diagnostic.rb +12 -3
  25. data/lib/rubydex/errors.rb +42 -1
  26. data/lib/rubydex/graph.rb +10 -3
  27. data/lib/rubydex/linter/custom_rule.rb +97 -0
  28. data/lib/rubydex/linter/helpers/path_helpers.rb +78 -0
  29. data/lib/rubydex/linter/helpers/source_access_helpers.rb +31 -0
  30. data/lib/rubydex/linter/rule_loader.rb +36 -0
  31. data/lib/rubydex/linter/rule_test_case.rb +343 -0
  32. data/lib/rubydex/linter/runner.rb +56 -0
  33. data/lib/rubydex/linter.rb +19 -0
  34. data/lib/rubydex/location.rb +3 -0
  35. data/lib/rubydex/mcp_server.rb +1 -2
  36. data/lib/rubydex/related_information.rb +17 -0
  37. data/lib/rubydex/rule.rb +33 -0
  38. data/lib/rubydex/severity.rb +70 -0
  39. data/lib/rubydex/skill.rb +88 -0
  40. data/lib/rubydex/skill_registry.rb +62 -0
  41. data/lib/rubydex/version.rb +1 -1
  42. data/lib/rubydex.rb +6 -0
  43. data/lib/rubydex_linter/rules/rule_structure.rb +125 -0
  44. data/rbi/rubydex.rbi +558 -17
  45. data/rust/Cargo.lock +2 -2
  46. data/rust/rubydex/Cargo.toml +1 -1
  47. data/rust/rubydex/benches/graph_memory.rs +3 -5
  48. data/rust/rubydex/src/config.rs +538 -157
  49. data/rust/rubydex/src/diagnostic.rs +66 -40
  50. data/rust/rubydex/src/errors.rs +0 -1
  51. data/rust/rubydex/src/indexing/local_graph.rs +6 -5
  52. data/rust/rubydex/src/indexing/rbs_indexer.rs +270 -6
  53. data/rust/rubydex/src/indexing/ruby_indexer.rs +10 -12
  54. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +134 -81
  55. data/rust/rubydex/src/lib.rs +1 -0
  56. data/rust/rubydex/src/listing.rs +26 -1
  57. data/rust/rubydex/src/main.rs +7 -4
  58. data/rust/rubydex/src/model/declaration.rs +302 -219
  59. data/rust/rubydex/src/model/graph.rs +27 -40
  60. data/rust/rubydex/src/model/name.rs +58 -17
  61. data/rust/rubydex/src/operation/ruby_builder.rs +30 -45
  62. data/rust/rubydex/src/path_helpers.rs +77 -0
  63. data/rust/rubydex/src/query/cypher/schema.rs +63 -0
  64. data/rust/rubydex/src/query/cypher/tests.rs +26 -1
  65. data/rust/rubydex/src/query/cypher.rs +8 -11
  66. data/rust/rubydex/src/query.rs +123 -43
  67. data/rust/rubydex/src/resolution.rs +139 -187
  68. data/rust/rubydex/src/resolution_tests.rs +247 -19
  69. data/rust/rubydex/src/test_utils/context.rs +2 -1
  70. data/rust/rubydex/src/test_utils/graph_test.rs +26 -12
  71. data/rust/rubydex/src/test_utils/local_graph_test.rs +19 -0
  72. data/rust/rubydex/tests/cli.rs +4 -4
  73. data/rust/rubydex-sys/src/config_api.rs +205 -0
  74. data/rust/rubydex-sys/src/cypher_api.rs +791 -0
  75. data/rust/rubydex-sys/src/diagnostic_api.rs +77 -8
  76. data/rust/rubydex-sys/src/graph_api.rs +13 -194
  77. data/rust/rubydex-sys/src/lib.rs +2 -0
  78. data/rust/rubydex-sys/src/name_api.rs +2 -6
  79. data/rust/rubydex-sys/src/utils.rs +37 -0
  80. data/skills/send-private-method/SKILL.md +133 -0
  81. metadata +31 -2
@@ -3,12 +3,87 @@
3
3
  use crate::graph_api::{GraphPointer, with_graph};
4
4
  use crate::location_api::{Location, create_location_for_uri_and_offset};
5
5
  use libc::c_char;
6
+ use rubydex::diagnostic::{Rule, Severity};
6
7
  use std::{ffi::CString, mem, ptr};
7
8
 
9
+ /// C-compatible enum representing diagnostic severity levels.
10
+ #[repr(C)]
11
+ #[derive(Debug, Copy, Clone, PartialEq, Eq)]
12
+ pub enum DiagnosticSeverity {
13
+ Error = 1,
14
+ Warning = 2,
15
+ Information = 3,
16
+ Hint = 4,
17
+ }
18
+
19
+ impl From<Severity> for DiagnosticSeverity {
20
+ fn from(severity: Severity) -> Self {
21
+ match severity {
22
+ Severity::Error => DiagnosticSeverity::Error,
23
+ Severity::Warning => DiagnosticSeverity::Warning,
24
+ Severity::Information => DiagnosticSeverity::Information,
25
+ Severity::Hint => DiagnosticSeverity::Hint,
26
+ }
27
+ }
28
+ }
29
+
30
+ #[repr(C)]
31
+ #[derive(Debug)]
32
+ pub struct CRule {
33
+ pub name: *const c_char,
34
+ pub name_length: usize,
35
+ pub default_severity: DiagnosticSeverity,
36
+ }
37
+
38
+ impl From<Rule> for CRule {
39
+ fn from(rule: Rule) -> Self {
40
+ let name = rule.name();
41
+
42
+ Self {
43
+ name: name.as_ptr().cast::<c_char>(),
44
+ name_length: name.len(),
45
+ default_severity: DiagnosticSeverity::from(rule.default_severity()),
46
+ }
47
+ }
48
+ }
49
+
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
+
8
83
  /// C-compatible struct representing a diagnostic entry.
9
84
  #[repr(C)]
10
85
  pub struct DiagnosticEntry {
11
- pub rule: *const c_char,
86
+ pub rule: CRule,
12
87
  pub message: *const c_char,
13
88
  pub location: *mut Location,
14
89
  }
@@ -50,10 +125,7 @@ pub unsafe extern "C" fn rdx_graph_diagnostics(pointer: GraphPointer) -> *mut Di
50
125
  let location = create_location_for_uri_and_offset(graph, document, diagnostic.offset());
51
126
 
52
127
  DiagnosticEntry {
53
- rule: CString::new(diagnostic.rule().to_string())
54
- .unwrap()
55
- .into_raw()
56
- .cast_const(),
128
+ rule: CRule::from(*diagnostic.rule()),
57
129
  message: CString::new(diagnostic.message()).unwrap().into_raw().cast_const(),
58
130
  location,
59
131
  }
@@ -82,9 +154,6 @@ pub unsafe extern "C" fn rdx_diagnostics_free(ptr: *mut DiagnosticArray) {
82
154
  let mut boxed_slice: Box<[DiagnosticEntry]> = unsafe { Box::from_raw(slice_ptr) };
83
155
 
84
156
  for entry in &mut *boxed_slice {
85
- if !entry.rule.is_null() {
86
- let _ = unsafe { CString::from_raw(entry.rule.cast_mut()) };
87
- }
88
157
  if !entry.message.is_null() {
89
158
  let _ = unsafe { CString::from_raw(entry.message.cast_mut()) };
90
159
  }
@@ -1,5 +1,6 @@
1
1
  //! This file provides the C API for the Graph object
2
2
 
3
+ use crate::config_api::ConfigPointer;
3
4
  use crate::declaration_api::CDeclaration;
4
5
  use crate::declaration_api::DeclarationsIter;
5
6
  use crate::declaration_api::decl_id_from_char_ptr;
@@ -7,7 +8,7 @@ use crate::document_api::DocumentsIter;
7
8
  use crate::reference_api::{CConstantReference, CMethodReference, ConstantReferencesIter, MethodReferencesIter};
8
9
  use crate::{name_api, utils};
9
10
  use libc::{c_char, c_void};
10
- use rubydex::errors::Errors;
11
+ use rubydex::config::Config;
11
12
  use rubydex::indexing::LanguageId;
12
13
  use rubydex::model::encoding::Encoding;
13
14
  use rubydex::model::graph::Graph;
@@ -15,12 +16,11 @@ use rubydex::model::ids::{DeclarationId, NameId, UriId, declaration_id_from_look
15
16
  use rubydex::model::keywords;
16
17
  use rubydex::model::name::NameRef;
17
18
  use rubydex::model::visibility::Visibility;
18
- use rubydex::query::cypher::{self, OutputFormat};
19
19
  use rubydex::query::{CompletionCandidate, CompletionContext, CompletionReceiver};
20
20
  use rubydex::resolution::Resolver;
21
21
  use rubydex::{indexing, integrity, listing, query};
22
22
  use std::ffi::CString;
23
- use std::path::{Path, PathBuf};
23
+ use std::path::PathBuf;
24
24
  use std::{mem, ptr, sync::RwLock};
25
25
 
26
26
  pub type GraphPointer = *mut c_void;
@@ -216,14 +216,8 @@ pub unsafe extern "C" fn rdx_graph_excluded_patterns(
216
216
 
217
217
  let c_strings: Vec<*const c_char> = excluded
218
218
  .iter()
219
- .filter_map(|path| {
220
- // Normalize all paths to use forward slashes. Otherwise, you get mixed backslashes and forward slashes
221
- // on Windows if a configuration file is using forward slashes. For example:
222
- //
223
- // C:\project/vendor/bundle
224
- let normalized = path.replace(std::path::MAIN_SEPARATOR, "/");
225
-
226
- CString::new(normalized)
219
+ .filter_map(|pattern| {
220
+ CString::new(pattern.as_ref())
227
221
  .ok()
228
222
  .map(|c_string| c_string.into_raw().cast_const())
229
223
  })
@@ -236,23 +230,6 @@ pub unsafe extern "C" fn rdx_graph_excluded_patterns(
236
230
  })
237
231
  }
238
232
 
239
- /// Sets the workspace path used as the root directory for indexing and relative path resolution. Silently ignores the
240
- /// call if the given path is not valid UTF-8, leaving the existing workspace path untouched (mirrors
241
- /// `rdx_graph_set_encoding`). This avoids unwinding across the FFI boundary on malformed input.
242
- ///
243
- /// # Safety
244
- ///
245
- /// - `pointer` must be a valid `GraphPointer` previously returned by this crate.
246
- /// - `path` must be a valid, null-terminated string.
247
- #[unsafe(no_mangle)]
248
- pub unsafe extern "C" fn rdx_graph_set_workspace_path(pointer: GraphPointer, path: *const c_char) {
249
- let Ok(path) = (unsafe { utils::convert_char_ptr_to_string(path) }) else {
250
- return;
251
- };
252
-
253
- with_mut_graph(pointer, |graph| graph.set_workspace_path(PathBuf::from(path)));
254
- }
255
-
256
233
  /// Returns the workspace path as a C string. Caller must free with `free_c_string`.
257
234
  ///
258
235
  /// # Safety
@@ -261,42 +238,23 @@ pub unsafe extern "C" fn rdx_graph_set_workspace_path(pointer: GraphPointer, pat
261
238
  #[unsafe(no_mangle)]
262
239
  pub unsafe extern "C" fn rdx_graph_workspace_path(pointer: GraphPointer) -> *const c_char {
263
240
  with_graph(pointer, |graph| {
264
- CString::new(graph.workspace_path().to_string_lossy().as_ref())
241
+ CString::new(utils::interop_path(graph.workspace_path()))
265
242
  .map_or(ptr::null(), |c_string| c_string.into_raw().cast_const())
266
243
  })
267
244
  }
268
245
 
269
- /// Loads configuration into the graph. A null `config_path` attempts to load the default configuration file.
270
- ///
271
- /// Returns NULL on success. On failure returns an owned, null-terminated error message that the caller must free with
272
- /// `free_c_string`.
273
- ///
274
- /// A `config_path` that is not valid UTF-8 is reported as an error message.
246
+ /// Applies a parsed configuration file to the graph, which adopts the workspace it was loaded for along with the
247
+ /// settings of its `[graph]` section. This is the only way to point the graph at a workspace other than the current
248
+ /// directory, and it replaces any previously applied configuration. Tool-specific sections are ignored.
275
249
  ///
276
250
  /// # Safety
277
251
  ///
278
252
  /// - `pointer` must be a valid `GraphPointer` previously returned by this crate.
279
- /// - `config_path` must either be NULL or a valid, null-terminated string.
253
+ /// - `config` must be a valid `ConfigPointer` previously returned by `rdx_config_load`.
280
254
  #[unsafe(no_mangle)]
281
- pub unsafe extern "C" fn rdx_graph_load_config(pointer: GraphPointer, config_path: *const c_char) -> *const c_char {
282
- let result = with_mut_graph(pointer, |graph| {
283
- if config_path.is_null() {
284
- graph.load_config(None)
285
- } else {
286
- match unsafe { utils::convert_char_ptr_to_string(config_path) } {
287
- Ok(config_path) => graph.load_config(Some(Path::new(&config_path))),
288
- Err(_) => Err(Errors::ConfigError("config file path is not valid UTF-8".to_string())),
289
- }
290
- }
291
- });
292
-
293
- match result {
294
- Ok(()) => ptr::null(),
295
- Err(error) => CString::new(error.to_string())
296
- .unwrap_or_default()
297
- .into_raw()
298
- .cast_const(),
299
- }
255
+ pub unsafe extern "C" fn rdx_graph_load_config(pointer: GraphPointer, config: ConfigPointer) {
256
+ let config = unsafe { &*config.cast::<Config>() };
257
+ with_mut_graph(pointer, |graph| graph.load_config(config));
300
258
  }
301
259
 
302
260
  /// Indexes all given file paths in parallel using the provided Graph pointer.
@@ -1066,145 +1024,6 @@ pub unsafe extern "C" fn rdx_keyword_get(name: *const c_char) -> *const CKeyword
1066
1024
  }
1067
1025
  }
1068
1026
 
1069
- /// The result of running a Cypher query, carrying either the formatted output or an error message.
1070
- #[repr(C)]
1071
- pub struct CQueryResult {
1072
- /// Non-null on success; null on error. Caller must free with `free_c_string`.
1073
- pub output: *const c_char,
1074
- /// Non-null on error; null on success. Caller must free with `free_c_string`.
1075
- pub error: *const c_char,
1076
- }
1077
-
1078
- impl CQueryResult {
1079
- fn success(output: &str) -> Self {
1080
- match CString::new(output) {
1081
- Ok(c_string) => Self {
1082
- output: c_string.into_raw().cast_const(),
1083
- error: ptr::null(),
1084
- },
1085
- Err(_) => Self::error("query output contained an interior NUL byte"),
1086
- }
1087
- }
1088
-
1089
- fn error(message: &str) -> Self {
1090
- Self {
1091
- output: ptr::null(),
1092
- error: CString::new(message).map_or(ptr::null(), |s| s.into_raw().cast_const()),
1093
- }
1094
- }
1095
- }
1096
-
1097
- /// The result of parsing a Cypher query into an opaque, reusable parsed-query object.
1098
- #[repr(C)]
1099
- pub struct CParseResult {
1100
- /// Non-null on success: a heap-allocated parsed query. Free with `rdx_cypher_query_free`.
1101
- pub query: *mut c_void,
1102
- /// Non-null on error; null on success. Caller must free with `free_c_string`.
1103
- pub error: *const c_char,
1104
- }
1105
-
1106
- /// Parses a Cypher query string into an opaque parsed-query object, without needing a graph.
1107
- ///
1108
- /// On success, `query` is a heap-allocated parsed query that can be executed against a graph with
1109
- /// `rdx_query_run` and must eventually be freed with `rdx_cypher_query_free`. On failure, `error`
1110
- /// holds the message.
1111
- ///
1112
- /// # Safety
1113
- ///
1114
- /// - `query` must be a valid, null-terminated UTF-8 string.
1115
- #[unsafe(no_mangle)]
1116
- pub unsafe extern "C" fn rdx_cypher_parse(query: *const c_char) -> CParseResult {
1117
- let Ok(query_str) = (unsafe { utils::convert_char_ptr_to_string(query) }) else {
1118
- return CParseResult {
1119
- query: ptr::null_mut(),
1120
- error: CString::new("query is not valid UTF-8").map_or(ptr::null(), |s| s.into_raw().cast_const()),
1121
- };
1122
- };
1123
-
1124
- match cypher::parse(&query_str) {
1125
- Ok(parsed) => CParseResult {
1126
- query: Box::into_raw(Box::new(parsed)).cast::<c_void>(),
1127
- error: ptr::null(),
1128
- },
1129
- Err(error) => CParseResult {
1130
- query: ptr::null_mut(),
1131
- error: CString::new(error.to_string()).map_or(ptr::null(), |s| s.into_raw().cast_const()),
1132
- },
1133
- }
1134
- }
1135
-
1136
- /// Frees a parsed query previously returned by `rdx_cypher_parse`.
1137
- ///
1138
- /// # Safety
1139
- ///
1140
- /// - `query` must be a pointer returned by `rdx_cypher_parse`, or null. It must not be used after.
1141
- #[unsafe(no_mangle)]
1142
- pub unsafe extern "C" fn rdx_cypher_query_free(query: *mut c_void) {
1143
- if query.is_null() {
1144
- return;
1145
- }
1146
- let _ = unsafe { Box::from_raw(query.cast::<cypher::Query>()) };
1147
- }
1148
-
1149
- /// Executes a previously parsed query (from `rdx_cypher_parse`) against the graph and returns the
1150
- /// formatted output or an error message. `format` must be `"table"` or `"json"`.
1151
- ///
1152
- /// # Safety
1153
- ///
1154
- /// - `query` must be a valid pointer returned by `rdx_cypher_parse`.
1155
- /// - `pointer` must be a valid `GraphPointer` previously returned by this crate.
1156
- /// - `format` must be a valid, null-terminated UTF-8 string.
1157
- #[unsafe(no_mangle)]
1158
- pub unsafe extern "C" fn rdx_query_run(
1159
- query: *const c_void,
1160
- pointer: GraphPointer,
1161
- format: *const c_char,
1162
- ) -> CQueryResult {
1163
- if query.is_null() {
1164
- return CQueryResult::error("query is null");
1165
- }
1166
-
1167
- let Ok(format_str) = (unsafe { utils::convert_char_ptr_to_string(format) }) else {
1168
- return CQueryResult::error("format is not valid UTF-8");
1169
- };
1170
-
1171
- let output_format = match format_str.as_str() {
1172
- "table" => OutputFormat::Table,
1173
- "json" => OutputFormat::Json,
1174
- other => {
1175
- return CQueryResult::error(&format!("unknown query format `{other}` (expected `table` or `json`)"));
1176
- }
1177
- };
1178
-
1179
- let parsed = unsafe { &*query.cast::<cypher::Query>() };
1180
-
1181
- with_graph(pointer, |graph| {
1182
- match cypher::run_parsed(graph, parsed, output_format) {
1183
- Ok(output) => CQueryResult::success(&output),
1184
- Err(error) => CQueryResult::error(&error.to_string()),
1185
- }
1186
- })
1187
- }
1188
-
1189
- /// Returns a description of the queryable Cypher schema (node labels, relationship types, and
1190
- /// properties) in the given format (`"table"` or `"json"`). The schema is static and requires no
1191
- /// graph. Caller must free the returned pointer with `free_c_string`.
1192
- ///
1193
- /// # Safety
1194
- ///
1195
- /// - `format` must be a valid, null-terminated UTF-8 string.
1196
- #[unsafe(no_mangle)]
1197
- pub unsafe extern "C" fn rdx_cypher_schema(format: *const c_char) -> *const c_char {
1198
- let format_str = unsafe { utils::convert_char_ptr_to_string(format) }.unwrap_or_else(|_| "table".to_string());
1199
- let output_format = if format_str == "json" {
1200
- OutputFormat::Json
1201
- } else {
1202
- OutputFormat::Table
1203
- };
1204
-
1205
- CString::new(cypher::schema(output_format)).map_or(ptr::null(), |s| s.into_raw().cast_const())
1206
- }
1207
-
1208
1027
  #[repr(u8)]
1209
1028
  #[derive(Debug, Clone, Copy)]
1210
1029
  pub enum CVisibility {
@@ -68,6 +68,8 @@ macro_rules! iterator {
68
68
  };
69
69
  }
70
70
 
71
+ pub mod config_api;
72
+ pub mod cypher_api;
71
73
  pub mod declaration_api;
72
74
  pub mod definition_api;
73
75
  pub mod diagnostic_api;
@@ -1,8 +1,4 @@
1
- use rubydex::model::{
2
- graph::Graph,
3
- ids::NameId,
4
- name::{Name, ParentScope},
5
- };
1
+ use rubydex::model::{graph::Graph, ids::NameId, name::ParentScope};
6
2
 
7
3
  /// Takes a constant name and a nesting stack (e.g.: `["Foo", "Bar::Baz", "Qux"]`) and transforms it into a `NameId`,
8
4
  /// registering each required part in the graph. Returns the `NameId` and a list of name ids that need to be untracked
@@ -74,7 +70,7 @@ fn process_qualified_name(
74
70
  };
75
71
 
76
72
  let str_id = graph.intern_string(part.to_owned());
77
- let name_id = graph.add_name(Name::new(str_id, parent_scope, nesting_for_part));
73
+ let name_id = graph.add_name(str_id, parent_scope, nesting_for_part);
78
74
  names_to_untrack.push(name_id);
79
75
  *current_name = ParentScope::Some(name_id);
80
76
  }
@@ -1,5 +1,6 @@
1
1
  use libc::{c_char, size_t};
2
2
  use std::ffi::{CStr, CString};
3
+ use std::path::Path;
3
4
  use std::slice;
4
5
  use std::str::Utf8Error;
5
6
 
@@ -68,3 +69,39 @@ pub unsafe extern "C" fn free_c_string_array(ptr: *const *const c_char, count: u
68
69
  .map(|arg| unsafe { CString::from_raw((*arg).cast_mut()) })
69
70
  .collect();
70
71
  }
72
+
73
+ /// Converts a Rust `&str` to an owned C string (`*const c_char`), suitable for returning across the
74
+ /// FFI boundary. The caller is responsible for freeing it with `free_c_string`.
75
+ ///
76
+ /// # Panics
77
+ ///
78
+ /// Panics if `value` contains an interior null byte, which should never occur in rubydex data.
79
+ #[must_use]
80
+ pub fn cstring_raw(value: &str) -> *const c_char {
81
+ CString::new(value).unwrap().into_raw().cast_const()
82
+ }
83
+
84
+ /// Rust uses backslashes as separators on Windows, but Ruby prefers forward slashes everywhere. We need to make sure
85
+ /// we're maintaining the right separators at the boundary.
86
+ #[must_use]
87
+ pub fn interop_path(path: &Path) -> String {
88
+ path.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/")
89
+ }
90
+
91
+ #[cfg(test)]
92
+ mod tests {
93
+ use super::*;
94
+
95
+ #[cfg(unix)]
96
+ #[test]
97
+ fn interop_path_leaves_a_unix_path_untouched() {
98
+ // A backslash is an ordinary character in a Unix file name, so rewriting it would name a different file.
99
+ assert_eq!(interop_path(Path::new(r"/tmp/we\ird")), r"/tmp/we\ird");
100
+ }
101
+
102
+ #[cfg(windows)]
103
+ #[test]
104
+ fn interop_path_separates_a_windows_path_with_forward_slashes() {
105
+ assert_eq!(interop_path(Path::new(r"D:\a\_temp\project")), "D:/a/_temp/project");
106
+ }
107
+ }
@@ -0,0 +1,133 @@
1
+ ---
2
+ name: send-private-method
3
+ description: Remediate a `send`/`__send__` call that reaches a private method, restoring a real public seam instead of bypassing visibility. Use when send is being used to invoke private functionality in a class.
4
+ ---
5
+
6
+ # Send Private Method
7
+
8
+ A test calls `obj.send(:some_private_method)` (or `__send__`) to invoke a method Ruby's visibility system would otherwise forbid. `send` deliberately bypasses encapsulation. `__send__` behaves identically and is the conventional spelling when a class defines its own `send`; Ruby warns when you redefine `__send__`, but it is not un-overridable. Remediation is the same for both. The smell is not "you used `send`" — it is "the test reaches past the public API into an implementation detail," which breaks the moment the private method is renamed, reorganized, or removed even though the public contract is unchanged.
9
+
10
+ ## Confirm the receiver and the method
11
+
12
+ You have a call site: a file path and a line number. Before changing anything, answer two questions:
13
+
14
+ 1. **What is the receiver's real type?** Trace the receiver expression at the flagged line back to its class. If it is a local/ivar, find where it is assigned (a `let`, `setup`, factory, or constructor).
15
+ 2. **Where is the private method defined?** Search the workspace for `def <method_name>` (and `define_method :<method_name>`). Confirm the definition is under `private` (or `private :<method_name>`). Note its enclosing class/module and read the method body — you cannot decide the fix without knowing what the method does.
16
+
17
+ A name-based search matches by method *name* across the whole workspace, so a public method and a private method sharing the same unqualified name on different classes can produce a false positive. If the receiver's type has a *public* method of that name, this is not the smell — stop here.
18
+
19
+ ## Decide which situation you are in
20
+
21
+ Read the method body and the test. Exactly one of these applies:
22
+
23
+ - **The caller needs behavior that should be public.** The private method does something the application legitimately depends on, and there is no public entry point that exposes it. The encapsulation boundary was drawn too tightly.
24
+ - **The caller is a test reaching past an existing public API.** A public method already wraps or drives this private one; the test bypassed it to assert on an intermediate result instead of the observable outcome. If the public outcome is already tested elsewhere, the private-method test is redundant — delete it rather than rewriting it.
25
+ - **The private method is the wrong home for the logic.** The behavior is genuinely useful to more than one caller, but it lives tucked away inside one class's private section. Both production and tests want it.
26
+
27
+ ## Fix per situation
28
+
29
+ ### 1. Promote to public (or `protected`)
30
+
31
+ If the behavior is part of the object's real contract, make it public and document it as API. Use `protected` only when every legitimate caller is an instance inside the same class hierarchy as the class that defines the method — never to paper over a single test's reach-in.
32
+
33
+ Before:
34
+ ```ruby
35
+ class Foo
36
+ def bar = qux.sum
37
+
38
+ private
39
+
40
+ def qux
41
+ @items.reject(&:voided?).map { |i| i.baz - i.discount }
42
+ end
43
+ end
44
+ # test
45
+ foo.send(:qux)
46
+ ```
47
+
48
+ After:
49
+ ```ruby
50
+ class Foo
51
+ def bar = qux.sum
52
+
53
+ # Public: items with discounts applied, voided items excluded.
54
+ # Used by reporting and by #bar's total.
55
+ def qux
56
+ @items.reject(&:voided?).map { |i| i.baz - i.discount }
57
+ end
58
+ end
59
+ # test
60
+ foo.qux
61
+ ```
62
+
63
+ ### 2. Drive the public entry point, assert on the observable outcome
64
+
65
+ If a public method already exercises the private one, call the public method and assert on what it produces — not on the private intermediate.
66
+
67
+ Before:
68
+ ```ruby
69
+ class Foo
70
+ def bar(baz) = Receipt.new(tax: baz.amount * qux(baz.quux))
71
+
72
+ private
73
+
74
+ def qux(quux) = RATES.fetch(quux)
75
+ end
76
+ # test
77
+ foo.send(:qux, "CA") # => 0.05
78
+ ```
79
+
80
+ After:
81
+ ```ruby
82
+ # test — assert on the observable result of the public API
83
+ result = foo.bar(baz_with(quux: "CA", amount: 100))
84
+ assert_equal 5.0, result.tax
85
+ ```
86
+
87
+ ### 3. Extract to its own object/module
88
+
89
+ When the logic serves multiple callers but is trapped in one class's private section, move it to a dedicated object or module with a public interface both callers can use.
90
+
91
+ Before:
92
+ ```ruby
93
+ class Foo
94
+ private
95
+
96
+ def bar(raw) = raw.transform_keys(&:to_s).compact
97
+ end
98
+ # test
99
+ foo.send(:bar, { baz: "Q" })
100
+ ```
101
+
102
+ After:
103
+ ```ruby
104
+ class Bar
105
+ def call(raw) = raw.transform_keys(&:to_s).compact
106
+ end
107
+
108
+ class Foo
109
+ def initialize(bar: Bar.new) = @bar = bar
110
+ def baz(raw) = @bar.call(raw)
111
+ end
112
+ # test
113
+ assert_equal({ "baz" => "Q" }, Bar.new.call({ baz: "Q" }))
114
+ ```
115
+
116
+ ## Private class methods and `module_function`
117
+
118
+ The three situations above cover instance methods. Two related visibility forms need special handling:
119
+
120
+ - **Private class methods** (`private_class_method :bar`, or a `def bar` under `private` inside `class << self`). A bare `private` does **not** apply to `def self.bar`: that singleton method stays public wherever it sits relative to the `private` line, so a public `def self.bar` under `private` is not this smell. A `send` like `Foo.send(:bar)` reaches a genuinely private singleton method. The remediation mirrors instance methods — promote with `public_class_method :bar` (or drop the `private_class_method :bar` call), drive an existing public class method, or extract. Do not use the instance-method patterns verbatim; the fix targets the singleton class.
121
+ - **`module_function`**. `module_function :foo` creates a public singleton method and a private instance method. If the test calls `instance.send(:foo)` on the private instance side, the public entry point already exists: call `MyModule.foo` directly. This is situation 2 — drive the public singleton method, do not promote the instance method.
122
+
123
+ ## Legitimate exceptions — and what to do about them
124
+
125
+ Not every `send` to a private name is a smell to remove. Handle these deliberately:
126
+
127
+ - **Dynamic dispatch over a validated allowlist.** The method name is genuinely computed (`send(action)`), and the action set is bounded and checked. Constrain it: define an `ALLOWED_ACTIONS = %i[...].freeze` constant, guard with `raise unless ALLOWED_ACTIONS.include?(action)`, and call `public_send(action)` so the visibility system still applies. If every allowed action is public, the smell is gone.
128
+ - **Framework or DSL callbacks.** Some libraries require `send` to reach hooks they themselves marked private. If the method name is dictated by the framework contract, leave the call but add a one-line comment naming the framework and the callback it satisfies.
129
+ - **Third-party code you cannot change.** The private method belongs to a gem or an owned-elsewhere class you must not edit. Wrap the reach-in behind a single named adapter method in your own code with a comment stating why (e.g. `# Sends :bar to avoid the gem's private API; remove when upstream exposes a public hook.`). The adapter localizes the violation so it is auditable and removable in one place.
130
+
131
+ ## What does NOT fix it
132
+
133
+ Switching `send` to `public_send` on a method that is *still private* does not fix anything — it just moves the failure from "silently bypassed encapsulation" to a `NoMethodError` at runtime. Answer the encapsulation question first; only then choose `public_send` (for dynamic dispatch over public methods) or a direct call (once promoted).
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-22 00:00:00.000000000 Z
11
+ date: 2026-08-20 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
@@ -24,6 +24,8 @@ files:
24
24
  - README.md
25
25
  - THIRD_PARTY_LICENSES.html
26
26
  - exe/rdx
27
+ - ext/rubydex/config.c
28
+ - ext/rubydex/config.h
27
29
  - ext/rubydex/declaration.c
28
30
  - ext/rubydex/declaration.h
29
31
  - ext/rubydex/definition.c
@@ -47,8 +49,18 @@ files:
47
49
  - ext/rubydex/signature.h
48
50
  - ext/rubydex/utils.c
49
51
  - ext/rubydex/utils.h
52
+ - lib/ruby_lsp/rubydex/addon.rb
50
53
  - lib/rubydex.rb
54
+ - lib/rubydex/cli.rb
55
+ - lib/rubydex/cli/command.rb
56
+ - lib/rubydex/cli/command/console.rb
57
+ - lib/rubydex/cli/command/lint.rb
58
+ - lib/rubydex/cli/command/lint/explain.rb
59
+ - lib/rubydex/cli/command/mcp.rb
60
+ - lib/rubydex/cli/command/query.rb
61
+ - lib/rubydex/cli/command/skill.rb
51
62
  - lib/rubydex/comment.rb
63
+ - lib/rubydex/config.rb
52
64
  - lib/rubydex/declaration.rb
53
65
  - lib/rubydex/diagnostic.rb
54
66
  - lib/rubydex/errors.rb
@@ -56,6 +68,13 @@ files:
56
68
  - lib/rubydex/graph.rb
57
69
  - lib/rubydex/keyword.rb
58
70
  - lib/rubydex/keyword_parameter.rb
71
+ - lib/rubydex/linter.rb
72
+ - lib/rubydex/linter/custom_rule.rb
73
+ - lib/rubydex/linter/helpers/path_helpers.rb
74
+ - lib/rubydex/linter/helpers/source_access_helpers.rb
75
+ - lib/rubydex/linter/rule_loader.rb
76
+ - lib/rubydex/linter/rule_test_case.rb
77
+ - lib/rubydex/linter/runner.rb
59
78
  - lib/rubydex/location.rb
60
79
  - lib/rubydex/mcp_server.rb
61
80
  - lib/rubydex/mcp_server/protocol.rb
@@ -68,8 +87,14 @@ files:
68
87
  - lib/rubydex/mcp_server/tools/search_declarations_tool.rb
69
88
  - lib/rubydex/mixin.rb
70
89
  - lib/rubydex/reference.rb
90
+ - lib/rubydex/related_information.rb
91
+ - lib/rubydex/rule.rb
92
+ - lib/rubydex/severity.rb
71
93
  - lib/rubydex/signature.rb
94
+ - lib/rubydex/skill.rb
95
+ - lib/rubydex/skill_registry.rb
72
96
  - lib/rubydex/version.rb
97
+ - lib/rubydex_linter/rules/rule_structure.rb
73
98
  - rbi/rubydex.rbi
74
99
  - rust/Cargo.lock
75
100
  - rust/Cargo.toml
@@ -79,6 +104,8 @@ files:
79
104
  - rust/rubydex-sys/Cargo.toml
80
105
  - rust/rubydex-sys/build.rs
81
106
  - rust/rubydex-sys/cbindgen.toml
107
+ - rust/rubydex-sys/src/config_api.rs
108
+ - rust/rubydex-sys/src/cypher_api.rs
82
109
  - rust/rubydex-sys/src/declaration_api.rs
83
110
  - rust/rubydex-sys/src/definition_api.rs
84
111
  - rust/rubydex-sys/src/diagnostic_api.rs
@@ -128,6 +155,7 @@ files:
128
155
  - rust/rubydex/src/operation/mod.rs
129
156
  - rust/rubydex/src/operation/printer.rs
130
157
  - rust/rubydex/src/operation/ruby_builder.rs
158
+ - rust/rubydex/src/path_helpers.rs
131
159
  - rust/rubydex/src/position.rs
132
160
  - rust/rubydex/src/query.rs
133
161
  - rust/rubydex/src/query/cypher.rs
@@ -146,6 +174,7 @@ files:
146
174
  - rust/rubydex/src/test_utils/local_graph_test.rs
147
175
  - rust/rubydex/tests/cli.rs
148
176
  - rust/rustfmt.toml
177
+ - skills/send-private-method/SKILL.md
149
178
  homepage: https://github.com/Shopify/rubydex
150
179
  licenses:
151
180
  - MIT