1use 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
21pub 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
43fn 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
62pub 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
119fn 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
362pub 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
370pub 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
457pub 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}