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::structural::resolution::DeclaredVisibility;
12use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, node_range, walk_named_tree_preorder};
13use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
14use brokk_bifrost_core::hash::HashSet;
15use tree_sitter::{Node, Parser, Tree};
16
17/// Intern one qualified-name segment in the process-global interner.
18fn ruby_segment(text: &str, kind: SegmentKind) -> SegmentId {
19    segment_interner().intern(text, kind)
20}
21
22/// Build the structured type/namespace chain for a sequence of Ruby class/
23/// module name segments (already atomic AST-derived strings — see
24/// [`extract_name_segments`], which walks `scope_resolution` nodes rather than
25/// splitting `A::B` text). Ruby's `package_name` is always empty and its legacy
26/// `short_name` joins EVERY namespace segment with a literal `$` (see this
27/// module's doc comment on [`RubyVisitor`]): `module A; class B` yields `A$B`.
28/// [`SegmentKind::Nested`] is the tag whose join renders a leading
29/// `$` regardless of the previous segment's kind, and the very first segment of
30/// a qualified name never gets a leading separator at all (there is no
31/// preceding segment) — so tagging every namespace segment `Companion`
32/// reproduces the `$`-joined chain exactly, including its first element.
33fn ruby_type_chain_fq(segments: &[String]) -> FqName {
34    let mut fq = FqName::new();
35    for segment in segments {
36        fq.push(ruby_segment(segment, SegmentKind::Nested));
37    }
38    fq
39}
40
41/// Extends a type/namespace chain with one trailing `Member` segment — the
42/// structured counterpart of [`member_short_name`], which appends `.name`
43/// after the `$`-joined chain.
44fn ruby_member_fq(type_segments: &[String], name: &str) -> FqName {
45    ruby_type_chain_fq(type_segments).with_pushed(ruby_segment(name, SegmentKind::Member))
46}
47
48/// Parses Ruby source into a tree-sitter tree, or `None` if parsing fails.
49pub fn parse_ruby_tree(source: &str) -> Option<Tree> {
50    let mut parser = Parser::new();
51    parser
52        .set_language(&tree_sitter_ruby::LANGUAGE.into())
53        .expect("failed to load ruby parser");
54    parser.parse(source, None)
55}
56
57/// Reads the source text backing a tree-sitter node.
58pub fn ruby_node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
59    brokk_bifrost_core::analyzer::common::node_source_text(node, source)
60}
61
62/// Walks a Ruby file and emits its declarations into `parsed`.
63///
64/// Ruby symbol identity follows the shared `CodeUnit` scheme used by every
65/// bifrost analyzer: `package_name` is empty, nested namespaces/types are joined
66/// in `short_name` with `$`, and a type's members are appended after a `.`. So
67/// `module A; class B; def c` yields `A$B` (class) and `A$B.c` (method), which
68/// `CodeUnit::identifier` resolves back to `B` and `c`.
69pub struct RubyVisitor<'a> {
70    pub file: &'a ProjectFile,
71    pub source: &'a str,
72    pub parsed: &'a mut ParsedFile,
73}
74
75/// A pending traversal step: visit `node` as a statement within the enclosing
76/// type's `segments`/`parent` context. The visitor uses an explicit stack of
77/// these instead of native recursion so deeply nested input cannot overflow the
78/// call stack (per AGENTS.md, and mirroring the Python visitor).
79struct RubyWork<'tree> {
80    node: Node<'tree>,
81    segments: Vec<String>,
82    parent: Option<CodeUnit>,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum RubyFieldScope {
87    Instance,
88    ClassVariable,
89    SingletonClass,
90}
91
92/// Pushes a node's named children as statement work items. Children are pushed
93/// in reverse so the stack pops them in source order.
94fn push_named_children<'tree>(
95    node: Node<'tree>,
96    segments: &[String],
97    parent: Option<&CodeUnit>,
98    stack: &mut Vec<RubyWork<'tree>>,
99) {
100    let mut cursor = node.walk();
101    let children: Vec<_> = node.named_children(&mut cursor).collect();
102    for child in children.into_iter().rev() {
103        stack.push(RubyWork {
104            node: child,
105            segments: segments.to_vec(),
106            parent: parent.cloned(),
107        });
108    }
109}
110
111impl RubyVisitor<'_> {
112    pub fn visit_program(&mut self, root: Node<'_>) {
113        let mut stack = Vec::new();
114        push_named_children(root, &[], None, &mut stack);
115        while let Some(work) = stack.pop() {
116            self.visit_statement(work.node, &work.segments, work.parent.as_ref(), &mut stack);
117        }
118    }
119
120    fn visit_statement<'tree>(
121        &mut self,
122        node: Node<'tree>,
123        segments: &[String],
124        parent: Option<&CodeUnit>,
125        stack: &mut Vec<RubyWork<'tree>>,
126    ) {
127        match node.kind() {
128            "class" => self.visit_class_like(node, segments, parent, false, stack),
129            "module" => self.visit_class_like(node, segments, parent, true, stack),
130            "singleton_class" => {
131                // `class << self` — its methods belong to the enclosing type.
132                if let Some(body) = node.child_by_field_name("body") {
133                    push_named_children(body, segments, parent, stack);
134                }
135            }
136            "method" | "singleton_method" => self.visit_method(node, segments, parent),
137            "assignment" | "operator_assignment" => {
138                self.visit_assignment(node, segments, parent, None)
139            }
140            "call" => self.visit_call(node, segments, parent),
141            kind if is_descendable_container(kind) => {
142                push_named_children(node, segments, parent, stack);
143            }
144            _ => {}
145        }
146    }
147
148    fn visit_class_like<'tree>(
149        &mut self,
150        node: Node<'tree>,
151        segments: &[String],
152        parent: Option<&CodeUnit>,
153        is_module: bool,
154        stack: &mut Vec<RubyWork<'tree>>,
155    ) {
156        let Some(name_node) = node.child_by_field_name("name") else {
157            return;
158        };
159        let name_segments = extract_name_segments(name_node, self.source);
160        if name_segments.is_empty() {
161            return;
162        }
163
164        let mut new_segments = segments.to_vec();
165        new_segments.extend(name_segments);
166        let short_name = new_segments.join("$");
167
168        let kind = if is_module {
169            CodeUnitType::Module
170        } else {
171            CodeUnitType::Class
172        };
173        let code_unit = CodeUnit::new_fq(
174            self.file.clone(),
175            kind,
176            String::new(),
177            short_name,
178            ruby_type_chain_fq(&new_segments),
179        );
180        self.parsed
181            .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
182        self.parsed
183            .add_signature(code_unit.clone(), first_line(node, self.source));
184
185        let mut owner_relations = extract_ruby_supertypes(node, self.source)
186            .into_iter()
187            .map(|target| {
188                let encoded = encode_superclass_relation(&target);
189                (target, encoded)
190            })
191            .collect::<Vec<_>>();
192        owner_relations.extend(raw_mixin_specs_for_type(node, self.source).into_iter().map(
193            |spec| {
194                let encoded = encode_mixin_relation(&spec);
195                (spec.raw_target, encoded)
196            },
197        ));
198        if !owner_relations.is_empty() {
199            self.parsed.set_raw_supertypes(
200                code_unit.clone(),
201                owner_relations
202                    .iter()
203                    .map(|(target, _)| target.clone())
204                    .collect(),
205            );
206            self.parsed.set_supertype_lookup_paths(
207                code_unit.clone(),
208                owner_relations
209                    .into_iter()
210                    .map(|(_, encoded)| encoded)
211                    .collect(),
212            );
213        }
214
215        self.visit_scope_field_assignments(
216            node,
217            &new_segments,
218            Some(&code_unit),
219            RubyFieldScope::SingletonClass,
220        );
221        if let Some(body) = node.child_by_field_name("body") {
222            push_named_children(body, &new_segments, Some(&code_unit), stack);
223        }
224    }
225
226    fn visit_method(&mut self, node: Node<'_>, segments: &[String], parent: Option<&CodeUnit>) {
227        let Some(name_node) = node.child_by_field_name("name") else {
228            return;
229        };
230        let name = ruby_node_text(name_node, self.source).trim();
231        if name.is_empty() {
232            return;
233        }
234        let short_name = member_short_name(segments, name);
235        let signature = node
236            .child_by_field_name("parameters")
237            .map(|params| ruby_node_text(params, self.source).trim().to_string());
238        let code_unit = CodeUnit::with_signature_and_fq(
239            self.file.clone(),
240            CodeUnitType::Function,
241            String::new(),
242            short_name,
243            signature,
244            false,
245            ruby_member_fq(segments, name),
246        );
247        self.parsed
248            .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
249        self.parsed.set_ruby_method_dispatch_mode(
250            code_unit.clone(),
251            ruby_method_dispatch_mode(node, self.source),
252        );
253        self.parsed.add_signature_with_metadata(
254            code_unit,
255            ruby_signature_metadata(first_line(node, self.source), node, self.source),
256        );
257        // Method bodies are otherwise leaves for declaration purposes, but Ruby
258        // instance/class variables are declarations even when first assigned in
259        // methods.
260        self.visit_scope_field_assignments(node, segments, parent, ruby_method_field_scope(node));
261    }
262
263    fn visit_assignment(
264        &mut self,
265        node: Node<'_>,
266        segments: &[String],
267        parent: Option<&CodeUnit>,
268        field_scope: Option<RubyFieldScope>,
269    ) {
270        let Some(left) = node.child_by_field_name("left") else {
271            return;
272        };
273        if let Some(field_scope) = ruby_field_scope_for_assignment_left(left, segments, field_scope)
274        {
275            self.visit_variable_field_assignment(node, left, segments, parent, field_scope);
276            return;
277        }
278        // Only constant assignments are declarations; locals are lowercase.
279        if !matches!(left.kind(), "constant" | "scope_resolution") {
280            return;
281        }
282        let name_path = extract_name_path(left, self.source);
283        if name_path.segments.is_empty() {
284            return;
285        }
286        let short_name = assignment_constant_short_name(segments, &name_path);
287        let code_unit = CodeUnit::new_fq(
288            self.file.clone(),
289            CodeUnitType::Field,
290            String::new(),
291            short_name,
292            assignment_constant_fq(segments, &name_path),
293        );
294        self.parsed
295            .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
296        self.parsed.add_signature(
297            code_unit,
298            ruby_node_text(node, self.source).trim().to_string(),
299        );
300    }
301
302    fn visit_scope_field_assignments(
303        &mut self,
304        node: Node<'_>,
305        segments: &[String],
306        parent: Option<&CodeUnit>,
307        field_scope: RubyFieldScope,
308    ) {
309        let mut stack = vec![node];
310        while let Some(current) = stack.pop() {
311            if current != node
312                && matches!(
313                    current.kind(),
314                    "class" | "module" | "method" | "singleton_method" | "singleton_class"
315                )
316            {
317                continue;
318            }
319            if matches!(current.kind(), "assignment" | "operator_assignment") {
320                self.visit_assignment(current, segments, parent, Some(field_scope));
321                continue;
322            }
323            for index in (0..current.named_child_count()).rev() {
324                if let Some(child) = current.named_child(index) {
325                    stack.push(child);
326                }
327            }
328        }
329    }
330
331    fn visit_variable_field_assignment(
332        &mut self,
333        node: Node<'_>,
334        left: Node<'_>,
335        segments: &[String],
336        parent: Option<&CodeUnit>,
337        field_scope: RubyFieldScope,
338    ) {
339        let Some(short_name) = ruby_field_short_name(segments, left, self.source, field_scope)
340        else {
341            return;
342        };
343        let fq = ruby_field_fq(segments, left, self.source, field_scope).unwrap_or_default();
344        let code_unit = CodeUnit::new_fq(
345            self.file.clone(),
346            CodeUnitType::Field,
347            String::new(),
348            short_name,
349            fq,
350        );
351        if self
352            .parsed
353            .first_range_start(&code_unit)
354            .is_some_and(|start| start <= node.start_byte())
355        {
356            return;
357        }
358        self.parsed
359            .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
360        self.parsed.add_signature(
361            code_unit,
362            ruby_node_text(node, self.source).trim().to_string(),
363        );
364    }
365
366    fn visit_call(&mut self, node: Node<'_>, segments: &[String], parent: Option<&CodeUnit>) {
367        let Some(method) = node.child_by_field_name("method") else {
368            return;
369        };
370        let method_name = ruby_node_text(method, self.source).trim();
371        match method_name {
372            "require" | "require_relative" | "load" | "autoload" => {
373                if let Some(info) = parse_ruby_require_call(node, self.source) {
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
889/// The one place Ruby builds callable signature metadata for a `def`, so no
890/// declaration path can record parameter labels and forget the modifier facts
891/// that make the callable keyable for procedure-summary binding (#2912):
892/// `receiver_contract_of` refuses to answer for a callable whose adapter never
893/// inspected modifiers.
894///
895/// A `singleton_method` (`def self.name`) and a `method` inside a
896/// `class << self` body bind no instance receiver, which is what the receiver
897/// contract calls static; [`method_is_singleton_context`] reads exactly those
898/// two shapes. An ordinary `method` in a class or module body takes `self`. A
899/// `def` at file scope is an instance method of `Object`, so it is not static
900/// either; it has no type owner, and the contract falls out of that. Ruby has
901/// no constructor declaration shape (`initialize` is an ordinary instance
902/// method) and spells visibility as a method call rather than a modifier node,
903/// so those two facts stay `false` and `Unknown`.
904fn ruby_signature_metadata(signature: String, node: Node<'_>, source: &str) -> SignatureMetadata {
905    let labels = match node.child_by_field_name("parameters") {
906        Some(parameters_node) => {
907            let mut cursor = parameters_node.walk();
908            parameters_node
909                .named_children(&mut cursor)
910                .filter_map(|child| ruby_parameter_label_node(child))
911                .map(|label_node| ruby_node_text(label_node, source).trim().to_string())
912                .filter(|label| !label.is_empty())
913                .collect()
914        }
915        None => Vec::new(),
916    };
917    SignatureMetadata::with_parameter_labels(signature, labels)
918        .with_dispatch_extensibility(DispatchExtensibility::Open)
919        .with_callable_modifiers(
920            method_is_singleton_context(node),
921            false,
922            DeclaredVisibility::Unknown,
923        )
924}
925
926fn ruby_parameter_label_node(node: Node<'_>) -> Option<Node<'_>> {
927    match node.kind() {
928        "identifier" => Some(node),
929        "optional_parameter"
930        | "keyword_parameter"
931        | "splat_parameter"
932        | "hash_splat_parameter"
933        | "block_parameter" => node
934            .child_by_field_name("name")
935            .or_else(|| first_identifier_descendant(node)),
936        _ => None,
937    }
938}
939
940fn first_identifier_descendant(node: Node<'_>) -> Option<Node<'_>> {
941    let mut stack = vec![node];
942    while let Some(current) = stack.pop() {
943        if current.kind() == "identifier" {
944            return Some(current);
945        }
946        for index in (0..current.named_child_count()).rev() {
947            if let Some(child) = current.named_child(index) {
948                stack.push(child);
949            }
950        }
951    }
952    None
953}
954
955/// Container node kinds the visitor recurses through to find conditionally
956/// declared symbols (e.g. a `def` inside an `if`). Excludes `method`/
957/// `singleton_method`, whose bodies are treated as leaves.
958pub fn is_descendable_container(kind: &str) -> bool {
959    matches!(
960        kind,
961        "if" | "unless"
962            | "elsif"
963            | "else"
964            | "while"
965            | "until"
966            | "for"
967            | "case"
968            | "case_match"
969            | "when"
970            | "in_clause"
971            | "begin"
972            | "body_statement"
973            | "do"
974            | "do_block"
975            | "block"
976            | "then"
977            | "ensure"
978            | "rescue"
979            | "parenthesized_statements"
980            | "begin_block"
981            | "end_block"
982    )
983}
984
985pub fn collect_ruby_identifiers(node: Node<'_>, source: &str, identifiers: &mut HashSet<String>) {
986    walk_named_tree_preorder(node, true, |node| {
987        if matches!(node.kind(), "identifier" | "constant") {
988            let text = ruby_node_text(node, source).trim();
989            if !text.is_empty() {
990                identifiers.insert(text.to_string());
991            }
992        }
993        WalkControl::Continue
994    });
995}
996
997#[cfg(test)]
998mod callable_modifier_tests {
999    use super::*;
1000    use crate::adapter::parse_ruby_file;
1001
1002    /// The Ruby half of #2912. `def self.name` and a `def` inside
1003    /// `class << self` bind no instance receiver; an ordinary `def` in a class
1004    /// or module body, `initialize` included, binds one; a `def` at file scope
1005    /// is an instance method of `Object` and is not static either. Every `def`
1006    /// states that the walk read its declaration shape.
1007    #[test]
1008    fn callable_metadata_records_ruby_singleton_structurally() {
1009        let source = "def top_level(value)\n  value\nend\n\nclass Widget\n  def initialize(spec)\n    @spec = spec\n  end\n\n  def self.build(spec)\n    new(spec)\n  end\n\n  def render(target)\n    target\n  end\n\n  class << self\n    def measure(target)\n      target\n    end\n  end\nend\n\nmodule Helpers\n  def helper\n  end\nend\n";
1010        let file = ProjectFile::new(std::env::temp_dir(), "widget.rb");
1011        let tree = parse_ruby_tree(source).expect("parse Ruby fixture");
1012        let parsed = parse_ruby_file(&file, source, &tree);
1013
1014        let modifiers = |fq_name: &str| {
1015            let metadata = parsed
1016                .signature_metadata
1017                .iter()
1018                .find(|(unit, _)| unit.fq_name() == fq_name)
1019                .and_then(|(_, metadata)| metadata.first())
1020                .unwrap_or_else(|| {
1021                    panic!(
1022                        "missing Ruby callable {fq_name}; recorded {:?}",
1023                        parsed
1024                            .signature_metadata
1025                            .keys()
1026                            .map(CodeUnit::fq_name)
1027                            .collect::<Vec<_>>()
1028                    )
1029                });
1030            assert!(
1031                metadata.callable_modifiers_recorded(),
1032                "{fq_name} must record that the walk read its declaration shape"
1033            );
1034            (
1035                metadata.callable_is_static(),
1036                metadata.callable_is_constructor(),
1037                metadata.parameters().len(),
1038            )
1039        };
1040
1041        assert_eq!(modifiers("top_level"), (false, false, 1));
1042        assert_eq!(modifiers("Widget.initialize"), (false, false, 1));
1043        assert_eq!(modifiers("Widget.build"), (true, false, 1));
1044        assert_eq!(modifiers("Widget.render"), (false, false, 1));
1045        assert_eq!(modifiers("Widget.measure"), (true, false, 1));
1046        assert_eq!(modifiers("Helpers.helper"), (false, false, 0));
1047    }
1048}