Skip to main content

brokk_bifrost_ruby/
structural.rs

1//! Ruby structural spec for `query_code`.
2
3use crate::local_bindings::{
4    LocalBindingTimeline, UnboundedLocalBindingBudget, collect_local_bindings,
5};
6use crate::syntax::single_static_string_content_node;
7use brokk_bifrost_core::analyzer::Language;
8use brokk_bifrost_core::analyzer::structural::adapter_helpers::{
9    attach_argument_role_with_derived_name, attach_role_with_derived_name, attach_terminal_callee,
10    first_named_child,
11};
12use brokk_bifrost_core::analyzer::structural::callable::CallSiteContext;
13use brokk_bifrost_core::analyzer::structural::edges::{
14    INVERSE_REFERENCE_EDGE_SUPPORT, ReferenceEdgeSupport,
15};
16use brokk_bifrost_core::analyzer::structural::facts::Span;
17use brokk_bifrost_core::analyzer::structural::kinds::{NormalizedKind, Role};
18use brokk_bifrost_core::analyzer::structural::materialization::{
19    DeclarationMaterializationSupport, RUBY_MATERIALIZATION_SUPPORT,
20};
21use brokk_bifrost_core::analyzer::structural::occurrences::{
22    NO_OCCURRENCE_ROLE_SUPPORT, OccurrenceRoleSupport,
23};
24use brokk_bifrost_core::analyzer::structural::resolution::{
25    LexicalEnvironmentSupport, NO_LEXICAL_ENVIRONMENT_SUPPORT,
26};
27use brokk_bifrost_core::analyzer::structural::routes::{
28    IdentityRouteSupport, NO_IDENTITY_ROUTE_SUPPORT,
29};
30use brokk_bifrost_core::analyzer::structural::spec::{RoleSink, StructuralSpec};
31use brokk_bifrost_core::hash::HashSet;
32use tree_sitter::Node;
33
34#[derive(Debug, Default)]
35pub struct RubyStructuralSpec;
36
37pub static RUBY_STRUCTURAL_SPEC: RubyStructuralSpec = RubyStructuralSpec;
38
39pub const RUBY_KIND_TABLE: &[(&str, NormalizedKind)] = &[
40    ("call", NormalizedKind::Call),
41    ("method", NormalizedKind::Function),
42    ("singleton_method", NormalizedKind::Method),
43    ("block", NormalizedKind::Lambda),
44    ("do_block", NormalizedKind::Lambda),
45    ("lambda", NormalizedKind::Lambda),
46    ("class", NormalizedKind::Class),
47    ("module", NormalizedKind::Class),
48    ("assignment", NormalizedKind::Assignment),
49    ("operator_assignment", NormalizedKind::Assignment),
50    ("scope_resolution", NormalizedKind::FieldAccess),
51    ("unary", NormalizedKind::NumericLiteral),
52    ("identifier", NormalizedKind::Identifier),
53    ("constant", NormalizedKind::Identifier),
54    ("instance_variable", NormalizedKind::Identifier),
55    ("class_variable", NormalizedKind::Identifier),
56    ("global_variable", NormalizedKind::Identifier),
57    ("self", NormalizedKind::Identifier),
58    ("simple_symbol", NormalizedKind::Identifier),
59    ("delimited_symbol", NormalizedKind::Identifier),
60    ("hash_key_symbol", NormalizedKind::Identifier),
61    ("string", NormalizedKind::StringLiteral),
62    ("integer", NormalizedKind::NumericLiteral),
63    ("float", NormalizedKind::NumericLiteral),
64    ("true", NormalizedKind::BooleanLiteral),
65    ("false", NormalizedKind::BooleanLiteral),
66    ("nil", NormalizedKind::NullLiteral),
67    ("return", NormalizedKind::Return),
68    ("rescue", NormalizedKind::Catch),
69    ("if", NormalizedKind::If),
70    ("unless", NormalizedKind::If),
71    ("while", NormalizedKind::WhileLoop),
72    ("until", NormalizedKind::WhileLoop),
73    ("for", NormalizedKind::ForLoop),
74];
75
76fn expression_target_node(mut node: Node<'_>) -> Node<'_> {
77    while matches!(node.kind(), "parenthesized_statements") {
78        let Some(child) = first_named_child(node) else {
79            break;
80        };
81        node = child;
82    }
83    node
84}
85
86fn expression_name_node<'tree>(expression: Node<'tree>) -> Option<Node<'tree>> {
87    let mut current = expression_target_node(expression);
88    loop {
89        match current.kind() {
90            "identifier" | "constant" | "instance_variable" | "class_variable"
91            | "global_variable" | "self" | "hash_key_symbol" => return Some(current),
92            "simple_symbol" | "delimited_symbol" => return symbol_name_node(current),
93            "scope_resolution" => current = current.child_by_field_name("name")?,
94            "call" => current = current.child_by_field_name("method")?,
95            "pair" => current = current.child_by_field_name("key")?,
96            _ => return None,
97        }
98    }
99}
100
101fn symbol_name_node(node: Node<'_>) -> Option<Node<'_>> {
102    first_named_child_of_kind(node, "string_content").or(Some(node))
103}
104
105fn first_named_child_of_kind<'tree>(node: Node<'tree>, kind: &str) -> Option<Node<'tree>> {
106    (0..node.named_child_count())
107        .filter_map(|index| node.named_child(index))
108        .find(|child| child.kind() == kind)
109}
110
111fn is_numeric_literal_node(node: Node<'_>) -> bool {
112    matches!(node.kind(), "integer" | "float")
113}
114
115fn is_signed_numeric_unary(node: Node<'_>) -> bool {
116    node.kind() == "unary"
117        && node
118            .child_by_field_name("operator")
119            .is_some_and(|operator| matches!(operator.kind(), "+" | "-"))
120        && node
121            .child_by_field_name("operand")
122            .map(expression_target_node)
123            .is_some_and(is_numeric_literal_node)
124}
125
126fn is_inside_signed_numeric_wrapper(node: Node<'_>) -> bool {
127    let Some(parent) = node.parent() else {
128        return false;
129    };
130    is_signed_numeric_unary(parent)
131}
132
133fn call_method_node(node: Node<'_>) -> Option<Node<'_>> {
134    node.child_by_field_name("method")
135}
136
137/// Whether `node` opens a new local-variable scope nested inside another
138/// scope's walk. The `block`/`do_block` wrapper directly under a `lambda` is
139/// the lambda's own body, not a nested scope, matching the semantic
140/// lowering's `callable_shape`.
141fn is_nested_scope_root(node: Node<'_>) -> bool {
142    match node.kind() {
143        "method" | "singleton_method" | "class" | "module" | "singleton_class" | "lambda" => true,
144        "block" | "do_block" => node.parent().is_none_or(|parent| parent.kind() != "lambda"),
145        _ => false,
146    }
147}
148
149/// Whether an identifier at this grammatical position is a value read: a
150/// position where Ruby evaluates the name, so an identifier that is not an
151/// active local variable is a zero-argument bare call. The list is a closed
152/// whitelist over parent node kinds and AST fields; every position not on it
153/// (parameter lists, assignment targets, pattern binders, method name fields)
154/// keeps its `Identifier` kind, preserving the honest status quo for
155/// constructs this pass does not understand.
156fn is_value_read_position(node: Node<'_>) -> bool {
157    let Some(parent) = node.parent() else {
158        return false;
159    };
160    let is_field = |field: &str| {
161        parent
162            .child_by_field_name(field)
163            .is_some_and(|child| child.id() == node.id())
164    };
165    match parent.kind() {
166        // Statement containers.
167        "program" | "body_statement" | "then" | "else" | "do" | "block_body" | "begin"
168        | "interpolation" => true,
169        // A parenthesized expression reads its content wherever the
170        // parentheses themselves are read -- except under `defined?`, whose
171        // operand arrives wrapped in parentheses and is observed, not
172        // evaluated.
173        "parenthesized_statements" => {
174            let mut ancestor = parent;
175            while ancestor.kind() == "parenthesized_statements" {
176                match ancestor.parent() {
177                    Some(next) => ancestor = next,
178                    None => return true,
179                }
180            }
181            !(ancestor.kind() == "unary"
182                && ancestor
183                    .child_by_field_name("operator")
184                    .is_some_and(|operator| operator.kind() == "defined?"))
185        }
186        // Argument positions, including spread and block-pass wrappers.
187        "argument_list" | "splat_argument" | "hash_splat_argument" | "block_argument" => true,
188        // Operand positions.
189        "binary" | "range" | "array" | "element_reference" => true,
190        // `defined?(x)` observes the name without evaluating it, so its
191        // operand stays an identifier.
192        "unary" => {
193            is_field("operand")
194                && parent
195                    .child_by_field_name("operator")
196                    .is_none_or(|operator| operator.kind() != "defined?")
197        }
198        // Conditions, case subjects, `when` values, and ternary branches.
199        "if" | "unless" | "elsif" | "while" | "until" | "conditional" | "case" | "case_match"
200        | "when" | "rescue_modifier" => true,
201        "pair" => is_field("value"),
202        "assignment" | "operator_assignment" => is_field("right"),
203        "call" => is_field("receiver"),
204        _ => false,
205    }
206}
207
208/// One iterative pass over the file: the start byte of every identifier that
209/// is a value-position bare call. Scopes are walked outermost-first so each
210/// nested block or lambda can inherit the bindings active at its creation
211/// byte, exactly as the semantic lowering's `collect_local_bindings` callers
212/// do; identifiers are classified against their scope's precomputed
213/// [`LocalBindingTimeline`], never by a per-identifier backward scan.
214fn bare_call_identifier_starts(root: Node<'_>, source: &str) -> HashSet<usize> {
215    let mut starts = HashSet::default();
216    let mut timelines: Vec<LocalBindingTimeline> = Vec::new();
217    let mut scopes: Vec<(Node<'_>, Option<usize>)> = vec![(root, None)];
218    while let Some((scope, inherited)) = scopes.pop() {
219        let body = scope.child_by_field_name("body").unwrap_or(scope);
220        let inherited_bindings = matches!(scope.kind(), "lambda" | "block" | "do_block")
221            .then(|| inherited.map(|index| (&timelines[index], scope.start_byte())))
222            .flatten();
223        let collection = collect_local_bindings(
224            source,
225            scope,
226            body,
227            inherited_bindings,
228            &mut UnboundedLocalBindingBudget,
229        )
230        .unwrap_or_else(|impossible| match impossible {});
231        let timeline_index = timelines.len();
232        timelines.push(collection.timeline);
233        let timeline = &timelines[timeline_index];
234
235        let mut walk = vec![scope];
236        while let Some(node) = walk.pop() {
237            for index in (0..node.named_child_count()).rev() {
238                let Some(child) = node.named_child(index) else {
239                    continue;
240                };
241                if is_nested_scope_root(child) {
242                    scopes.push((child, Some(timeline_index)));
243                } else {
244                    walk.push(child);
245                }
246            }
247            if node.kind() == "identifier"
248                && is_value_read_position(node)
249                && !timeline.is_active_at(node_text(node, source), node.start_byte())
250            {
251                starts.insert(node.start_byte());
252            }
253        }
254    }
255    starts
256}
257
258fn attach_argument_roles(sink: &mut RoleSink<'_>, arguments: Node<'_>) {
259    for index in 0..arguments.named_child_count() {
260        if !sink.should_continue() {
261            break;
262        }
263        let Some(argument) = arguments.named_child(index) else {
264            continue;
265        };
266        if argument.kind() == "pair" {
267            if let Some(key) = argument.child_by_field_name("key")
268                && let Some(value) = argument
269                    .child_by_field_name("value")
270                    .map(expression_target_node)
271            {
272                sink.kwarg(expression_name_node(key).unwrap_or(key), value);
273            }
274        } else {
275            attach_argument_role_with_derived_name(sink, argument, expression_name_node);
276        }
277    }
278}
279
280fn node_text<'source>(node: Node<'_>, source: &'source str) -> &'source str {
281    node.utf8_text(source.as_bytes()).unwrap_or("")
282}
283
284fn module_argument_node(node: Node<'_>) -> Option<Node<'_>> {
285    let arguments = node.child_by_field_name("arguments")?;
286    (0..arguments.named_child_count())
287        .filter_map(|index| arguments.named_child(index))
288        .find(|argument| argument.kind() == "string")
289}
290
291fn is_import_call(node: Node<'_>, source: &str) -> bool {
292    if node.child_by_field_name("receiver").is_some() {
293        return false;
294    }
295
296    let Some(method) = call_method_node(node) else {
297        return false;
298    };
299    matches!(
300        node_text(method, source).trim(),
301        "require" | "require_relative" | "load" | "autoload"
302    ) && module_argument_node(node).is_some()
303}
304
305fn static_string_content_span(node: Node<'_>) -> Option<Span> {
306    if node.kind() != "string" {
307        return None;
308    }
309    let content = single_static_string_content_node(node)?;
310    Some(Span {
311        start_byte: content.start_byte(),
312        end_byte: content.end_byte(),
313    })
314}
315
316impl StructuralSpec for RubyStructuralSpec {
317    fn language(&self) -> Language {
318        Language::Ruby
319    }
320
321    fn kind_table(&self) -> &'static [(&'static str, NormalizedKind)] {
322        RUBY_KIND_TABLE
323    }
324
325    /// One scan of the file for the identifiers that are value-position bare
326    /// calls: whether `x` reads a local or calls a method depends on the
327    /// assignments and parameters lexically before it, which `refine_kind`
328    /// cannot see per node.
329    fn call_site_context(&self, root: Node<'_>, source: &str) -> CallSiteContext {
330        CallSiteContext::with_identifier_call_starts(bare_call_identifier_starts(root, source))
331    }
332
333    fn refine_kind(
334        &self,
335        node: Node<'_>,
336        kind: NormalizedKind,
337        enclosing: Option<NormalizedKind>,
338        source: &str,
339        context: &CallSiteContext,
340    ) -> NormalizedKind {
341        if node.kind() == "identifier" && context.is_identifier_call_at(node.start_byte()) {
342            NormalizedKind::Call
343        } else if node.kind() == "call" && is_import_call(node, source) {
344            NormalizedKind::Import
345        } else if node.kind() == "method"
346            && kind == NormalizedKind::Function
347            && enclosing == Some(NormalizedKind::Class)
348        {
349            NormalizedKind::Method
350        } else {
351            kind
352        }
353    }
354
355    fn should_extract(&self, node: Node<'_>, kind: NormalizedKind) -> bool {
356        if kind == NormalizedKind::Lambda
357            && matches!(node.kind(), "block" | "do_block")
358            && node
359                .parent()
360                .is_some_and(|parent| parent.kind() == "lambda")
361        {
362            return false;
363        }
364
365        if kind == NormalizedKind::NumericLiteral {
366            if node.kind() == "unary" {
367                return is_signed_numeric_unary(node);
368            }
369            if is_numeric_literal_node(node) && is_inside_signed_numeric_wrapper(node) {
370                return false;
371            }
372        }
373
374        true
375    }
376
377    fn supports_kind(&self, kind: NormalizedKind) -> bool {
378        kind == NormalizedKind::Import
379            || self
380                .kind_table()
381                .iter()
382                .any(|(_, fact_kind)| fact_kind.satisfies(kind))
383    }
384
385    fn supports_role(&self, role: Role) -> bool {
386        role != Role::Decorator
387    }
388
389    /// Ruby has not learned occurrence-role classification yet (#1473).
390    /// The empty table is the honest answer: queries and assertions that ask
391    /// for an occurrence role here report incomplete rather than clean-empty.
392    fn occurrence_role_support(&self) -> &OccurrenceRoleSupport {
393        &NO_OCCURRENCE_ROLE_SUPPORT
394    }
395
396    fn lexical_environment_support(&self) -> &LexicalEnvironmentSupport {
397        &NO_LEXICAL_ENVIRONMENT_SUPPORT
398    }
399
400    fn materialization_support(&self) -> &DeclarationMaterializationSupport {
401        &RUBY_MATERIALIZATION_SUPPORT
402    }
403
404    fn reference_edge_support(&self) -> &ReferenceEdgeSupport {
405        &INVERSE_REFERENCE_EDGE_SUPPORT
406    }
407
408    fn identity_route_support(&self) -> &IdentityRouteSupport {
409        &NO_IDENTITY_ROUTE_SUPPORT
410    }
411
412    fn extract(&self, node: Node<'_>, kind: NormalizedKind, sink: &mut RoleSink<'_>) {
413        match kind {
414            NormalizedKind::Call => {
415                // An identifier fact only carries the Call kind through the
416                // bare-call refinement above, and it is its own callee.
417                if node.kind() == "identifier" {
418                    attach_terminal_callee(sink, node, Some(node));
419                } else if let Some(method) = call_method_node(node) {
420                    attach_terminal_callee(sink, method, expression_name_node(method));
421                }
422                if let Some(receiver) = node.child_by_field_name("receiver") {
423                    attach_role_with_derived_name(
424                        sink,
425                        Role::Receiver,
426                        receiver,
427                        expression_name_node,
428                    );
429                }
430                if let Some(arguments) = node.child_by_field_name("arguments") {
431                    attach_argument_roles(sink, arguments);
432                }
433                if let Some(block) = node.child_by_field_name("block") {
434                    attach_role_with_derived_name(sink, Role::Arg, block, expression_name_node);
435                }
436            }
437            NormalizedKind::FieldAccess => {
438                if let Some(field) = node.child_by_field_name("name") {
439                    attach_role_with_derived_name(sink, Role::Field, field, expression_name_node);
440                    if let Some(name) = expression_name_node(field) {
441                        sink.set_name(name);
442                    }
443                }
444                if let Some(object) = node.child_by_field_name("scope") {
445                    attach_role_with_derived_name(sink, Role::Object, object, expression_name_node);
446                }
447            }
448            NormalizedKind::Function
449            | NormalizedKind::Method
450            | NormalizedKind::Class
451            | NormalizedKind::Declaration => {
452                if let Some(name) = node.child_by_field_name("name") {
453                    sink.set_name(expression_name_node(name).unwrap_or(name));
454                }
455            }
456            NormalizedKind::Assignment => {
457                if let Some(left) = node.child_by_field_name("left") {
458                    let left = expression_target_node(left);
459                    attach_role_with_derived_name(sink, Role::Left, left, expression_name_node);
460                    if let Some(name) = expression_name_node(left) {
461                        sink.set_name(name);
462                    }
463                }
464                if let Some(right) = node.child_by_field_name("right") {
465                    let right = expression_target_node(right);
466                    attach_role_with_derived_name(sink, Role::Right, right, expression_name_node);
467                }
468            }
469            NormalizedKind::Import => {
470                if let Some(module) = module_argument_node(node)
471                    && let Some(name) = static_string_content_span(module)
472                {
473                    sink.role_named_span(Role::Module, module, name);
474                }
475            }
476            NormalizedKind::Identifier => match expression_name_node(node) {
477                Some(name) => sink.set_name(name),
478                None => sink.set_name(node),
479            },
480            _ => {}
481        }
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn parse(source: &str) -> tree_sitter::Tree {
490        let mut parser = tree_sitter::Parser::new();
491        parser
492            .set_language(&tree_sitter_ruby::LANGUAGE.into())
493            .expect("Ruby grammar is valid");
494        parser.parse(source, None).expect("source parses")
495    }
496
497    /// The start byte of the `occurrence`-th (0-based) appearance of `needle`
498    /// as a whole identifier in `source`.
499    fn identifier_start(source: &str, needle: &str, occurrence: usize) -> usize {
500        let mut found = 0;
501        let mut from = 0;
502        loop {
503            let start = from
504                + source[from..]
505                    .find(needle)
506                    .unwrap_or_else(|| panic!("needle {needle:?} occurrence {occurrence}"));
507            let boundary = |byte: Option<u8>| {
508                byte.is_none_or(|byte| !(byte.is_ascii_alphanumeric() || byte == b'_'))
509            };
510            if boundary(source.as_bytes().get(start.wrapping_sub(1)).copied())
511                && boundary(source.as_bytes().get(start + needle.len()).copied())
512            {
513                if found == occurrence {
514                    return start;
515                }
516                found += 1;
517            }
518            from = start + needle.len();
519        }
520    }
521
522    fn bare_call_starts(source: &str) -> HashSet<usize> {
523        let tree = parse(source);
524        bare_call_identifier_starts(tree.root_node(), source)
525    }
526
527    fn assert_bare_call(source: &str, needle: &str, occurrence: usize, expected: bool) {
528        let starts = bare_call_starts(source);
529        let start = identifier_start(source, needle, occurrence);
530        assert_eq!(
531            starts.contains(&start),
532            expected,
533            "{needle:?} occurrence {occurrence} at byte {start} in {source:?}; classified starts: {starts:?}"
534        );
535    }
536
537    /// The issue's smallest fixture: the bare source call in argument
538    /// position is a call; the enclosing `dfb_sink(...)` is a `call` grammar
539    /// node whose method identifier is not separately classified.
540    #[test]
541    fn argument_position_bare_call_is_classified() {
542        let source = "def dfb_source\n  \"tainted\"\nend\n\ndef dfb_sink(value)\nend\n\ndef run\n  dfb_sink(dfb_source)\nend\n";
543        assert_bare_call(source, "dfb_source", 1, true);
544        assert_bare_call(source, "dfb_sink", 1, false);
545    }
546
547    /// The assignment-value form: the right side is a call, the assigned
548    /// local on the left is not, and the later read of the local is not.
549    #[test]
550    fn assignment_value_bare_call_is_classified_and_the_local_is_not() {
551        let source = "def dfb_source\n  \"tainted\"\nend\n\ndef dfb_sink(value)\nend\n\ndef run\n  value = dfb_source\n  dfb_sink(value)\nend\n";
552        assert_bare_call(source, "dfb_source", 1, true);
553        assert_bare_call(source, "value", 1, false);
554        assert_bare_call(source, "value", 2, false);
555    }
556
557    /// A statement-position bare call keeps its classification, and the
558    /// parenthesized form never enters the identifier path (its `call` node
559    /// is the call; the method identifier is a field of it).
560    #[test]
561    fn statement_position_and_parenthesized_forms_are_unchanged() {
562        let source =
563            "def dfb_source\n  \"tainted\"\nend\n\ndef run\n  dfb_source\n  dfb_source()\nend\n";
564        assert_bare_call(source, "dfb_source", 1, true);
565        assert_bare_call(source, "dfb_source", 2, false);
566    }
567
568    /// A local variable named like the source method shadows the bare call:
569    /// once assigned, reads of the name stay identifiers. The assignment's
570    /// own right side is a bare call to another name.
571    #[test]
572    fn assigned_local_shadows_the_same_named_bare_call() {
573        let source = "def run\n  dfb_source = compute\n  dfb_sink(dfb_source)\nend\n";
574        assert_bare_call(source, "compute", 0, true);
575        assert_bare_call(source, "dfb_source", 0, false);
576        assert_bare_call(source, "dfb_source", 1, false);
577    }
578
579    /// A parameter named like the source method makes every read a local
580    /// read over the whole body.
581    #[test]
582    fn parameter_shadows_the_same_named_bare_call() {
583        let source = "def run(dfb_source)\n  dfb_sink(dfb_source)\nend\n";
584        assert_bare_call(source, "dfb_source", 1, false);
585    }
586
587    /// A same-named method call on a receiver is the `call` node's own
588    /// business: its method identifier is not classified, its bound receiver
589    /// is a local read, and an unbound receiver is itself a bare call.
590    #[test]
591    fn receiver_calls_classify_only_the_unbound_receiver() {
592        let source =
593            "def run\n  helper = Helper.new\n  helper.dfb_source\n  unbound.dfb_source\nend\n";
594        assert_bare_call(source, "dfb_source", 0, false);
595        assert_bare_call(source, "dfb_source", 1, false);
596        assert_bare_call(source, "helper", 1, false);
597        assert_bare_call(source, "unbound", 0, true);
598    }
599
600    /// Nested argument positions classify the innermost bare call.
601    #[test]
602    fn nested_argument_positions_are_classified() {
603        let source = "def run\n  dfb_sink(wrap(dfb_source))\nend\n";
604        assert_bare_call(source, "dfb_source", 0, true);
605        assert_bare_call(source, "wrap", 0, false);
606    }
607
608    /// Blocks inherit the bindings active at their creation byte: a local
609    /// assigned before the block stays a local inside it, and a free name
610    /// inside the block is a bare call.
611    #[test]
612    fn blocks_inherit_active_bindings() {
613        let source = "def run\n  captured = 1\n  items.each do |x|\n    dfb_sink(captured)\n    dfb_sink(free_name)\n    dfb_sink(x)\n  end\nend\n";
614        assert_bare_call(source, "captured", 1, false);
615        assert_bare_call(source, "free_name", 0, true);
616        assert_bare_call(source, "x", 1, false);
617        assert_bare_call(source, "items", 0, true);
618    }
619
620    /// The binding rule is lexical: a read before the assignment to the same
621    /// name is a bare call at that byte.
622    #[test]
623    fn reads_before_the_activating_assignment_are_bare_calls() {
624        let source = "def run\n  dfb_sink(v)\n  v = 1\n  dfb_sink(v)\nend\n";
625        assert_bare_call(source, "v", 0, true);
626        assert_bare_call(source, "v", 1, false);
627        assert_bare_call(source, "v", 2, false);
628    }
629
630    /// The statement-position rule is timeline-gated too: a trailing read of
631    /// a parameter is a local read, not a call (parity with the semantic
632    /// lowering, which models it as a lexical input flow).
633    #[test]
634    fn trailing_local_reads_in_statement_position_stay_identifiers() {
635        let source = "def run(x)\n  compute\n  x\nend\n";
636        assert_bare_call(source, "compute", 0, true);
637        assert_bare_call(source, "x", 1, false);
638    }
639
640    /// `defined?(name)` observes the name without evaluating it, in both the
641    /// parenthesized and the bare-operand spelling.
642    #[test]
643    fn defined_operands_are_not_classified() {
644        let source = "def run\n  defined?(maybe_missing)\n  defined? bare_operand\nend\n";
645        assert_bare_call(source, "maybe_missing", 0, false);
646        assert_bare_call(source, "bare_operand", 0, false);
647    }
648
649    /// Top-level program statements are a scope of their own.
650    #[test]
651    fn top_level_reads_follow_the_program_scope_timeline() {
652        let source = "x = 1\nx\nfree_top_level\n";
653        assert_bare_call(source, "x", 1, false);
654        assert_bare_call(source, "free_top_level", 0, true);
655    }
656
657    /// Value reads in conditions, ternaries, operands, and interpolations
658    /// are classified; binder positions (parameters, assignment targets,
659    /// pattern binders) never are.
660    #[test]
661    fn condition_and_operand_positions_are_classified() {
662        let source = "def run(bound)\n  if cond_call\n    bound + operand_call\n  end\n  cond_call ? bound : other_call\n  \"#{interp_call}\"\nend\n";
663        assert_bare_call(source, "cond_call", 0, true);
664        assert_bare_call(source, "operand_call", 0, true);
665        assert_bare_call(source, "other_call", 0, true);
666        assert_bare_call(source, "interp_call", 0, true);
667        assert_bare_call(source, "bound", 1, false);
668        assert_bare_call(source, "bound", 2, false);
669    }
670
671    /// Pattern-match binders and their reads stay identifiers; the matched
672    /// subject is a read.
673    #[test]
674    fn pattern_binders_are_preserved_as_identifiers() {
675        let source =
676            "def run\n  case subject_call\n  in [first, second]\n    dfb_sink(first)\n  end\nend\n";
677        assert_bare_call(source, "subject_call", 0, true);
678        assert_bare_call(source, "first", 0, false);
679        assert_bare_call(source, "first", 1, false);
680        assert_bare_call(source, "second", 0, false);
681    }
682
683    /// Methods do not inherit enclosing locals: the same name that is a local
684    /// outside is a bare call inside a nested method or class body.
685    #[test]
686    fn methods_and_classes_do_not_inherit_locals() {
687        let source = "outer = 1\nouter\ndef run\n  outer\nend\nclass Widget\n  outer\nend\n";
688        assert_bare_call(source, "outer", 1, false);
689        assert_bare_call(source, "outer", 2, true);
690        assert_bare_call(source, "outer", 3, true);
691    }
692}