Skip to main content

brokk_bifrost_ruby/
imports.rs

1//! Ruby's load-path knowledge: `require`/`require_relative`/`load`/`autoload`
2//! parsing and resolution, the `autoload` constant edge collector, and the
3//! Gemfile-driven Zeitwerk conventions.
4//!
5//! The memoized products these build -- the autoload constant index, the five
6//! Zeitwerk cells, the reverse import index -- stay on `RubyAnalyzer` in
7//! `brokk-bifrost-analysis`; only the decisions that fill them live here.
8
9use crate::declarations::{extract_name_segments, parse_ruby_tree, ruby_node_text as node_text};
10use crate::graph_support::RubySource;
11use brokk_bifrost_core::analyzer::model::ImportInfo;
12use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, walk_named_tree_preorder};
13use brokk_bifrost_core::analyzer::{CodeUnit, Language, ProjectFile};
14use brokk_bifrost_core::hash::{HashMap, HashSet};
15use std::ffi::OsStr;
16use std::path::{Component, Path, PathBuf};
17use tree_sitter::Node;
18
19pub const ZEITWERK_AUTOLOAD_EXCLUDED_APP_DIRS: &[&str] = &["assets", "javascript", "views"];
20
21/// Parses a `require`/`require_relative`/`load`/`autoload` call into an
22/// [`ImportInfo`]. The required path string is stored in `identifier`; the kind
23/// is recoverable from `raw_snippet`.
24pub fn parse_ruby_require_call(node: Node<'_>, source: &str) -> Option<ImportInfo> {
25    let raw_snippet = node_text(node, source).trim().to_string();
26    let arguments = node.child_by_field_name("arguments")?;
27    let mut cursor = arguments.walk();
28    let path = arguments
29        .named_children(&mut cursor)
30        .find_map(|arg| string_literal_value(arg, source))?;
31
32    Some(ImportInfo {
33        raw_snippet,
34        is_wildcard: false,
35        is_global: false,
36        identifier: Some(path),
37        alias: None,
38        path: None,
39        binder_span: None,
40    })
41}
42
43/// Extracts the contents of a string literal node (`"foo"` -> `foo`).
44fn string_literal_value(node: Node<'_>, source: &str) -> Option<String> {
45    if node.kind() != "string" {
46        return None;
47    }
48    let text = node_text(node, source).trim();
49    let trimmed = text.trim_matches(['"', '\'']);
50    (!trimmed.is_empty()).then(|| trimmed.to_string())
51}
52
53fn symbol_name(node: Node<'_>, source: &str) -> Option<String> {
54    if node.kind() != "simple_symbol" {
55        return None;
56    }
57    let text = node_text(node, source).trim();
58    let stripped = text.strip_prefix(':').unwrap_or(text);
59    (!stripped.is_empty()).then(|| stripped.to_string())
60}
61
62/// Resolves the in-project file path of a supported Ruby require target.
63///
64/// `require_relative` is resolved relative to the requiring file's directory.
65/// Bare `require` is resolved as a project-root-relative load path only when a
66/// matching project file exists.
67pub fn resolve_required_file(file: &ProjectFile, import: &ImportInfo) -> Option<ProjectFile> {
68    let raw_path = import.identifier.as_deref()?;
69    if import.raw_snippet.starts_with("autoload") {
70        return resolve_project_required_file(file, Path::new(raw_path));
71    }
72    if import.raw_snippet.starts_with("require_relative") {
73        let base = file.rel_path().parent().unwrap_or_else(|| Path::new(""));
74        return resolve_relative_required_file(file, &base.join(raw_path));
75    }
76    if import.raw_snippet.starts_with("require") {
77        return resolve_project_required_file(file, Path::new(raw_path));
78    }
79    None
80}
81
82fn resolve_relative_required_file(file: &ProjectFile, path: &Path) -> Option<ProjectFile> {
83    resolve_candidate(file, path, false)
84}
85
86fn resolve_project_required_file(file: &ProjectFile, path: &Path) -> Option<ProjectFile> {
87    if path.is_absolute() {
88        return None;
89    }
90    resolve_required_path_candidates(file, path)
91        .or_else(|| resolve_required_path_candidates(file, &Path::new("lib").join(path)))
92}
93
94fn resolve_required_path_candidates(file: &ProjectFile, path: &Path) -> Option<ProjectFile> {
95    resolve_candidate(file, path, false).or_else(|| {
96        path.extension()
97            .is_none()
98            .then(|| resolve_candidate(file, path, true))
99            .flatten()
100    })
101}
102
103fn resolve_candidate(
104    file: &ProjectFile,
105    path: &Path,
106    directory_index: bool,
107) -> Option<ProjectFile> {
108    let mut candidate = normalize_relative(path)?;
109    if directory_index {
110        candidate.push("index");
111    }
112    if candidate.extension().is_none() {
113        candidate.set_extension("rb");
114    }
115    let project_file = ProjectFile::new(file.root().to_path_buf(), candidate);
116    project_file.exists().then_some(project_file)
117}
118
119/// Resolves `.`/`..` components without touching the filesystem. Returns `None`
120/// if the path escapes the project root.
121fn normalize_relative(path: &Path) -> Option<PathBuf> {
122    let mut out = PathBuf::new();
123    for component in path.components() {
124        match component {
125            Component::CurDir => {}
126            Component::ParentDir => {
127                if !out.pop() {
128                    return None;
129                }
130            }
131            Component::Normal(part) => out.push(part),
132            Component::RootDir | Component::Prefix(_) => return None,
133        }
134    }
135    (!out.as_os_str().is_empty()).then_some(out)
136}
137
138pub fn collect_ruby_autoload_edges(
139    file: &ProjectFile,
140    source: &str,
141    root: Node<'_>,
142    index: &mut HashMap<String, HashSet<ProjectFile>>,
143) {
144    enum Exit {
145        Lexical(usize),
146    }
147
148    let mut stack = vec![(root, false)];
149    let mut lexical_stack: Vec<String> = Vec::new();
150    let mut exits: Vec<Exit> = Vec::new();
151    while let Some((node, exiting)) = stack.pop() {
152        if exiting {
153            if let Some(Exit::Lexical(len)) = exits.pop() {
154                lexical_stack.truncate(len);
155            }
156            continue;
157        }
158
159        let mut pushed_exit = false;
160        if matches!(node.kind(), "class" | "module")
161            && let Some(name) = node.child_by_field_name("name")
162        {
163            let previous_len = lexical_stack.len();
164            let mut segments = lexical_stack.clone();
165            segments.extend(extract_name_segments(name, source));
166            if !segments.is_empty() {
167                lexical_stack = segments;
168                exits.push(Exit::Lexical(previous_len));
169                stack.push((node, true));
170                pushed_exit = true;
171            }
172        }
173
174        if node.kind() == "call"
175            && let Some((constant, path)) = parse_ruby_autoload_call(node, source)
176        {
177            let mut segments = lexical_stack.clone();
178            segments.push(constant);
179            let key = segments.join("$");
180            let files = index.entry(key).or_default();
181            files.insert(file.clone());
182            let import = ImportInfo {
183                raw_snippet: node_text(node, source).trim().to_string(),
184                is_wildcard: false,
185                is_global: false,
186                identifier: Some(path),
187                alias: None,
188                path: None,
189                binder_span: None,
190            };
191            if let Some(required) = resolve_required_file(file, &import) {
192                files.insert(required);
193            }
194        }
195
196        let mut cursor = node.walk();
197        let children: Vec<_> = node.named_children(&mut cursor).collect();
198        for child in children.into_iter().rev() {
199            stack.push((child, false));
200        }
201        if !pushed_exit {
202            continue;
203        }
204    }
205}
206
207pub fn parse_ruby_autoload_call(node: Node<'_>, source: &str) -> Option<(String, String)> {
208    let method = node.child_by_field_name("method")?;
209    if node_text(method, source).trim() != "autoload" {
210        return None;
211    }
212    let arguments = node.child_by_field_name("arguments")?;
213    let mut cursor = arguments.walk();
214    let mut args = arguments.named_children(&mut cursor);
215    let constant = symbol_name(args.next()?, source)?;
216    let path = args.find_map(|arg| string_literal_value(arg, source))?;
217    Some((constant, path))
218}
219
220pub fn is_ruby_autoload_symbol_argument(node: Node<'_>, source: &str) -> bool {
221    if node.kind() != "simple_symbol" {
222        return false;
223    }
224    let Some(arguments) = node.parent() else {
225        return false;
226    };
227    if arguments.kind() != "argument_list" {
228        return false;
229    }
230    let mut cursor = arguments.walk();
231    if arguments.named_children(&mut cursor).next() != Some(node) {
232        return false;
233    }
234    let Some(call) = arguments.parent() else {
235        return false;
236    };
237    call.kind() == "call" && parse_ruby_autoload_call(call, source).is_some()
238}
239
240pub fn ruby_symbol_name(node: Node<'_>, source: &str) -> Option<String> {
241    symbol_name(node, source)
242}
243
244pub fn gemfile_declares_zeitwerk_autoloading(contents: &str) -> bool {
245    contents.lines().any(|line| {
246        let line = line
247            .split_once('#')
248            .map_or(line, |(before, _)| before)
249            .trim();
250        let Some(after_gem) = line.strip_prefix("gem") else {
251            return false;
252        };
253        if !after_gem
254            .chars()
255            .next()
256            .is_some_and(|ch| ch.is_ascii_whitespace() || ch == '(')
257        {
258            return false;
259        }
260        let args = after_gem
261            .trim_start()
262            .strip_prefix('(')
263            .unwrap_or(after_gem);
264        gem_args_name(args.trim_start()).is_some_and(is_zeitwerk_autoload_gem)
265    })
266}
267
268pub fn gemfile_lock_declares_zeitwerk_autoloading(contents: &str) -> bool {
269    contents.lines().any(|line| {
270        let trimmed = line.trim_start();
271        let Some((gem, rest)) = gemfile_lock_gem_line(trimmed) else {
272            return false;
273        };
274        is_zeitwerk_autoload_gem(gem) && rest.trim_start().starts_with('(')
275    })
276}
277
278fn gem_args_name(args: &str) -> Option<&str> {
279    let quote = args.chars().next()?;
280    if !matches!(quote, '"' | '\'') {
281        return None;
282    }
283    let rest = &args[quote.len_utf8()..];
284    rest.find(quote).map(|end| &rest[..end])
285}
286
287fn gemfile_lock_gem_line(line: &str) -> Option<(&str, &str)> {
288    let name_len = line
289        .char_indices()
290        .find_map(|(index, ch)| (ch.is_ascii_whitespace() || ch == '(').then_some(index))
291        .unwrap_or(line.len());
292    if name_len == 0 {
293        return None;
294    }
295    Some((&line[..name_len], &line[name_len..]))
296}
297
298fn is_zeitwerk_autoload_gem(gem: &str) -> bool {
299    matches!(gem, "rails" | "zeitwerk")
300}
301
302pub fn is_zeitwerk_autoload_file(file: &ProjectFile) -> bool {
303    if file.rel_path().extension() != Some(OsStr::new("rb")) {
304        return false;
305    }
306    let mut components = file.rel_path().components();
307    if components.next() != Some(Component::Normal(OsStr::new("app"))) {
308        return false;
309    }
310    let Some(Component::Normal(app_dir)) = components.next() else {
311        return false;
312    };
313    let Some(app_dir) = app_dir.to_str() else {
314        return false;
315    };
316    !ZEITWERK_AUTOLOAD_EXCLUDED_APP_DIRS.contains(&app_dir)
317}
318
319pub fn collect_ruby_reference_identifiers<'a>(
320    source: &'a str,
321    root: Node<'_>,
322    mut sink: impl FnMut(&'a str),
323) {
324    walk_named_tree_preorder(root, true, |node| {
325        if let Some(method) = method_call_identifier(node, source) {
326            sink(method);
327        }
328        if let Some(constant) = constant_reference_identifier(node, source) {
329            sink(constant);
330        }
331        WalkControl::Continue
332    });
333}
334
335fn method_call_identifier<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
336    if node.kind() != "call" {
337        return None;
338    }
339    let method = node.child_by_field_name("method")?;
340    Some(ruby_node_text(method, source))
341}
342
343fn constant_reference_identifier<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
344    if node.kind() != "constant" {
345        return None;
346    }
347    if let Some(parent) = node.parent()
348        && matches!(parent.kind(), "class" | "module")
349    {
350        return None;
351    }
352    Some(ruby_node_text(node, source))
353}
354
355fn ruby_node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
356    source
357        .get(node.start_byte()..node.end_byte())
358        .unwrap_or("")
359        .trim()
360}
361
362/// Project files this file pulls in via supported Ruby require forms.
363pub fn ruby_required_files(ruby: &dyn RubySource, file: &ProjectFile) -> Vec<ProjectFile> {
364    ruby.import_info_of(file)
365        .iter()
366        .filter_map(|import| resolve_required_file(file, import))
367        .collect()
368}
369
370/// Whether a supported load directive cannot be closed over project files.
371///
372/// A bare `require` can load a gem or a caller-provided load-path entry at
373/// runtime. Navigation can still offer best-effort indexed results, but a
374/// diagnostic must not claim that a constant is absent while that boundary
375/// remains open.
376pub fn ruby_has_unresolved_load_directive(ruby: &dyn RubySource, file: &ProjectFile) -> bool {
377    ruby.import_info_of(file)
378        .iter()
379        .any(|import| resolve_required_file(file, import).is_none())
380}
381
382pub fn ruby_autoload_visible_files_for_constant(
383    ruby: &dyn RubySource,
384    constant: &str,
385) -> HashSet<ProjectFile> {
386    ruby.autoload_constant_files()
387        .get(constant)
388        .cloned()
389        .unwrap_or_default()
390}
391
392pub fn build_autoload_constant_files(
393    ruby: &dyn RubySource,
394) -> HashMap<String, HashSet<ProjectFile>> {
395    let mut index: HashMap<String, HashSet<ProjectFile>> = HashMap::default();
396    for file in ruby.all_files() {
397        let Ok(source) = ruby.project().read_source(&file) else {
398            continue;
399        };
400        let Some(tree) = parse_ruby_tree(&source) else {
401            continue;
402        };
403        collect_ruby_autoload_edges(&file, &source, tree.root_node(), &mut index);
404    }
405    index
406}
407
408pub fn detect_zeitwerk_autoload_conventions(ruby: &dyn RubySource) -> bool {
409    ruby_project_file_contents(ruby, "Gemfile")
410        .as_deref()
411        .is_some_and(gemfile_declares_zeitwerk_autoloading)
412        || ruby_project_file_contents(ruby, "Gemfile.lock")
413            .as_deref()
414            .is_some_and(gemfile_lock_declares_zeitwerk_autoloading)
415}
416
417fn ruby_project_file_contents(ruby: &dyn RubySource, rel_path: &str) -> Option<String> {
418    let file = ProjectFile::new(ruby.project().root().to_path_buf(), rel_path);
419    ruby.project().read_source(&file).ok()
420}
421
422pub fn build_zeitwerk_autoload_files(ruby: &dyn RubySource) -> HashSet<ProjectFile> {
423    if !ruby.has_zeitwerk_autoload_conventions() {
424        return HashSet::default();
425    }
426    ruby.project()
427        .analyzable_files(Language::Ruby)
428        .map(|files| {
429            files
430                .into_iter()
431                .filter(is_zeitwerk_autoload_file)
432                .collect()
433        })
434        .unwrap_or_default()
435}
436
437pub fn build_zeitwerk_consumer_files(ruby: &dyn RubySource) -> HashSet<ProjectFile> {
438    if !ruby.has_zeitwerk_autoload_conventions() {
439        return HashSet::default();
440    }
441    ruby.project()
442        .analyzable_files(Language::Ruby)
443        .map(|files| files.into_iter().collect())
444        .unwrap_or_default()
445}
446
447pub fn build_zeitwerk_autoload_code_units(ruby: &dyn RubySource) -> HashSet<CodeUnit> {
448    let mut units = HashSet::default();
449    for file in ruby.zeitwerk_autoload_files() {
450        for code_unit in ruby.top_level_declarations(file) {
451            units.insert(code_unit.clone());
452        }
453    }
454    units
455}
456
457/// The whole-workspace reference-identifier scan behind
458/// `zeitwerk_reference_files_for_identifier`.
459///
460/// This is a `read_source` + `parse_ruby_tree` +
461/// `collect_ruby_reference_identifiers` pass over every consumer file, and the
462/// analyzer runs it lazily from inside `RubyQueryResolver`'s post-budget scan-set
463/// augmentation. That timing is part of the augmentation contract even though no
464/// assertion pins it, so the `OnceLock` and its `get_or_init` call site stay on
465/// the analyzer; only the walk lives here.
466pub fn build_zeitwerk_reference_files(
467    ruby: &dyn RubySource,
468) -> HashMap<String, HashSet<ProjectFile>> {
469    let mut references: HashMap<String, HashSet<ProjectFile>> = HashMap::default();
470    for file in ruby.zeitwerk_consumer_files() {
471        let Ok(source) = ruby.project().read_source(file) else {
472            continue;
473        };
474        let Some(tree) = parse_ruby_tree(&source) else {
475            continue;
476        };
477        collect_ruby_reference_identifiers(&source, tree.root_node(), |identifier| {
478            references
479                .entry(identifier.to_string())
480                .or_default()
481                .insert(file.clone());
482        });
483    }
484    references
485}
486
487pub fn ruby_zeitwerk_visible_files_for<'a>(
488    ruby: &'a dyn RubySource,
489    file: &ProjectFile,
490) -> Option<&'a HashSet<ProjectFile>> {
491    ruby.zeitwerk_consumer_files()
492        .contains(file)
493        .then(|| ruby.zeitwerk_autoload_files())
494}
495
496pub fn ruby_effective_imported_code_units(
497    ruby: &dyn RubySource,
498    file: &ProjectFile,
499) -> HashSet<CodeUnit> {
500    let mut units = HashSet::default();
501    for required in ruby_required_files(ruby, file) {
502        for code_unit in ruby.top_level_declarations(&required) {
503            units.insert(code_unit.clone());
504        }
505    }
506    if ruby.zeitwerk_consumer_files().contains(file) {
507        units.extend(
508            ruby.zeitwerk_autoload_code_units()
509                .iter()
510                .filter(|code_unit| code_unit.source() != file)
511                .cloned(),
512        );
513    }
514    units
515}
516
517pub fn ruby_transitive_referencing_files_of(
518    ruby: &dyn RubySource,
519    file: &ProjectFile,
520) -> HashSet<ProjectFile> {
521    let reverse_index = ruby.reverse_import_index();
522    let mut referencing = HashSet::default();
523    let mut visited = HashSet::default();
524    visited.insert(file.clone());
525    let mut stack: Vec<ProjectFile> = reverse_index
526        .get(file)
527        .map(|files| files.iter().cloned().collect())
528        .unwrap_or_default();
529    while let Some(next) = stack.pop() {
530        if !visited.insert(next.clone()) {
531            continue;
532        }
533        referencing.insert(next.clone());
534        if let Some(parents) = reverse_index.get(&next) {
535            stack.extend(parents.iter().cloned());
536        }
537    }
538    referencing
539}
540
541pub fn ruby_imported_files_from_infos(
542    file: &ProjectFile,
543    imports: &[ImportInfo],
544) -> HashSet<ProjectFile> {
545    imports
546        .iter()
547        .filter_map(|import| resolve_required_file(file, import))
548        .collect()
549}