Skip to main content

bonsai_lang_lua/
lib.rs

1//! Lua language adapter.
2use bonsai_common::FileId;
3use bonsai_lang_api::{
4    decl_index_with_handler, extract_imports_via,
5    kit::{
6        collect_kinds, collect_receiver_field_writes, first_named_child_of_kind, language_from_pack,
7        node_text, parse_with, span_of,
8    },
9    AdapterContext, AdapterError, AssignValueKind, CallTargetExtraction, DeclIndex, FlowEvent,
10    GrammarHandler, ImportIndex, ImportScope, ImportSpec, LanguageAdapter, LanguageCapabilities, LanguageId,
11    Ref, RefKind,
12};
13use tree_sitter::{Language, Node, Tree};
14
15fn lua_foreach_binding(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
16    if !matches!(node.kind(), "for_statement" | "for_in_statement") {
17        return None;
18    }
19    let clause = node.child_by_field_name("clause").or_else(|| {
20        let mut cursor = node.walk();
21        let clause = node
22            .named_children(&mut cursor)
23            .find(|child| child.kind() == "for_generic_clause");
24        clause
25    })?;
26    Some((clause.named_child(0)?, clause.named_child(1)?))
27}
28
29/// Select the grammar's complete Lua call target. Method calls use a
30/// `method_index_expression` (`resource:close`) in the `name` field rather
31/// than the `dot_index_expression` used by ordinary table lookup. The
32/// adapter preserves `:` until its post-lowering normalization can retain the
33/// language-defined implicit receiver distinction.
34fn lua_call_target<'tree>(node: Node<'tree>, src: &[u8]) -> Option<CallTargetExtraction<'tree>> {
35    if !matches!(node.kind(), "function_call" | "method_call") {
36        return None;
37    }
38    let target = node.child_by_field_name("name").or_else(|| node.named_child(0))?;
39    if !matches!(
40        target.kind(),
41        "identifier" | "dot_index_expression" | "bracket_index_expression" | "method_index_expression"
42    ) {
43        return None;
44    }
45    let full_text = node_text(&target, src)
46        .chars()
47        .filter(|character| !character.is_whitespace())
48        .collect::<String>();
49    (!full_text.is_empty()).then_some(CallTargetExtraction {
50        node: target,
51        full_text,
52    })
53}
54
55pub const LANG_ID: LanguageId = LanguageId::new("lua");
56const PACK_NAME: &str = "lua";
57
58fn lua_static_key(node: Node<'_>, src: &[u8]) -> Option<String> {
59    let raw = node_text(&node, src).trim();
60    if node.kind() == "identifier" {
61        return (!raw.is_empty()).then(|| raw.to_string());
62    }
63    if node.kind() != "string" {
64        return None;
65    }
66    let quote = raw.as_bytes().first().copied()?;
67    if !matches!(quote, b'\'' | b'"') || raw.as_bytes().last().copied() != Some(quote) {
68        return None;
69    }
70    let value = raw.get(1..raw.len().checked_sub(1)?)?;
71    (!value.is_empty() && !value.contains('\\')).then(|| value.to_string())
72}
73
74// tree-sitter-lua (MunifTanjim) handler:
75//   - `function_declaration` covers `function foo()` and `function M.foo()`
76//   - `function_definition` covers anonymous `function() ... end`
77//   - `local_function` covers `local function foo()` scoped to the chunk
78//   - Lua has no native exception construct; pcall/xpcall are the
79//     idiomatic try-equivalent (function calls; we rely on the
80//     do_block-descent + call-arg walking to surface their bodies).
81const HANDLER: GrammarHandler = GrammarHandler {
82    expression_value_kind_extractor: None,
83    literal_value_kinds: &["nil", "number", "true", "false"],
84    string_literal_kinds: &["string"],
85    comment_kinds: &["comment", "hash_bang_line"],
86    doc_comment_prefixes: &["---"],
87    decorator_kinds: &[],
88    parameter_container_kinds: &["parameters"],
89    parameter_kinds: &["identifier", "vararg_expression"],
90    parameter_annotation_name_extractor: None,
91    variadic_parameter_kinds: &["vararg_expression"],
92    binding_identifier_kinds: &["identifier"],
93    identifier_kinds: &["identifier"],
94    aggregate_pattern_kinds: &["variable_list"],
95    named_aggregate_kinds: &["table_constructor"],
96    positional_aggregate_kinds: &["table_constructor"],
97    aggregate_pair_kinds: &["field"],
98    aggregate_key_field_names: &["name"],
99    aggregate_value_field_names: &["value"],
100    static_field_name_kinds: &["identifier"],
101    static_subscript_key_extractor: Some(lua_static_key),
102    lambda_value_container_kinds: &["table_constructor", "field"],
103    transparent_call_wrapper_kinds: &["dot_index_expression", "bracket_index_expression"],
104    // Lua wraps both sides of an assignment in list nodes. A list with one
105    // parsed child is one expression/place; multi-child lists remain
106    // aggregate bindings for the shared parallel-assignment lowering.
107    single_expression_group_kinds: &["expression_list", "variable_list"],
108    assignment_target_wrapper_kinds: &["variable_declaration"],
109    binding_declaration_keyword_spellings: &["local"],
110    nested_type_ownership: true,
111    fn_kinds: &["function_declaration", "function_definition", "local_function"],
112    class_kinds: &[],
113    class_decl_kinds: &[],
114    method_kinds: &[],
115    method_context_kinds: &[],
116    method_owner_barrier_kinds: &[],
117    constructor_method_kinds: &[],
118    constructor_names: bonsai_lang_api::NO_CONSTRUCTOR_METHOD_NAMES,
119    if_kinds: &["if_statement"],
120    branch_then_field_names: &["consequence", "body"],
121    branch_else_field_names: &["alternative"],
122    branch_condition_field_names: &["condition"],
123    loop_body_field_names: &["body"],
124    loop_body_kinds: &["block"],
125    branch_arm_kinds: &["block", "elseif_statement", "else_statement"],
126    additional_alternative_kinds: &["elseif_statement", "else_statement"],
127    for_kinds: &["for_statement"],
128    foreach_kinds: &["for_in_statement"],
129    foreach_binding_extractor: Some(lua_foreach_binding),
130    while_kinds: &["while_statement"],
131    do_kinds: &["repeat_statement"],
132    loop_kinds: &[],
133    call_kinds: &["function_call", "method_call"],
134    call_callee_field_names: &["name"],
135    call_receiver_field_names: &["table", "prefix"],
136    call_member_field_names: &["method", "field"],
137    call_argument_field_names: &["arguments"],
138    call_argument_container_kinds: &["arguments"],
139    call_target_extractor: Some(lua_call_target),
140    lambda_body_field_names: &["body"],
141    argument_passing_mode_extractor: None,
142    call_ref_kinds: &["function_call", "method_call"],
143    member_expression_kinds: &["dot_index_expression"],
144    subscript_expression_kinds: &["bracket_index_expression"],
145    member_base_field_names: &["table", "prefix"],
146    member_name_field_names: &["field"],
147    subscript_base_field_names: &["table", "prefix"],
148    // tree-sitter-lua names the parsed key of `table[key]` as `field`.
149    // Keep `index` for grammar-pack compatibility, but derive both from CST
150    // roles rather than re-reading bracket text.
151    subscript_index_field_names: &["field", "index"],
152    assignment_kinds: &["assignment_statement", "variable_declaration"],
153    return_kinds: &["return_statement"],
154    throw_kinds: &[],
155    lambda_kinds: &["function_definition"],
156    try_kinds: &[],
157    catch_kinds: &[],
158    finally_kinds: &[],
159    break_kinds: &["break_statement"],
160    control_label_field_names: &[],
161    // Lua has no `continue` keyword. `goto label` is a general jump,
162    // not a loop continue, so leaving this empty avoids mis-tagging
163    // arbitrary gotos as `FlowEvent::Continue`.
164    continue_kinds: &[],
165    yield_kinds: &[],
166    await_kinds: &[],
167    defer_kinds: &[],
168    using_kinds: &[],
169    special_forms: &[],
170    method_receiver_param_index: None,
171    implicit_receiver_names: &[],
172    implicit_receiver_prefixes: &[],
173    tail_expression_returns: false,
174    void_return_type_names: &[],
175    ..bonsai_lang_api::EMPTY_HANDLER
176};
177
178#[derive(Debug, Default, Copy, Clone)]
179pub struct LuaAdapter;
180
181impl LuaAdapter {
182    #[must_use]
183    pub fn new() -> Self {
184        Self
185    }
186}
187
188impl LanguageAdapter for LuaAdapter {
189    fn language_id(&self) -> LanguageId {
190        LANG_ID
191    }
192    fn display_name(&self) -> &'static str {
193        "Lua"
194    }
195    fn file_extensions(&self) -> &'static [&'static str] {
196        &["lua"]
197    }
198    fn tree_sitter_language(&self) -> Result<Language, AdapterError> {
199        language_from_pack(PACK_NAME)
200    }
201    fn capabilities(&self) -> LanguageCapabilities {
202        LanguageCapabilities {
203            module_default_export_names: &[],
204            universal_type_names: &[],
205            module_path_syntax: bonsai_lang_api::ModulePathSyntax::none(),
206            constructor_method_names: bonsai_lang_api::NO_CONSTRUCTOR_METHOD_NAMES,
207            super_receiver_tokens: &[],
208            // `function T:method(...)` introduces the language-defined
209            // receiver binding `self`; Lua has no super-dispatch token.
210            implicit_receiver_tokens: &["self"],
211            ..LanguageCapabilities::partial_baseline()
212        }
213    }
214    fn extract_declarations(&self, file: FileId, ctx: &AdapterContext<'_>) -> DeclIndex {
215        let mut idx = decl_index_with_handler(PACK_NAME, file, ctx, &HANDLER);
216        let (local_fn_spans, table_member_names) =
217            if let Some((snapshot, tree)) = bonsai_lang_api::kit::parse_with(PACK_NAME, file, ctx) {
218                let source = snapshot.text.as_bytes();
219                idx.refs
220                    .extend(synthesize_lua_global_arg_refs(&tree, source, file));
221                // Two grammar variants for `local function foo() ... end`:
222                //   - some tree-sitter-lua releases produce a dedicated
223                //     `local_function` node kind;
224                //   - the MunifTanjim/tree-sitter-lua grammar parses
225                //     `local function helper(x) ... end` as a regular
226                //     `function_declaration` whose role on the chunk
227                //     is the `local_declaration` field. Detect the
228                //     latter by walking the chunk's children and
229                //     checking each child's field name.
230                let mut spans: Vec<bonsai_common::Span> = collect_kinds(&tree, &["local_function"])
231                    .into_iter()
232                    .map(|local_fn_node| span_of(file, &local_fn_node))
233                    .collect();
234                let root = tree.root_node();
235                let mut chunk_cursor = root.walk();
236                // Field-name walk handles the MunifTanjim shape where
237                // `local function` rides as a `function_declaration`
238                // tagged with the `local_declaration` field.
239                if chunk_cursor.goto_first_child() {
240                    loop {
241                        if chunk_cursor.field_name() == Some("local_declaration")
242                            && chunk_cursor.node().kind() == "function_declaration"
243                        {
244                            spans.push(span_of(file, &chunk_cursor.node()));
245                        }
246                        if !chunk_cursor.goto_next_sibling() {
247                            break;
248                        }
249                    }
250                }
251                (spans, collect_lua_table_member_names(&tree, source, file))
252            } else {
253                (Vec::new(), Vec::new())
254            };
255        // Lua has no language-level module boundary; file stem is the
256        // closest semantic anchor for qualified_name and module_path.
257        // See `docs/contributing/design-patterns.mdx::Semantic Resolution Always`.
258        bonsai_lang_api::apply_file_stem_semantic_identity(&mut idx, ctx);
259        apply_lua_table_member_semantic_identity(&mut idx, &table_member_names);
260        // `local function` is chunk-private (file-scoped). Mark these
261        // as Visibility::Private so the resolver refuses cross-file
262        // calls to local Lua helpers.
263        for decl in &mut idx.defs {
264            if local_fn_spans.contains(&decl.span) {
265                decl.visibility = bonsai_lang_api::Visibility::Private;
266            }
267        }
268        // Lua module-table return idiom: `local M = {}; function M.foo(...
269        // ); ... return M`. The trailing `return M` declares M as the
270        // file's exported surface. Decls attached to the table (named
271        // `M.foo`) keep `Public`; sibling top-level free functions
272        // become `Visibility::Module` so the resolver narrows
273        // cross-file candidate sets to the explicit exports.
274        if let Some((snapshot, tree)) = bonsai_lang_api::kit::parse_with(PACK_NAME, file, ctx) {
275            let src = snapshot.text.as_bytes();
276            if let Some(table_name) = collect_lua_module_export_table(&tree, src) {
277                let table_dotted_prefix = format!("{table_name}.");
278                let table_member_decls: std::collections::HashSet<bonsai_common::Span> =
279                    collect_lua_table_member_decl_spans(&tree, src, &table_name, file);
280                for decl in &mut idx.defs {
281                    if !matches!(decl.kind, bonsai_lang_api::DeclKind::Function) {
282                        continue;
283                    }
284                    if decl.parent.is_some() {
285                        continue;
286                    }
287                    if matches!(decl.visibility, bonsai_lang_api::Visibility::Private) {
288                        continue;
289                    }
290                    let attached_to_table = table_member_decls.contains(&decl.span)
291                        || decl.name.starts_with(&table_dotted_prefix);
292                    if !attached_to_table {
293                        decl.visibility = bonsai_lang_api::Visibility::Module;
294                    }
295                }
296            }
297        }
298        let table_field_assigns = parse_with(PACK_NAME, file, ctx)
299            .map(|(snapshot, tree)| {
300                collect_lua_table_literal_field_assigns(&tree, snapshot.text.as_bytes(), file)
301            })
302            .unwrap_or_default();
303        for decl in &mut idx.defs {
304            insert_lua_table_field_assigns_in_events(&mut decl.flow_events, &table_field_assigns);
305            normalize_lua_dot_calls(&mut decl.flow_events);
306            enrich_lua_factory_receiver_field_writes(decl);
307            bonsai_lang_api::normalize_call_result_assignment_sources(&mut decl.flow_events);
308        }
309        // Precompute `self.<field> → Type` bindings from each
310        // class's constructor `receiver_field_writes` so receiver-
311        // typed dispatch through stable instance state is an O(1)
312        // lookup against the method's `type_aliases` instead of a
313        // per-call walk over sibling decls.
314        // Local constructor-result receiver typing follows adapter facts and
315        // declarations; spelling alone is not constructor evidence.
316        bonsai_lang_api::apply_constructor_result_type_aliases(&mut idx);
317        bonsai_lang_api::apply_class_field_type_aliases(&mut idx);
318        idx
319    }
320    fn extract_imports(&self, file: FileId, ctx: &AdapterContext<'_>) -> ImportIndex {
321        let mut idx = extract_imports_via(PACK_NAME, file, ctx, parse_imports);
322        if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
323            if let (Some(table_name), Some(module)) = (
324                collect_lua_module_export_table(&tree, snapshot.text.as_bytes()),
325                lua_file_module_name(file, ctx),
326            ) {
327                idx.imports.push(ImportSpec {
328                    span: span_of(file, &tree.root_node()),
329                    module,
330                    alias: Some(table_name),
331                    is_wildcard: false,
332                    original_name: None,
333                    // Resolver-only self-module binding for the
334                    // `local M = {}; ...; return M` export idiom.
335                    // It is not an import statement and must not
336                    // appear in browse/export import inventories.
337                    scope: ImportScope::Local,
338                });
339            }
340        }
341        idx
342    }
343}
344
345/// Preserve the table owner of Lua's declaration syntax
346/// `function Table.member(...)`. The generic declaration walker correctly
347/// extracts the callable's short name, while the adapter owns the table path
348/// needed to resolve `Table.member(...)` as the same declaration.
349fn collect_lua_table_member_names(
350    tree: &Tree,
351    src: &[u8],
352    file: FileId,
353) -> Vec<(bonsai_common::Span, String)> {
354    let mut out = Vec::new();
355    for declaration in collect_kinds(tree, &["function_declaration"]) {
356        let Some(name_node) = declaration.child_by_field_name("name") else {
357            continue;
358        };
359        let rendered = node_text(&name_node, src).trim();
360        if !rendered.contains(['.', ':']) {
361            continue;
362        }
363        let canonical = rendered
364            .chars()
365            .filter(|character| !character.is_whitespace())
366            .map(|character| if character == ':' { '.' } else { character })
367            .collect::<String>();
368        if canonical.split('.').any(str::is_empty) {
369            continue;
370        }
371        out.push((span_of(file, &declaration), canonical));
372    }
373    out
374}
375
376fn apply_lua_table_member_semantic_identity(
377    index: &mut DeclIndex,
378    table_members: &[(bonsai_common::Span, String)],
379) {
380    for declaration in &mut index.defs {
381        let Some((_, qualified_name)) = table_members.iter().find(|(span, _)| *span == declaration.span)
382        else {
383            continue;
384        };
385        declaration.qualified_name = Some(qualified_name.clone());
386    }
387}
388
389#[derive(Clone, Debug)]
390struct LuaTableFieldAssigns {
391    assign_span: bonsai_common::Span,
392    target: String,
393    fields: Vec<FlowEvent>,
394}
395
396fn collect_lua_table_literal_field_assigns(
397    tree: &Tree,
398    src: &[u8],
399    file: FileId,
400) -> Vec<LuaTableFieldAssigns> {
401    let mut out = Vec::new();
402    for assignment in collect_kinds(tree, &["assignment_statement"]) {
403        let Some(variable_list) = first_named_child_of_kind(&assignment, "variable_list") else {
404            continue;
405        };
406        let Some(expression_list) = first_named_child_of_kind(&assignment, "expression_list") else {
407            continue;
408        };
409        let Some(target_node) = variable_list
410            .child_by_field_name("name")
411            .or_else(|| first_named_child_of_kind(&variable_list, "identifier"))
412        else {
413            continue;
414        };
415        let target = node_text(&target_node, src).trim();
416        if target.is_empty() || !target.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
417            continue;
418        }
419        let Some(table) = expression_list
420            .child_by_field_name("value")
421            .filter(|node| node.kind() == "table_constructor")
422            .or_else(|| first_named_child_of_kind(&expression_list, "table_constructor"))
423        else {
424            continue;
425        };
426        let mut fields = Vec::new();
427        let mut cursor = table.walk();
428        for field in table
429            .named_children(&mut cursor)
430            .filter(|node| node.kind() == "field")
431        {
432            let Some(name_node) = field.child_by_field_name("name") else {
433                continue;
434            };
435            let Some(value_node) = field.child_by_field_name("value") else {
436                continue;
437            };
438            let key = node_text(&name_node, src).trim();
439            if key.is_empty() || !key.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
440                continue;
441            }
442            let sources = lua_value_source_names(value_node, src);
443            fields.push(FlowEvent::Assign {
444                span: span_of(file, &value_node),
445                target: format!("{target}.{key}"),
446                source_name: (sources.len() == 1).then(|| sources[0].clone()),
447                source_call: None,
448                source_call_args: Vec::new(),
449                source_names: sources.clone(),
450                declares_new_binding: false,
451                value_kind: Some(if sources.is_empty() {
452                    AssignValueKind::Literal
453                } else {
454                    AssignValueKind::Compound
455                }),
456            });
457        }
458        if !fields.is_empty() {
459            out.push(LuaTableFieldAssigns {
460                assign_span: span_of(file, &assignment),
461                target: target.to_string(),
462                fields,
463            });
464        }
465    }
466    out
467}
468
469fn lua_value_source_names(node: Node<'_>, src: &[u8]) -> Vec<String> {
470    fn collect(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
471        match node.kind() {
472            "identifier" => {
473                let name = node_text(&node, src).trim();
474                if !name.is_empty() {
475                    out.push(name.to_string());
476                }
477                return;
478            }
479            "dot_index_expression" | "bracket_index_expression" => {
480                let name = node_text(&node, src)
481                    .replace([' ', '\t', '\n', '\r'], "")
482                    .replace('[', ".")
483                    .replace(']', "")
484                    .replace(['\"', '\''], "");
485                if !name.is_empty() {
486                    out.push(name);
487                }
488                return;
489            }
490            "string" | "number" | "nil" | "true" | "false" => return,
491            _ => {}
492        }
493        let mut cursor = node.walk();
494        for child in node.named_children(&mut cursor) {
495            collect(child, src, out);
496        }
497    }
498
499    let mut out = Vec::new();
500    collect(node, src, &mut out);
501    out.sort();
502    out.dedup();
503    out
504}
505
506fn insert_lua_table_field_assigns_in_events(
507    events: &mut Vec<FlowEvent>,
508    field_assigns: &[LuaTableFieldAssigns],
509) {
510    let mut index = 0usize;
511    while index < events.len() {
512        match &mut events[index] {
513            FlowEvent::Branch {
514                then_events,
515                else_events,
516                ..
517            } => {
518                insert_lua_table_field_assigns_in_events(then_events, field_assigns);
519                insert_lua_table_field_assigns_in_events(else_events, field_assigns);
520            }
521            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
522                insert_lua_table_field_assigns_in_events(body, field_assigns);
523            }
524            FlowEvent::Try {
525                body,
526                catch_events,
527                finally_events,
528                ..
529            } => {
530                insert_lua_table_field_assigns_in_events(body, field_assigns);
531                insert_lua_table_field_assigns_in_events(catch_events, field_assigns);
532                insert_lua_table_field_assigns_in_events(finally_events, field_assigns);
533            }
534            _ => {}
535        }
536
537        let inserts = match &events[index] {
538            FlowEvent::Assign { span, target, .. } => field_assigns
539                .iter()
540                .filter(|item| {
541                    item.target == *target
542                        && span.file == item.assign_span.file
543                        && span.start <= item.assign_span.end
544                        && item.assign_span.start <= span.end
545                })
546                .flat_map(|item| item.fields.clone())
547                .collect::<Vec<_>>(),
548            _ => Vec::new(),
549        };
550        if inserts.is_empty() {
551            index += 1;
552            continue;
553        }
554        let inserted = inserts.len();
555        events.splice((index + 1)..=index, inserts);
556        index += inserted + 1;
557    }
558}
559
560fn normalize_lua_dot_calls(events: &mut [FlowEvent]) {
561    for event in events {
562        match event {
563            FlowEvent::Call {
564                name,
565                receiver,
566                call_kind,
567                ..
568            } if name.contains(':') => {
569                // `table:method(args)` injects `table` as the implicit
570                // receiver. Canonicalize the adapter fact to the shared
571                // dotted name representation only after preserving that
572                // execution semantic.
573                let canonical = name.replace(':', ".");
574                *receiver = canonical.rsplit_once('.').map(|(owner, _)| owner.to_string());
575                *name = canonical;
576                *call_kind = bonsai_lang_api::CallKind::Method;
577            }
578            FlowEvent::Call {
579                name,
580                receiver,
581                receiver_types,
582                call_kind,
583                ..
584            } if name.contains('.') => {
585                // Lua's `table.member(args)` syntax does not inject an
586                // implicit receiver. Only `table:member(args)` does, and
587                // the grammar preserves that colon in the call name. The
588                // table qualifier is a namespace expression here; retaining
589                // it as a receiver would make the shared resolver treat the
590                // explicit first argument as an implicit receiver and shift
591                // every parameter mapping by one.
592                *call_kind = bonsai_lang_api::CallKind::Function;
593                *receiver = None;
594                receiver_types.clear();
595            }
596            FlowEvent::Branch {
597                then_events,
598                else_events,
599                ..
600            } => {
601                normalize_lua_dot_calls(then_events);
602                normalize_lua_dot_calls(else_events);
603            }
604            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
605                normalize_lua_dot_calls(body);
606            }
607            FlowEvent::Try {
608                body,
609                catch_events,
610                finally_events,
611                ..
612            } => {
613                normalize_lua_dot_calls(body);
614                normalize_lua_dot_calls(catch_events);
615                normalize_lua_dot_calls(finally_events);
616            }
617            _ => {}
618        }
619    }
620}
621
622fn enrich_lua_factory_receiver_field_writes(decl: &mut bonsai_lang_api::Decl) {
623    // Run receiver-field collection whenever the method carries an
624    // explicit `self` param (the dot-def form `function T.m(self, ...)`)
625    // -- not only for factories that `return self`. A plain mutator
626    // `self.field = <param>` must still record a receiver_field_write so
627    // stored taint flows through instance state (audit L6).
628    let has_self_param = decl.params.iter().any(|param| param == "self");
629    if !has_self_param && !lua_returns_name(&decl.flow_events, "self") {
630        return;
631    }
632    let writes = collect_receiver_field_writes(&decl.flow_events, &decl.params, None, &["self"], &[]);
633    if writes.is_empty() {
634        return;
635    }
636    decl.receiver_field_writes.extend(writes);
637    if !decl.implicit_receiver_names.iter().any(|name| name == "self") {
638        decl.implicit_receiver_names.push("self".to_string());
639    }
640    decl.receiver_field_writes
641        .sort_by_key(|write| (write.span.start, write.target.clone()));
642    decl.receiver_field_writes.dedup_by(|a, b| {
643        a.span == b.span && a.target == b.target && a.source_param_indices == b.source_param_indices
644    });
645}
646
647fn lua_returns_name(events: &[FlowEvent], name: &str) -> bool {
648    events.iter().any(|event| match event {
649        FlowEvent::Return {
650            value_name,
651            value_flow,
652            ..
653        } => value_name.as_deref() == Some(name) || value_flow.place.as_deref() == Some(name),
654        FlowEvent::Branch {
655            then_events,
656            else_events,
657            ..
658        } => lua_returns_name(then_events, name) || lua_returns_name(else_events, name),
659        FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
660            lua_returns_name(body, name)
661        }
662        FlowEvent::Try {
663            body,
664            catch_events,
665            finally_events,
666            ..
667        } => {
668            lua_returns_name(body, name)
669                || lua_returns_name(catch_events, name)
670                || lua_returns_name(finally_events, name)
671        }
672        _ => false,
673    })
674}
675
676/// Surface every bare `arg` identifier as a Read ref. Lua exposes the
677/// chunk's argv as a global named `arg`, and rules query it directly
678/// — without these refs the matcher has nothing to bind to.
679fn synthesize_lua_global_arg_refs(tree: &Tree, src: &[u8], file: FileId) -> Vec<Ref> {
680    collect_kinds(tree, &["identifier"])
681        .into_iter()
682        .filter(|node| node_text(node, src) == "arg")
683        .map(|node| Ref {
684            span: span_of(file, &node),
685            name: "arg".to_string(),
686            kind: RefKind::Read,
687            scope: None,
688            resolved: None,
689        })
690        .collect()
691}
692
693/// Lift every `require(...)` call into an `ImportSpec`. Lua has no
694/// native import keyword; `local X = require('pkg')` is the idiom and
695/// the only signal we have to associate a local binding with a module.
696fn parse_imports(tree: &Tree, src: &[u8], file: FileId) -> Vec<ImportSpec> {
697    let mut imports = Vec::new();
698    // Side-effect loads (`require('pkg')` with no binding) are still
699    // indexed so rules can match on the module presence alone.
700    for call_node in collect_kinds(tree, &["function_call"]) {
701        let Some(name_node) = call_node.child_by_field_name("name") else {
702            continue;
703        };
704        if node_text(&name_node, src) != "require" {
705            continue;
706        }
707        let Some(arg_list) = call_node.child_by_field_name("arguments") else {
708            continue;
709        };
710        let module = first_named_child_of_kind(&arg_list, "string")
711            .and_then(|string_node| first_named_child_of_kind(&string_node, "string_content"))
712            .map(|content_node| node_text(&content_node, src).to_string())
713            .unwrap_or_default();
714        if module.is_empty() {
715            continue;
716        }
717        let alias = call_node
718            .parent()
719            .filter(|parent| parent.kind() == "expression_list")
720            .and_then(|expr_list| expr_list.parent())
721            .filter(|parent| parent.kind() == "assignment_statement")
722            .and_then(|assignment| first_named_child_of_kind(&assignment, "variable_list"))
723            .and_then(|var_list| first_named_child_of_kind(&var_list, "identifier"))
724            .map(|ident| node_text(&ident, src).to_string());
725        let member = call_node
726            .parent()
727            .filter(|parent| parent.kind() == "dot_index_expression")
728            .and_then(|dot| dot.child_by_field_name("field"))
729            .map(|field| node_text(&field, src).to_string())
730            .filter(|field| !field.trim().is_empty());
731        imports.push(ImportSpec {
732            span: span_of(file, &call_node),
733            module: module.clone(),
734            alias,
735            is_wildcard: false,
736            original_name: None,
737            scope: ImportScope::Module,
738        });
739        if let Some(member) = member {
740            if let Some(local) = local_lua_assignment_target_for_call(call_node, src) {
741                imports.push(ImportSpec {
742                    span: span_of(file, &call_node),
743                    module,
744                    alias: Some(local),
745                    is_wildcard: false,
746                    original_name: Some(member),
747                    scope: ImportScope::Local,
748                });
749            }
750        }
751    }
752    imports
753}
754
755fn local_lua_assignment_target_for_call(call_node: tree_sitter::Node<'_>, src: &[u8]) -> Option<String> {
756    let expr_node = call_node
757        .parent()
758        .filter(|parent| parent.kind() == "dot_index_expression")
759        .unwrap_or(call_node);
760    let assignment = expr_node
761        .parent()
762        .filter(|parent| parent.kind() == "expression_list")
763        .and_then(|expr_list| expr_list.parent())
764        .filter(|parent| parent.kind() == "assignment_statement")?;
765    first_named_child_of_kind(&assignment, "variable_list")
766        .and_then(|var_list| first_named_child_of_kind(&var_list, "identifier"))
767        .map(|ident| node_text(&ident, src).to_string())
768        .filter(|text| !text.trim().is_empty())
769}
770
771fn lua_file_module_name(file: FileId, ctx: &AdapterContext<'_>) -> Option<String> {
772    let path = ctx
773        .workspace_relative_path(file)
774        .or_else(|| ctx.vfs.path(file).ok().map(|p| (*p).clone()))?;
775    path.file_stem()
776        .map(|stem| stem.to_string_lossy().into_owned())
777        .filter(|stem| !stem.is_empty())
778}
779
780/// Find the file's tail `return <ident>` and return `<ident>` if the
781/// chunk's last statement is a bare-identifier return. This matches
782/// the Lua module-export idiom (`return M`). Computed returns
783/// (`return setmetatable(...)`) and absent returns yield `None`,
784/// in which case the caller does not narrow visibility.
785fn collect_lua_module_export_table(tree: &Tree, src: &[u8]) -> Option<String> {
786    let root = tree.root_node();
787    let mut last_return: Option<tree_sitter::Node<'_>> = None;
788    let mut cursor = root.walk();
789    // The export idiom places the return at the very end, but the
790    // grammar permits multiple `return` statements in a chunk.
791    for child in root.named_children(&mut cursor) {
792        if child.kind() == "return_statement" {
793            last_return = Some(child);
794        }
795    }
796    let return_stmt = last_return?;
797    let exprs = match return_stmt.child_by_field_name("expression_list") {
798        Some(node) => node,
799        None => {
800            // Older grammar releases expose `expression_list` as an
801            // unnamed child rather than a labelled field. Bind the
802            // search result to a local so the cursor outlives the
803            // `find` iterator's borrow.
804            let mut return_cursor = return_stmt.walk();
805            let found = return_stmt
806                .named_children(&mut return_cursor)
807                .find(|child| child.kind() == "expression_list");
808            found?
809        }
810    };
811    let mut expr_cursor = exprs.walk();
812    let mut returned_exprs: Vec<tree_sitter::Node<'_>> = exprs.named_children(&mut expr_cursor).collect();
813    // Multi-return (`return a, b`) is not the export idiom.
814    if returned_exprs.len() != 1 {
815        return None;
816    }
817    let only_expr = returned_exprs.pop()?;
818    // Computed returns (`return setmetatable(...)`) are skipped — only
819    // a bare identifier names the module-table.
820    if only_expr.kind() != "identifier" {
821        return None;
822    }
823    Some(node_text(&only_expr, src).to_string())
824}
825
826/// Walk every `function_declaration` and collect spans for those whose
827/// `name` is a `dot_index_expression` rooted at `table_name` — i.e.
828/// `function M.foo(...)`. The returned set is the export-set for the
829/// module-table return idiom.
830fn collect_lua_table_member_decl_spans(
831    tree: &Tree,
832    src: &[u8],
833    table_name: &str,
834    file: FileId,
835) -> std::collections::HashSet<bonsai_common::Span> {
836    let mut member_spans = std::collections::HashSet::new();
837    for fn_node in collect_kinds(tree, &["function_declaration"]) {
838        let Some(name_node) = fn_node.child_by_field_name("name") else {
839            continue;
840        };
841        // Free functions (`function foo()`) have a plain identifier
842        // here; only dotted forms attach to a table.
843        if name_node.kind() != "dot_index_expression" {
844            continue;
845        }
846        let Some(table_node) = name_node.child_by_field_name("table") else {
847            continue;
848        };
849        if node_text(&table_node, src) != table_name {
850            continue;
851        }
852        member_spans.insert(span_of(file, &fn_node));
853    }
854    member_spans
855}
856
857#[cfg(test)]
858#[path = "tests.rs"]
859mod tests;