Skip to main content

brokk_bifrost_ruby/
declarations.rs

1use crate::imports::parse_ruby_require_call;
2use crate::mixins::{encode_mixin_relation, encode_superclass_relation, raw_mixin_specs_for_type};
3use brokk_bifrost_core::analyzer::fq_name::{FqName, SegmentId, SegmentKind, segment_interner};
4use brokk_bifrost_core::analyzer::model::{
5    CodeUnitType, DispatchExtensibility, RubyMethodDispatchMode, SignatureMetadata,
6};
7use brokk_bifrost_core::analyzer::parsed_file::ParsedFile;
8use brokk_bifrost_core::analyzer::structural::materialization::{
9    GenerationKind, MaterializationRecord,
10};
11use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, node_range, walk_named_tree_preorder};
12use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
13use brokk_bifrost_core::hash::HashSet;
14use tree_sitter::{Node, Parser, Tree};
15
16/// Intern one qualified-name segment in the process-global interner.
17fn ruby_segment(text: &str, kind: SegmentKind) -> SegmentId {
18    segment_interner().intern(text, kind)
19}
20
21/// Build the structured type/namespace chain for a sequence of Ruby class/
22/// module name segments (already atomic AST-derived strings — see
23/// [`extract_name_segments`], which walks `scope_resolution` nodes rather than
24/// splitting `A::B` text). Ruby's `package_name` is always empty and its legacy
25/// `short_name` joins EVERY namespace segment with a literal `$` (see this
26/// module's doc comment on [`RubyVisitor`]): `module A; class B` yields `A$B`.
27/// [`SegmentKind::Nested`] is the tag whose join renders a leading
28/// `$` regardless of the previous segment's kind, and the very first segment of
29/// a qualified name never gets a leading separator at all (there is no
30/// preceding segment) — so tagging every namespace segment `Companion`
31/// reproduces the `$`-joined chain exactly, including its first element.
32fn ruby_type_chain_fq(segments: &[String]) -> FqName {
33    let mut fq = FqName::new();
34    for segment in segments {
35        fq.push(ruby_segment(segment, SegmentKind::Nested));
36    }
37    fq
38}
39
40/// Extends a type/namespace chain with one trailing `Member` segment — the
41/// structured counterpart of [`member_short_name`], which appends `.name`
42/// after the `$`-joined chain.
43fn ruby_member_fq(type_segments: &[String], name: &str) -> FqName {
44    ruby_type_chain_fq(type_segments).with_pushed(ruby_segment(name, SegmentKind::Member))
45}
46
47/// Parses Ruby source into a tree-sitter tree, or `None` if parsing fails.
48pub fn parse_ruby_tree(source: &str) -> Option<Tree> {
49    let mut parser = Parser::new();
50    parser
51        .set_language(&tree_sitter_ruby::LANGUAGE.into())
52        .expect("failed to load ruby parser");
53    parser.parse(source, None)
54}
55
56/// Reads the source text backing a tree-sitter node.
57pub fn ruby_node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
58    brokk_bifrost_core::analyzer::common::node_source_text(node, source)
59}
60
61/// Walks a Ruby file and emits its declarations into `parsed`.
62///
63/// Ruby symbol identity follows the shared `CodeUnit` scheme used by every
64/// bifrost analyzer: `package_name` is empty, nested namespaces/types are joined
65/// in `short_name` with `$`, and a type's members are appended after a `.`. So
66/// `module A; class B; def c` yields `A$B` (class) and `A$B.c` (method), which
67/// `CodeUnit::identifier` resolves back to `B` and `c`.
68pub struct RubyVisitor<'a> {
69    pub file: &'a ProjectFile,
70    pub source: &'a str,
71    pub parsed: &'a mut ParsedFile,
72}
73
74/// A pending traversal step: visit `node` as a statement within the enclosing
75/// type's `segments`/`parent` context. The visitor uses an explicit stack of
76/// these instead of native recursion so deeply nested input cannot overflow the
77/// call stack (per AGENTS.md, and mirroring the Python visitor).
78struct RubyWork<'tree> {
79    node: Node<'tree>,
80    segments: Vec<String>,
81    parent: Option<CodeUnit>,
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum RubyFieldScope {
86    Instance,
87    ClassVariable,
88    SingletonClass,
89}
90
91/// Pushes a node's named children as statement work items. Children are pushed
92/// in reverse so the stack pops them in source order.
93fn push_named_children<'tree>(
94    node: Node<'tree>,
95    segments: &[String],
96    parent: Option<&CodeUnit>,
97    stack: &mut Vec<RubyWork<'tree>>,
98) {
99    let mut cursor = node.walk();
100    let children: Vec<_> = node.named_children(&mut cursor).collect();
101    for child in children.into_iter().rev() {
102        stack.push(RubyWork {
103            node: child,
104            segments: segments.to_vec(),
105            parent: parent.cloned(),
106        });
107    }
108}
109
110impl RubyVisitor<'_> {
111    pub fn visit_program(&mut self, root: Node<'_>) {
112        let mut stack = Vec::new();
113        push_named_children(root, &[], None, &mut stack);
114        while let Some(work) = stack.pop() {
115            self.visit_statement(work.node, &work.segments, work.parent.as_ref(), &mut stack);
116        }
117    }
118
119    fn visit_statement<'tree>(
120        &mut self,
121        node: Node<'tree>,
122        segments: &[String],
123        parent: Option<&CodeUnit>,
124        stack: &mut Vec<RubyWork<'tree>>,
125    ) {
126        match node.kind() {
127            "class" => self.visit_class_like(node, segments, parent, false, stack),
128            "module" => self.visit_class_like(node, segments, parent, true, stack),
129            "singleton_class" => {
130                // `class << self` — its methods belong to the enclosing type.
131                if let Some(body) = node.child_by_field_name("body") {
132                    push_named_children(body, segments, parent, stack);
133                }
134            }
135            "method" | "singleton_method" => self.visit_method(node, segments, parent),
136            "assignment" | "operator_assignment" => {
137                self.visit_assignment(node, segments, parent, None)
138            }
139            "call" => self.visit_call(node, segments, parent),
140            kind if is_descendable_container(kind) => {
141                push_named_children(node, segments, parent, stack);
142            }
143            _ => {}
144        }
145    }
146
147    fn visit_class_like<'tree>(
148        &mut self,
149        node: Node<'tree>,
150        segments: &[String],
151        parent: Option<&CodeUnit>,
152        is_module: bool,
153        stack: &mut Vec<RubyWork<'tree>>,
154    ) {
155        let Some(name_node) = node.child_by_field_name("name") else {
156            return;
157        };
158        let name_segments = extract_name_segments(name_node, self.source);
159        if name_segments.is_empty() {
160            return;
161        }
162
163        let mut new_segments = segments.to_vec();
164        new_segments.extend(name_segments);
165        let short_name = new_segments.join("$");
166
167        let kind = if is_module {
168            CodeUnitType::Module
169        } else {
170            CodeUnitType::Class
171        };
172        let code_unit = CodeUnit::new_fq(
173            self.file.clone(),
174            kind,
175            String::new(),
176            short_name,
177            ruby_type_chain_fq(&new_segments),
178        );
179        self.parsed
180            .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
181        self.parsed
182            .add_signature(code_unit.clone(), first_line(node, self.source));
183
184        let mut owner_relations = extract_ruby_supertypes(node, self.source)
185            .into_iter()
186            .map(|target| {
187                let encoded = encode_superclass_relation(&target);
188                (target, encoded)
189            })
190            .collect::<Vec<_>>();
191        owner_relations.extend(raw_mixin_specs_for_type(node, self.source).into_iter().map(
192            |spec| {
193                let encoded = encode_mixin_relation(&spec);
194                (spec.raw_target, encoded)
195            },
196        ));
197        if !owner_relations.is_empty() {
198            self.parsed.set_raw_supertypes(
199                code_unit.clone(),
200                owner_relations
201                    .iter()
202                    .map(|(target, _)| target.clone())
203                    .collect(),
204            );
205            self.parsed.set_supertype_lookup_paths(
206                code_unit.clone(),
207                owner_relations
208                    .into_iter()
209                    .map(|(_, encoded)| encoded)
210                    .collect(),
211            );
212        }
213
214        self.visit_scope_field_assignments(
215            node,
216            &new_segments,
217            Some(&code_unit),
218            RubyFieldScope::SingletonClass,
219        );
220        if let Some(body) = node.child_by_field_name("body") {
221            push_named_children(body, &new_segments, Some(&code_unit), stack);
222        }
223    }
224
225    fn visit_method(&mut self, node: Node<'_>, segments: &[String], parent: Option<&CodeUnit>) {
226        let Some(name_node) = node.child_by_field_name("name") else {
227            return;
228        };
229        let name = ruby_node_text(name_node, self.source).trim();
230        if name.is_empty() {
231            return;
232        }
233        let short_name = member_short_name(segments, name);
234        let signature = node
235            .child_by_field_name("parameters")
236            .map(|params| ruby_node_text(params, self.source).trim().to_string());
237        let code_unit = CodeUnit::with_signature_and_fq(
238            self.file.clone(),
239            CodeUnitType::Function,
240            String::new(),
241            short_name,
242            signature,
243            false,
244            ruby_member_fq(segments, name),
245        );
246        self.parsed
247            .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
248        self.parsed.set_ruby_method_dispatch_mode(
249            code_unit.clone(),
250            ruby_method_dispatch_mode(node, self.source),
251        );
252        self.parsed.add_signature_with_metadata(
253            code_unit,
254            ruby_signature_metadata(first_line(node, self.source), node, self.source),
255        );
256        // Method bodies are otherwise leaves for declaration purposes, but Ruby
257        // instance/class variables are declarations even when first assigned in
258        // methods.
259        self.visit_scope_field_assignments(node, segments, parent, ruby_method_field_scope(node));
260    }
261
262    fn visit_assignment(
263        &mut self,
264        node: Node<'_>,
265        segments: &[String],
266        parent: Option<&CodeUnit>,
267        field_scope: Option<RubyFieldScope>,
268    ) {
269        let Some(left) = node.child_by_field_name("left") else {
270            return;
271        };
272        if let Some(field_scope) = ruby_field_scope_for_assignment_left(left, segments, field_scope)
273        {
274            self.visit_variable_field_assignment(node, left, segments, parent, field_scope);
275            return;
276        }
277        // Only constant assignments are declarations; locals are lowercase.
278        if !matches!(left.kind(), "constant" | "scope_resolution") {
279            return;
280        }
281        let name_path = extract_name_path(left, self.source);
282        if name_path.segments.is_empty() {
283            return;
284        }
285        let short_name = assignment_constant_short_name(segments, &name_path);
286        let code_unit = CodeUnit::new_fq(
287            self.file.clone(),
288            CodeUnitType::Field,
289            String::new(),
290            short_name,
291            assignment_constant_fq(segments, &name_path),
292        );
293        self.parsed
294            .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
295        self.parsed.add_signature(
296            code_unit,
297            ruby_node_text(node, self.source).trim().to_string(),
298        );
299    }
300
301    fn visit_scope_field_assignments(
302        &mut self,
303        node: Node<'_>,
304        segments: &[String],
305        parent: Option<&CodeUnit>,
306        field_scope: RubyFieldScope,
307    ) {
308        let mut stack = vec![node];
309        while let Some(current) = stack.pop() {
310            if current != node
311                && matches!(
312                    current.kind(),
313                    "class" | "module" | "method" | "singleton_method" | "singleton_class"
314                )
315            {
316                continue;
317            }
318            if matches!(current.kind(), "assignment" | "operator_assignment") {
319                self.visit_assignment(current, segments, parent, Some(field_scope));
320                continue;
321            }
322            for index in (0..current.named_child_count()).rev() {
323                if let Some(child) = current.named_child(index) {
324                    stack.push(child);
325                }
326            }
327        }
328    }
329
330    fn visit_variable_field_assignment(
331        &mut self,
332        node: Node<'_>,
333        left: Node<'_>,
334        segments: &[String],
335        parent: Option<&CodeUnit>,
336        field_scope: RubyFieldScope,
337    ) {
338        let Some(short_name) = ruby_field_short_name(segments, left, self.source, field_scope)
339        else {
340            return;
341        };
342        let fq = ruby_field_fq(segments, left, self.source, field_scope).unwrap_or_default();
343        let code_unit = CodeUnit::new_fq(
344            self.file.clone(),
345            CodeUnitType::Field,
346            String::new(),
347            short_name,
348            fq,
349        );
350        if self
351            .parsed
352            .first_range_start(&code_unit)
353            .is_some_and(|start| start <= node.start_byte())
354        {
355            return;
356        }
357        self.parsed
358            .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
359        self.parsed.add_signature(
360            code_unit,
361            ruby_node_text(node, self.source).trim().to_string(),
362        );
363    }
364
365    fn visit_call(&mut self, node: Node<'_>, segments: &[String], parent: Option<&CodeUnit>) {
366        let Some(method) = node.child_by_field_name("method") else {
367            return;
368        };
369        let method_name = ruby_node_text(method, self.source).trim();
370        match method_name {
371            "require" | "require_relative" | "load" | "autoload" => {
372                if let Some(info) = parse_ruby_require_call(node, self.source) {
373                    self.parsed.import_statements.push(info.raw_snippet.clone());
374                    self.parsed.imports.push(info);
375                }
376            }
377            "attr_accessor" | "attr_reader" | "attr_writer" => {
378                self.visit_attr_macro(node, method_name, segments, parent);
379            }
380            "alias_method" => {
381                self.visit_alias_method(node, segments, parent);
382            }
383            _ => {}
384        }
385    }
386
387    fn visit_attr_macro(
388        &mut self,
389        node: Node<'_>,
390        method_name: &str,
391        segments: &[String],
392        parent: Option<&CodeUnit>,
393    ) {
394        // `attr_accessor` and friends only declare members inside a type body.
395        let Some(parent) = parent else {
396            return;
397        };
398        let Some(arguments) = node.child_by_field_name("arguments") else {
399            return;
400        };
401        let mut cursor = arguments.walk();
402        let mut dynamic_argument_seen = false;
403        for arg in arguments.named_children(&mut cursor) {
404            let Some(name) = literal_symbol_or_string_name(arg, self.source) else {
405                // A non-literal argument generates *something* the analyzer
406                // cannot name. The site record is what keeps the generated
407                // set explicitly unknown rather than silently empty.
408                dynamic_argument_seen = true;
409                continue;
410            };
411            let member_name = attr_field_member_name(node, &name);
412            let field_name = format!("@{name}");
413            let code_unit = CodeUnit::new_fq(
414                self.file.clone(),
415                CodeUnitType::Field,
416                String::new(),
417                member_short_name(segments, &member_name),
418                ruby_scoped_field_fq(segments, &field_name, method_is_singleton_context(node)),
419            );
420            self.parsed.replace_code_unit(
421                code_unit.clone(),
422                node,
423                self.source,
424                Some(parent.clone()),
425                None,
426            );
427            self.parsed
428                .record_materialization(MaterializationRecord::GeneratedDeclaration {
429                    site: node_range(node),
430                    argument: node_range(arg),
431                    kind: GenerationKind::AccessorMacro,
432                    unit: code_unit.clone(),
433                });
434            self.parsed.add_signature(
435                code_unit,
436                ruby_node_text(node, self.source).trim().to_string(),
437            );
438            if matches!(method_name, "attr_accessor" | "attr_reader") {
439                self.add_member_function(
440                    node,
441                    arg,
442                    segments,
443                    parent,
444                    &name,
445                    GenerationKind::AccessorMacro,
446                );
447            }
448            if matches!(method_name, "attr_accessor" | "attr_writer") {
449                self.add_member_function(
450                    node,
451                    arg,
452                    segments,
453                    parent,
454                    &format!("{name}="),
455                    GenerationKind::AccessorMacro,
456                );
457            }
458        }
459        if dynamic_argument_seen {
460            self.parsed
461                .record_materialization(MaterializationRecord::DynamicGenerationSite {
462                    site: node_range(node),
463                    kind: GenerationKind::AccessorMacro,
464                });
465        }
466    }
467
468    fn visit_alias_method(
469        &mut self,
470        node: Node<'_>,
471        segments: &[String],
472        parent: Option<&CodeUnit>,
473    ) {
474        let Some(parent) = parent else {
475            return;
476        };
477        let Some(arguments) = node.child_by_field_name("arguments") else {
478            return;
479        };
480        let mut cursor = arguments.walk();
481        let Some(alias_arg) = arguments.named_children(&mut cursor).next() else {
482            return;
483        };
484        let Some(alias_name) = literal_symbol_or_string_name(alias_arg, self.source) else {
485            // A dynamic alias name generates a method the analyzer cannot
486            // name; record the site so the generated set stays explicitly
487            // unknown.
488            self.parsed
489                .record_materialization(MaterializationRecord::DynamicGenerationSite {
490                    site: node_range(node),
491                    kind: GenerationKind::AliasMacro,
492                });
493            return;
494        };
495        self.add_member_function(
496            node,
497            alias_arg,
498            segments,
499            parent,
500            &alias_name,
501            GenerationKind::AliasMacro,
502        );
503    }
504
505    fn add_member_function(
506        &mut self,
507        signature_node: Node<'_>,
508        range_node: Node<'_>,
509        segments: &[String],
510        parent: &CodeUnit,
511        name: &str,
512        generation: GenerationKind,
513    ) {
514        let code_unit = CodeUnit::new_fq(
515            self.file.clone(),
516            CodeUnitType::Function,
517            String::new(),
518            member_short_name(segments, name),
519            ruby_member_fq(segments, name),
520        );
521        self.parsed.replace_code_unit(
522            code_unit.clone(),
523            range_node,
524            self.source,
525            Some(parent.clone()),
526            None,
527        );
528        self.parsed
529            .record_materialization(MaterializationRecord::GeneratedDeclaration {
530                site: node_range(signature_node),
531                argument: node_range(range_node),
532                kind: generation,
533                unit: code_unit.clone(),
534            });
535        self.parsed.set_ruby_method_dispatch_mode(
536            code_unit.clone(),
537            ruby_method_dispatch_mode(signature_node, self.source),
538        );
539        self.parsed.add_signature(
540            code_unit,
541            ruby_node_text(signature_node, self.source)
542                .trim()
543                .to_string(),
544        );
545    }
546}
547
548/// Builds a member's `short_name` from its enclosing type segments and own name.
549fn member_short_name(segments: &[String], name: &str) -> String {
550    if segments.is_empty() {
551        name.to_string()
552    } else {
553        format!("{}.{}", segments.join("$"), name)
554    }
555}
556
557fn attr_field_member_name(node: Node<'_>, name: &str) -> String {
558    if method_is_singleton_context(node) {
559        format!("$singleton.@{name}")
560    } else {
561        format!("@{name}")
562    }
563}
564
565pub fn ruby_variable_field_name(node: Node<'_>, source: &str) -> Option<String> {
566    if !matches!(node.kind(), "instance_variable" | "class_variable") {
567        return None;
568    }
569    let name = ruby_node_text(node, source).trim();
570    (!name.is_empty()).then(|| name.to_string())
571}
572
573/// The rendered member tail shared by [`ruby_field_short_name`] and
574/// [`ruby_field_fq`]. Singleton-scoped fields retain the established
575/// `$singleton.@field` spelling, while [`ruby_field_fq`] records `$singleton`
576/// and the field itself as distinct scope/member segments.
577fn ruby_field_member_name(node: Node<'_>, source: &str, scope: RubyFieldScope) -> Option<String> {
578    let name = ruby_variable_field_name(node, source)?;
579    Some(match scope {
580        RubyFieldScope::Instance | RubyFieldScope::ClassVariable => name,
581        RubyFieldScope::SingletonClass => format!("$singleton.{name}"),
582    })
583}
584
585pub fn ruby_field_short_name(
586    segments: &[String],
587    node: Node<'_>,
588    source: &str,
589    scope: RubyFieldScope,
590) -> Option<String> {
591    if segments.is_empty() {
592        return None;
593    }
594    let member = ruby_field_member_name(node, source, scope)?;
595    Some(member_short_name(segments, &member))
596}
597
598/// The structured counterpart of [`ruby_field_short_name`].
599fn ruby_field_fq(
600    segments: &[String],
601    node: Node<'_>,
602    source: &str,
603    scope: RubyFieldScope,
604) -> Option<FqName> {
605    if segments.is_empty() {
606        return None;
607    }
608    let name = ruby_variable_field_name(node, source)?;
609    Some(ruby_scoped_field_fq(
610        segments,
611        &name,
612        scope == RubyFieldScope::SingletonClass,
613    ))
614}
615
616/// Builds the structured identity for a Ruby field. `$singleton` is a real
617/// synthetic owner scope, not part of the terminal field identifier.
618fn ruby_scoped_field_fq(segments: &[String], name: &str, singleton: bool) -> FqName {
619    let mut fq = ruby_type_chain_fq(segments);
620    if singleton {
621        fq.push(ruby_segment("$singleton", SegmentKind::Package));
622    }
623    fq.push(ruby_segment(name, SegmentKind::Member));
624    fq
625}
626
627pub fn ruby_field_scope_for_assignment_left(
628    left: Node<'_>,
629    segments: &[String],
630    current_scope: Option<RubyFieldScope>,
631) -> Option<RubyFieldScope> {
632    if segments.is_empty() {
633        return None;
634    }
635    match left.kind() {
636        "class_variable" => Some(RubyFieldScope::ClassVariable),
637        "instance_variable" => Some(current_scope.unwrap_or(RubyFieldScope::SingletonClass)),
638        _ => None,
639    }
640}
641
642fn ruby_method_field_scope(node: Node<'_>) -> RubyFieldScope {
643    if method_is_singleton_context(node) {
644        RubyFieldScope::SingletonClass
645    } else {
646        RubyFieldScope::Instance
647    }
648}
649
650fn ruby_method_dispatch_mode(node: Node<'_>, source: &str) -> RubyMethodDispatchMode {
651    if module_function_applies_to_method(node, source) {
652        RubyMethodDispatchMode::ModuleFunction
653    } else if method_is_singleton_context(node) {
654        RubyMethodDispatchMode::Singleton
655    } else {
656        RubyMethodDispatchMode::Instance
657    }
658}
659
660fn method_is_singleton_context(node: Node<'_>) -> bool {
661    if node.kind() == "singleton_method" {
662        return true;
663    }
664    let mut parent = node.parent();
665    while let Some(current) = parent {
666        if current.kind() == "singleton_class" {
667            return true;
668        }
669        if matches!(current.kind(), "class" | "module") {
670            break;
671        }
672        parent = current.parent();
673    }
674    false
675}
676
677fn module_function_applies_to_method(node: Node<'_>, source: &str) -> bool {
678    if node.kind() != "method" {
679        return false;
680    }
681    let Some(name_node) = node.child_by_field_name("name") else {
682        return false;
683    };
684    let method_name = ruby_node_text(name_node, source).trim();
685    let Some(module) = enclosing_module_for_module_function(node) else {
686        return false;
687    };
688    let Some(body) = module.child_by_field_name("body") else {
689        return false;
690    };
691
692    let mut bare_module_function_active = false;
693    let mut stack = vec![body];
694    while let Some(current) = stack.pop() {
695        if current != body
696            && matches!(
697                current.kind(),
698                "class" | "module" | "method" | "singleton_method"
699            )
700        {
701            continue;
702        }
703        if current.kind() == "identifier"
704            && current.start_byte() < node.start_byte()
705            && ruby_node_text(current, source).trim() == "module_function"
706        {
707            bare_module_function_active = true;
708            continue;
709        }
710        if current.kind() == "call"
711            && let Some(method) = current.child_by_field_name("method")
712            && ruby_node_text(method, source).trim() == "module_function"
713        {
714            let mut names = module_function_names(current, source);
715            if names.next().is_none() {
716                if current.start_byte() < node.start_byte() {
717                    bare_module_function_active = true;
718                }
719            } else if module_function_names(current, source).any(|name| name == method_name) {
720                return true;
721            }
722            continue;
723        }
724        for index in (0..current.named_child_count()).rev() {
725            if let Some(child) = current.named_child(index) {
726                stack.push(child);
727            }
728        }
729    }
730    bare_module_function_active
731}
732
733fn enclosing_module_for_module_function(node: Node<'_>) -> Option<Node<'_>> {
734    let mut parent = node.parent();
735    while let Some(current) = parent {
736        match current.kind() {
737            "module" => return Some(current),
738            "class" => return None,
739            _ => parent = current.parent(),
740        }
741    }
742    None
743}
744
745fn module_function_names<'a>(node: Node<'_>, source: &'a str) -> impl Iterator<Item = String> + 'a {
746    let mut names = Vec::new();
747    if let Some(arguments) = node.child_by_field_name("arguments") {
748        let mut cursor = arguments.walk();
749        for arg in arguments.named_children(&mut cursor) {
750            if let Some(name) = literal_symbol_or_string_name(arg, source) {
751                names.push(name);
752            }
753        }
754    }
755    names.into_iter()
756}
757
758fn assignment_constant_short_name(lexical_segments: &[String], name_path: &RubyNamePath) -> String {
759    let Some((name, owner_segments)) = name_path.segments.split_last() else {
760        return String::new();
761    };
762    if owner_segments.is_empty() {
763        return member_short_name(lexical_segments, name);
764    }
765    if name_path.absolute || owner_segments.len() > 1 || lexical_segments.is_empty() {
766        return member_short_name(owner_segments, name);
767    }
768
769    let mut resolved_owner = Vec::new();
770    resolved_owner.extend_from_slice(lexical_segments);
771    resolved_owner.extend_from_slice(owner_segments);
772    member_short_name(&resolved_owner, name)
773}
774
775/// The structured counterpart of [`assignment_constant_short_name`] — mirrors
776/// its branches exactly, building an [`FqName`] from the same owner segments
777/// instead of a `$`-joined string. The owner segments come from the constant
778/// reference's own AST-derived name path (`extract_name_path`), which may name
779/// a different (re-opened) namespace than the lexically enclosing one, so this
780/// builds a fresh chain rather than extending a `parent` `CodeUnit`'s `fq`.
781fn assignment_constant_fq(lexical_segments: &[String], name_path: &RubyNamePath) -> FqName {
782    let Some((name, owner_segments)) = name_path.segments.split_last() else {
783        return FqName::new();
784    };
785    if owner_segments.is_empty() {
786        return ruby_member_fq(lexical_segments, name);
787    }
788    if name_path.absolute || owner_segments.len() > 1 || lexical_segments.is_empty() {
789        return ruby_member_fq(owner_segments, name);
790    }
791
792    let mut resolved_owner = Vec::new();
793    resolved_owner.extend_from_slice(lexical_segments);
794    resolved_owner.extend_from_slice(owner_segments);
795    ruby_member_fq(&resolved_owner, name)
796}
797
798pub struct RubyNamePath {
799    pub segments: Vec<String>,
800    pub absolute: bool,
801}
802
803/// Extracts the namespace segments from a class/module name node by walking the
804/// AST (not by string-splitting `::`). A plain `(constant)` yields one segment;
805/// a `(scope_resolution)` like `A::B` walks its `scope` and `name` fields to
806/// yield `["A", "B"]`.
807pub fn extract_name_segments(name_node: Node<'_>, source: &str) -> Vec<String> {
808    extract_name_path(name_node, source).segments
809}
810
811pub fn extract_name_path(name_node: Node<'_>, source: &str) -> RubyNamePath {
812    match name_node.kind() {
813        "scope_resolution" => {
814            let mut path = name_node
815                .child_by_field_name("scope")
816                .map(|scope| extract_name_path(scope, source))
817                .unwrap_or_else(|| RubyNamePath {
818                    segments: Vec::new(),
819                    absolute: true,
820                });
821            if let Some(name) = name_node.child_by_field_name("name") {
822                path.segments.extend(extract_name_segments(name, source));
823            }
824            path
825        }
826        _ => {
827            let text = ruby_node_text(name_node, source).trim();
828            let segments = if text.is_empty() {
829                Vec::new()
830            } else {
831                vec![text.to_string()]
832            };
833            RubyNamePath {
834                segments,
835                absolute: false,
836            }
837        }
838    }
839}
840
841/// Renders a `constant`/`scope_resolution` reference node into the internal
842/// `$`-joined name used as a `CodeUnit` key (e.g. `A::B` -> `A$B`).
843pub fn qualified_internal_name(node: Node<'_>, source: &str) -> Option<String> {
844    let segments = extract_name_segments(node, source);
845    (!segments.is_empty()).then(|| segments.join("$"))
846}
847
848/// Collects a class/module's true superclass. Ruby mixins are intentionally not
849/// type hierarchy ancestors; they are modeled separately for method lookup.
850fn extract_ruby_supertypes(node: Node<'_>, source: &str) -> Vec<String> {
851    let mut supertypes = Vec::new();
852
853    if let Some(superclass) = node.child_by_field_name("superclass") {
854        let mut cursor = superclass.walk();
855        if let Some(expr) = superclass.named_children(&mut cursor).next()
856            && let Some(name) = qualified_internal_name(expr, source)
857        {
858            supertypes.push(name);
859        }
860    }
861
862    supertypes
863}
864
865/// Extracts the bare name from a literal `attr_*`/`alias_method` argument,
866/// which is usually a symbol (`:name`) or string (`"name"`).
867fn literal_symbol_or_string_name(node: Node<'_>, source: &str) -> Option<String> {
868    if !matches!(node.kind(), "simple_symbol" | "string") {
869        return None;
870    }
871    let text = ruby_node_text(node, source).trim();
872    let stripped = text
873        .strip_prefix(':')
874        .unwrap_or(text)
875        .trim_matches(['"', '\'']);
876    (!stripped.is_empty()).then(|| stripped.to_string())
877}
878
879/// First non-blank line of a node's source, used as a one-line signature.
880fn first_line(node: Node<'_>, source: &str) -> String {
881    ruby_node_text(node, source)
882        .lines()
883        .map(str::trim)
884        .find(|line| !line.is_empty())
885        .unwrap_or_default()
886        .to_string()
887}
888
889fn ruby_signature_metadata(signature: String, node: Node<'_>, source: &str) -> SignatureMetadata {
890    let Some(parameters_node) = node.child_by_field_name("parameters") else {
891        return SignatureMetadata::new(signature, Vec::new())
892            .with_dispatch_extensibility(DispatchExtensibility::Open);
893    };
894    let mut cursor = parameters_node.walk();
895    let labels = parameters_node
896        .named_children(&mut cursor)
897        .filter_map(|child| ruby_parameter_label_node(child))
898        .map(|label_node| ruby_node_text(label_node, source).trim().to_string())
899        .filter(|label| !label.is_empty())
900        .collect();
901    SignatureMetadata::with_parameter_labels(signature, labels)
902        .with_dispatch_extensibility(DispatchExtensibility::Open)
903}
904
905fn ruby_parameter_label_node(node: Node<'_>) -> Option<Node<'_>> {
906    match node.kind() {
907        "identifier" => Some(node),
908        "optional_parameter"
909        | "keyword_parameter"
910        | "splat_parameter"
911        | "hash_splat_parameter"
912        | "block_parameter" => node
913            .child_by_field_name("name")
914            .or_else(|| first_identifier_descendant(node)),
915        _ => None,
916    }
917}
918
919fn first_identifier_descendant(node: Node<'_>) -> Option<Node<'_>> {
920    let mut stack = vec![node];
921    while let Some(current) = stack.pop() {
922        if current.kind() == "identifier" {
923            return Some(current);
924        }
925        for index in (0..current.named_child_count()).rev() {
926            if let Some(child) = current.named_child(index) {
927                stack.push(child);
928            }
929        }
930    }
931    None
932}
933
934/// Container node kinds the visitor recurses through to find conditionally
935/// declared symbols (e.g. a `def` inside an `if`). Excludes `method`/
936/// `singleton_method`, whose bodies are treated as leaves.
937pub fn is_descendable_container(kind: &str) -> bool {
938    matches!(
939        kind,
940        "if" | "unless"
941            | "elsif"
942            | "else"
943            | "while"
944            | "until"
945            | "for"
946            | "case"
947            | "case_match"
948            | "when"
949            | "in_clause"
950            | "begin"
951            | "body_statement"
952            | "do"
953            | "do_block"
954            | "block"
955            | "then"
956            | "ensure"
957            | "rescue"
958            | "parenthesized_statements"
959            | "begin_block"
960            | "end_block"
961    )
962}
963
964pub fn collect_ruby_identifiers(node: Node<'_>, source: &str, identifiers: &mut HashSet<String>) {
965    walk_named_tree_preorder(node, true, |node| {
966        if matches!(node.kind(), "identifier" | "constant") {
967            let text = ruby_node_text(node, source).trim();
968            if !text.is_empty() {
969                identifiers.insert(text.to_string());
970            }
971        }
972        WalkControl::Continue
973    });
974}