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.imports.push(info);
374                }
375            }
376            "attr_accessor" | "attr_reader" | "attr_writer" => {
377                self.visit_attr_macro(node, method_name, segments, parent);
378            }
379            "alias_method" => {
380                self.visit_alias_method(node, segments, parent);
381            }
382            _ => {}
383        }
384    }
385
386    fn visit_attr_macro(
387        &mut self,
388        node: Node<'_>,
389        method_name: &str,
390        segments: &[String],
391        parent: Option<&CodeUnit>,
392    ) {
393        // `attr_accessor` and friends only declare members inside a type body.
394        let Some(parent) = parent else {
395            return;
396        };
397        let Some(arguments) = node.child_by_field_name("arguments") else {
398            return;
399        };
400        let mut cursor = arguments.walk();
401        let mut dynamic_argument_seen = false;
402        for arg in arguments.named_children(&mut cursor) {
403            let Some(name) = literal_symbol_or_string_name(arg, self.source) else {
404                // A non-literal argument generates *something* the analyzer
405                // cannot name. The site record is what keeps the generated
406                // set explicitly unknown rather than silently empty.
407                dynamic_argument_seen = true;
408                continue;
409            };
410            let member_name = attr_field_member_name(node, &name);
411            let field_name = format!("@{name}");
412            let code_unit = CodeUnit::new_fq(
413                self.file.clone(),
414                CodeUnitType::Field,
415                String::new(),
416                member_short_name(segments, &member_name),
417                ruby_scoped_field_fq(segments, &field_name, method_is_singleton_context(node)),
418            );
419            self.parsed.replace_code_unit(
420                code_unit.clone(),
421                node,
422                self.source,
423                Some(parent.clone()),
424                None,
425            );
426            self.parsed
427                .record_materialization(MaterializationRecord::GeneratedDeclaration {
428                    site: node_range(node),
429                    argument: node_range(arg),
430                    kind: GenerationKind::AccessorMacro,
431                    unit: code_unit.clone(),
432                });
433            self.parsed.add_signature(
434                code_unit,
435                ruby_node_text(node, self.source).trim().to_string(),
436            );
437            if matches!(method_name, "attr_accessor" | "attr_reader") {
438                self.add_member_function(
439                    node,
440                    arg,
441                    segments,
442                    parent,
443                    &name,
444                    GenerationKind::AccessorMacro,
445                );
446            }
447            if matches!(method_name, "attr_accessor" | "attr_writer") {
448                self.add_member_function(
449                    node,
450                    arg,
451                    segments,
452                    parent,
453                    &format!("{name}="),
454                    GenerationKind::AccessorMacro,
455                );
456            }
457        }
458        if dynamic_argument_seen {
459            self.parsed
460                .record_materialization(MaterializationRecord::DynamicGenerationSite {
461                    site: node_range(node),
462                    kind: GenerationKind::AccessorMacro,
463                });
464        }
465    }
466
467    fn visit_alias_method(
468        &mut self,
469        node: Node<'_>,
470        segments: &[String],
471        parent: Option<&CodeUnit>,
472    ) {
473        let Some(parent) = parent else {
474            return;
475        };
476        let Some(arguments) = node.child_by_field_name("arguments") else {
477            return;
478        };
479        let mut cursor = arguments.walk();
480        let Some(alias_arg) = arguments.named_children(&mut cursor).next() else {
481            return;
482        };
483        let Some(alias_name) = literal_symbol_or_string_name(alias_arg, self.source) else {
484            // A dynamic alias name generates a method the analyzer cannot
485            // name; record the site so the generated set stays explicitly
486            // unknown.
487            self.parsed
488                .record_materialization(MaterializationRecord::DynamicGenerationSite {
489                    site: node_range(node),
490                    kind: GenerationKind::AliasMacro,
491                });
492            return;
493        };
494        self.add_member_function(
495            node,
496            alias_arg,
497            segments,
498            parent,
499            &alias_name,
500            GenerationKind::AliasMacro,
501        );
502    }
503
504    fn add_member_function(
505        &mut self,
506        signature_node: Node<'_>,
507        range_node: Node<'_>,
508        segments: &[String],
509        parent: &CodeUnit,
510        name: &str,
511        generation: GenerationKind,
512    ) {
513        let code_unit = CodeUnit::new_fq(
514            self.file.clone(),
515            CodeUnitType::Function,
516            String::new(),
517            member_short_name(segments, name),
518            ruby_member_fq(segments, name),
519        );
520        self.parsed.replace_code_unit(
521            code_unit.clone(),
522            range_node,
523            self.source,
524            Some(parent.clone()),
525            None,
526        );
527        self.parsed
528            .record_materialization(MaterializationRecord::GeneratedDeclaration {
529                site: node_range(signature_node),
530                argument: node_range(range_node),
531                kind: generation,
532                unit: code_unit.clone(),
533            });
534        self.parsed.set_ruby_method_dispatch_mode(
535            code_unit.clone(),
536            ruby_method_dispatch_mode(signature_node, self.source),
537        );
538        self.parsed.add_signature(
539            code_unit,
540            ruby_node_text(signature_node, self.source)
541                .trim()
542                .to_string(),
543        );
544    }
545}
546
547/// Builds a member's `short_name` from its enclosing type segments and own name.
548fn member_short_name(segments: &[String], name: &str) -> String {
549    if segments.is_empty() {
550        name.to_string()
551    } else {
552        format!("{}.{}", segments.join("$"), name)
553    }
554}
555
556fn attr_field_member_name(node: Node<'_>, name: &str) -> String {
557    if method_is_singleton_context(node) {
558        format!("$singleton.@{name}")
559    } else {
560        format!("@{name}")
561    }
562}
563
564pub fn ruby_variable_field_name(node: Node<'_>, source: &str) -> Option<String> {
565    if !matches!(node.kind(), "instance_variable" | "class_variable") {
566        return None;
567    }
568    let name = ruby_node_text(node, source).trim();
569    (!name.is_empty()).then(|| name.to_string())
570}
571
572/// The rendered member tail shared by [`ruby_field_short_name`] and
573/// [`ruby_field_fq`]. Singleton-scoped fields retain the established
574/// `$singleton.@field` spelling, while [`ruby_field_fq`] records `$singleton`
575/// and the field itself as distinct scope/member segments.
576fn ruby_field_member_name(node: Node<'_>, source: &str, scope: RubyFieldScope) -> Option<String> {
577    let name = ruby_variable_field_name(node, source)?;
578    Some(match scope {
579        RubyFieldScope::Instance | RubyFieldScope::ClassVariable => name,
580        RubyFieldScope::SingletonClass => format!("$singleton.{name}"),
581    })
582}
583
584pub fn ruby_field_short_name(
585    segments: &[String],
586    node: Node<'_>,
587    source: &str,
588    scope: RubyFieldScope,
589) -> Option<String> {
590    if segments.is_empty() {
591        return None;
592    }
593    let member = ruby_field_member_name(node, source, scope)?;
594    Some(member_short_name(segments, &member))
595}
596
597/// The structured counterpart of [`ruby_field_short_name`].
598fn ruby_field_fq(
599    segments: &[String],
600    node: Node<'_>,
601    source: &str,
602    scope: RubyFieldScope,
603) -> Option<FqName> {
604    if segments.is_empty() {
605        return None;
606    }
607    let name = ruby_variable_field_name(node, source)?;
608    Some(ruby_scoped_field_fq(
609        segments,
610        &name,
611        scope == RubyFieldScope::SingletonClass,
612    ))
613}
614
615/// Builds the structured identity for a Ruby field. `$singleton` is a real
616/// synthetic owner scope, not part of the terminal field identifier.
617fn ruby_scoped_field_fq(segments: &[String], name: &str, singleton: bool) -> FqName {
618    let mut fq = ruby_type_chain_fq(segments);
619    if singleton {
620        fq.push(ruby_segment("$singleton", SegmentKind::Package));
621    }
622    fq.push(ruby_segment(name, SegmentKind::Member));
623    fq
624}
625
626pub fn ruby_field_scope_for_assignment_left(
627    left: Node<'_>,
628    segments: &[String],
629    current_scope: Option<RubyFieldScope>,
630) -> Option<RubyFieldScope> {
631    if segments.is_empty() {
632        return None;
633    }
634    match left.kind() {
635        "class_variable" => Some(RubyFieldScope::ClassVariable),
636        "instance_variable" => Some(current_scope.unwrap_or(RubyFieldScope::SingletonClass)),
637        _ => None,
638    }
639}
640
641fn ruby_method_field_scope(node: Node<'_>) -> RubyFieldScope {
642    if method_is_singleton_context(node) {
643        RubyFieldScope::SingletonClass
644    } else {
645        RubyFieldScope::Instance
646    }
647}
648
649fn ruby_method_dispatch_mode(node: Node<'_>, source: &str) -> RubyMethodDispatchMode {
650    if module_function_applies_to_method(node, source) {
651        RubyMethodDispatchMode::ModuleFunction
652    } else if method_is_singleton_context(node) {
653        RubyMethodDispatchMode::Singleton
654    } else {
655        RubyMethodDispatchMode::Instance
656    }
657}
658
659fn method_is_singleton_context(node: Node<'_>) -> bool {
660    if node.kind() == "singleton_method" {
661        return true;
662    }
663    let mut parent = node.parent();
664    while let Some(current) = parent {
665        if current.kind() == "singleton_class" {
666            return true;
667        }
668        if matches!(current.kind(), "class" | "module") {
669            break;
670        }
671        parent = current.parent();
672    }
673    false
674}
675
676fn module_function_applies_to_method(node: Node<'_>, source: &str) -> bool {
677    if node.kind() != "method" {
678        return false;
679    }
680    let Some(name_node) = node.child_by_field_name("name") else {
681        return false;
682    };
683    let method_name = ruby_node_text(name_node, source).trim();
684    let Some(module) = enclosing_module_for_module_function(node) else {
685        return false;
686    };
687    let Some(body) = module.child_by_field_name("body") else {
688        return false;
689    };
690
691    let mut bare_module_function_active = false;
692    let mut stack = vec![body];
693    while let Some(current) = stack.pop() {
694        if current != body
695            && matches!(
696                current.kind(),
697                "class" | "module" | "method" | "singleton_method"
698            )
699        {
700            continue;
701        }
702        if current.kind() == "identifier"
703            && current.start_byte() < node.start_byte()
704            && ruby_node_text(current, source).trim() == "module_function"
705        {
706            bare_module_function_active = true;
707            continue;
708        }
709        if current.kind() == "call"
710            && let Some(method) = current.child_by_field_name("method")
711            && ruby_node_text(method, source).trim() == "module_function"
712        {
713            let mut names = module_function_names(current, source);
714            if names.next().is_none() {
715                if current.start_byte() < node.start_byte() {
716                    bare_module_function_active = true;
717                }
718            } else if module_function_names(current, source).any(|name| name == method_name) {
719                return true;
720            }
721            continue;
722        }
723        for index in (0..current.named_child_count()).rev() {
724            if let Some(child) = current.named_child(index) {
725                stack.push(child);
726            }
727        }
728    }
729    bare_module_function_active
730}
731
732fn enclosing_module_for_module_function(node: Node<'_>) -> Option<Node<'_>> {
733    let mut parent = node.parent();
734    while let Some(current) = parent {
735        match current.kind() {
736            "module" => return Some(current),
737            "class" => return None,
738            _ => parent = current.parent(),
739        }
740    }
741    None
742}
743
744fn module_function_names<'a>(node: Node<'_>, source: &'a str) -> impl Iterator<Item = String> + 'a {
745    let mut names = Vec::new();
746    if let Some(arguments) = node.child_by_field_name("arguments") {
747        let mut cursor = arguments.walk();
748        for arg in arguments.named_children(&mut cursor) {
749            if let Some(name) = literal_symbol_or_string_name(arg, source) {
750                names.push(name);
751            }
752        }
753    }
754    names.into_iter()
755}
756
757fn assignment_constant_short_name(lexical_segments: &[String], name_path: &RubyNamePath) -> String {
758    let Some((name, owner_segments)) = name_path.segments.split_last() else {
759        return String::new();
760    };
761    if owner_segments.is_empty() {
762        return member_short_name(lexical_segments, name);
763    }
764    if name_path.absolute || owner_segments.len() > 1 || lexical_segments.is_empty() {
765        return member_short_name(owner_segments, name);
766    }
767
768    let mut resolved_owner = Vec::new();
769    resolved_owner.extend_from_slice(lexical_segments);
770    resolved_owner.extend_from_slice(owner_segments);
771    member_short_name(&resolved_owner, name)
772}
773
774/// The structured counterpart of [`assignment_constant_short_name`] — mirrors
775/// its branches exactly, building an [`FqName`] from the same owner segments
776/// instead of a `$`-joined string. The owner segments come from the constant
777/// reference's own AST-derived name path (`extract_name_path`), which may name
778/// a different (re-opened) namespace than the lexically enclosing one, so this
779/// builds a fresh chain rather than extending a `parent` `CodeUnit`'s `fq`.
780fn assignment_constant_fq(lexical_segments: &[String], name_path: &RubyNamePath) -> FqName {
781    let Some((name, owner_segments)) = name_path.segments.split_last() else {
782        return FqName::new();
783    };
784    if owner_segments.is_empty() {
785        return ruby_member_fq(lexical_segments, name);
786    }
787    if name_path.absolute || owner_segments.len() > 1 || lexical_segments.is_empty() {
788        return ruby_member_fq(owner_segments, name);
789    }
790
791    let mut resolved_owner = Vec::new();
792    resolved_owner.extend_from_slice(lexical_segments);
793    resolved_owner.extend_from_slice(owner_segments);
794    ruby_member_fq(&resolved_owner, name)
795}
796
797pub struct RubyNamePath {
798    pub segments: Vec<String>,
799    pub absolute: bool,
800}
801
802/// Extracts the namespace segments from a class/module name node by walking the
803/// AST (not by string-splitting `::`). A plain `(constant)` yields one segment;
804/// a `(scope_resolution)` like `A::B` walks its `scope` and `name` fields to
805/// yield `["A", "B"]`.
806pub fn extract_name_segments(name_node: Node<'_>, source: &str) -> Vec<String> {
807    extract_name_path(name_node, source).segments
808}
809
810pub fn extract_name_path(name_node: Node<'_>, source: &str) -> RubyNamePath {
811    match name_node.kind() {
812        "scope_resolution" => {
813            let mut path = name_node
814                .child_by_field_name("scope")
815                .map(|scope| extract_name_path(scope, source))
816                .unwrap_or_else(|| RubyNamePath {
817                    segments: Vec::new(),
818                    absolute: true,
819                });
820            if let Some(name) = name_node.child_by_field_name("name") {
821                path.segments.extend(extract_name_segments(name, source));
822            }
823            path
824        }
825        _ => {
826            let text = ruby_node_text(name_node, source).trim();
827            let segments = if text.is_empty() {
828                Vec::new()
829            } else {
830                vec![text.to_string()]
831            };
832            RubyNamePath {
833                segments,
834                absolute: false,
835            }
836        }
837    }
838}
839
840/// Renders a `constant`/`scope_resolution` reference node into the internal
841/// `$`-joined name used as a `CodeUnit` key (e.g. `A::B` -> `A$B`).
842pub fn qualified_internal_name(node: Node<'_>, source: &str) -> Option<String> {
843    let segments = extract_name_segments(node, source);
844    (!segments.is_empty()).then(|| segments.join("$"))
845}
846
847/// Collects a class/module's true superclass. Ruby mixins are intentionally not
848/// type hierarchy ancestors; they are modeled separately for method lookup.
849fn extract_ruby_supertypes(node: Node<'_>, source: &str) -> Vec<String> {
850    let mut supertypes = Vec::new();
851
852    if let Some(superclass) = node.child_by_field_name("superclass") {
853        let mut cursor = superclass.walk();
854        if let Some(expr) = superclass.named_children(&mut cursor).next()
855            && let Some(name) = qualified_internal_name(expr, source)
856        {
857            supertypes.push(name);
858        }
859    }
860
861    supertypes
862}
863
864/// Extracts the bare name from a literal `attr_*`/`alias_method` argument,
865/// which is usually a symbol (`:name`) or string (`"name"`).
866fn literal_symbol_or_string_name(node: Node<'_>, source: &str) -> Option<String> {
867    if !matches!(node.kind(), "simple_symbol" | "string") {
868        return None;
869    }
870    let text = ruby_node_text(node, source).trim();
871    let stripped = text
872        .strip_prefix(':')
873        .unwrap_or(text)
874        .trim_matches(['"', '\'']);
875    (!stripped.is_empty()).then(|| stripped.to_string())
876}
877
878/// First non-blank line of a node's source, used as a one-line signature.
879fn first_line(node: Node<'_>, source: &str) -> String {
880    ruby_node_text(node, source)
881        .lines()
882        .map(str::trim)
883        .find(|line| !line.is_empty())
884        .unwrap_or_default()
885        .to_string()
886}
887
888fn ruby_signature_metadata(signature: String, node: Node<'_>, source: &str) -> SignatureMetadata {
889    let Some(parameters_node) = node.child_by_field_name("parameters") else {
890        return SignatureMetadata::new(signature, Vec::new())
891            .with_dispatch_extensibility(DispatchExtensibility::Open);
892    };
893    let mut cursor = parameters_node.walk();
894    let labels = parameters_node
895        .named_children(&mut cursor)
896        .filter_map(|child| ruby_parameter_label_node(child))
897        .map(|label_node| ruby_node_text(label_node, source).trim().to_string())
898        .filter(|label| !label.is_empty())
899        .collect();
900    SignatureMetadata::with_parameter_labels(signature, labels)
901        .with_dispatch_extensibility(DispatchExtensibility::Open)
902}
903
904fn ruby_parameter_label_node(node: Node<'_>) -> Option<Node<'_>> {
905    match node.kind() {
906        "identifier" => Some(node),
907        "optional_parameter"
908        | "keyword_parameter"
909        | "splat_parameter"
910        | "hash_splat_parameter"
911        | "block_parameter" => node
912            .child_by_field_name("name")
913            .or_else(|| first_identifier_descendant(node)),
914        _ => None,
915    }
916}
917
918fn first_identifier_descendant(node: Node<'_>) -> Option<Node<'_>> {
919    let mut stack = vec![node];
920    while let Some(current) = stack.pop() {
921        if current.kind() == "identifier" {
922            return Some(current);
923        }
924        for index in (0..current.named_child_count()).rev() {
925            if let Some(child) = current.named_child(index) {
926                stack.push(child);
927            }
928        }
929    }
930    None
931}
932
933/// Container node kinds the visitor recurses through to find conditionally
934/// declared symbols (e.g. a `def` inside an `if`). Excludes `method`/
935/// `singleton_method`, whose bodies are treated as leaves.
936pub fn is_descendable_container(kind: &str) -> bool {
937    matches!(
938        kind,
939        "if" | "unless"
940            | "elsif"
941            | "else"
942            | "while"
943            | "until"
944            | "for"
945            | "case"
946            | "case_match"
947            | "when"
948            | "in_clause"
949            | "begin"
950            | "body_statement"
951            | "do"
952            | "do_block"
953            | "block"
954            | "then"
955            | "ensure"
956            | "rescue"
957            | "parenthesized_statements"
958            | "begin_block"
959            | "end_block"
960    )
961}
962
963pub fn collect_ruby_identifiers(node: Node<'_>, source: &str, identifiers: &mut HashSet<String>) {
964    walk_named_tree_preorder(node, true, |node| {
965        if matches!(node.kind(), "identifier" | "constant") {
966            let text = ruby_node_text(node, source).trim();
967            if !text.is_empty() {
968                identifiers.insert(text.to_string());
969            }
970        }
971        WalkControl::Continue
972    });
973}