Skip to main content

mati_core/analysis/resolvers/
ruby.rs

1//! Ruby import resolver.
2//!
3//! Resolves three categories of Ruby imports:
4//!
5//! 1. **`require_relative`** (`ImportKind::Relative`) — resolves relative to the
6//!    importing file's directory.
7//! 2. **`require` / `require_dependency`** (`ImportKind::Normal`) — tries resolution
8//!    relative to the importing file, then against every discovered `lib/` root
9//!    (monorepo-aware: `lib/`, `core/lib/`, `api/lib/`, etc.).
10//! 3. **Class inheritance / module inclusion** (`ImportKind::Inherits` /
11//!    `ImportKind::Includes`) — resolved via Zeitwerk path conventions. A constant
12//!    like `Foo::Bar` is converted to `foo/bar.rb` and searched across all
13//!    discovered autoload roots (`app/models/`, `app/controllers/`, `lib/`, etc.).
14
15use 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
50// ── require_relative resolution ─────────────────────────────────────────────
51
52fn 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
78// ── require / require_dependency resolution ─────────────────────────────────
79
80fn resolve_normal(
81    require_path: &str,
82    importing_file: &str,
83    file_index: &FileIndex,
84) -> Option<String> {
85    // First try relative to importing file (same as require_relative behavior).
86    if let Some(found) = resolve_relative(require_path, importing_file, file_index) {
87        return Some(found);
88    }
89
90    // Try every discovered lib/ root (monorepo-aware).
91    // E.g. require 'spree/core' → core/lib/spree/core.rb
92    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    // Fallback: try bare lib/ even if not discovered (non-Ruby-dominant repos).
105    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    // Try autoload roots too — require_dependency 'app/services/foo' or
113    // require 'discourse' might resolve against an autoload root.
114    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
124// ── Zeitwerk constant-to-path resolution ────────────────────────────────────
125
126/// Resolve a Ruby constant name (e.g. `"Foo::Bar::Baz"`) to a repo-relative
127/// file path using Zeitwerk autoload conventions.
128///
129/// Zeitwerk convention: `Foo::Bar::Baz` lives at `<root>/foo/bar/baz.rb` under
130/// any autoload root. Also checks the nested-folder variant
131/// `<root>/foo/bar/baz/baz.rb` (less common but valid).
132fn 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    // Search autoload roots first (app/models/, app/controllers/, etc.),
137    // then lib/ roots.
138    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        // Direct: <root>/foo/bar/baz.rb
145        let direct_path = format!("{root}{path_suffix}.rb");
146        if file_index.contains(&direct_path) {
147            return Some(direct_path);
148        }
149
150        // Nested-folder: <root>/foo/bar/baz/baz.rb
151        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    // For single-segment constants (e.g. "ApplicationController"), also try
160    // a direct match without the autoload root prefix — the file might be at
161    // the repo root or in a non-standard location.
162    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// ── Tests ─────────────────────────────────────────────────────────────────────
174
175#[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    // ── Existing tests (require_relative / require) ───────────────────────
208
209    #[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    // ── Zeitwerk resolution (Inherits + Includes) ─────────────────────────
285
286    #[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        // Solidus-style monorepo: core/app/models/spree/order.rb
348        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    // ── P2: Monorepo lib/ fallback ────────────────────────────────────────
366
367    #[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        // Regression guard: sinatra-style require still resolves via lib/
381        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    // ── P3: require_dependency resolves via autoload roots ────────────────
387
388    #[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}