mati_core/analysis/resolvers/
ruby.rs1use std::path::Path;
16
17use super::{camel_to_snake, FileIndex, LanguageResolver};
18use crate::analysis::parser::import::ImportKind;
19use crate::analysis::parser::ImportStatement;
20use crate::analysis::walker::Language;
21
22pub struct RubyResolver;
23
24impl LanguageResolver for RubyResolver {
25 fn resolve(
26 &self,
27 import: &ImportStatement,
28 importing_file: &str,
29 file_index: &FileIndex,
30 ) -> Option<String> {
31 match import.kind {
32 ImportKind::Inherits | ImportKind::Includes => {
33 resolve_zeitwerk(&import.path, file_index)
34 }
35 ImportKind::Relative => resolve_relative(&import.path, importing_file, file_index),
36 ImportKind::Normal => resolve_normal(&import.path, importing_file, file_index),
37 _ => None,
38 }
39 }
40
41 fn language(&self) -> Language {
42 Language::Ruby
43 }
44
45 fn name(&self) -> &'static str {
46 "ruby"
47 }
48}
49
50fn resolve_relative(
53 require_path: &str,
54 importing_file: &str,
55 file_index: &FileIndex,
56) -> Option<String> {
57 let parent = Path::new(importing_file)
58 .parent()
59 .map(|p| p.to_string_lossy().into_owned())
60 .unwrap_or_default();
61
62 let resolved = if parent.is_empty() {
63 require_path.to_string()
64 } else {
65 format!("{parent}/{require_path}")
66 };
67
68 let with_rb = format!("{resolved}.rb");
69 if file_index.contains(&with_rb) {
70 return Some(with_rb);
71 }
72 if file_index.contains(&resolved) {
73 return Some(resolved);
74 }
75 None
76}
77
78fn resolve_normal(
81 require_path: &str,
82 importing_file: &str,
83 file_index: &FileIndex,
84) -> Option<String> {
85 if let Some(found) = resolve_relative(require_path, importing_file, file_index) {
87 return Some(found);
88 }
89
90 let lib_roots = file_index.ruby_lib_roots();
93 for root in lib_roots {
94 let lib_rb = format!("{root}{require_path}.rb");
95 if file_index.contains(&lib_rb) {
96 return Some(lib_rb);
97 }
98 let lib_exact = format!("{root}{require_path}");
99 if file_index.contains(&lib_exact) {
100 return Some(lib_exact);
101 }
102 }
103
104 if lib_roots.is_empty() || !lib_roots.contains(&"lib/".to_string()) {
106 let lib_rb = format!("lib/{require_path}.rb");
107 if file_index.contains(&lib_rb) {
108 return Some(lib_rb);
109 }
110 }
111
112 for root in file_index.ruby_autoload_roots() {
115 let ar_rb = format!("{root}{require_path}.rb");
116 if file_index.contains(&ar_rb) {
117 return Some(ar_rb);
118 }
119 }
120
121 None
122}
123
124fn resolve_zeitwerk(constant: &str, file_index: &FileIndex) -> Option<String> {
133 let parts: Vec<String> = constant.split("::").map(camel_to_snake).collect();
134 let path_suffix = parts.join("/");
135
136 let all_roots = file_index
139 .ruby_autoload_roots()
140 .iter()
141 .chain(file_index.ruby_lib_roots().iter());
142
143 for root in all_roots {
144 let direct_path = format!("{root}{path_suffix}.rb");
146 if file_index.contains(&direct_path) {
147 return Some(direct_path);
148 }
149
150 if let Some(last) = parts.last() {
152 let nested_path = format!("{root}{path_suffix}/{last}.rb");
153 if file_index.contains(&nested_path) {
154 return Some(nested_path);
155 }
156 }
157 }
158
159 if !constant.contains("::") {
163 let snake = camel_to_snake(constant);
164 let direct = format!("{snake}.rb");
165 if file_index.contains(&direct) {
166 return Some(direct);
167 }
168 }
169
170 None
171}
172
173#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::analysis::parser::import::ImportKind;
179
180 fn idx(paths: &[&str]) -> FileIndex {
181 FileIndex::new(paths.iter().map(|s| s.to_string()))
182 }
183
184 fn idx_with_roots(paths: &[&str], autoload_roots: &[&str], lib_roots: &[&str]) -> FileIndex {
185 let mut fi = FileIndex::new(paths.iter().map(|s| s.to_string()));
186 fi.set_ruby_autoload_roots(autoload_roots.iter().map(|s| s.to_string()).collect());
187 fi.set_ruby_lib_roots(lib_roots.iter().map(|s| s.to_string()).collect());
188 fi
189 }
190
191 fn import_relative(path: &str) -> ImportStatement {
192 ImportStatement::new(path, ImportKind::Relative, 1)
193 }
194
195 fn import_normal(path: &str) -> ImportStatement {
196 ImportStatement::new(path, ImportKind::Normal, 1)
197 }
198
199 fn import_inherits(path: &str) -> ImportStatement {
200 ImportStatement::new(path, ImportKind::Inherits, 1)
201 }
202
203 fn import_includes(path: &str) -> ImportStatement {
204 ImportStatement::new(path, ImportKind::Includes, 1)
205 }
206
207 #[test]
210 fn require_relative_resolves() {
211 let file_index = idx(&["lib/app.rb", "lib/helpers.rb"]);
212 let result = RubyResolver.resolve(&import_relative("helpers"), "lib/app.rb", &file_index);
213 assert_eq!(result, Some("lib/helpers.rb".into()));
214 }
215
216 #[test]
217 fn require_relative_nested() {
218 let file_index = idx(&["lib/app.rb", "lib/utils/format.rb"]);
219 let result =
220 RubyResolver.resolve(&import_relative("utils/format"), "lib/app.rb", &file_index);
221 assert_eq!(result, Some("lib/utils/format.rb".into()));
222 }
223
224 #[test]
225 fn require_normal_no_match() {
226 let file_index = idx(&["lib/app.rb"]);
227 let result = RubyResolver.resolve(&import_normal("json"), "lib/app.rb", &file_index);
228 assert_eq!(result, None);
229 }
230
231 #[test]
232 fn nonexistent_returns_none() {
233 let file_index = idx(&["lib/app.rb"]);
234 assert_eq!(
235 RubyResolver.resolve(&import_relative("missing"), "lib/app.rb", &file_index),
236 None
237 );
238 }
239
240 #[test]
241 fn require_with_lib_prefix_resolves() {
242 let file_index = idx(&["lib/sinatra/base.rb", "lib/sinatra.rb"]);
243 let result = RubyResolver.resolve(
244 &import_normal("sinatra/base"),
245 "lib/sinatra.rb",
246 &file_index,
247 );
248 assert_eq!(result, Some("lib/sinatra/base.rb".into()));
249 }
250
251 #[test]
252 fn require_relative_unchanged() {
253 let file_index = idx(&["test/test_helper.rb", "test/helpers.rb"]);
254 let result = RubyResolver.resolve(
255 &import_relative("helpers"),
256 "test/test_helper.rb",
257 &file_index,
258 );
259 assert_eq!(result, Some("test/helpers.rb".into()));
260 }
261
262 #[test]
263 fn external_gem_require_returns_none() {
264 let file_index = idx(&["lib/app.rb"]);
265 let result = RubyResolver.resolve(&import_normal("json"), "lib/app.rb", &file_index);
266 assert_eq!(result, None);
267 }
268
269 #[test]
270 fn nested_lib_path_resolves() {
271 let file_index = idx(&[
272 "lib/sinatra.rb",
273 "lib/sinatra/main.rb",
274 "lib/sinatra/base.rb",
275 ]);
276 let result = RubyResolver.resolve(
277 &import_normal("sinatra/main"),
278 "lib/sinatra.rb",
279 &file_index,
280 );
281 assert_eq!(result, Some("lib/sinatra/main.rb".into()));
282 }
283
284 #[test]
287 fn simple_class_inheritance_resolves_to_app_models() {
288 let fi = idx_with_roots(
289 &["app/models/user.rb", "app/models/application_record.rb"],
290 &["app/models/"],
291 &[],
292 );
293 let result = RubyResolver.resolve(
294 &import_inherits("ApplicationRecord"),
295 "app/models/user.rb",
296 &fi,
297 );
298 assert_eq!(result, Some("app/models/application_record.rb".into()));
299 }
300
301 #[test]
302 fn controller_inheritance_resolves() {
303 let fi = idx_with_roots(
304 &[
305 "app/controllers/foos_controller.rb",
306 "app/controllers/application_controller.rb",
307 ],
308 &["app/controllers/"],
309 &[],
310 );
311 let result = RubyResolver.resolve(
312 &import_inherits("ApplicationController"),
313 "app/controllers/foos_controller.rb",
314 &fi,
315 );
316 assert_eq!(
317 result,
318 Some("app/controllers/application_controller.rb".into())
319 );
320 }
321
322 #[test]
323 fn concern_inclusion_resolves_via_concerns_dir() {
324 let fi = idx_with_roots(
325 &["app/models/post.rb", "app/models/concerns/searchable.rb"],
326 &["app/models/", "app/models/concerns/"],
327 &[],
328 );
329 let result =
330 RubyResolver.resolve(&import_includes("Searchable"), "app/models/post.rb", &fi);
331 assert_eq!(result, Some("app/models/concerns/searchable.rb".into()));
332 }
333
334 #[test]
335 fn namespaced_constant_resolves_via_nested_path() {
336 let fi = idx_with_roots(
337 &["app/models/my_app/bar.rb", "app/models/foo.rb"],
338 &["app/models/"],
339 &[],
340 );
341 let result = RubyResolver.resolve(&import_inherits("MyApp::Bar"), "app/models/foo.rb", &fi);
342 assert_eq!(result, Some("app/models/my_app/bar.rb".into()));
343 }
344
345 #[test]
346 fn monorepo_autoload_roots_detected() {
347 let fi = idx_with_roots(
349 &[
350 "core/app/models/spree/order.rb",
351 "core/app/models/spree/product.rb",
352 "core/app/models/spree/base.rb",
353 ],
354 &["core/app/models/"],
355 &[],
356 );
357 let result = RubyResolver.resolve(
358 &import_inherits("Spree::Base"),
359 "core/app/models/spree/order.rb",
360 &fi,
361 );
362 assert_eq!(result, Some("core/app/models/spree/base.rb".into()));
363 }
364
365 #[test]
368 fn monorepo_lib_require_resolves() {
369 let fi = idx_with_roots(
370 &["core/lib/spree/core.rb", "api/lib/spree/api.rb"],
371 &[],
372 &["core/lib/", "api/lib/"],
373 );
374 let result = RubyResolver.resolve(&import_normal("spree/core"), "core/lib/spree.rb", &fi);
375 assert_eq!(result, Some("core/lib/spree/core.rb".into()));
376 }
377
378 #[test]
379 fn repo_root_lib_still_works() {
380 let fi = idx_with_roots(&["lib/sinatra/base.rb", "lib/sinatra.rb"], &[], &["lib/"]);
382 let result = RubyResolver.resolve(&import_normal("sinatra/base"), "lib/sinatra.rb", &fi);
383 assert_eq!(result, Some("lib/sinatra/base.rb".into()));
384 }
385
386 #[test]
389 fn require_dependency_resolves_via_autoload_roots() {
390 let fi = idx_with_roots(&["app/services/foo.rb"], &["app/services/"], &[]);
391 let result = RubyResolver.resolve(
392 &import_normal("foo"),
393 "app/controllers/bar_controller.rb",
394 &fi,
395 );
396 assert_eq!(result, Some("app/services/foo.rb".into()));
397 }
398}