Skip to main content

bonsai_lang_javascript/
lib.rs

1//! JavaScript language adapter.
2use bonsai_common::{FileId, SymbolId};
3use bonsai_lang_api::{
4    decl_index_with_handler, extract_imports_via,
5    kit::{
6        call_arg_from_nodes_with_handler, collect_kinds, first_named_child_of_kind, language_from_pack,
7        node_text, normalize_call_name_whitespace, parse_with, span_of,
8    },
9    AdapterContext, AdapterError, CallTargetExtraction, CharacterSubstitutionDomain,
10    CharacterSubstitutionFact, ConditionEquality, ConditionExpressionFact, ConditionOperandFact, DeclIndex,
11    DynamicKeyFilterFact, FiniteLiteralSelectionFact, GrammarHandler, ImportIndex, ImportScope, ImportSpec,
12    LanguageAdapter, LanguageCapabilities, LanguageId, SameOriginPathConstraintFact, StaticScalarValue,
13    StaticStringMapEntry, StaticStringMapFact, StringCompositionFact, StringCompositionPart,
14    TypeAliasBinding, Visibility, EMPTY_HANDLER,
15};
16use bonsai_lang_api::{CallArg, CallKind, DeclKind, FlowEvent};
17use std::collections::{HashMap, HashSet};
18use tree_sitter::{Language, Node, Tree};
19
20fn javascript_call_target<'tree>(node: Node<'tree>, src: &[u8]) -> Option<CallTargetExtraction<'tree>> {
21    let target = match node.kind() {
22        "call_expression" => node.child_by_field_name("function")?,
23        "new_expression" => node.child_by_field_name("constructor")?,
24        _ => return None,
25    };
26    let full_text = node_text(&target, src).trim();
27    (!full_text.is_empty()).then_some(CallTargetExtraction {
28        node: target,
29        full_text: full_text.to_string(),
30    })
31}
32
33fn javascript_foreach_binding(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
34    (node.kind() == "for_in_statement")
35        .then(|| {
36            Some((
37                node.child_by_field_name("left")?,
38                node.child_by_field_name("right")?,
39            ))
40        })
41        .flatten()
42}
43
44pub const LANG_ID: LanguageId = LanguageId::new("javascript");
45pub const JS_TS_MODULE_RESOLUTION_EXTENSIONS: &[&str] = &["js", "jsx", "ts", "tsx", "mjs", "cjs"];
46const PACK_NAME: &str = "javascript";
47const HANDLER: GrammarHandler = GrammarHandler {
48    expression_value_kind_extractor: None,
49    literal_value_kinds: &["null", "number", "true", "false"],
50    string_literal_kinds: &["string", "template_string"],
51    comment_kinds: &["comment", "hash_bang_line"],
52    doc_comment_prefixes: &["/**"],
53    decorator_kinds: &["decorator"],
54    parameter_container_kinds: &["formal_parameters"],
55    parameter_kinds: &["identifier", "required_parameter", "optional_parameter"],
56    parameter_annotation_kinds: &["decorator"],
57    variadic_parameter_kinds: &["rest_pattern", "rest_parameter"],
58    destructured_parameter_kinds: &["object_pattern", "array_pattern"],
59    binding_identifier_kinds: &["identifier", "shorthand_property_identifier_pattern"],
60    binding_lhs_pattern_kinds: &["assignment_pattern"],
61    binding_pattern_field_names: &["left"],
62    non_binding_pattern_field_names: &["type", "key", "property"],
63    identifier_kinds: &["identifier", "shorthand_property_identifier", "this", "super"],
64    aggregate_pattern_kinds: &["array_pattern", "object_pattern"],
65    named_aggregate_kinds: &["object"],
66    positional_aggregate_kinds: &["array"],
67    aggregate_pair_kinds: &["pair", "pair_pattern"],
68    aggregate_key_field_names: &["key"],
69    aggregate_value_field_names: &["value"],
70    static_field_name_kinds: &["identifier", "property_identifier"],
71    shorthand_field_kinds: &["shorthand_property_identifier"],
72    spread_kinds: &["spread_element"],
73    spread_value_field_names: &["argument"],
74    lambda_value_container_kinds: &["object", "pair", "array"],
75    transparent_call_wrapper_kinds: &[
76        "member_expression",
77        "parenthesized_expression",
78        "await_expression",
79    ],
80    single_expression_group_kinds: &["expressions"],
81    assignment_target_wrapper_kinds: &["variable_declarator"],
82    binding_declaration_keyword_spellings: &["var", "let", "const"],
83    fn_kinds: &[
84        "function_declaration",
85        "function_expression",
86        "method_definition",
87        "generator_function_declaration",
88        "generator_function",
89    ],
90    class_kinds: &["class_declaration"],
91    class_decl_kinds: &[("class_declaration", DeclKind::Class)],
92    method_kinds: &["method_definition"],
93    method_context_kinds: &["class_declaration"],
94    if_kinds: &["if_statement", "switch_statement"],
95    branch_then_field_names: &["consequence", "body"],
96    branch_else_field_names: &["alternative"],
97    branch_condition_field_names: &["condition", "value"],
98    loop_body_field_names: &["body"],
99    loop_body_kinds: &["statement_block", "expression_statement"],
100    branch_arm_kinds: &[
101        "statement_block",
102        "expression_statement",
103        "switch_case",
104        "switch_default",
105    ],
106    for_kinds: &["for_statement"],
107    foreach_kinds: &["for_in_statement"],
108    foreach_binding_extractor: Some(javascript_foreach_binding),
109    while_kinds: &["while_statement"],
110    do_kinds: &["do_statement"],
111    call_kinds: &["call_expression", "new_expression"],
112    constructor_call_kinds: &["new_expression"],
113    call_callee_field_names: &["function", "constructor"],
114    constructor_type_field_names: &["constructor"],
115    call_target_extractor: Some(javascript_call_target),
116    call_argument_field_names: &["arguments"],
117    call_argument_container_kinds: &["arguments"],
118    argument_wrapper_kinds: &["pair"],
119    argument_name_field_names: &["key"],
120    argument_value_field_names: &["value"],
121    transparent_expression_wrapper_kinds: &["parenthesized_expression"],
122    lambda_body_field_names: &["body"],
123    pseudo_call_extractor: Some(extract_ecmascript_pseudo_call),
124    syntax_event_extractor: None,
125    argument_passing_mode_extractor: None,
126    call_ref_kinds: &["call_expression", "new_expression"],
127    member_expression_kinds: &["member_expression"],
128    subscript_expression_kinds: &["subscript_expression"],
129    member_base_field_names: &["object"],
130    member_name_field_names: &["property"],
131    subscript_base_field_names: &["object"],
132    subscript_index_field_names: &["index"],
133    static_subscript_key_extractor: Some(ecmascript_static_subscript_key),
134    constructor_names: &["constructor"],
135    runtime_type_guard_operators: &["instanceof"],
136    runtime_typeof_operators: &["typeof"],
137    runtime_type_equality_operators: &["==", "==="],
138    runtime_type_wrapper_kinds: &["parenthesized_expression"],
139    value_free_unary_operators: &["typeof"],
140    assignment_kinds: &[
141        "assignment_expression",
142        "augmented_assignment_expression",
143        "variable_declarator",
144        "variable_declaration",
145    ],
146    compound_assignment_operators: &[
147        "+=", "-=", "*=", "/=", "%=", "**=", "<<=", ">>=", ">>>=", "&=", "^=", "|=", "&&=", "||=", "??=",
148    ],
149    return_kinds: &["return_statement"],
150    throw_kinds: &["throw_statement"],
151    lambda_kinds: &["arrow_function", "function_expression"],
152    try_kinds: &["try_statement"],
153    catch_kinds: &["catch_clause"],
154    finally_kinds: &["finally_clause"],
155    break_kinds: &["break_statement"],
156    continue_kinds: &["continue_statement"],
157    control_label_field_names: &["label"],
158    yield_kinds: &["yield_expression"],
159    yield_value_field_names: &["argument"],
160    await_kinds: &["await_expression"],
161    using_kinds: &["with_statement"],
162    using_body_field_names: &["body"],
163    try_body_field_names: &["body"],
164    implicit_receiver_names: &["this"],
165    ..EMPTY_HANDLER
166};
167
168/// Lower JSX/TSX opening elements to their component-call semantics. The
169/// concrete grammar vocabulary lives here and is shared only with the
170/// TypeScript adapter, which uses the same ECMAScript frontend shapes.
171pub fn extract_ecmascript_pseudo_call(
172    node: Node<'_>,
173    file: FileId,
174    src: &[u8],
175    handler: &GrammarHandler,
176) -> Option<FlowEvent> {
177    if !matches!(node.kind(), "jsx_self_closing_element" | "jsx_opening_element") {
178        return None;
179    }
180    let name_node = node.child_by_field_name("name").or_else(|| {
181        let mut cursor = node.walk();
182        let name = node.named_children(&mut cursor).find(|child| {
183            matches!(
184                child.kind(),
185                "identifier"
186                    | "nested_identifier"
187                    | "member_expression"
188                    | "jsx_namespace_name"
189                    | "jsx_member_expression"
190            )
191        });
192        name
193    })?;
194    let component = node_text(&name_node, src).trim().to_string();
195    if component.is_empty() {
196        return None;
197    }
198
199    let mut args = Vec::new();
200    let mut cursor = node.walk();
201    for attribute in node.named_children(&mut cursor) {
202        if attribute.kind() != "jsx_attribute" {
203            continue;
204        }
205        let attr_name = attribute
206            .named_child(0)
207            .map(|name| node_text(&name, src).trim().to_string())
208            .unwrap_or_default();
209        let name = (!attr_name.is_empty()).then_some(attr_name.clone());
210        if let Some(wrapper) = attribute.named_child(1) {
211            let value = wrapper.named_child(0).unwrap_or(wrapper);
212            if let Some(argument) =
213                call_arg_from_nodes_with_handler(attribute, value, file, src, name, handler)
214            {
215                args.push(argument);
216            }
217        } else if !attr_name.is_empty() {
218            // Boolean JSX attributes are the exact literal `true`; they carry
219            // no value dependency even though the rendering uses the name.
220            args.push(CallArg {
221                passing_mode: Default::default(),
222                span: span_of(file, &attribute),
223                name,
224                place: None,
225                source_names: Vec::new(),
226                value_text: normalize_call_name_whitespace(&attr_name),
227            });
228        }
229    }
230
231    Some(FlowEvent::Call {
232        span: span_of(file, &node),
233        receiver: None,
234        receiver_types: Vec::new(),
235        name: component,
236        call_kind: CallKind::Function,
237        args,
238    })
239}
240
241#[derive(Debug, Default, Copy, Clone)]
242pub struct JavaScriptAdapter;
243
244impl JavaScriptAdapter {
245    /// Construct a fresh adapter. Stateless; cheap to copy.
246    #[must_use]
247    pub fn new() -> Self {
248        Self
249    }
250}
251
252impl LanguageAdapter for JavaScriptAdapter {
253    fn language_id(&self) -> LanguageId {
254        LANG_ID
255    }
256    fn display_name(&self) -> &'static str {
257        "JavaScript"
258    }
259    fn file_extensions(&self) -> &'static [&'static str] {
260        &["js", "mjs", "cjs", "jsx"]
261    }
262    fn tree_sitter_language(&self) -> Result<Language, AdapterError> {
263        language_from_pack(PACK_NAME)
264    }
265    fn capabilities(&self) -> LanguageCapabilities {
266        LanguageCapabilities {
267            module_export_aliases: &["exports", "module.exports"],
268            module_default_export_names: &["default"],
269            universal_type_names: &[],
270            module_path_syntax: bonsai_lang_api::ModulePathSyntax::none(),
271            constructor_method_names: &["constructor"],
272            super_receiver_tokens: &["super"],
273            implicit_receiver_tokens: &["this"],
274            receiver_type_syntax: bonsai_lang_api::ReceiverTypeSyntax {
275                wrapper_calls: &[],
276                class_object_suffixes: &[".constructor"],
277            },
278            module_resolution_extensions: JS_TS_MODULE_RESOLUTION_EXTENSIONS,
279            ..LanguageCapabilities::partial_baseline()
280        }
281    }
282    fn extract_declarations(&self, file: FileId, ctx: &AdapterContext<'_>) -> DeclIndex {
283        let mut decl_index = decl_index_with_handler(PACK_NAME, file, ctx, &HANDLER);
284        if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
285            let src = snapshot.text.as_bytes();
286            populate_ecmascript_compiler_facts(&mut decl_index, &tree, file, src);
287            apply_ecmascript_assigned_member_callable_owners(&mut decl_index, &tree, file, src);
288            apply_js_ts_commonjs_named_export_aliases(&mut decl_index, &tree, src, file);
289        }
290        // Module identity = workspace-relative path with the JS/TS extension stripped.
291        let module_segments = ctx
292            .workspace_relative_path(file)
293            .map(|p| js_ts_module_segments(&p))
294            .unwrap_or_default();
295        if !module_segments.is_empty() {
296            bonsai_lang_api::apply_module_path_semantic_identity(&mut decl_index, module_segments);
297        } else {
298            // Fall back to the file stem when the workspace root is unknown.
299            bonsai_lang_api::apply_file_stem_semantic_identity(&mut decl_index, ctx);
300        }
301        if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
302            apply_js_ts_default_export_aliases(&mut decl_index, &tree, snapshot.text.as_bytes(), file);
303        }
304        // ECMAScript private fields/methods are syntactically marked by a leading `#`.
305        for decl in &mut decl_index.defs {
306            if decl.name.starts_with('#') {
307                decl.visibility = Visibility::Private;
308            }
309        }
310        // Populate `bases` from `class_heritage > extends_clause` so the resolver
311        // can narrow virtual-dispatch candidates consistently with TypeScript.
312        if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
313            let src = snapshot.text.as_bytes();
314            let bases_by_span = collect_javascript_class_bases(&tree, file, src);
315            for decl in &mut decl_index.defs {
316                if let Some(bases) = bases_by_span
317                    .iter()
318                    .find_map(|(span, bases)| (*span == decl.span).then_some(bases))
319                {
320                    decl.bases = bases.clone();
321                }
322            }
323            rewrite_javascript_super_constructor_invocations(&mut decl_index);
324            apply_javascript_getter_property_sources(&mut decl_index, &tree, src, file);
325        }
326        for decl in &mut decl_index.defs {
327            bonsai_lang_api::normalize_call_result_assignment_sources(&mut decl.flow_events);
328        }
329        if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
330            rewrite_javascript_object_destructuring_sources(
331                &mut decl_index,
332                &tree,
333                snapshot.text.as_bytes(),
334                file,
335            );
336            inject_javascript_object_literal_field_assigns(
337                &mut decl_index,
338                &tree,
339                snapshot.text.as_bytes(),
340                file,
341            );
342            apply_javascript_array_literal_types(&mut decl_index, &tree, snapshot.text.as_bytes(), file);
343        }
344        // Precompute `self.<field> → Type` bindings from each
345        // class's constructor `receiver_field_writes` so receiver-
346        // typed dispatch through stable instance state is an O(1)
347        // lookup against the method's `type_aliases` instead of a
348        // per-call walk over sibling decls.
349        // Local constructor-result receiver typing
350        // (`const c = new Foo()` → `c: Foo`) comes from the `new_expression`
351        // CST node or exact declaration resolution. JavaScript identifiers
352        // are never classified from capitalization conventions.
353        bonsai_lang_api::apply_constructor_result_type_aliases(&mut decl_index);
354        bonsai_lang_api::apply_class_field_type_aliases(&mut decl_index);
355        bonsai_lang_api::apply_call_receiver_types(&mut decl_index);
356        decl_index
357    }
358    fn extract_imports(&self, file: FileId, ctx: &AdapterContext<'_>) -> ImportIndex {
359        extract_imports_via(PACK_NAME, file, ctx, parse_imports)
360    }
361}
362
363/// Attach ECMAScript-specific syntax meaning to the language-neutral compiler
364/// IR. TypeScript deliberately reuses this lowering because its expression
365/// grammar extends ECMAScript; downstream engines see only typed boolean
366/// relations and exact decoded literal values.
367pub fn populate_ecmascript_compiler_facts(index: &mut DeclIndex, tree: &Tree, file: FileId, src: &[u8]) {
368    mark_ecmascript_const_bindings_immutable(index, tree, file, src);
369    for branch in collect_kinds(tree, &["if_statement"]) {
370        let branch_span = span_of(file, &branch);
371        let Some(condition) = branch.child_by_field_name("condition") else {
372            continue;
373        };
374        let Some(fact) = index
375            .branch_conditions
376            .iter_mut()
377            .find(|fact| fact.branch_span == branch_span)
378        else {
379            continue;
380        };
381        fact.expression = Some(lower_ecmascript_condition_expression(condition, file, src));
382    }
383
384    for node in collect_kinds(tree, &["string", "string_literal"]) {
385        let span = span_of(file, &node);
386        let Some(value) = ecmascript_static_string_literal(node, src) else {
387            continue;
388        };
389        if let Some(literal) = index.strings.iter_mut().find(|literal| literal.span == span) {
390            literal.static_value = Some(value);
391        }
392    }
393    index.static_string_maps = ecmascript_static_string_maps(index, tree, file, src);
394    index.finite_literal_selections = ecmascript_finite_literal_selections(index, tree, file, src);
395    index.character_substitutions = ecmascript_character_substitutions(&index.defs, tree, file, src);
396    index.character_constraints = bonsai_lang_api::character_constraints_from_substitutions(
397        &index.defs,
398        &index.character_substitutions,
399    );
400    index.string_compositions = ecmascript_string_compositions(tree, file, src);
401    index.same_origin_path_constraints = ecmascript_same_origin_path_constraints(index, tree, file, src);
402    index.dynamic_key_filters = ecmascript_dynamic_key_filters(index, tree, file, src);
403    bonsai_lang_api::kit::populate_call_argument_static_values(
404        index,
405        tree,
406        file,
407        src,
408        &HANDLER,
409        ecmascript_static_scalar,
410    );
411}
412
413/// Model classic ECMAScript object/prototype methods from their assignment
414/// syntax.
415///
416/// JavaScript libraries commonly declare a method family without `class`:
417///
418/// ```text
419/// app.init = function init() { this.configure(); };
420/// app.configure = function configure() {};
421/// ```
422///
423/// Tree-sitter proves the static member owner, member name, and callable RHS.
424/// Lower those declarations under one synthetic structural owner so the
425/// normal implicit-receiver/type pipeline can resolve `this.configure()`.
426/// Dynamic/computed members, arrows (whose `this` is lexical), and named
427/// function expressions whose inner name differs from the assigned property
428/// fail closed.
429pub fn apply_ecmascript_assigned_member_callable_owners(
430    index: &mut DeclIndex,
431    tree: &Tree,
432    file: FileId,
433    src: &[u8],
434) {
435    #[derive(Debug)]
436    struct MemberCallable {
437        assignment_start: u64,
438        decl_index: usize,
439        owner: String,
440        owner_name: String,
441        owner_span: bonsai_common::Span,
442        property: String,
443    }
444
445    let mut members = Vec::new();
446    for assignment in collect_kinds(tree, &["assignment_expression"]) {
447        let (Some(left), Some(right)) = (
448            assignment.child_by_field_name("left"),
449            assignment.child_by_field_name("right"),
450        ) else {
451            continue;
452        };
453        if !matches!(right.kind(), "function_expression" | "generator_function")
454            || left.kind() != "member_expression"
455        {
456            continue;
457        }
458        let left_text = node_text(&left, src);
459        if left_text.trim() == "module.exports" || commonjs_named_export_member(left_text).is_some() {
460            // CommonJS export members are module bindings, not object-method
461            // receiver families. The export-alias lowering below preserves
462            // their public names and links namespace `require` calls.
463            continue;
464        }
465        let (Some(object), Some(property_node)) = (
466            left.child_by_field_name("object"),
467            left.child_by_field_name("property"),
468        ) else {
469            continue;
470        };
471        let Some(owner) = ecmascript_static_member_owner(object, src) else {
472            continue;
473        };
474        if matches!(owner.as_str(), "this" | "super") {
475            continue;
476        }
477        let property = node_text(&property_node, src).trim();
478        if property.is_empty()
479            || !matches!(
480                property_node.kind(),
481                "identifier" | "property_identifier" | "private_property_identifier"
482            )
483        {
484            continue;
485        }
486        let callable_span = span_of(file, &right);
487        let Some(decl_index) = index.defs.iter().position(|decl| {
488            decl.span == callable_span && decl.parent.is_none() && matches!(decl.kind, DeclKind::Function)
489        }) else {
490            continue;
491        };
492        let declared_name = index.defs[decl_index].name.trim();
493        let assigned_name = format!("{owner}.{property}");
494        if declared_name != property && declared_name != assigned_name {
495            continue;
496        }
497        let owner_name = owner
498            .strip_suffix(".prototype")
499            .unwrap_or(owner.as_str())
500            .to_string();
501        members.push(MemberCallable {
502            assignment_start: u64::try_from(assignment.start_byte()).unwrap_or(u64::MAX),
503            decl_index,
504            owner,
505            owner_name,
506            owner_span: span_of(file, &object),
507            property: property.to_string(),
508        });
509    }
510    members.sort_by_key(|member| member.assignment_start);
511    members.dedup_by_key(|member| member.decl_index);
512    if members.is_empty() {
513        return;
514    }
515
516    let mut next_symbol = index
517        .defs
518        .iter()
519        .map(|decl| decl.symbol.raw())
520        .max()
521        .map_or(0, |symbol| symbol.saturating_add(1));
522    let mut owners = HashMap::<String, SymbolId>::new();
523    for member in &members {
524        if owners.contains_key(&member.owner) {
525            continue;
526        }
527        if let Some(symbol) = index
528            .defs
529            .iter()
530            .find(|decl| {
531                decl.name == member.owner_name
532                    && matches!(
533                        decl.kind,
534                        DeclKind::Class
535                            | DeclKind::Struct
536                            | DeclKind::Trait
537                            | DeclKind::Interface
538                            | DeclKind::Enum
539                    )
540            })
541            .map(|decl| decl.symbol)
542        {
543            owners.insert(member.owner.clone(), symbol);
544            continue;
545        }
546
547        let symbol = SymbolId::new(next_symbol);
548        next_symbol = next_symbol.saturating_add(1);
549        owners.insert(member.owner.clone(), symbol);
550        index.defs.push(bonsai_lang_api::Decl {
551            symbol,
552            kind: DeclKind::Struct,
553            name: member.owner_name.clone(),
554            qualified_name: None,
555            module_path: Default::default(),
556            span: member.owner_span,
557            name_span: member.owner_span,
558            visibility: Visibility::Public,
559            parent: None,
560            body_span: None,
561            flow_events: Vec::new(),
562            has_implicit_returns: false,
563            params: Vec::new(),
564            param_annotations: Vec::new(),
565            param_default_calls: Vec::new(),
566            type_aliases: Vec::new(),
567            bases: Vec::new(),
568            receiver_param_index: None,
569            receiver_field_writes: Vec::new(),
570            receiver_field_initializers: Vec::new(),
571            implicit_receiver_names: Vec::new(),
572            receiver_state_sources: Vec::new(),
573            return_type: None,
574            is_variadic: false,
575        });
576    }
577
578    for member in members {
579        let Some(owner) = owners.get(&member.owner).copied() else {
580            continue;
581        };
582        let decl = &mut index.defs[member.decl_index];
583        decl.kind = DeclKind::Method;
584        decl.name = member.property;
585        decl.qualified_name = None;
586        decl.parent = Some(owner);
587        if !decl.implicit_receiver_names.iter().any(|name| name == "this") {
588            decl.implicit_receiver_names.push("this".to_string());
589        }
590    }
591}
592
593fn ecmascript_static_member_owner(node: Node<'_>, src: &[u8]) -> Option<String> {
594    match node.kind() {
595        "identifier" | "property_identifier" => {
596            let name = node_text(&node, src).trim();
597            (!name.is_empty()).then(|| name.to_string())
598        }
599        "member_expression" => {
600            let object = node.child_by_field_name("object")?;
601            let property = node.child_by_field_name("property")?;
602            if !matches!(
603                property.kind(),
604                "identifier" | "property_identifier" | "private_property_identifier"
605            ) {
606                return None;
607            }
608            let owner = ecmascript_static_member_owner(object, src)?;
609            let property = node_text(&property, src).trim();
610            (!property.is_empty()).then(|| format!("{owner}.{property}"))
611        }
612        _ => None,
613    }
614}
615
616/// Mark simple `const name = value` bindings as immutable compiler places.
617///
618/// This is syntax owned by the ECMAScript adapters. Shared consumers can use
619/// the fact without knowing JavaScript/TypeScript declaration spellings, and
620/// `let`/`var`, destructuring, or malformed declarations remain mutable or
621/// unknown.
622fn mark_ecmascript_const_bindings_immutable(index: &mut DeclIndex, tree: &Tree, file: FileId, src: &[u8]) {
623    for declaration in collect_kinds(tree, &["lexical_declaration"]) {
624        let is_const = (0..declaration.child_count())
625            .filter_map(|index| declaration.child(index as u32))
626            .any(|child| child.kind() == "const");
627        if !is_const {
628            continue;
629        }
630        let mut cursor = declaration.walk();
631        for declarator in declaration
632            .named_children(&mut cursor)
633            .filter(|node| node.kind() == "variable_declarator")
634        {
635            let (Some(name), Some(value)) = (
636                declarator.child_by_field_name("name"),
637                declarator.child_by_field_name("value"),
638            ) else {
639                continue;
640            };
641            if name.kind() != "identifier" {
642                continue;
643            }
644            let target = node_text(&name, src).trim();
645            let value_span = span_of(file, &value);
646            let declarator_span = span_of(file, &declarator);
647            if target.is_empty() {
648                continue;
649            }
650            for fact in index.assignment_values.iter_mut().filter(|fact| {
651                fact.target.as_deref() == Some(target)
652                    && fact.value_span == value_span
653                    && fact.assignment_span.start <= declarator_span.start
654                    && declarator_span.end <= fact.assignment_span.end
655            }) {
656                fact.target_is_immutable = true;
657            }
658        }
659    }
660}
661
662/// Lower complete ECMAScript string concatenations into typed compiler facts.
663/// Unsupported operands reject the whole composition so downstream security
664/// proofs can never mistake a partially understood expression for a safe one.
665fn ecmascript_string_compositions(tree: &Tree, file: FileId, src: &[u8]) -> Vec<StringCompositionFact> {
666    let mut facts = Vec::new();
667    for declarator in collect_kinds(tree, &["variable_declarator"]) {
668        let (Some(name), Some(value)) = (
669            declarator.child_by_field_name("name"),
670            declarator.child_by_field_name("value"),
671        ) else {
672            continue;
673        };
674        if name.kind() != "identifier" {
675            continue;
676        }
677        let mut parts = Vec::new();
678        if lower_ecmascript_string_composition(value, file, src, &mut parts) && parts.len() > 1 {
679            facts.push(StringCompositionFact {
680                container_span: span_of(file, &declarator),
681                value_span: span_of(file, &value),
682                target: Some(node_text(&name, src).trim().to_string()),
683                parts,
684            });
685        }
686    }
687    for return_node in collect_kinds(tree, &["return_statement"]) {
688        let Some(value) = return_node.named_child(0) else {
689            continue;
690        };
691        let mut parts = Vec::new();
692        if lower_ecmascript_string_composition(value, file, src, &mut parts) && parts.len() > 1 {
693            facts.push(StringCompositionFact {
694                container_span: span_of(file, &return_node),
695                value_span: span_of(file, &value),
696                target: None,
697                parts,
698            });
699        }
700    }
701    // Preserve complete nested concatenations as expression-owned facts too.
702    // Object/map fields and direct call arguments are not declarations or
703    // returns, but their exact value spans are retained by ExpressionField
704    // and CallArgumentValueFact so consumers can join these facts precisely.
705    for value in collect_kinds(tree, &["binary_expression", "template_string"]) {
706        let mut parts = Vec::new();
707        if lower_ecmascript_string_composition(value, file, src, &mut parts) && parts.len() > 1 {
708            let value_span = span_of(file, &value);
709            facts.push(StringCompositionFact {
710                container_span: value_span,
711                value_span,
712                target: None,
713                parts,
714            });
715        }
716    }
717    facts.sort_by_key(|fact| {
718        (
719            fact.container_span.start,
720            fact.container_span.end,
721            fact.value_span.start,
722            fact.value_span.end,
723        )
724    });
725    facts.dedup();
726    facts
727}
728
729fn lower_ecmascript_string_composition(
730    mut node: Node<'_>,
731    file: FileId,
732    src: &[u8],
733    out: &mut Vec<StringCompositionPart>,
734) -> bool {
735    node = unwrap_ecmascript_expression(node);
736    if let Some(value) = ecmascript_static_string_literal(node, src) {
737        out.push(StringCompositionPart::Literal { value });
738        return true;
739    }
740    if let Some(place) = ecmascript_exact_place(node, src) {
741        out.push(StringCompositionPart::Place { place });
742        return true;
743    }
744    if node.kind() == "call_expression" {
745        let Some(function) = node.child_by_field_name("function") else {
746            return false;
747        };
748        out.push(StringCompositionPart::Call {
749            span: span_of(file, &function),
750        });
751        return true;
752    }
753    if node.kind() == "template_string" {
754        let mut cursor = node.walk();
755        for child in node.named_children(&mut cursor) {
756            match child.kind() {
757                "string_fragment" => {
758                    let Some(value) = decode_ecmascript_string_contents(node_text(&child, src), '`') else {
759                        return false;
760                    };
761                    out.push(StringCompositionPart::Literal { value });
762                }
763                "template_substitution" => {
764                    let Some(expression) = child.named_child(0) else {
765                        return false;
766                    };
767                    if !lower_ecmascript_string_composition(expression, file, src, out) {
768                        return false;
769                    }
770                }
771                "comment" => {}
772                _ => return false,
773            }
774        }
775        return !out.is_empty();
776    }
777    if node.kind() != "binary_expression" {
778        return false;
779    }
780    let (Some(left), Some(right)) = (
781        node.child_by_field_name("left"),
782        node.child_by_field_name("right"),
783    ) else {
784        return false;
785    };
786    let operator = src
787        .get(left.end_byte()..right.start_byte())
788        .and_then(|bytes| std::str::from_utf8(bytes).ok())
789        .map(str::trim);
790    operator == Some("+")
791        && lower_ecmascript_string_composition(left, file, src, out)
792        && lower_ecmascript_string_composition(right, file, src, out)
793}
794
795fn ecmascript_exact_place(node: Node<'_>, src: &[u8]) -> Option<String> {
796    let node = unwrap_ecmascript_expression(node);
797    match node.kind() {
798        "identifier" | "this" => {
799            let place = node_text(&node, src).trim();
800            (!place.is_empty()).then(|| place.to_string())
801        }
802        "member_expression" => {
803            let object = ecmascript_exact_place(node.child_by_field_name("object")?, src)?;
804            let property = node.child_by_field_name("property")?;
805            if property.kind() == "private_property_identifier" {
806                return None;
807            }
808            let property = node_text(&property, src).trim();
809            (!property.is_empty()).then(|| format!("{object}.{property}"))
810        }
811        _ => None,
812    }
813}
814
815fn ecmascript_same_origin_path_constraints(
816    index: &DeclIndex,
817    tree: &Tree,
818    file: FileId,
819    src: &[u8],
820) -> Vec<SameOriginPathConstraintFact> {
821    let mut facts = Vec::new();
822    let mut guarded_expressions = collect_kinds(tree, &["return_statement"])
823        .into_iter()
824        .filter_map(|return_node| return_node.named_child(0))
825        .collect::<Vec<_>>();
826    guarded_expressions.extend(
827        collect_kinds(tree, &["arrow_function"])
828            .into_iter()
829            .filter_map(|arrow| arrow.child_by_field_name("body"))
830            .filter(|body| body.kind() != "statement_block"),
831    );
832    for expression in guarded_expressions {
833        let return_span = span_of(file, &expression);
834        let Some(decl) = index
835            .defs
836            .iter()
837            .filter(|decl| {
838                matches!(decl.kind, DeclKind::Function | DeclKind::Method)
839                    && decl.span.start <= return_span.start
840                    && return_span.end <= decl.span.end
841            })
842            .min_by_key(|decl| decl.span.len())
843        else {
844            continue;
845        };
846        let expression = unwrap_ecmascript_expression(expression);
847        if expression.kind() != "ternary_expression" {
848            continue;
849        }
850        let (Some(condition), Some(consequence), Some(alternative)) = (
851            expression.child_by_field_name("condition"),
852            expression.child_by_field_name("consequence"),
853            expression.child_by_field_name("alternative"),
854        ) else {
855            continue;
856        };
857        for (input_param_index, parameter) in decl.params.iter().enumerate() {
858            if !ecmascript_expression_is_exact_place(consequence, parameter, src)
859                || ecmascript_static_string_literal(alternative, src).as_deref() != Some("/")
860            {
861                continue;
862            }
863            let mut terms = Vec::new();
864            ecmascript_collect_logical_terms(condition, "&&", src, &mut terms);
865            let requires_absolute_path = terms
866                .iter()
867                .any(|term| ecmascript_starts_with_literal(*term, parameter, "/", false, src));
868            let rejects_scheme_relative_path = terms
869                .iter()
870                .any(|term| ecmascript_starts_with_literal(*term, parameter, "//", true, src));
871            if requires_absolute_path && rejects_scheme_relative_path {
872                // Exactly one leading slash excludes both a URI scheme and
873                // an authority component. The frontend derives those facts
874                // from the two parsed predicates rather than API-name policy.
875                facts.push(SameOriginPathConstraintFact {
876                    function_span: decl.span,
877                    guard_span: span_of(file, &expression),
878                    input_place: parameter.clone(),
879                    input_param_index: Some(input_param_index),
880                    provider_call: None,
881                    rejects_scheme: true,
882                    rejects_authority: true,
883                    requires_absolute_path,
884                    rejects_scheme_relative_path,
885                });
886            }
887        }
888    }
889    facts.sort_by_key(|fact| (fact.function_span.start, fact.guard_span.start));
890    facts.dedup();
891    facts
892}
893
894fn ecmascript_collect_logical_terms<'tree>(
895    expression: Node<'tree>,
896    operator: &str,
897    src: &[u8],
898    out: &mut Vec<Node<'tree>>,
899) {
900    let expression = unwrap_ecmascript_expression(expression);
901    let operands = (
902        expression.child_by_field_name("left"),
903        expression.child_by_field_name("right"),
904    );
905    if expression.kind() == "binary_expression"
906        && operands.0.zip(operands.1).is_some_and(|(left, right)| {
907            src.get(left.end_byte()..right.start_byte())
908                .and_then(|bytes| std::str::from_utf8(bytes).ok())
909                .is_some_and(|value| value.trim() == operator)
910        })
911    {
912        let (Some(left), Some(right)) = operands else {
913            return;
914        };
915        ecmascript_collect_logical_terms(left, operator, src, out);
916        ecmascript_collect_logical_terms(right, operator, src, out);
917    } else {
918        out.push(expression);
919    }
920}
921
922fn ecmascript_expression_is_exact_place(expression: Node<'_>, place: &str, src: &[u8]) -> bool {
923    let expression = unwrap_ecmascript_expression(expression);
924    expression.kind() == "identifier" && node_text(&expression, src).trim() == place
925}
926
927fn ecmascript_starts_with_literal(
928    expression: Node<'_>,
929    receiver: &str,
930    literal: &str,
931    negated: bool,
932    src: &[u8],
933) -> bool {
934    let expression = unwrap_ecmascript_expression(expression);
935    let call = if negated {
936        if expression.kind() != "unary_expression" {
937            return false;
938        }
939        let Some(argument) = expression.child_by_field_name("argument") else {
940            return false;
941        };
942        if src
943            .get(expression.start_byte()..argument.start_byte())
944            .and_then(|bytes| std::str::from_utf8(bytes).ok())
945            .is_none_or(|prefix| prefix.trim() != "!")
946        {
947            return false;
948        }
949        unwrap_ecmascript_expression(argument)
950    } else {
951        expression
952    };
953    if call.kind() != "call_expression" {
954        return false;
955    }
956    let Some(function) = call.child_by_field_name("function") else {
957        return false;
958    };
959    if function.kind() != "member_expression"
960        || function
961            .child_by_field_name("object")
962            .is_none_or(|object| !ecmascript_expression_is_exact_place(object, receiver, src))
963        || function
964            .child_by_field_name("property")
965            .is_none_or(|property| node_text(&property, src).trim() != "startsWith")
966    {
967        return false;
968    }
969    let arguments = ecmascript_call_arguments(call);
970    let [argument] = arguments.as_slice() else {
971        return false;
972    };
973    ecmascript_static_string_literal(*argument, src).as_deref() == Some(literal)
974}
975
976fn ecmascript_dynamic_key_filters(
977    index: &DeclIndex,
978    tree: &Tree,
979    file: FileId,
980    src: &[u8],
981) -> Vec<DynamicKeyFilterFact> {
982    let mut facts = Vec::new();
983    for function in collect_kinds(tree, &["function_declaration"]) {
984        let function_span = span_of(file, &function);
985        let Some(decl) = index.defs.iter().find(|decl| decl.span == function_span) else {
986            continue;
987        };
988        let Some(body) = function.child_by_field_name("body") else {
989            continue;
990        };
991        if let Some(fact) = ecmascript_property_path_filter(tree, function, body, decl, file, src) {
992            facts.push(fact);
993        }
994        for block in named_descendants_of_kind(body, "statement_block") {
995            let Some(fact) =
996                ecmascript_dynamic_key_filter_in_block(tree, function, body, block, decl, file, src)
997            else {
998                continue;
999            };
1000            facts.push(fact);
1001        }
1002    }
1003    facts.sort_by_key(|fact| (fact.function_span.start, fact.guard_span.start));
1004    facts.dedup_by_key(|fact| (fact.function_span, fact.guard_span));
1005    facts
1006}
1007
1008fn ecmascript_dynamic_key_filter_in_block(
1009    tree: &Tree,
1010    function: Node<'_>,
1011    function_body: Node<'_>,
1012    block: Node<'_>,
1013    decl: &bonsai_lang_api::Decl,
1014    file: FileId,
1015    src: &[u8],
1016) -> Option<DynamicKeyFilterFact> {
1017    let statements = named_children(block);
1018    let [output_decl, loop_node, return_node] = statements.as_slice() else {
1019        return None;
1020    };
1021    if output_decl.kind() != "lexical_declaration"
1022        || loop_node.kind() != "for_in_statement"
1023        || return_node.kind() != "return_statement"
1024    {
1025        return None;
1026    }
1027
1028    let (output_name, output_value) = single_variable_declaration(*output_decl, src)?;
1029    if output_value.kind() != "object" || !named_children(output_value).is_empty() {
1030        return None;
1031    }
1032    let returned = return_node.named_child(0)?;
1033    if returned.kind() != "identifier" || node_text(&returned, src).trim() != output_name {
1034        return None;
1035    }
1036
1037    let left = loop_node.child_by_field_name("left")?;
1038    let bindings = named_children(left);
1039    let [key_node, value_node] = bindings.as_slice() else {
1040        return None;
1041    };
1042    if key_node.kind() != "identifier" || value_node.kind() != "identifier" {
1043        return None;
1044    }
1045    let key = node_text(key_node, src).trim();
1046    let value = node_text(value_node, src).trim();
1047
1048    let iteration = loop_node.child_by_field_name("right")?;
1049    let iteration_call = unwrap_ecmascript_expression(iteration);
1050    let (iteration_receiver, iteration_method) = ecmascript_member_call(iteration_call, src)?;
1051    if iteration_receiver != "Object" || iteration_method != "entries" {
1052        return None;
1053    }
1054    let iteration_args = ecmascript_call_arguments(iteration_call);
1055    let [iteration_input] = iteration_args.as_slice() else {
1056        return None;
1057    };
1058    let input = unwrap_ecmascript_expression(*iteration_input);
1059    if input.kind() != "identifier" {
1060        return None;
1061    }
1062    let input_name = node_text(&input, src).trim();
1063    let input_param_index = decl.params.iter().position(|param| param == input_name)?;
1064
1065    let loop_body = loop_node.child_by_field_name("body")?;
1066    let loop_statements = named_children(loop_body);
1067    let [guard, write_statement] = loop_statements.as_slice() else {
1068        return None;
1069    };
1070    if guard.kind() != "if_statement" || write_statement.kind() != "expression_statement" {
1071        return None;
1072    }
1073    if guard.child_by_field_name("alternative").is_some()
1074        || guard.child_by_field_name("consequence")?.kind() != "continue_statement"
1075    {
1076        return None;
1077    }
1078    let condition = unwrap_ecmascript_expression(guard.child_by_field_name("condition")?);
1079    let (collection, membership_check) = ecmascript_member_call(condition, src)?;
1080    let membership_args = ecmascript_call_arguments(condition);
1081    let [membership_subject] = membership_args.as_slice() else {
1082        return None;
1083    };
1084    if membership_subject.kind() != "identifier" || node_text(membership_subject, src).trim() != key {
1085        return None;
1086    }
1087
1088    let write = write_statement.named_child(0)?;
1089    if write.kind() != "assignment_expression" {
1090        return None;
1091    }
1092    let target = write.child_by_field_name("left")?;
1093    if target.kind() != "subscript_expression"
1094        || target
1095            .child_by_field_name("object")
1096            .is_none_or(|node| node.kind() != "identifier" || node_text(&node, src).trim() != output_name)
1097        || target
1098            .child_by_field_name("index")
1099            .is_none_or(|node| node.kind() != "identifier" || node_text(&node, src).trim() != key)
1100    {
1101        return None;
1102    }
1103    let recursive_call = unwrap_ecmascript_expression(write.child_by_field_name("right")?);
1104    if recursive_call.kind() != "call_expression" {
1105        return None;
1106    }
1107    let recursive_function = recursive_call.child_by_field_name("function")?;
1108    let recursive_args = ecmascript_call_arguments(recursive_call);
1109    let [recursive_value] = recursive_args.as_slice() else {
1110        return None;
1111    };
1112    if recursive_function.kind() != "identifier"
1113        || node_text(&recursive_function, src).trim() != decl.name
1114        || recursive_value.kind() != "identifier"
1115        || node_text(recursive_value, src).trim() != value
1116    {
1117        return None;
1118    }
1119
1120    // The denylist must be one immutable top-level lexical binding shared by
1121    // this top-level helper. This gives the adapter a closed lexical proof
1122    // without teaching shared analysis JavaScript name-resolution rules.
1123    if function.parent().is_none_or(|parent| {
1124        parent.kind() != "program"
1125            && !(parent.kind() == "export_statement"
1126                && parent
1127                    .parent()
1128                    .is_some_and(|grandparent| grandparent.kind() == "program"))
1129    }) {
1130        return None;
1131    }
1132    let (collection_constructor, rejected_exact_values) =
1133        ecmascript_exact_top_level_collection(tree, function, function_body, collection, src)?;
1134
1135    Some(DynamicKeyFilterFact {
1136        function_span: decl.span,
1137        guard_span: span_of(file, guard),
1138        input_param_index,
1139        output_place: Some(output_name.to_string()),
1140        collection_constructor,
1141        membership_check: membership_check.to_string(),
1142        rejected_exact_values,
1143        recursive: true,
1144    })
1145}
1146
1147fn ecmascript_property_path_filter(
1148    tree: &Tree,
1149    function: Node<'_>,
1150    function_body: Node<'_>,
1151    decl: &bonsai_lang_api::Decl,
1152    file: FileId,
1153    src: &[u8],
1154) -> Option<DynamicKeyFilterFact> {
1155    if function.parent().is_none_or(|parent| {
1156        parent.kind() != "program"
1157            && !(parent.kind() == "export_statement"
1158                && parent
1159                    .parent()
1160                    .is_some_and(|grandparent| grandparent.kind() == "program"))
1161    }) {
1162        return None;
1163    }
1164    let bindings = ecmascript_bindings(tree, src);
1165    let statements = named_children(function_body);
1166    for (declaration_index, declaration) in statements.iter().enumerate() {
1167        if declaration.kind() != "lexical_declaration"
1168            || declaration
1169                .child(0)
1170                .is_none_or(|keyword| keyword.kind() != "const")
1171        {
1172            continue;
1173        }
1174        let Some((segments, initializer)) = single_variable_declaration(*declaration, src) else {
1175            continue;
1176        };
1177        let Some(input_param_index) =
1178            ecmascript_property_path_split_input(initializer, &decl.params, &bindings, src)
1179        else {
1180            continue;
1181        };
1182        if ecmascript_binding_is_mutated(function_body, segments, declaration.end_byte(), src) {
1183            continue;
1184        }
1185        for guard in statements.iter().skip(declaration_index + 1) {
1186            if guard.kind() != "if_statement" || guard.child_by_field_name("alternative").is_some() {
1187                continue;
1188            }
1189            let condition = unwrap_ecmascript_expression(guard.child_by_field_name("condition")?);
1190            let Some((collection, membership_check)) =
1191                ecmascript_every_segment_is_denylisted(condition, segments, src)
1192            else {
1193                continue;
1194            };
1195            let (collection_constructor, rejected_exact_values) =
1196                ecmascript_exact_top_level_collection(tree, function, function_body, collection, src)?;
1197            return Some(DynamicKeyFilterFact {
1198                function_span: decl.span,
1199                guard_span: span_of(file, guard),
1200                input_param_index,
1201                output_place: Some(segments.to_string()),
1202                collection_constructor,
1203                membership_check: membership_check.to_string(),
1204                rejected_exact_values,
1205                recursive: false,
1206            });
1207        }
1208    }
1209    None
1210}
1211
1212fn ecmascript_property_path_split_input(
1213    initializer: Node<'_>,
1214    params: &[String],
1215    bindings: &EcmascriptBindings<'_>,
1216    src: &[u8],
1217) -> Option<usize> {
1218    let mut split = unwrap_ecmascript_expression(initializer);
1219    if split.kind() == "call_expression" {
1220        let function = split.child_by_field_name("function")?;
1221        if function.kind() == "member_expression"
1222            && function
1223                .child_by_field_name("property")
1224                .is_some_and(|property| node_text(&property, src).trim() == "filter")
1225        {
1226            let callback = ecmascript_call_arguments(split).first().copied()?;
1227            if !ecmascript_nonempty_segment_filter(callback, src) {
1228                return None;
1229            }
1230            split = unwrap_ecmascript_expression(function.child_by_field_name("object")?);
1231        }
1232    }
1233    if split.kind() != "call_expression" {
1234        return None;
1235    }
1236    let function = split.child_by_field_name("function")?;
1237    if function.kind() != "member_expression"
1238        || function
1239            .child_by_field_name("property")
1240            .is_none_or(|property| node_text(&property, src).trim() != "split")
1241    {
1242        return None;
1243    }
1244    let delimiter = ecmascript_call_arguments(split).first().copied()?;
1245    let pattern = delimiter.child_by_field_name("pattern")?;
1246    let pattern = node_text(&pattern, src);
1247    let character_class = pattern.strip_suffix('+').unwrap_or(pattern);
1248    let characters = ecmascript_exact_regex_characters_from_pattern(character_class)?;
1249    if ![".", "[", "]"]
1250        .iter()
1251        .all(|required| characters.iter().any(|character| character == required))
1252    {
1253        return None;
1254    }
1255    let input = unwrap_ecmascript_expression(function.child_by_field_name("object")?);
1256    let input_name = if input.kind() == "identifier" {
1257        node_text(&input, src).trim()
1258    } else if input.kind() == "call_expression" {
1259        let callee = input.child_by_field_name("function")?;
1260        if callee.kind() != "identifier"
1261            || node_text(&callee, src).trim() != "String"
1262            || bindings.by_name.contains_key("String")
1263        {
1264            return None;
1265        }
1266        let arguments = ecmascript_call_arguments(input);
1267        let [argument] = arguments.as_slice() else {
1268            return None;
1269        };
1270        if argument.kind() != "identifier" {
1271            return None;
1272        }
1273        node_text(argument, src).trim()
1274    } else {
1275        return None;
1276    };
1277    params.iter().position(|param| param == input_name)
1278}
1279
1280fn ecmascript_exact_regex_characters_from_pattern(pattern: &str) -> Option<Vec<String>> {
1281    let inner = pattern.strip_prefix('[')?.strip_suffix(']')?;
1282    if inner.starts_with('^') || inner.is_empty() {
1283        return None;
1284    }
1285    let mut characters = Vec::new();
1286    let mut input = inner.chars();
1287    while let Some(character) = input.next() {
1288        if character == '-' {
1289            return None;
1290        }
1291        let character = if character == '\\' {
1292            match input.next()? {
1293                escaped if !escaped.is_ascii_alphanumeric() => escaped,
1294                _ => return None,
1295            }
1296        } else {
1297            character
1298        };
1299        characters.push(character.to_string());
1300    }
1301    characters.sort();
1302    characters.dedup();
1303    Some(characters)
1304}
1305
1306fn ecmascript_nonempty_segment_filter(callback: Node<'_>, src: &[u8]) -> bool {
1307    let Some((parameter, body)) = ecmascript_arrow_parts(callback, src) else {
1308        return false;
1309    };
1310    let body = unwrap_ecmascript_expression(body);
1311    let (Some(left), Some(right)) = (
1312        body.child_by_field_name("left"),
1313        body.child_by_field_name("right"),
1314    ) else {
1315        return false;
1316    };
1317    let operator = src
1318        .get(left.end_byte()..right.start_byte())
1319        .and_then(|bytes| std::str::from_utf8(bytes).ok())
1320        .map(str::trim);
1321    let Some(object) = left.child_by_field_name("object") else {
1322        return false;
1323    };
1324    let Some(property) = left.child_by_field_name("property") else {
1325        return false;
1326    };
1327    left.kind() == "member_expression"
1328        && node_text(&object, src).trim() == parameter
1329        && node_text(&property, src).trim() == "length"
1330        && operator == Some(">")
1331        && node_text(&right, src).trim() == "0"
1332}
1333
1334fn ecmascript_every_segment_is_denylisted<'a>(
1335    condition: Node<'a>,
1336    segments: &str,
1337    src: &'a [u8],
1338) -> Option<(&'a str, &'a str)> {
1339    let function = condition.child_by_field_name("function")?;
1340    if condition.kind() != "call_expression"
1341        || function.kind() != "member_expression"
1342        || function
1343            .child_by_field_name("object")
1344            .is_none_or(|object| node_text(&object, src).trim() != segments)
1345        || function
1346            .child_by_field_name("property")
1347            .is_none_or(|property| node_text(&property, src).trim() != "some")
1348    {
1349        return None;
1350    }
1351    let condition_arguments = ecmascript_call_arguments(condition);
1352    let callback = condition_arguments.first().copied()?;
1353    let (parameter, body) = ecmascript_arrow_parts(callback, src)?;
1354    let (collection, membership_check) = ecmascript_member_call(unwrap_ecmascript_expression(body), src)?;
1355    let membership_arguments = ecmascript_call_arguments(unwrap_ecmascript_expression(body));
1356    let [subject] = membership_arguments.as_slice() else {
1357        return None;
1358    };
1359    (subject.kind() == "identifier" && node_text(subject, src).trim() == parameter)
1360        .then_some((collection, membership_check))
1361}
1362
1363fn ecmascript_binding_is_mutated(body: Node<'_>, binding: &str, after: usize, src: &[u8]) -> bool {
1364    let mut stack = vec![body];
1365    while let Some(node) = stack.pop() {
1366        if node.start_byte() > after {
1367            if matches!(
1368                node.kind(),
1369                "assignment_expression" | "augmented_assignment_expression"
1370            ) && node.child_by_field_name("left").is_some_and(|left| {
1371                node_text(&left, src).trim() == binding
1372                    || left
1373                        .child_by_field_name("object")
1374                        .is_some_and(|object| node_text(&object, src).trim() == binding)
1375            }) {
1376                return true;
1377            }
1378            if node.kind() == "call_expression" {
1379                if let Some(function) = node.child_by_field_name("function") {
1380                    if function.kind() == "member_expression"
1381                        && function
1382                            .child_by_field_name("object")
1383                            .is_some_and(|object| node_text(&object, src).trim() == binding)
1384                        && function
1385                            .child_by_field_name("property")
1386                            .is_some_and(|property| node_text(&property, src).trim() != "some")
1387                    {
1388                        return true;
1389                    }
1390                }
1391            }
1392        }
1393        let mut cursor = node.walk();
1394        stack.extend(node.named_children(&mut cursor));
1395    }
1396    false
1397}
1398
1399fn ecmascript_exact_top_level_collection(
1400    tree: &Tree,
1401    function: Node<'_>,
1402    function_body: Node<'_>,
1403    collection: &str,
1404    src: &[u8],
1405) -> Option<(String, Vec<String>)> {
1406    if named_descendants_of_kind(function_body, "variable_declarator")
1407        .iter()
1408        .any(|declarator| {
1409            declarator
1410                .child_by_field_name("name")
1411                .is_some_and(|name| name.kind() == "identifier" && node_text(&name, src).trim() == collection)
1412        })
1413    {
1414        return None;
1415    }
1416    let mut candidates = Vec::new();
1417    for declarator in collect_kinds(tree, &["variable_declarator"]) {
1418        if declarator.start_byte() >= function.start_byte()
1419            || declarator
1420                .parent()
1421                .and_then(|parent| parent.parent())
1422                .is_none_or(|parent| parent.kind() != "program")
1423        {
1424            continue;
1425        }
1426        let Some(name) = declarator.child_by_field_name("name") else {
1427            continue;
1428        };
1429        if name.kind() != "identifier" || node_text(&name, src).trim() != collection {
1430            continue;
1431        }
1432        let declaration = declarator.parent()?;
1433        if declaration.child(0).is_none_or(|token| token.kind() != "const") {
1434            continue;
1435        }
1436        let value = declarator.child_by_field_name("value")?;
1437        if value.kind() != "new_expression" {
1438            continue;
1439        }
1440        let constructor = value.child_by_field_name("constructor")?;
1441        if constructor.kind() != "identifier" {
1442            continue;
1443        }
1444        let arguments = ecmascript_call_arguments(value);
1445        let [array] = arguments.as_slice() else {
1446            continue;
1447        };
1448        if array.kind() != "array" {
1449            continue;
1450        }
1451        let items = named_children(*array);
1452        if items.is_empty() || items.iter().any(|item| item.kind() == "spread_element") {
1453            continue;
1454        }
1455        let values: Option<Vec<_>> = items
1456            .iter()
1457            .map(|item| ecmascript_static_string_literal(*item, src))
1458            .collect();
1459        let Some(values) = values else {
1460            continue;
1461        };
1462        candidates.push((node_text(&constructor, src).trim().to_string(), values));
1463    }
1464    let [candidate] = candidates.as_slice() else {
1465        return None;
1466    };
1467    if collect_kinds(
1468        tree,
1469        &["assignment_expression", "augmented_assignment_expression"],
1470    )
1471    .iter()
1472    .any(|assignment| {
1473        assignment
1474            .child_by_field_name("left")
1475            .is_some_and(|left| left.kind() == "identifier" && node_text(&left, src).trim() == collection)
1476    }) {
1477        return None;
1478    }
1479    Some(candidate.clone())
1480}
1481
1482fn named_descendants_of_kind<'tree>(root: Node<'tree>, kind: &str) -> Vec<Node<'tree>> {
1483    let mut found = Vec::new();
1484    let mut stack = vec![root];
1485    while let Some(node) = stack.pop() {
1486        let mut cursor = node.walk();
1487        for child in node.named_children(&mut cursor) {
1488            if child.kind() == kind {
1489                found.push(child);
1490            }
1491            stack.push(child);
1492        }
1493    }
1494    found
1495}
1496
1497fn named_children(node: Node<'_>) -> Vec<Node<'_>> {
1498    let mut cursor = node.walk();
1499    node.named_children(&mut cursor).collect()
1500}
1501
1502fn single_variable_declaration<'tree, 'src>(
1503    declaration: Node<'tree>,
1504    src: &'src [u8],
1505) -> Option<(&'src str, Node<'tree>)> {
1506    let declarators: Vec<_> = named_children(declaration)
1507        .into_iter()
1508        .filter(|node| node.kind() == "variable_declarator")
1509        .collect();
1510    let [declarator] = declarators.as_slice() else {
1511        return None;
1512    };
1513    let name = declarator.child_by_field_name("name")?;
1514    let value = declarator.child_by_field_name("value")?;
1515    (name.kind() == "identifier").then_some((node_text(&name, src).trim(), value))
1516}
1517
1518fn ecmascript_member_call<'a>(call: Node<'_>, src: &'a [u8]) -> Option<(&'a str, &'a str)> {
1519    if call.kind() != "call_expression" {
1520        return None;
1521    }
1522    let function = call.child_by_field_name("function")?;
1523    if function.kind() != "member_expression" {
1524        return None;
1525    }
1526    let object = function.child_by_field_name("object")?;
1527    let property = function.child_by_field_name("property")?;
1528    if object.kind() != "identifier" || property.kind() != "property_identifier" {
1529        return None;
1530    }
1531    Some((node_text(&object, src).trim(), node_text(&property, src).trim()))
1532}
1533
1534fn ecmascript_static_string_maps(
1535    index: &DeclIndex,
1536    tree: &Tree,
1537    file: FileId,
1538    src: &[u8],
1539) -> Vec<StaticStringMapFact> {
1540    let mut maps = Vec::new();
1541    for declarator in collect_kinds(tree, &["variable_declarator"]) {
1542        let Some(target_node) = declarator.child_by_field_name("name") else {
1543            continue;
1544        };
1545        let Some(value_node) = declarator.child_by_field_name("value") else {
1546            continue;
1547        };
1548        if target_node.kind() != "identifier" || value_node.kind() != "object" {
1549            continue;
1550        }
1551        let target = node_text(&target_node, src).trim();
1552        if target.is_empty() {
1553            continue;
1554        }
1555        let Some(entries) = ecmascript_exact_string_map_entries(value_node, src) else {
1556            continue;
1557        };
1558        let value_span = span_of(file, &value_node);
1559        let Some(assignment_span) = index.assignment_values.iter().find_map(|fact| {
1560            (fact.value_span == value_span && fact.target.as_deref() == Some(target))
1561                .then_some(fact.assignment_span)
1562        }) else {
1563            continue;
1564        };
1565        maps.push(StaticStringMapFact {
1566            assignment_span,
1567            target: target.to_string(),
1568            entries,
1569        });
1570    }
1571    maps.sort_by_key(|fact| (fact.assignment_span.start, fact.assignment_span.end));
1572    maps
1573}
1574
1575fn ecmascript_finite_literal_selections(
1576    index: &DeclIndex,
1577    tree: &Tree,
1578    file: FileId,
1579    src: &[u8],
1580) -> Vec<FiniteLiteralSelectionFact> {
1581    let bindings = ecmascript_bindings(tree, src);
1582    if !bindings
1583        .bindings
1584        .iter()
1585        .any(|binding| binding.finite_map.is_some())
1586    {
1587        return Vec::new();
1588    }
1589    let mut selections = Vec::new();
1590    for node in collect_kinds(tree, &["call_expression", "subscript_expression"]) {
1591        let (map_target, object) = if node.kind() == "call_expression" {
1592            let Some(function) = node.child_by_field_name("function") else {
1593                continue;
1594            };
1595            if function.kind() != "member_expression" {
1596                continue;
1597            }
1598            let Some(object) = function.child_by_field_name("object") else {
1599                continue;
1600            };
1601            let Some(property) = function.child_by_field_name("property") else {
1602                continue;
1603            };
1604            if object.kind() != "identifier" || node_text(&property, src).trim() != "get" {
1605                continue;
1606            }
1607            (node_text(&object, src).trim(), object)
1608        } else {
1609            let Some(object) = node.child_by_field_name("object") else {
1610                continue;
1611            };
1612            if object.kind() != "identifier" {
1613                continue;
1614            }
1615            (node_text(&object, src).trim(), object)
1616        };
1617        let Some(binding) = bindings.resolve(map_target, object.start_byte(), object.end_byte()) else {
1618            continue;
1619        };
1620        if binding.finite_map.is_none()
1621            || binding.initializer.end_byte() > node.start_byte()
1622            || bindings.unsafe_bindings.contains(&binding.declaration.id())
1623        {
1624            continue;
1625        }
1626        let selection_span = span_of(file, &node);
1627        let Some(fact) = bonsai_lang_api::kit::finite_literal_selection_fact_for_span(
1628            index,
1629            tree,
1630            selection_span,
1631            |value_node| ecmascript_expression_is_finite_selection(value_node, node, src, &bindings),
1632        ) else {
1633            continue;
1634        };
1635        selections.push(fact);
1636    }
1637    bonsai_lang_api::kit::sort_dedup_finite_literal_selections(&mut selections);
1638    selections
1639}
1640
1641#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1642enum EcmascriptFiniteMapKind {
1643    Object,
1644    Map,
1645}
1646
1647#[derive(Copy, Clone, Debug)]
1648struct EcmascriptBinding<'tree> {
1649    name: &'tree str,
1650    declaration: Node<'tree>,
1651    initializer: Node<'tree>,
1652    scope: Node<'tree>,
1653    finite_map: Option<EcmascriptFiniteMapKind>,
1654}
1655
1656struct EcmascriptBindings<'tree> {
1657    bindings: Vec<EcmascriptBinding<'tree>>,
1658    by_name: HashMap<String, Vec<usize>>,
1659    unsafe_bindings: HashSet<usize>,
1660    object_intrinsic_unshadowed: bool,
1661}
1662
1663impl<'tree> EcmascriptBindings<'tree> {
1664    fn resolve(&self, name: &str, use_start: usize, use_end: usize) -> Option<&EcmascriptBinding<'tree>> {
1665        let candidates = self.by_name.get(name)?;
1666        let smallest_scope = candidates
1667            .iter()
1668            .map(|index| &self.bindings[*index])
1669            .filter(|binding| binding.scope.start_byte() <= use_start && use_end <= binding.scope.end_byte())
1670            .map(|binding| binding.scope.end_byte() - binding.scope.start_byte())
1671            .min()?;
1672        let mut candidates = candidates
1673            .iter()
1674            .map(|index| &self.bindings[*index])
1675            .filter(|binding| {
1676                binding.scope.start_byte() <= use_start
1677                    && use_end <= binding.scope.end_byte()
1678                    && binding.scope.end_byte() - binding.scope.start_byte() == smallest_scope
1679            });
1680        let binding = candidates.next()?;
1681        candidates.next().is_none().then_some(binding)
1682    }
1683}
1684
1685fn ecmascript_bindings<'tree>(tree: &'tree Tree, src: &'tree [u8]) -> EcmascriptBindings<'tree> {
1686    let mut bindings = Vec::new();
1687    for declarator in collect_kinds(tree, &["variable_declarator"]) {
1688        let Some(target) = declarator.child_by_field_name("name") else {
1689            continue;
1690        };
1691        let Some(scope) = ecmascript_binding_scope(declarator) else {
1692            continue;
1693        };
1694        let is_const = declarator
1695            .parent()
1696            .filter(|parent| parent.kind() == "lexical_declaration")
1697            .and_then(|declaration| declaration.child(0))
1698            .is_some_and(|keyword| keyword.kind() == "const");
1699        let is_exported = declarator
1700            .parent()
1701            .and_then(|declaration| declaration.parent())
1702            .is_some_and(|parent| parent.kind() == "export_statement");
1703        let value = declarator.child_by_field_name("value");
1704        for bound in ecmascript_pattern_identifiers(target) {
1705            let name = node_text(&bound, src).trim();
1706            if name.is_empty() {
1707                continue;
1708            }
1709            let is_simple_target = target.kind() == "identifier" && target.id() == bound.id();
1710            bindings.push(EcmascriptBinding {
1711                name,
1712                declaration: if is_simple_target { declarator } else { bound },
1713                initializer: value.unwrap_or(target),
1714                scope,
1715                finite_map: (is_simple_target && is_const && !is_exported)
1716                    .then(|| value.and_then(|value| ecmascript_finite_literal_map_kind(value, src)))
1717                    .flatten(),
1718            });
1719        }
1720    }
1721
1722    for parameters in collect_kinds(tree, &["formal_parameters"]) {
1723        let Some(owner) = parameters.parent() else {
1724            continue;
1725        };
1726        let Some(scope) = owner.child_by_field_name("body") else {
1727            continue;
1728        };
1729        let mut cursor = parameters.walk();
1730        for parameter in parameters.named_children(&mut cursor) {
1731            let pattern = match parameter.kind() {
1732                "required_parameter" | "optional_parameter" => parameter
1733                    .child_by_field_name("pattern")
1734                    .or_else(|| parameter.child_by_field_name("name"))
1735                    .unwrap_or(parameter),
1736                _ => parameter,
1737            };
1738            for bound in ecmascript_pattern_identifiers(pattern) {
1739                push_ecmascript_blocking_binding(&mut bindings, bound, scope, src);
1740            }
1741        }
1742    }
1743
1744    for arrow in collect_kinds(tree, &["arrow_function"]) {
1745        let Some(parameter) = arrow.child_by_field_name("parameter") else {
1746            continue;
1747        };
1748        let Some(scope) = arrow.child_by_field_name("body") else {
1749            continue;
1750        };
1751        for bound in ecmascript_pattern_identifiers(parameter) {
1752            push_ecmascript_blocking_binding(&mut bindings, bound, scope, src);
1753        }
1754    }
1755
1756    for catch_clause in collect_kinds(tree, &["catch_clause"]) {
1757        let Some(parameter) = catch_clause.child_by_field_name("parameter") else {
1758            continue;
1759        };
1760        let Some(scope) = catch_clause.child_by_field_name("body") else {
1761            continue;
1762        };
1763        for bound in ecmascript_pattern_identifiers(parameter) {
1764            push_ecmascript_blocking_binding(&mut bindings, bound, scope, src);
1765        }
1766    }
1767
1768    for declaration in collect_kinds(
1769        tree,
1770        &[
1771            "function_declaration",
1772            "generator_function_declaration",
1773            "class_declaration",
1774        ],
1775    ) {
1776        let Some(name) = declaration.child_by_field_name("name") else {
1777            continue;
1778        };
1779        let Some(scope) = ecmascript_binding_scope(declaration) else {
1780            continue;
1781        };
1782        push_ecmascript_blocking_binding(&mut bindings, name, scope, src);
1783    }
1784
1785    for expression in collect_kinds(tree, &["function_expression", "generator_function", "class"]) {
1786        let Some(name) = expression.child_by_field_name("name") else {
1787            continue;
1788        };
1789        let Some(scope) = expression.child_by_field_name("body") else {
1790            continue;
1791        };
1792        push_ecmascript_blocking_binding(&mut bindings, name, scope, src);
1793    }
1794
1795    let mut by_name: HashMap<String, Vec<usize>> = HashMap::new();
1796    for (index, binding) in bindings.iter().enumerate() {
1797        by_name.entry(binding.name.to_string()).or_default().push(index);
1798    }
1799    let map_intrinsic_unshadowed =
1800        !by_name.contains_key("Map") && !ecmascript_declares_static_name(tree, src, "Map");
1801    let object_intrinsic_unshadowed =
1802        !by_name.contains_key("Object") && !ecmascript_declares_static_name(tree, src, "Object");
1803    if !map_intrinsic_unshadowed {
1804        for binding in &mut bindings {
1805            if binding.finite_map == Some(EcmascriptFiniteMapKind::Map) {
1806                binding.finite_map = None;
1807            }
1808        }
1809    }
1810    let mut compiler_bindings = EcmascriptBindings {
1811        bindings,
1812        by_name,
1813        unsafe_bindings: HashSet::new(),
1814        object_intrinsic_unshadowed,
1815    };
1816    let declaration_identifiers: HashSet<_> = compiler_bindings
1817        .bindings
1818        .iter()
1819        .filter_map(|binding| {
1820            if binding.declaration.kind() == "variable_declarator" {
1821                binding
1822                    .declaration
1823                    .child_by_field_name("name")
1824                    .map(|name| name.id())
1825            } else {
1826                Some(binding.declaration.id())
1827            }
1828        })
1829        .collect();
1830    compiler_bindings.unsafe_bindings = collect_kinds(tree, &["identifier"])
1831        .into_iter()
1832        .filter_map(|identifier| {
1833            if declaration_identifiers.contains(&identifier.id()) {
1834                return None;
1835            }
1836            let name = node_text(&identifier, src).trim();
1837            let binding = compiler_bindings.resolve(name, identifier.start_byte(), identifier.end_byte())?;
1838            let kind = binding.finite_map?;
1839            (!ecmascript_identifier_is_read_only_map_use(
1840                identifier,
1841                kind,
1842                src,
1843                compiler_bindings.object_intrinsic_unshadowed,
1844            ))
1845            .then_some(binding.declaration.id())
1846        })
1847        .collect();
1848    compiler_bindings
1849}
1850
1851fn push_ecmascript_blocking_binding<'tree>(
1852    bindings: &mut Vec<EcmascriptBinding<'tree>>,
1853    bound: Node<'tree>,
1854    scope: Node<'tree>,
1855    src: &'tree [u8],
1856) {
1857    let name = node_text(&bound, src).trim();
1858    if name.is_empty() {
1859        return;
1860    }
1861    bindings.push(EcmascriptBinding {
1862        name,
1863        declaration: bound,
1864        initializer: bound,
1865        scope,
1866        finite_map: None,
1867    });
1868}
1869
1870fn ecmascript_pattern_identifiers(pattern: Node<'_>) -> Vec<Node<'_>> {
1871    match pattern.kind() {
1872        "identifier" | "shorthand_property_identifier_pattern" => vec![pattern],
1873        "required_parameter" | "optional_parameter" => pattern
1874            .child_by_field_name("pattern")
1875            .or_else(|| pattern.child_by_field_name("name"))
1876            .map(ecmascript_pattern_identifiers)
1877            .unwrap_or_default(),
1878        "assignment_pattern" => pattern
1879            .child_by_field_name("left")
1880            .map(ecmascript_pattern_identifiers)
1881            .unwrap_or_default(),
1882        "pair_pattern" => pattern
1883            .child_by_field_name("value")
1884            .map(ecmascript_pattern_identifiers)
1885            .unwrap_or_default(),
1886        "rest_pattern" => pattern
1887            .named_child(0)
1888            .map(ecmascript_pattern_identifiers)
1889            .unwrap_or_default(),
1890        "object_assignment_pattern" => pattern
1891            .child_by_field_name("left")
1892            .or_else(|| pattern.child_by_field_name("shorthand"))
1893            .map(ecmascript_pattern_identifiers)
1894            .unwrap_or_default(),
1895        "array_pattern" | "object_pattern" => {
1896            let mut cursor = pattern.walk();
1897            pattern
1898                .named_children(&mut cursor)
1899                .flat_map(ecmascript_pattern_identifiers)
1900                .collect()
1901        }
1902        _ => Vec::new(),
1903    }
1904}
1905
1906fn ecmascript_declares_static_name(tree: &Tree, src: &[u8], wanted: &str) -> bool {
1907    collect_kinds(
1908        tree,
1909        &[
1910            "function_declaration",
1911            "class_declaration",
1912            "generator_function_declaration",
1913        ],
1914    )
1915    .into_iter()
1916    .any(|declaration| {
1917        declaration
1918            .child_by_field_name("name")
1919            .is_some_and(|name| node_text(&name, src).trim() == wanted)
1920    }) || collect_kinds(tree, &["import_statement"])
1921        .into_iter()
1922        .any(|import| {
1923            let mut stack = vec![import];
1924            while let Some(node) = stack.pop() {
1925                if matches!(node.kind(), "identifier" | "type_identifier")
1926                    && node_text(&node, src).trim() == wanted
1927                {
1928                    return true;
1929                }
1930                let mut cursor = node.walk();
1931                stack.extend(node.named_children(&mut cursor));
1932            }
1933            false
1934        })
1935}
1936
1937fn ecmascript_binding_scope(mut node: Node<'_>) -> Option<Node<'_>> {
1938    while let Some(parent) = node.parent() {
1939        if matches!(
1940            parent.kind(),
1941            "for_statement"
1942                | "for_in_statement"
1943                | "for_of_statement"
1944                | "statement_block"
1945                | "switch_body"
1946                | "program"
1947        ) {
1948            return Some(parent);
1949        }
1950        node = parent;
1951    }
1952    None
1953}
1954
1955fn ecmascript_finite_literal_map_kind(mut node: Node<'_>, src: &[u8]) -> Option<EcmascriptFiniteMapKind> {
1956    while matches!(
1957        node.kind(),
1958        "parenthesized_expression" | "as_expression" | "satisfies_expression" | "type_assertion"
1959    ) && node.named_child_count() >= 1
1960    {
1961        let inner = node
1962            .child_by_field_name("expression")
1963            .or_else(|| node.named_child(0))?;
1964        node = inner;
1965    }
1966    if node.kind() == "object" {
1967        let mut cursor = node.walk();
1968        return node
1969            .named_children(&mut cursor)
1970            .all(|child| {
1971                if child.kind() != "pair" {
1972                    return false;
1973                }
1974                child
1975                    .child_by_field_name("key")
1976                    .and_then(|key| ecmascript_static_property_name(key, src))
1977                    .is_some()
1978                    && child
1979                        .child_by_field_name("value")
1980                        .is_some_and(|value| ecmascript_is_literal_value(value, src))
1981            })
1982            .then_some(EcmascriptFiniteMapKind::Object);
1983    }
1984    if node.kind() != "new_expression" {
1985        return None;
1986    }
1987    let constructor = node.child_by_field_name("constructor")?;
1988    if constructor.kind() != "identifier" || node_text(&constructor, src).trim() != "Map" {
1989        return None;
1990    }
1991    let arguments = node.child_by_field_name("arguments")?;
1992    let mut cursor = arguments.walk();
1993    let values: Vec<_> = arguments.named_children(&mut cursor).collect();
1994    (values.len() == 1 && ecmascript_is_literal_map_entries(values[0], src))
1995        .then_some(EcmascriptFiniteMapKind::Map)
1996}
1997
1998fn ecmascript_is_literal_map_entries(node: Node<'_>, src: &[u8]) -> bool {
1999    if node.kind() != "array" {
2000        return false;
2001    }
2002    let mut cursor = node.walk();
2003    let is_literal = node.named_children(&mut cursor).all(|entry| {
2004        if entry.kind() != "array" {
2005            return false;
2006        }
2007        let mut entry_cursor = entry.walk();
2008        let values: Vec<_> = entry.named_children(&mut entry_cursor).collect();
2009        values.len() == 2
2010            && ecmascript_is_literal_value(values[0], src)
2011            && ecmascript_is_literal_value(values[1], src)
2012    });
2013    is_literal
2014}
2015
2016fn ecmascript_identifier_is_read_only_map_use(
2017    identifier: Node<'_>,
2018    kind: EcmascriptFiniteMapKind,
2019    src: &[u8],
2020    object_intrinsic_unshadowed: bool,
2021) -> bool {
2022    let Some(access) = identifier.parent() else {
2023        return object_intrinsic_unshadowed
2024            && ecmascript_identifier_is_safe_has_own_argument(identifier, src);
2025    };
2026    if access.child_by_field_name("object").map(|object| object.id()) != Some(identifier.id()) {
2027        return object_intrinsic_unshadowed
2028            && ecmascript_identifier_is_safe_has_own_argument(identifier, src);
2029    }
2030    if ecmascript_access_is_write_target(access) {
2031        return false;
2032    }
2033    if access.kind() == "subscript_expression" {
2034        return true;
2035    }
2036    if access.kind() != "member_expression" {
2037        return false;
2038    }
2039    let Some(parent) = access.parent() else {
2040        return true;
2041    };
2042    if parent.kind() != "call_expression"
2043        || parent
2044            .child_by_field_name("function")
2045            .map(|function| function.id())
2046            != Some(access.id())
2047    {
2048        return true;
2049    }
2050    let Some(property) = access.child_by_field_name("property") else {
2051        return false;
2052    };
2053    let property = node_text(&property, src).trim();
2054    match kind {
2055        EcmascriptFiniteMapKind::Map => matches!(
2056            property,
2057            "get" | "has" | "entries" | "keys" | "values" | "forEach"
2058        ),
2059        EcmascriptFiniteMapKind::Object => false,
2060    }
2061}
2062
2063fn ecmascript_identifier_is_safe_has_own_argument(identifier: Node<'_>, src: &[u8]) -> bool {
2064    let Some(arguments) = identifier.parent().filter(|parent| parent.kind() == "arguments") else {
2065        return false;
2066    };
2067    let Some(first_argument) = arguments.named_child(0) else {
2068        return false;
2069    };
2070    if first_argument.id() != identifier.id() {
2071        return false;
2072    }
2073    let Some(call) = arguments
2074        .parent()
2075        .filter(|parent| parent.kind() == "call_expression")
2076    else {
2077        return false;
2078    };
2079    let Some(function) = call.child_by_field_name("function") else {
2080        return false;
2081    };
2082    matches!(
2083        ecmascript_static_member_path(function, src).as_slice(),
2084        ["Object", "hasOwn"] | ["Object", "prototype", "hasOwnProperty", "call"]
2085    )
2086}
2087
2088fn ecmascript_static_member_path<'a>(node: Node<'_>, src: &'a [u8]) -> Vec<&'a str> {
2089    if matches!(node.kind(), "identifier" | "property_identifier") {
2090        let name = node_text(&node, src).trim();
2091        return (!name.is_empty()).then_some(vec![name]).unwrap_or_default();
2092    }
2093    if node.kind() != "member_expression" {
2094        return Vec::new();
2095    }
2096    let Some(object) = node.child_by_field_name("object") else {
2097        return Vec::new();
2098    };
2099    let Some(property) = node.child_by_field_name("property") else {
2100        return Vec::new();
2101    };
2102    let mut path = ecmascript_static_member_path(object, src);
2103    let property = node_text(&property, src).trim();
2104    if path.is_empty() || property.is_empty() {
2105        return Vec::new();
2106    }
2107    path.push(property);
2108    path
2109}
2110
2111fn ecmascript_access_is_write_target(access: Node<'_>) -> bool {
2112    let Some(parent) = access.parent() else {
2113        return false;
2114    };
2115    match parent.kind() {
2116        "assignment_expression" | "augmented_assignment_expression" => parent
2117            .child_by_field_name("left")
2118            .is_some_and(|left| left.id() == access.id()),
2119        "update_expression" => true,
2120        "unary_expression" => parent
2121            .child(0)
2122            .is_some_and(|operator| operator.kind() == "delete"),
2123        _ => false,
2124    }
2125}
2126
2127fn ecmascript_expression_is_finite_selection(
2128    node: Node<'_>,
2129    selection: Node<'_>,
2130    src: &[u8],
2131    bindings: &EcmascriptBindings<'_>,
2132) -> bool {
2133    if node.id() == selection.id() {
2134        return true;
2135    }
2136    if selection.start_byte() < node.start_byte() || selection.end_byte() > node.end_byte() {
2137        return false;
2138    }
2139    match node.kind() {
2140        "parenthesized_expression" | "as_expression" | "satisfies_expression" | "type_assertion" => node
2141            .named_child(0)
2142            .is_some_and(|inner| ecmascript_expression_is_finite_selection(inner, selection, src, bindings)),
2143        "binary_expression" => {
2144            let Some(left) = node.child_by_field_name("left") else {
2145                return false;
2146            };
2147            let Some(right) = node.child_by_field_name("right") else {
2148                return false;
2149            };
2150            (selection.start_byte() >= left.start_byte()
2151                && selection.end_byte() <= left.end_byte()
2152                && ecmascript_expression_is_finite_selection(left, selection, src, bindings)
2153                && ecmascript_is_literal_value_at(right, src, bindings))
2154                || (selection.start_byte() >= right.start_byte()
2155                    && selection.end_byte() <= right.end_byte()
2156                    && ecmascript_expression_is_finite_selection(right, selection, src, bindings)
2157                    && ecmascript_is_literal_value_at(left, src, bindings))
2158        }
2159        "ternary_expression" => {
2160            let Some(consequence) = node.child_by_field_name("consequence") else {
2161                return false;
2162            };
2163            let Some(alternative) = node.child_by_field_name("alternative") else {
2164                return false;
2165            };
2166            (selection.start_byte() >= consequence.start_byte()
2167                && selection.end_byte() <= consequence.end_byte()
2168                && ecmascript_expression_is_finite_selection(consequence, selection, src, bindings)
2169                && ecmascript_is_literal_value_at(alternative, src, bindings))
2170                || (selection.start_byte() >= alternative.start_byte()
2171                    && selection.end_byte() <= alternative.end_byte()
2172                    && ecmascript_expression_is_finite_selection(alternative, selection, src, bindings)
2173                    && ecmascript_is_literal_value_at(consequence, src, bindings))
2174        }
2175        _ => false,
2176    }
2177}
2178
2179fn ecmascript_is_literal_value_at(node: Node<'_>, src: &[u8], bindings: &EcmascriptBindings<'_>) -> bool {
2180    if matches!(node.kind(), "identifier" | "undefined") && node_text(&node, src).trim() == "undefined" {
2181        return bindings
2182            .resolve("undefined", node.start_byte(), node.end_byte())
2183            .is_none();
2184    }
2185    ecmascript_is_literal_value(node, src)
2186}
2187
2188fn ecmascript_is_literal_value(mut node: Node<'_>, src: &[u8]) -> bool {
2189    while matches!(
2190        node.kind(),
2191        "parenthesized_expression" | "as_expression" | "satisfies_expression" | "type_assertion"
2192    ) && node.named_child_count() >= 1
2193    {
2194        let Some(inner) = node
2195            .child_by_field_name("expression")
2196            .or_else(|| node.named_child(0))
2197        else {
2198            return false;
2199        };
2200        node = inner;
2201    }
2202    match node.kind() {
2203        "string" | "string_literal" => ecmascript_static_string_literal(node, src).is_some(),
2204        "number" | "true" | "false" | "null" => true,
2205        "array" => {
2206            let mut cursor = node.walk();
2207            let is_literal = node
2208                .named_children(&mut cursor)
2209                .all(|child| ecmascript_is_literal_value(child, src));
2210            is_literal
2211        }
2212        "object" => {
2213            let mut cursor = node.walk();
2214            let is_literal = node.named_children(&mut cursor).all(|child| {
2215                child.kind() == "pair"
2216                    && child
2217                        .child_by_field_name("key")
2218                        .and_then(|key| ecmascript_static_property_name(key, src))
2219                        .is_some()
2220                    && child
2221                        .child_by_field_name("value")
2222                        .is_some_and(|value| ecmascript_is_literal_value(value, src))
2223            });
2224            is_literal
2225        }
2226        _ => false,
2227    }
2228}
2229
2230fn ecmascript_exact_string_map_entries(object: Node<'_>, src: &[u8]) -> Option<Vec<StaticStringMapEntry>> {
2231    let mut entries = Vec::new();
2232    let mut cursor = object.walk();
2233    for child in object.named_children(&mut cursor) {
2234        if child.kind() != "pair" {
2235            return None;
2236        }
2237        let key = child.child_by_field_name("key")?;
2238        let value = child.child_by_field_name("value")?;
2239        entries.push(StaticStringMapEntry {
2240            key: ecmascript_static_property_name(key, src)?,
2241            value: ecmascript_static_string_literal(value, src)?,
2242        });
2243    }
2244    (!entries.is_empty()).then_some(entries)
2245}
2246
2247fn ecmascript_static_property_name(node: Node<'_>, src: &[u8]) -> Option<String> {
2248    ecmascript_static_string_literal(node, src).or_else(|| {
2249        matches!(node.kind(), "property_identifier" | "identifier")
2250            .then(|| node_text(&node, src).trim().to_string())
2251            .filter(|value| !value.is_empty())
2252    })
2253}
2254
2255fn ecmascript_character_substitutions(
2256    defs: &[bonsai_lang_api::Decl],
2257    tree: &Tree,
2258    file: FileId,
2259    src: &[u8],
2260) -> Vec<CharacterSubstitutionFact> {
2261    let bindings = ecmascript_bindings(tree, src);
2262    let mut facts = Vec::new();
2263    for return_node in collect_kinds(tree, &["return_statement"]) {
2264        let return_span = span_of(file, &return_node);
2265        let Some(decl) = defs
2266            .iter()
2267            .filter(|decl| {
2268                matches!(
2269                    decl.kind,
2270                    DeclKind::Function | DeclKind::Method | DeclKind::Constructor
2271                ) && decl.span.start <= return_span.start
2272                    && return_span.end <= decl.span.end
2273            })
2274            .min_by_key(|decl| decl.span.len())
2275        else {
2276            continue;
2277        };
2278        let Some(expression) = return_node.named_child(0) else {
2279            continue;
2280        };
2281        let Some((input_param_index, table, exact_mappings, domain, transform_span)) =
2282            ecmascript_character_substitution(expression, &decl.params, &bindings, file, src)
2283        else {
2284            continue;
2285        };
2286        facts.push(CharacterSubstitutionFact {
2287            function_span: decl.span,
2288            transform_span,
2289            input_param_index,
2290            exact_mappings,
2291            table,
2292            domain,
2293        });
2294    }
2295    // Expression-bodied arrows have no `return_statement`, but their body is
2296    // the function's sole return path by language definition.
2297    for arrow in collect_kinds(tree, &["arrow_function"]) {
2298        let Some(body) = arrow.child_by_field_name("body") else {
2299            continue;
2300        };
2301        if body.kind() == "statement_block" {
2302            continue;
2303        }
2304        let arrow_span = span_of(file, &arrow);
2305        let Some(decl) = defs
2306            .iter()
2307            .filter(|decl| {
2308                matches!(decl.kind, DeclKind::Function | DeclKind::Method)
2309                    && decl.span.start <= arrow_span.start
2310                    && arrow_span.end <= decl.span.end
2311            })
2312            .min_by_key(|decl| decl.span.len())
2313        else {
2314            continue;
2315        };
2316        let Some((input_param_index, table, exact_mappings, domain, transform_span)) =
2317            ecmascript_character_substitution(body, &decl.params, &bindings, file, src)
2318        else {
2319            continue;
2320        };
2321        facts.push(CharacterSubstitutionFact {
2322            function_span: decl.span,
2323            transform_span,
2324            input_param_index,
2325            exact_mappings,
2326            table,
2327            domain,
2328        });
2329    }
2330    facts.sort_by_key(|fact| (fact.function_span.start, fact.transform_span.start));
2331    facts.dedup();
2332    facts
2333}
2334
2335fn ecmascript_character_substitution(
2336    expression: Node<'_>,
2337    params: &[String],
2338    bindings: &EcmascriptBindings<'_>,
2339    file: FileId,
2340    src: &[u8],
2341) -> Option<(
2342    usize,
2343    String,
2344    Vec<StaticStringMapEntry>,
2345    CharacterSubstitutionDomain,
2346    bonsai_common::Span,
2347)> {
2348    if let Some((input_param_index, mappings, characters, transform_span)) =
2349        ecmascript_inline_replace_chain(expression, params, bindings, file, src)
2350    {
2351        return Some((
2352            input_param_index,
2353            String::new(),
2354            mappings,
2355            CharacterSubstitutionDomain::ExactCharacters { characters },
2356            transform_span,
2357        ));
2358    }
2359    let call = unwrap_ecmascript_expression(expression);
2360    if call.kind() != "call_expression" {
2361        return None;
2362    }
2363    let function = call.child_by_field_name("function")?;
2364    if function.kind() != "member_expression" {
2365        return None;
2366    }
2367    let receiver = function.child_by_field_name("object")?;
2368    let method = function.child_by_field_name("property")?;
2369    let method = node_text(&method, src).trim();
2370    let arguments = ecmascript_call_arguments(call);
2371
2372    if method == "replace" {
2373        let input_param_index = ecmascript_transform_input_param_index(receiver, params, bindings, src)?;
2374        let pattern = arguments.first().copied()?;
2375        let callback = arguments.get(1).copied()?;
2376        let characters = ecmascript_exact_regex_characters(pattern, src)?;
2377        if let Some((table, callback_parameter)) = ecmascript_map_lookup_callback(callback, src) {
2378            if callback_parameter.is_empty() {
2379                return None;
2380            }
2381            return Some((
2382                input_param_index,
2383                table,
2384                Vec::new(),
2385                CharacterSubstitutionDomain::ExactCharacters { characters },
2386                span_of(file, &call),
2387            ));
2388        }
2389        let exact_mappings = ecmascript_numeric_hex_escape_mappings(callback, &characters, src)?;
2390        return Some((
2391            input_param_index,
2392            String::new(),
2393            exact_mappings,
2394            CharacterSubstitutionDomain::ExactCharacters { characters },
2395            span_of(file, &call),
2396        ));
2397    }
2398
2399    if method != "join" || receiver.kind() != "call_expression" {
2400        return None;
2401    }
2402    let join_separator = arguments.first().copied()?;
2403    if ecmascript_static_string_literal(join_separator, src).as_deref() != Some("") {
2404        return None;
2405    }
2406    let map_function = receiver.child_by_field_name("function")?;
2407    if map_function.kind() != "member_expression"
2408        || map_function
2409            .child_by_field_name("property")
2410            .map(|property| node_text(&property, src).trim() == "map")
2411            != Some(true)
2412    {
2413        return None;
2414    }
2415    let iterated = map_function.child_by_field_name("object")?;
2416    let input = ecmascript_spread_only_array_input(iterated, src)?;
2417    let input_param_index = params.iter().position(|param| param == input)?;
2418    let callback = ecmascript_call_arguments(receiver).first().copied()?;
2419    let (table, callback_parameter) = ecmascript_identity_fallback_map_callback(callback, src)?;
2420    if callback_parameter.is_empty() {
2421        return None;
2422    }
2423    Some((
2424        input_param_index,
2425        table,
2426        Vec::new(),
2427        CharacterSubstitutionDomain::TableKeysWithIdentityFallback,
2428        span_of(file, &call),
2429    ))
2430}
2431
2432fn ecmascript_inline_replace_chain(
2433    expression: Node<'_>,
2434    params: &[String],
2435    bindings: &EcmascriptBindings<'_>,
2436    file: FileId,
2437    src: &[u8],
2438) -> Option<(usize, Vec<StaticStringMapEntry>, Vec<String>, bonsai_common::Span)> {
2439    let transform_span = span_of(file, &expression);
2440    let mut current = unwrap_ecmascript_expression(expression);
2441    let mut mappings = Vec::new();
2442    let mut characters = Vec::new();
2443    while current.kind() == "call_expression" {
2444        let function = current.child_by_field_name("function")?;
2445        if function.kind() != "member_expression" {
2446            break;
2447        }
2448        let receiver = function.child_by_field_name("object")?;
2449        let method = function.child_by_field_name("property")?;
2450        if node_text(&method, src).trim() != "replace" {
2451            break;
2452        }
2453        let args = ecmascript_call_arguments(current);
2454        let [pattern, replacement] = args.as_slice() else {
2455            return None;
2456        };
2457        let replaced = ecmascript_global_regex_characters(*pattern, bindings, src)?;
2458        let replacement = ecmascript_static_string_literal(*replacement, src)?;
2459        for input in replaced {
2460            let output = ecmascript_expand_static_replacement(&replacement, &input)?;
2461            if mappings
2462                .iter()
2463                .any(|entry: &StaticStringMapEntry| entry.key == input && entry.value != output)
2464            {
2465                return None;
2466            }
2467            if !mappings.iter().any(|entry| entry.key == input) {
2468                characters.push(input.clone());
2469                mappings.push(StaticStringMapEntry {
2470                    key: input,
2471                    value: output.clone(),
2472                });
2473            }
2474        }
2475        current = unwrap_ecmascript_expression(receiver);
2476    }
2477    if mappings.is_empty() {
2478        return None;
2479    }
2480    let input = if current.kind() == "identifier" {
2481        node_text(&current, src).trim()
2482    } else if current.kind() == "call_expression" {
2483        let function = current.child_by_field_name("function")?;
2484        if function.kind() != "identifier"
2485            || node_text(&function, src).trim() != "String"
2486            || bindings
2487                .resolve("String", function.start_byte(), function.end_byte())
2488                .is_some()
2489        {
2490            return None;
2491        }
2492        let args = ecmascript_call_arguments(current);
2493        let [input] = args.as_slice() else {
2494            return None;
2495        };
2496        if input.kind() != "identifier" {
2497            return None;
2498        }
2499        node_text(input, src).trim()
2500    } else {
2501        return None;
2502    };
2503    let input_param_index = params.iter().position(|param| param == input)?;
2504    characters.sort();
2505    characters.dedup();
2506    mappings.sort_by(|left, right| left.key.cmp(&right.key));
2507    Some((input_param_index, mappings, characters, transform_span))
2508}
2509
2510fn ecmascript_transform_input_param_index(
2511    receiver: Node<'_>,
2512    params: &[String],
2513    bindings: &EcmascriptBindings<'_>,
2514    src: &[u8],
2515) -> Option<usize> {
2516    let receiver = unwrap_ecmascript_expression(receiver);
2517    if receiver.kind() == "identifier" {
2518        let input = node_text(&receiver, src).trim();
2519        return params.iter().position(|parameter| parameter == input);
2520    }
2521    if receiver.kind() != "call_expression" {
2522        return None;
2523    }
2524    let function = receiver.child_by_field_name("function")?;
2525    if function.kind() != "identifier"
2526        || node_text(&function, src).trim() != "String"
2527        || bindings
2528            .resolve("String", function.start_byte(), function.end_byte())
2529            .is_some()
2530    {
2531        return None;
2532    }
2533    let args = ecmascript_call_arguments(receiver);
2534    let [input] = args.as_slice() else {
2535        return None;
2536    };
2537    if input.kind() != "identifier" {
2538        return None;
2539    }
2540    let input = node_text(input, src).trim();
2541    params.iter().position(|parameter| parameter == input)
2542}
2543
2544/// Apply the context-free subset of ECMAScript replacement-string runtime
2545/// semantics. `$&` denotes the complete matched scalar and `$$` denotes a
2546/// literal dollar. Prefix/suffix and capture substitutions depend on dynamic
2547/// match context, so those forms fail closed.
2548fn ecmascript_expand_static_replacement(template: &str, matched: &str) -> Option<String> {
2549    let mut out = String::new();
2550    let mut input = template.chars().peekable();
2551    while let Some(character) = input.next() {
2552        if character != '$' {
2553            out.push(character);
2554            continue;
2555        }
2556        match input.next() {
2557            Some('$') => out.push('$'),
2558            Some('&') => out.push_str(matched),
2559            Some('`' | '\'' | '0'..='9' | '<') => return None,
2560            Some(other) => {
2561                // ECMAScript preserves an unrecognized `$x` sequence.
2562                out.push('$');
2563                out.push(other);
2564            }
2565            None => out.push('$'),
2566        }
2567    }
2568    Some(out)
2569}
2570
2571fn ecmascript_global_regex_characters<'tree>(
2572    mut regex: Node<'tree>,
2573    bindings: &EcmascriptBindings<'tree>,
2574    src: &[u8],
2575) -> Option<Vec<String>> {
2576    if regex.kind() == "identifier" {
2577        let name = node_text(&regex, src).trim();
2578        let binding = bindings.resolve(name, regex.start_byte(), regex.end_byte())?;
2579        if binding.declaration.kind() != "variable_declarator"
2580            || binding
2581                .declaration
2582                .child_by_field_name("name")
2583                .is_none_or(|target| target.kind() != "identifier")
2584            || binding
2585                .declaration
2586                .parent()
2587                .filter(|declaration| declaration.kind() == "lexical_declaration")
2588                .and_then(|declaration| declaration.child(0))
2589                .is_none_or(|keyword| keyword.kind() != "const")
2590        {
2591            return None;
2592        }
2593        regex = binding.initializer;
2594    }
2595    if regex.kind() != "regex" {
2596        return None;
2597    }
2598    let flags = regex
2599        .child_by_field_name("flags")
2600        .map(|node| node_text(&node, src).trim())
2601        .or_else(|| node_text(&regex, src).rsplit_once('/').map(|(_, flags)| flags))?;
2602    if !flags.contains('g') {
2603        return None;
2604    }
2605    let pattern = regex.child_by_field_name("pattern")?;
2606    let pattern_text = node_text(&pattern, src);
2607    if let Some(characters) = ecmascript_exact_regex_characters(regex, src) {
2608        return Some(characters);
2609    }
2610    let mut chars = pattern_text.chars();
2611    let character = match chars.next()? {
2612        '\\' => match chars.next()? {
2613            'r' => '\r',
2614            'n' => '\n',
2615            't' => '\t',
2616            escaped if !escaped.is_ascii_alphanumeric() => escaped,
2617            _ => return None,
2618        },
2619        character if !".^$*+?()[]{}|".contains(character) => character,
2620        _ => return None,
2621    };
2622    chars.next().is_none().then(|| vec![character.to_string()])
2623}
2624
2625fn unwrap_ecmascript_expression(mut node: Node<'_>) -> Node<'_> {
2626    loop {
2627        if matches!(node.kind(), "parenthesized_expression" | "expression") && node.named_child_count() == 1 {
2628            node = node.named_child(0).expect("single named child");
2629            continue;
2630        }
2631        if matches!(node.kind(), "as_expression" | "type_assertion") {
2632            let Some(value) = node.named_child(0) else {
2633                break;
2634            };
2635            node = value;
2636            continue;
2637        }
2638        break;
2639    }
2640    node
2641}
2642
2643fn ecmascript_call_arguments(call: Node<'_>) -> Vec<Node<'_>> {
2644    let Some(arguments) = call.child_by_field_name("arguments") else {
2645        return Vec::new();
2646    };
2647    let mut cursor = arguments.walk();
2648    arguments.named_children(&mut cursor).collect()
2649}
2650
2651fn ecmascript_spread_only_array_input<'a>(array: Node<'a>, src: &'a [u8]) -> Option<&'a str> {
2652    if array.kind() != "array" || array.named_child_count() != 1 {
2653        return None;
2654    }
2655    let spread = array.named_child(0)?;
2656    if spread.kind() != "spread_element" {
2657        return None;
2658    }
2659    let argument = spread.named_child(0)?;
2660    (argument.kind() == "identifier").then(|| node_text(&argument, src).trim())
2661}
2662
2663fn ecmascript_map_lookup_callback(callback: Node<'_>, src: &[u8]) -> Option<(String, String)> {
2664    let (parameter, body) = ecmascript_arrow_parts(callback, src)?;
2665    let (table, key) = ecmascript_subscript_parts(body, src)?;
2666    (key == parameter).then_some((table.to_string(), parameter.to_string()))
2667}
2668
2669/// Prove an expression-bodied replacement callback of the form
2670/// `prefix + c.charCodeAt(0).toString(16).padStart(width, fill)` and evaluate
2671/// it for the regex's finite compiler-decoded input alphabet.
2672fn ecmascript_numeric_hex_escape_mappings(
2673    callback: Node<'_>,
2674    characters: &[String],
2675    src: &[u8],
2676) -> Option<Vec<StaticStringMapEntry>> {
2677    let (parameter, body) = ecmascript_arrow_parts(callback, src)?;
2678    let body = unwrap_ecmascript_expression(body);
2679    if body.kind() != "binary_expression" {
2680        return None;
2681    }
2682    let left = body.child_by_field_name("left")?;
2683    let right = body.child_by_field_name("right")?;
2684    if src
2685        .get(left.end_byte()..right.start_byte())
2686        .and_then(|bytes| std::str::from_utf8(bytes).ok())
2687        .map(str::trim)
2688        != Some("+")
2689    {
2690        return None;
2691    }
2692    let prefix = ecmascript_static_string_literal(left, src)?;
2693    let (pad_receiver, pad_method, pad_args) = ecmascript_nested_member_call(right, src)?;
2694    if pad_method != "padStart" || pad_args.len() != 2 {
2695        return None;
2696    }
2697    let width = ecmascript_static_usize(pad_args[0], src)?;
2698    let fill = ecmascript_static_string_literal(pad_args[1], src)?;
2699    if width == 0 || fill.is_empty() {
2700        return None;
2701    }
2702    let (string_receiver, string_method, string_args) = ecmascript_nested_member_call(pad_receiver, src)?;
2703    if string_method != "toString"
2704        || string_args.len() != 1
2705        || ecmascript_static_usize(string_args[0], src)? != 16
2706    {
2707        return None;
2708    }
2709    let (code_receiver, code_method, code_args) = ecmascript_nested_member_call(string_receiver, src)?;
2710    if code_method != "charCodeAt"
2711        || code_args.len() != 1
2712        || ecmascript_static_usize(code_args[0], src)? != 0
2713        || code_receiver.kind() != "identifier"
2714        || node_text(&code_receiver, src).trim() != parameter
2715    {
2716        return None;
2717    }
2718
2719    characters
2720        .iter()
2721        .map(|input| {
2722            let mut utf16 = input.encode_utf16();
2723            let code = utf16.next()?;
2724            if utf16.next().is_some() {
2725                return None;
2726            }
2727            let digits = format!("{code:x}");
2728            let padding = width.saturating_sub(digits.chars().count());
2729            let mut encoded = String::with_capacity(prefix.len() + padding * fill.len() + digits.len());
2730            encoded.push_str(&prefix);
2731            for _ in 0..padding {
2732                encoded.push_str(&fill);
2733            }
2734            encoded.push_str(&digits);
2735            Some(StaticStringMapEntry {
2736                key: input.clone(),
2737                value: encoded,
2738            })
2739        })
2740        .collect()
2741}
2742
2743fn ecmascript_nested_member_call<'tree>(
2744    call: Node<'tree>,
2745    src: &[u8],
2746) -> Option<(Node<'tree>, String, Vec<Node<'tree>>)> {
2747    let call = unwrap_ecmascript_expression(call);
2748    if call.kind() != "call_expression" {
2749        return None;
2750    }
2751    let function = call.child_by_field_name("function")?;
2752    if function.kind() != "member_expression" {
2753        return None;
2754    }
2755    let object = function.child_by_field_name("object")?;
2756    let property = function.child_by_field_name("property")?;
2757    let method = node_text(&property, src).trim().to_string();
2758    (!method.is_empty()).then(|| (object, method, ecmascript_call_arguments(call)))
2759}
2760
2761fn ecmascript_static_usize(node: Node<'_>, src: &[u8]) -> Option<usize> {
2762    (node.kind() == "number")
2763        .then(|| node_text(&node, src).trim().parse().ok())
2764        .flatten()
2765}
2766
2767fn ecmascript_identity_fallback_map_callback(callback: Node<'_>, src: &[u8]) -> Option<(String, String)> {
2768    let (parameter, body) = ecmascript_arrow_parts(callback, src)?;
2769    if body.kind() != "binary_expression" {
2770        return None;
2771    }
2772    let left = body.child_by_field_name("left")?;
2773    let right = body.child_by_field_name("right")?;
2774    if src
2775        .get(left.end_byte()..right.start_byte())
2776        .and_then(|bytes| std::str::from_utf8(bytes).ok())
2777        .map(str::trim)
2778        != Some("??")
2779        || node_text(&right, src).trim() != parameter
2780    {
2781        return None;
2782    }
2783    let (table, key) = ecmascript_subscript_parts(left, src)?;
2784    (key == parameter).then_some((table.to_string(), parameter.to_string()))
2785}
2786
2787fn ecmascript_arrow_parts<'a>(arrow: Node<'a>, src: &'a [u8]) -> Option<(&'a str, Node<'a>)> {
2788    if arrow.kind() != "arrow_function" {
2789        return None;
2790    }
2791    let parameter_node = arrow
2792        .child_by_field_name("parameter")
2793        .or_else(|| arrow.child_by_field_name("parameters"))?;
2794    let parameter = if parameter_node.kind() == "identifier" {
2795        parameter_node
2796    } else {
2797        let mut stack = vec![parameter_node];
2798        let mut found = None;
2799        while let Some(node) = stack.pop() {
2800            if node.kind() == "identifier" {
2801                found = Some(node);
2802                break;
2803            }
2804            let mut cursor = node.walk();
2805            stack.extend(node.named_children(&mut cursor));
2806        }
2807        found?
2808    };
2809    let body = arrow.child_by_field_name("body")?;
2810    Some((node_text(&parameter, src).trim(), body))
2811}
2812
2813fn ecmascript_subscript_parts<'a>(subscript: Node<'a>, src: &'a [u8]) -> Option<(&'a str, &'a str)> {
2814    if subscript.kind() != "subscript_expression" {
2815        return None;
2816    }
2817    let object = subscript.child_by_field_name("object")?;
2818    let index = subscript.child_by_field_name("index")?;
2819    if object.kind() != "identifier" || index.kind() != "identifier" {
2820        return None;
2821    }
2822    Some((node_text(&object, src).trim(), node_text(&index, src).trim()))
2823}
2824
2825fn ecmascript_exact_regex_characters(regex: Node<'_>, src: &[u8]) -> Option<Vec<String>> {
2826    if regex.kind() != "regex" {
2827        return None;
2828    }
2829    let pattern = regex.child_by_field_name("pattern")?;
2830    let pattern = node_text(&pattern, src);
2831    let inner = pattern.strip_prefix('[')?.strip_suffix(']')?;
2832    if inner.starts_with('^') || inner.is_empty() {
2833        return None;
2834    }
2835    let mut characters = Vec::new();
2836    let mut chars = inner.chars();
2837    while let Some(character) = chars.next() {
2838        if character == '-' {
2839            return None;
2840        }
2841        let decoded = if character == '\\' {
2842            match chars.next()? {
2843                'r' => '\r',
2844                'n' => '\n',
2845                't' => '\t',
2846                '0' => '\0',
2847                'x' => {
2848                    let digits = [chars.next()?, chars.next()?].into_iter().collect::<String>();
2849                    char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?
2850                }
2851                'u' => {
2852                    let digits = [chars.next()?, chars.next()?, chars.next()?, chars.next()?]
2853                        .into_iter()
2854                        .collect::<String>();
2855                    char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?
2856                }
2857                escaped if !escaped.is_ascii_alphanumeric() => escaped,
2858                _ => return None,
2859            }
2860        } else {
2861            character
2862        };
2863        characters.push(decoded.to_string());
2864    }
2865    characters.sort();
2866    characters.dedup();
2867    Some(characters)
2868}
2869
2870fn lower_ecmascript_condition_expression(
2871    node: Node<'_>,
2872    file: FileId,
2873    src: &[u8],
2874) -> ConditionExpressionFact {
2875    if node.kind() == "parenthesized_expression" {
2876        if let Some(inner) = node.named_child(0) {
2877            return lower_ecmascript_condition_expression(inner, file, src);
2878        }
2879    }
2880
2881    let span = span_of(file, &node);
2882    if node.kind() == "unary_expression" {
2883        if let Some(operand) = node
2884            .child_by_field_name("argument")
2885            .or_else(|| node.named_child(0))
2886        {
2887            let prefix = src
2888                .get(node.start_byte()..operand.start_byte())
2889                .and_then(|bytes| std::str::from_utf8(bytes).ok())
2890                .map(str::trim);
2891            if prefix == Some("!") {
2892                return ConditionExpressionFact::Not {
2893                    span,
2894                    operand: Box::new(lower_ecmascript_condition_expression(operand, file, src)),
2895                };
2896            }
2897        }
2898    }
2899
2900    if node.kind() == "binary_expression" {
2901        if let (Some(left), Some(right)) = (
2902            node.child_by_field_name("left"),
2903            node.child_by_field_name("right"),
2904        ) {
2905            let operator = src
2906                .get(left.end_byte()..right.start_byte())
2907                .and_then(|bytes| std::str::from_utf8(bytes).ok())
2908                .map(str::trim);
2909            match operator {
2910                Some("||") => {
2911                    return merge_ecmascript_condition_junction(
2912                        span,
2913                        lower_ecmascript_condition_expression(left, file, src),
2914                        lower_ecmascript_condition_expression(right, file, src),
2915                        false,
2916                    );
2917                }
2918                Some("&&") => {
2919                    return merge_ecmascript_condition_junction(
2920                        span,
2921                        lower_ecmascript_condition_expression(left, file, src),
2922                        lower_ecmascript_condition_expression(right, file, src),
2923                        true,
2924                    );
2925                }
2926                Some("==" | "===" | "!=" | "!==") => {
2927                    if let Some((subject, type_name)) = ecmascript_type_test_operands(left, right, file, src)
2928                        .or_else(|| ecmascript_type_test_operands(right, left, file, src))
2929                    {
2930                        let type_test = ConditionExpressionFact::TypeTest {
2931                            span,
2932                            subject,
2933                            type_name,
2934                        };
2935                        return if matches!(operator, Some("==" | "===")) {
2936                            type_test
2937                        } else {
2938                            ConditionExpressionFact::Not {
2939                                span,
2940                                operand: Box::new(type_test),
2941                            }
2942                        };
2943                    }
2944                    let relation = if matches!(operator, Some("==" | "===")) {
2945                        ConditionEquality::Equal
2946                    } else {
2947                        ConditionEquality::NotEqual
2948                    };
2949                    return ConditionExpressionFact::Equality {
2950                        span,
2951                        relation,
2952                        left: ecmascript_condition_operand(left, file, src),
2953                        right: ecmascript_condition_operand(right, file, src),
2954                    };
2955                }
2956                _ => {}
2957            }
2958        }
2959    }
2960
2961    ConditionExpressionFact::Atom { span }
2962}
2963
2964fn ecmascript_type_test_operands(
2965    type_query: Node<'_>,
2966    type_literal: Node<'_>,
2967    file: FileId,
2968    src: &[u8],
2969) -> Option<(ConditionOperandFact, String)> {
2970    if type_query.kind() != "unary_expression" {
2971        return None;
2972    }
2973    let subject = type_query
2974        .child_by_field_name("argument")
2975        .or_else(|| type_query.named_child(0))?;
2976    let operator = src
2977        .get(type_query.start_byte()..subject.start_byte())
2978        .and_then(|bytes| std::str::from_utf8(bytes).ok())?
2979        .trim();
2980    if operator != "typeof" {
2981        return None;
2982    }
2983    let type_name = ecmascript_static_string_literal(type_literal, src)?;
2984    Some((ecmascript_condition_operand(subject, file, src), type_name))
2985}
2986
2987fn merge_ecmascript_condition_junction(
2988    span: bonsai_common::Span,
2989    left: ConditionExpressionFact,
2990    right: ConditionExpressionFact,
2991    all: bool,
2992) -> ConditionExpressionFact {
2993    let mut operands = Vec::new();
2994    let mut push = |operand: ConditionExpressionFact| match (all, operand) {
2995        (true, ConditionExpressionFact::All { operands: nested, .. })
2996        | (false, ConditionExpressionFact::Any { operands: nested, .. }) => operands.extend(nested),
2997        (_, operand) => operands.push(operand),
2998    };
2999    push(left);
3000    push(right);
3001    if all {
3002        ConditionExpressionFact::All { span, operands }
3003    } else {
3004        ConditionExpressionFact::Any { span, operands }
3005    }
3006}
3007
3008fn ecmascript_condition_operand(node: Node<'_>, file: FileId, src: &[u8]) -> ConditionOperandFact {
3009    ConditionOperandFact {
3010        span: span_of(file, &node),
3011        value_flow: bonsai_lang_api::kit::expression_flow_from_node_with_handler(node, file, src, &HANDLER),
3012        static_string: ecmascript_static_string_literal(node, src),
3013        static_value: ecmascript_static_scalar(node, src),
3014    }
3015}
3016
3017fn ecmascript_static_string_literal(node: Node<'_>, src: &[u8]) -> Option<String> {
3018    if !matches!(node.kind(), "string" | "string_literal") {
3019        return None;
3020    }
3021    let text = node_text(&node, src);
3022    let quote = text.as_bytes().first().copied()?;
3023    if !matches!(quote, b'\'' | b'"') || text.as_bytes().last().copied() != Some(quote) {
3024        return None;
3025    }
3026    let inner = text.get(1..text.len().checked_sub(1)?)?;
3027    decode_ecmascript_string_contents(inner, quote as char)
3028}
3029
3030fn ecmascript_static_scalar(node: Node<'_>, src: &[u8]) -> Option<StaticScalarValue> {
3031    match node.kind() {
3032        "true" => Some(StaticScalarValue::Boolean(true)),
3033        "false" => Some(StaticScalarValue::Boolean(false)),
3034        "null" => Some(StaticScalarValue::Null),
3035        "string" | "string_literal" => Some(StaticScalarValue::String(ecmascript_static_string_literal(
3036            node, src,
3037        )?)),
3038        _ => None,
3039    }
3040}
3041
3042pub fn ecmascript_static_subscript_key(node: Node<'_>, src: &[u8]) -> Option<String> {
3043    match ecmascript_static_scalar(node, src)? {
3044        StaticScalarValue::String(value) => Some(value),
3045        StaticScalarValue::Boolean(_) | StaticScalarValue::Null => None,
3046    }
3047}
3048
3049fn decode_ecmascript_string_contents(inner: &str, quote: char) -> Option<String> {
3050    let mut output = String::new();
3051    let mut chars = inner.chars().peekable();
3052    while let Some(character) = chars.next() {
3053        if character != '\\' {
3054            if matches!(character, '\r' | '\n') {
3055                return None;
3056            }
3057            output.push(character);
3058            continue;
3059        }
3060        let escaped = chars.next()?;
3061        match escaped {
3062            '\\' => output.push('\\'),
3063            '\'' if quote == '\'' => output.push('\''),
3064            '"' if quote == '"' => output.push('"'),
3065            '`' if quote == '`' => output.push('`'),
3066            '/' => output.push('/'),
3067            'b' => output.push('\u{0008}'),
3068            'f' => output.push('\u{000c}'),
3069            'n' => output.push('\n'),
3070            'r' => output.push('\r'),
3071            't' => output.push('\t'),
3072            'v' => output.push('\u{000b}'),
3073            '0' if !chars.peek().is_some_and(char::is_ascii_digit) => output.push('\0'),
3074            'x' => output.push(decode_ecmascript_hex(&mut chars, 2)?),
3075            'u' => {
3076                if chars.peek() == Some(&'{') {
3077                    chars.next();
3078                    let mut digits = String::new();
3079                    for digit in chars.by_ref() {
3080                        if digit == '}' {
3081                            break;
3082                        }
3083                        if !digit.is_ascii_hexdigit() || digits.len() == 6 {
3084                            return None;
3085                        }
3086                        digits.push(digit);
3087                    }
3088                    if digits.is_empty() {
3089                        return None;
3090                    }
3091                    output.push(char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?);
3092                } else {
3093                    output.push(decode_ecmascript_hex(&mut chars, 4)?);
3094                }
3095            }
3096            '\n' => {}
3097            '\r' => {
3098                if chars.peek() == Some(&'\n') {
3099                    chars.next();
3100                }
3101            }
3102            // ECMAScript identity escapes decode to the escaped code point.
3103            // Decimal/octal escapes are context-sensitive and deliberately
3104            // remain unknown.
3105            other if !other.is_ascii_digit() => output.push(other),
3106            _ => return None,
3107        }
3108    }
3109    Some(output)
3110}
3111
3112fn decode_ecmascript_hex(
3113    chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
3114    digits: usize,
3115) -> Option<char> {
3116    let mut value = 0_u32;
3117    for _ in 0..digits {
3118        value = value.checked_mul(16)?;
3119        value = value.checked_add(chars.next()?.to_digit(16)?)?;
3120    }
3121    char::from_u32(value)
3122}
3123
3124/// Type locals initialized by an ECMAScript array literal from the CST. This
3125/// supplies the same semantic fact a compiler obtains from `const xs = []`;
3126/// external standard-library summaries can then require an `Array` receiver
3127/// instead of matching a method spelling on arbitrary user objects.
3128fn apply_javascript_array_literal_types(index: &mut DeclIndex, tree: &Tree, src: &[u8], file: FileId) {
3129    let mut bindings = Vec::new();
3130    for declarator in collect_kinds(tree, &["variable_declarator"]) {
3131        let Some(name_node) = declarator.child_by_field_name("name") else {
3132            continue;
3133        };
3134        let Some(value_node) = declarator.child_by_field_name("value") else {
3135            continue;
3136        };
3137        if value_node.kind() != "array" || name_node.kind() != "identifier" {
3138            continue;
3139        }
3140        let name = node_text(&name_node, src).trim();
3141        if !name.is_empty() {
3142            bindings.push((span_of(file, &declarator), name.to_string()));
3143        }
3144    }
3145    for (span, name) in bindings {
3146        let owner = index
3147            .defs
3148            .iter()
3149            .enumerate()
3150            .filter(|(_, decl)| {
3151                matches!(
3152                    decl.kind,
3153                    DeclKind::Function | DeclKind::Method | DeclKind::Constructor
3154                ) && decl.span.file == span.file
3155                    && decl.span.start <= span.start
3156                    && span.end <= decl.span.end
3157            })
3158            .min_by_key(|(_, decl)| decl.span.len())
3159            .map(|(idx, _)| idx);
3160        let Some(owner) = owner else { continue };
3161        let binding = TypeAliasBinding {
3162            name,
3163            type_name: "Array".to_string(),
3164        };
3165        if !index.defs[owner].type_aliases.contains(&binding) {
3166            index.defs[owner].type_aliases.push(binding);
3167        }
3168    }
3169}
3170
3171/// Combine ES-module `import` statements with CommonJS `require(...)` calls.
3172fn parse_imports(tree: &Tree, src: &[u8], file: FileId) -> Vec<ImportSpec> {
3173    let mut import_specs = js_ts_imports(file, tree, src);
3174    import_specs.extend(js_ts_require_calls(file, tree, src));
3175    import_specs
3176}
3177
3178/// Shared ES-module import parser used by both the JavaScript and
3179/// TypeScript adapters. Handles:
3180///   `import x from "y"`             — default import
3181///   `import { a, b as c } from "z"` — named imports + alias
3182///   `import * as ns from "n"`       — namespace import
3183pub fn js_ts_imports(file: FileId, tree: &tree_sitter::Tree, src: &[u8]) -> Vec<ImportSpec> {
3184    let mut imports = Vec::new();
3185    for import_node in collect_kinds(tree, &["import_statement"]) {
3186        let Some(source) = import_node.child_by_field_name("source") else {
3187            continue;
3188        };
3189        // Prefer the inner `string_fragment` to avoid quote characters in the module path.
3190        let module = first_named_child_of_kind(&source, "string_fragment")
3191            .map(|fragment| node_text(&fragment, src).to_string())
3192            .unwrap_or_else(|| {
3193                // Older grammars expose the literal directly — strip surrounding quotes.
3194                node_text(&source, src)
3195                    .trim_matches(|c: char| matches!(c, '"' | '\''))
3196                    .to_string()
3197            });
3198        let module = normalize_node_builtin_scheme(&module);
3199        if module.is_empty() {
3200            continue;
3201        }
3202        // We split each import statement into multiple `ImportSpec` rows so the
3203        // resolver / security matcher can rewrite call sites accurately:
3204        //   - Module-scope base entry covers the statement itself.
3205        //   - `{ a as b }` renames become Module-scope (the alias `b` is a
3206        //     distinct local binding worth surfacing in `imports` browse).
3207        //   - Shorthand `{ a }` becomes Local-scope so bare `a(...)` expands
3208        //     to `module.a` while default browse hides it.
3209        let import_clause = first_named_child_of_kind(&import_node, "import_clause");
3210        let mut module_alias: Option<String> = None;
3211        let mut default_alias: Option<String> = None;
3212        let mut is_wildcard = false;
3213        let mut renames: Vec<(String, String)> = Vec::new();
3214        let mut shorthands: Vec<String> = Vec::new();
3215        if let Some(import_clause) = import_clause {
3216            let mut clause_cursor = import_clause.walk();
3217            for clause_child in import_clause.named_children(&mut clause_cursor) {
3218                match clause_child.kind() {
3219                    "identifier" => {
3220                        // Default import: `import Foo from "..."`.
3221                        default_alias = Some(node_text(&clause_child, src).to_string());
3222                    }
3223                    "namespace_import" => {
3224                        // `import * as ns from "..."` — single binding bound to the whole module.
3225                        module_alias = first_named_child_of_kind(&clause_child, "identifier")
3226                            .map(|ident| node_text(&ident, src).to_string());
3227                        is_wildcard = true;
3228                    }
3229                    "named_imports" => {
3230                        let mut named_cursor = clause_child.walk();
3231                        for specifier in clause_child.named_children(&mut named_cursor) {
3232                            if specifier.kind() != "import_specifier" {
3233                                continue;
3234                            }
3235                            let original_name = specifier
3236                                .child_by_field_name("name")
3237                                .map(|name_node| node_text(&name_node, src).to_string());
3238                            let local_alias = specifier
3239                                .child_by_field_name("alias")
3240                                .map(|alias_node| node_text(&alias_node, src).to_string());
3241                            match (original_name, local_alias) {
3242                                // `{ a as b }` — distinct local binding `b`.
3243                                (Some(orig), Some(local)) => renames.push((orig, local)),
3244                                // `{ a }` — local name matches the export name.
3245                                (Some(orig), None) => shorthands.push(orig),
3246                                _ => {}
3247                            }
3248                        }
3249                    }
3250                    _ => {}
3251                }
3252            }
3253        }
3254        imports.push(ImportSpec {
3255            span: span_of(file, &import_node),
3256            module: module.clone(),
3257            alias: module_alias,
3258            is_wildcard,
3259            original_name: None,
3260            scope: ImportScope::Module,
3261        });
3262        if let Some(default_alias) = default_alias.filter(|alias| !alias.is_empty()) {
3263            imports.push(ImportSpec {
3264                span: span_of(file, &import_node),
3265                module: module.clone(),
3266                alias: Some(default_alias),
3267                is_wildcard: false,
3268                original_name: Some("default".to_string()),
3269                scope: ImportScope::Module,
3270            });
3271        }
3272        for (original_name, local_alias) in renames {
3273            imports.push(ImportSpec {
3274                span: span_of(file, &import_node),
3275                module: module.clone(),
3276                alias: Some(local_alias),
3277                is_wildcard: false,
3278                original_name: Some(original_name),
3279                scope: ImportScope::Module,
3280            });
3281        }
3282        for shorthand_name in shorthands {
3283            imports.push(ImportSpec {
3284                span: span_of(file, &import_node),
3285                module: module.clone(),
3286                alias: Some(shorthand_name.clone()),
3287                is_wildcard: false,
3288                original_name: Some(shorthand_name),
3289                scope: ImportScope::Local,
3290            });
3291        }
3292    }
3293    imports
3294}
3295
3296/// Surface ECMAScript `export default` and CommonJS
3297/// `module.exports = function ...` as an additional callable/type
3298/// binding named `default` in the exporting module. The original
3299/// declaration remains indexed by its real local name, so same-file
3300/// references still resolve while default imports / callable
3301/// CommonJS requires can target the language-level export name.
3302pub fn apply_js_ts_default_export_aliases(decl_index: &mut DeclIndex, tree: &Tree, src: &[u8], file: FileId) {
3303    let mut default_exports = Vec::new();
3304    for export_node in collect_kinds(tree, &["export_statement"]) {
3305        if !export_statement_has_default_modifier(export_node) {
3306            continue;
3307        }
3308        let target = export_node
3309            .child_by_field_name("declaration")
3310            .or_else(|| export_node.child_by_field_name("value"));
3311        let Some(target) = target else {
3312            continue;
3313        };
3314        if target.kind() == "identifier" {
3315            let name = node_text(&target, src).to_string();
3316            if !name.is_empty() {
3317                default_exports.push(DefaultExportTarget::Name(name));
3318            }
3319        } else {
3320            default_exports.push(DefaultExportTarget::Span(span_of(file, &target)));
3321        }
3322    }
3323    for assignment in collect_kinds(tree, &["assignment_expression"]) {
3324        let left = assignment.child_by_field_name("left");
3325        let right = assignment.child_by_field_name("right");
3326        let (Some(left), Some(right)) = (left, right) else {
3327            continue;
3328        };
3329        if node_text(&left, src).trim() != "module.exports" {
3330            continue;
3331        }
3332        if right.kind() == "identifier" {
3333            let name = node_text(&right, src).trim();
3334            if !name.is_empty() {
3335                default_exports.push(DefaultExportTarget::Name(name.to_string()));
3336            }
3337            continue;
3338        }
3339        default_exports.push(DefaultExportTarget::Span(span_of(file, &right)));
3340        if let Some(name_node) = right.child_by_field_name("name") {
3341            let name = node_text(&name_node, src).trim();
3342            if !name.is_empty() {
3343                default_exports.push(DefaultExportTarget::Name(name.to_string()));
3344            }
3345        }
3346    }
3347    if default_exports.is_empty() || decl_index.defs.iter().any(|decl| decl.name == "default") {
3348        return;
3349    }
3350
3351    let mut next_symbol = decl_index
3352        .defs
3353        .iter()
3354        .map(|decl| decl.symbol.raw())
3355        .max()
3356        .map_or(0, |raw| raw.saturating_add(1));
3357    let mut aliases = Vec::new();
3358    let mut seen_sources = Vec::new();
3359    for target in default_exports {
3360        let Some(source) = decl_index
3361            .defs
3362            .iter()
3363            .filter(|decl| {
3364                matches!(
3365                    decl.kind,
3366                    bonsai_lang_api::DeclKind::Function
3367                        | bonsai_lang_api::DeclKind::Method
3368                        | bonsai_lang_api::DeclKind::Constructor
3369                        | bonsai_lang_api::DeclKind::Class
3370                )
3371            })
3372            .find(|decl| match &target {
3373                DefaultExportTarget::Span(span) => decl.span == *span,
3374                DefaultExportTarget::Name(name) => decl.name == *name,
3375            })
3376        else {
3377            continue;
3378        };
3379        if seen_sources.contains(&source.symbol) {
3380            continue;
3381        }
3382        seen_sources.push(source.symbol);
3383        let mut alias = source.clone();
3384        alias.symbol = SymbolId::new(next_symbol);
3385        next_symbol = next_symbol.saturating_add(1);
3386        alias.name = "default".to_string();
3387        alias.qualified_name = if alias.module_path.is_empty() {
3388            Some("default".to_string())
3389        } else {
3390            Some(format!("{}.default", alias.module_path.segments.join(".")))
3391        };
3392        aliases.push(alias);
3393    }
3394    decl_index.defs.extend(aliases);
3395}
3396
3397/// Surface JS/TS named exports under their public export member name
3398/// when it differs from the local implementation name:
3399///
3400/// - `exports.name = function localName(...) { ... }`
3401/// - `module.exports.name = function localName(...) { ... }`
3402/// - `module.exports = { name: localName }`
3403/// - `export { localName as name }`
3404///
3405/// The shared declaration walker owns duplicate suppression for
3406/// same-name function expressions, so this only adds real export
3407/// aliases. Without these aliases, cross-file import/require calls
3408/// resolve only when the public export name happens to equal the local
3409/// function name.
3410pub fn apply_js_ts_commonjs_named_export_aliases(
3411    decl_index: &mut DeclIndex,
3412    tree: &Tree,
3413    src: &[u8],
3414    file: FileId,
3415) {
3416    let mut aliases = Vec::new();
3417    let mut next_symbol = decl_index
3418        .defs
3419        .iter()
3420        .map(|decl| decl.symbol.raw())
3421        .max()
3422        .map_or(0, |raw| raw.saturating_add(1));
3423    for assignment in collect_kinds(tree, &["assignment_expression"]) {
3424        let left = assignment.child_by_field_name("left");
3425        let right = assignment.child_by_field_name("right");
3426        let (Some(left), Some(right)) = (left, right) else {
3427            continue;
3428        };
3429        let Some(export_name) = commonjs_named_export_member(node_text(&left, src)) else {
3430            if node_text(&left, src).trim() == "module.exports" {
3431                collect_commonjs_object_export_aliases(
3432                    decl_index,
3433                    &mut aliases,
3434                    &mut next_symbol,
3435                    right,
3436                    src,
3437                );
3438            }
3439            continue;
3440        };
3441        if export_name == "default" || export_name.is_empty() {
3442            continue;
3443        }
3444        let right_span = span_of(file, &right);
3445        push_named_export_alias_for_span(
3446            decl_index,
3447            &mut aliases,
3448            &mut next_symbol,
3449            export_name,
3450            right_span,
3451        );
3452    }
3453    collect_es_named_export_aliases(decl_index, &mut aliases, &mut next_symbol, tree, src);
3454    decl_index.defs.extend(aliases);
3455}
3456
3457fn collect_commonjs_object_export_aliases(
3458    decl_index: &DeclIndex,
3459    aliases: &mut Vec<bonsai_lang_api::Decl>,
3460    next_symbol: &mut u32,
3461    right: Node<'_>,
3462    src: &[u8],
3463) {
3464    if right.kind() != "object" {
3465        return;
3466    }
3467    let mut cursor = right.walk();
3468    for child in right.named_children(&mut cursor) {
3469        if child.kind() != "pair" {
3470            continue;
3471        }
3472        let Some(key) = child.child_by_field_name("key") else {
3473            continue;
3474        };
3475        let Some(value) = child.child_by_field_name("value") else {
3476            continue;
3477        };
3478        let export_name = node_text(&key, src).trim().to_string();
3479        if export_name.is_empty() || export_name == "default" {
3480            continue;
3481        }
3482        if value.kind() == "identifier" {
3483            let local_name = node_text(&value, src).trim();
3484            push_named_export_alias_for_name(decl_index, aliases, next_symbol, export_name, local_name);
3485        } else {
3486            push_named_export_alias_for_span(
3487                decl_index,
3488                aliases,
3489                next_symbol,
3490                export_name,
3491                span_of(decl_index.file, &value),
3492            );
3493        }
3494    }
3495}
3496
3497/// True when the parsed export statement carries ECMAScript's direct
3498/// `default` modifier. Named exports such as `export { value as default }`
3499/// contain a named `export_specifier` identifier instead and deliberately do
3500/// not satisfy this predicate.
3501fn export_statement_has_default_modifier(node: Node<'_>) -> bool {
3502    let mut cursor = node.walk();
3503    if !cursor.goto_first_child() {
3504        return false;
3505    }
3506    loop {
3507        let child = cursor.node();
3508        if !child.is_named() && child.kind() == "default" {
3509            return true;
3510        }
3511        if !cursor.goto_next_sibling() {
3512            return false;
3513        }
3514    }
3515}
3516
3517fn collect_es_named_export_aliases(
3518    decl_index: &DeclIndex,
3519    aliases: &mut Vec<bonsai_lang_api::Decl>,
3520    next_symbol: &mut u32,
3521    tree: &Tree,
3522    src: &[u8],
3523) {
3524    for export_node in collect_kinds(tree, &["export_statement"]) {
3525        let mut stack = vec![export_node];
3526        while let Some(current) = stack.pop() {
3527            if current.kind() == "export_specifier" {
3528                let local_name = current.child_by_field_name("name");
3529                let export_name = current.child_by_field_name("alias");
3530                if let (Some(local), Some(exported)) = (local_name, export_name) {
3531                    let export_name = node_text(&exported, src).trim().to_string();
3532                    let local_name = node_text(&local, src).trim();
3533                    push_named_export_alias_for_name(
3534                        decl_index,
3535                        aliases,
3536                        next_symbol,
3537                        export_name,
3538                        local_name,
3539                    );
3540                }
3541                continue;
3542            }
3543            let mut cursor = current.walk();
3544            for child in current.named_children(&mut cursor) {
3545                stack.push(child);
3546            }
3547        }
3548    }
3549}
3550
3551fn push_named_export_alias_for_name(
3552    decl_index: &DeclIndex,
3553    aliases: &mut Vec<bonsai_lang_api::Decl>,
3554    next_symbol: &mut u32,
3555    export_name: String,
3556    local_name: &str,
3557) {
3558    if export_name.is_empty() || local_name.is_empty() || export_name == local_name {
3559        return;
3560    }
3561    let Some(source) = decl_index.defs.iter().find(|decl| decl.name == local_name) else {
3562        return;
3563    };
3564    push_named_export_alias(decl_index, aliases, next_symbol, export_name, source);
3565}
3566
3567fn push_named_export_alias_for_span(
3568    decl_index: &DeclIndex,
3569    aliases: &mut Vec<bonsai_lang_api::Decl>,
3570    next_symbol: &mut u32,
3571    export_name: String,
3572    source_span: bonsai_common::Span,
3573) {
3574    if export_name.is_empty() {
3575        return;
3576    }
3577    let Some(source) = decl_index.defs.iter().find(|decl| decl.span == source_span) else {
3578        return;
3579    };
3580    push_named_export_alias(decl_index, aliases, next_symbol, export_name, source);
3581}
3582
3583fn push_named_export_alias(
3584    decl_index: &DeclIndex,
3585    aliases: &mut Vec<bonsai_lang_api::Decl>,
3586    next_symbol: &mut u32,
3587    export_name: String,
3588    source: &bonsai_lang_api::Decl,
3589) {
3590    if source.name == export_name {
3591        return;
3592    }
3593    if !matches!(
3594        source.kind,
3595        bonsai_lang_api::DeclKind::Function
3596            | bonsai_lang_api::DeclKind::Method
3597            | bonsai_lang_api::DeclKind::Constructor
3598            | bonsai_lang_api::DeclKind::Class
3599    ) {
3600        return;
3601    }
3602    if decl_index.defs.iter().chain(aliases.iter()).any(|decl| {
3603        decl.name == export_name && decl.span == source.span && decl.body_span == source.body_span
3604    }) {
3605        return;
3606    }
3607    let mut alias = source.clone();
3608    alias.symbol = SymbolId::new(*next_symbol);
3609    *next_symbol = next_symbol.saturating_add(1);
3610    alias.name = export_name;
3611    alias.qualified_name = None;
3612    aliases.push(alias);
3613}
3614
3615fn commonjs_named_export_member(left: &str) -> Option<String> {
3616    let left = left.trim();
3617    let member = left
3618        .strip_prefix("exports.")
3619        .or_else(|| left.strip_prefix("module.exports."))?;
3620    (!member.is_empty()
3621        && member
3622            .chars()
3623            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$'))
3624    .then(|| member.to_string())
3625}
3626
3627enum DefaultExportTarget {
3628    Span(bonsai_common::Span),
3629    Name(String),
3630}
3631
3632#[derive(Clone, Debug)]
3633struct JsDestructureSource {
3634    assign_span: bonsai_common::Span,
3635    target: String,
3636    base: String,
3637    source: String,
3638}
3639
3640fn rewrite_javascript_object_destructuring_sources(
3641    decl_index: &mut DeclIndex,
3642    tree: &Tree,
3643    src: &[u8],
3644    file: FileId,
3645) {
3646    let rewrites = collect_javascript_object_destructuring_sources(tree, src, file);
3647    if rewrites.is_empty() {
3648        return;
3649    }
3650    for decl in &mut decl_index.defs {
3651        let owner_span = decl.body_span.unwrap_or(decl.span);
3652        let relevant = rewrites
3653            .iter()
3654            .filter(|item| span_contains_or_equal(owner_span, item.assign_span))
3655            .cloned()
3656            .collect::<Vec<_>>();
3657        if !relevant.is_empty() {
3658            rewrite_destructuring_sources_in_events(&mut decl.flow_events, &relevant);
3659        }
3660    }
3661}
3662
3663fn collect_javascript_object_destructuring_sources(
3664    tree: &Tree,
3665    src: &[u8],
3666    file: FileId,
3667) -> Vec<JsDestructureSource> {
3668    let mut out = Vec::new();
3669    for declarator in collect_kinds(tree, &["variable_declarator"]) {
3670        let Some(pattern) = declarator.child_by_field_name("name") else {
3671            continue;
3672        };
3673        let Some(value) = declarator.child_by_field_name("value") else {
3674            continue;
3675        };
3676        if pattern.kind() != "object_pattern" {
3677            continue;
3678        }
3679        let base = normalize_js_member_text(node_text(&value, src));
3680        if base.is_empty() {
3681            continue;
3682        }
3683        collect_js_object_pattern_sources(pattern, &base, span_of(file, &declarator), src, &mut out);
3684    }
3685    out
3686}
3687
3688fn collect_js_object_pattern_sources(
3689    pattern: Node<'_>,
3690    base: &str,
3691    assign_span: bonsai_common::Span,
3692    src: &[u8],
3693    out: &mut Vec<JsDestructureSource>,
3694) {
3695    let mut cursor = pattern.walk();
3696    for child in pattern.named_children(&mut cursor) {
3697        match child.kind() {
3698            "shorthand_property_identifier_pattern" => {
3699                let target = node_text(&child, src).trim().to_string();
3700                if !target.is_empty() {
3701                    out.push(JsDestructureSource {
3702                        assign_span,
3703                        base: base.to_string(),
3704                        source: format!("{base}.{target}"),
3705                        target,
3706                    });
3707                }
3708            }
3709            "pair_pattern" => {
3710                let Some(key_node) = child.child_by_field_name("key") else {
3711                    continue;
3712                };
3713                let Some(value_node) = child.child_by_field_name("value") else {
3714                    continue;
3715                };
3716                let Some(key) = js_object_field_key(key_node, src) else {
3717                    continue;
3718                };
3719                if let Some(target) = js_destructure_target_name(value_node, src) {
3720                    out.push(JsDestructureSource {
3721                        assign_span,
3722                        base: base.to_string(),
3723                        source: format!("{base}.{key}"),
3724                        target,
3725                    });
3726                }
3727            }
3728            "object_assignment_pattern" => {
3729                let Some(left) = child.child_by_field_name("left") else {
3730                    continue;
3731                };
3732                let target = node_text(&left, src).trim().to_string();
3733                if !target.is_empty() {
3734                    out.push(JsDestructureSource {
3735                        assign_span,
3736                        base: base.to_string(),
3737                        source: format!("{base}.{target}"),
3738                        target,
3739                    });
3740                }
3741            }
3742            "rest_pattern" => {}
3743            _ => {}
3744        }
3745    }
3746}
3747
3748fn js_destructure_target_name(node: Node<'_>, src: &[u8]) -> Option<String> {
3749    match node.kind() {
3750        "identifier" | "shorthand_property_identifier_pattern" => {
3751            let target = node_text(&node, src).trim().to_string();
3752            (!target.is_empty()).then_some(target)
3753        }
3754        "assignment_pattern" => node
3755            .child_by_field_name("left")
3756            .and_then(|left| js_destructure_target_name(left, src)),
3757        _ => None,
3758    }
3759}
3760
3761fn rewrite_destructuring_sources_in_events(events: &mut Vec<FlowEvent>, rewrites: &[JsDestructureSource]) {
3762    let original = std::mem::take(events);
3763    for mut event in original {
3764        match &mut event {
3765            FlowEvent::Branch {
3766                then_events,
3767                else_events,
3768                ..
3769            } => {
3770                rewrite_destructuring_sources_in_events(then_events, rewrites);
3771                rewrite_destructuring_sources_in_events(else_events, rewrites);
3772            }
3773            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
3774                rewrite_destructuring_sources_in_events(body, rewrites);
3775            }
3776            FlowEvent::Try {
3777                body,
3778                catch_events,
3779                finally_events,
3780                ..
3781            } => {
3782                rewrite_destructuring_sources_in_events(body, rewrites);
3783                rewrite_destructuring_sources_in_events(catch_events, rewrites);
3784                rewrite_destructuring_sources_in_events(finally_events, rewrites);
3785            }
3786            _ => {}
3787        }
3788
3789        let rewrite = match &event {
3790            FlowEvent::Assign { span, target, .. } => rewrites
3791                .iter()
3792                .find(|item| item.target == *target && spans_overlap_or_contain(*span, item.assign_span)),
3793            _ => None,
3794        };
3795        if let Some(rewrite) = rewrite {
3796            // Destructuring consumes both the aggregate and one exact field. Keep
3797            // those as separate events: the IDG source filter intentionally does
3798            // not let a structural `base` source shadow the exact `base.field`
3799            // source on the same event. The shared target/span interns one Write
3800            // node with both incoming edges.
3801            let mut aggregate_event = event.clone();
3802            set_destructuring_assignment_source(
3803                &mut aggregate_event,
3804                &rewrite.base,
3805                bonsai_lang_api::AssignValueKind::Destructure,
3806            );
3807            set_destructuring_assignment_source(
3808                &mut event,
3809                &rewrite.source,
3810                bonsai_lang_api::AssignValueKind::Compound,
3811            );
3812            events.push(aggregate_event);
3813        }
3814        events.push(event);
3815    }
3816}
3817
3818fn set_destructuring_assignment_source(
3819    event: &mut FlowEvent,
3820    source: &str,
3821    assignment_kind: bonsai_lang_api::AssignValueKind,
3822) {
3823    let FlowEvent::Assign {
3824        source_name,
3825        source_call,
3826        source_call_args,
3827        source_names,
3828        value_kind,
3829        ..
3830    } = event
3831    else {
3832        return;
3833    };
3834    *source_name = Some(source.to_string());
3835    *source_call = None;
3836    source_call_args.clear();
3837    source_names.clear();
3838    source_names.push(source.to_string());
3839    *value_kind = Some(assignment_kind);
3840}
3841
3842#[derive(Clone, Debug)]
3843struct JsObjectFieldAssigns {
3844    assign_span: bonsai_common::Span,
3845    target: String,
3846    fields: Vec<FlowEvent>,
3847}
3848
3849fn inject_javascript_object_literal_field_assigns(
3850    decl_index: &mut DeclIndex,
3851    tree: &Tree,
3852    src: &[u8],
3853    file: FileId,
3854) {
3855    let field_assigns = collect_javascript_object_literal_field_assigns(tree, src, file);
3856    if field_assigns.is_empty() {
3857        return;
3858    }
3859    for decl in &mut decl_index.defs {
3860        let owner_span = decl.body_span.unwrap_or(decl.span);
3861        let relevant = field_assigns
3862            .iter()
3863            .filter(|item| span_contains_or_equal(owner_span, item.assign_span))
3864            .cloned()
3865            .collect::<Vec<_>>();
3866        if !relevant.is_empty() {
3867            insert_object_field_assigns_in_events(&mut decl.flow_events, &relevant);
3868        }
3869    }
3870}
3871
3872fn collect_javascript_object_literal_field_assigns(
3873    tree: &Tree,
3874    src: &[u8],
3875    file: FileId,
3876) -> Vec<JsObjectFieldAssigns> {
3877    let mut out = Vec::new();
3878    for declarator in collect_kinds(tree, &["variable_declarator"]) {
3879        let Some(name) = declarator.child_by_field_name("name") else {
3880            continue;
3881        };
3882        let Some(value) = declarator.child_by_field_name("value") else {
3883            continue;
3884        };
3885        if value.kind() != "object" || name.kind() != "identifier" {
3886            continue;
3887        }
3888        let target = node_text(&name, src).trim().to_string();
3889        push_javascript_object_literal_field_assigns(
3890            &mut out,
3891            span_of(file, &declarator),
3892            &target,
3893            value,
3894            src,
3895            file,
3896        );
3897    }
3898    for assignment in collect_kinds(tree, &["assignment_expression"]) {
3899        let Some(left) = assignment.child_by_field_name("left") else {
3900            continue;
3901        };
3902        let Some(right) = assignment.child_by_field_name("right") else {
3903            continue;
3904        };
3905        if right.kind() != "object" {
3906            continue;
3907        }
3908        let target = normalize_js_member_text(node_text(&left, src));
3909        push_javascript_object_literal_field_assigns(
3910            &mut out,
3911            span_of(file, &assignment),
3912            &target,
3913            right,
3914            src,
3915            file,
3916        );
3917    }
3918    out
3919}
3920
3921fn push_javascript_object_literal_field_assigns(
3922    out: &mut Vec<JsObjectFieldAssigns>,
3923    assign_span: bonsai_common::Span,
3924    target: &str,
3925    object: Node<'_>,
3926    src: &[u8],
3927    file: FileId,
3928) {
3929    if target.trim().is_empty() {
3930        return;
3931    }
3932    let mut fields = Vec::new();
3933    let mut cursor = object.walk();
3934    for child in object.named_children(&mut cursor) {
3935        match child.kind() {
3936            "pair" => {
3937                let Some(key_node) = child.child_by_field_name("key") else {
3938                    continue;
3939                };
3940                let Some(value_node) = child.child_by_field_name("value") else {
3941                    continue;
3942                };
3943                let Some(key) = js_object_field_key(key_node, src) else {
3944                    continue;
3945                };
3946                let sources = js_value_source_names(value_node, src);
3947                fields.push(FlowEvent::Assign {
3948                    span: span_of(file, &value_node),
3949                    target: format!("{target}.{key}"),
3950                    source_name: (sources.len() == 1).then(|| sources[0].clone()),
3951                    source_call: None,
3952                    source_call_args: Vec::new(),
3953                    source_names: sources,
3954                    declares_new_binding: false,
3955                    value_kind: Some(bonsai_lang_api::AssignValueKind::Compound),
3956                });
3957            }
3958            "shorthand_property_identifier" => {
3959                let key = node_text(&child, src).trim().to_string();
3960                if key.is_empty() {
3961                    continue;
3962                }
3963                fields.push(FlowEvent::Assign {
3964                    span: span_of(file, &child),
3965                    target: format!("{target}.{key}"),
3966                    source_name: Some(key.clone()),
3967                    source_call: None,
3968                    source_call_args: Vec::new(),
3969                    source_names: vec![key],
3970                    declares_new_binding: false,
3971                    value_kind: Some(bonsai_lang_api::AssignValueKind::Compound),
3972                });
3973            }
3974            "spread_element" => {}
3975            _ => {}
3976        }
3977    }
3978    if !fields.is_empty() {
3979        out.push(JsObjectFieldAssigns {
3980            assign_span,
3981            target: target.to_string(),
3982            fields,
3983        });
3984    }
3985}
3986
3987fn insert_object_field_assigns_in_events(
3988    events: &mut Vec<FlowEvent>,
3989    field_assigns: &[JsObjectFieldAssigns],
3990) {
3991    let mut index = 0usize;
3992    while index < events.len() {
3993        match &mut events[index] {
3994            FlowEvent::Branch {
3995                then_events,
3996                else_events,
3997                ..
3998            } => {
3999                insert_object_field_assigns_in_events(then_events, field_assigns);
4000                insert_object_field_assigns_in_events(else_events, field_assigns);
4001            }
4002            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
4003                insert_object_field_assigns_in_events(body, field_assigns);
4004            }
4005            FlowEvent::Try {
4006                body,
4007                catch_events,
4008                finally_events,
4009                ..
4010            } => {
4011                insert_object_field_assigns_in_events(body, field_assigns);
4012                insert_object_field_assigns_in_events(catch_events, field_assigns);
4013                insert_object_field_assigns_in_events(finally_events, field_assigns);
4014            }
4015            _ => {}
4016        }
4017
4018        let inserts = match &events[index] {
4019            FlowEvent::Assign { span, target, .. } => field_assigns
4020                .iter()
4021                .filter(|item| item.target == *target && spans_overlap_or_contain(*span, item.assign_span))
4022                .flat_map(|item| item.fields.clone())
4023                .filter(|field_event| !event_list_contains_assign(events, field_event))
4024                .collect::<Vec<_>>(),
4025            _ => Vec::new(),
4026        };
4027        if inserts.is_empty() {
4028            index += 1;
4029            continue;
4030        }
4031        let inserted = inserts.len();
4032        events.splice((index + 1)..=index, inserts);
4033        index += inserted + 1;
4034    }
4035}
4036
4037fn event_list_contains_assign(events: &[FlowEvent], candidate: &FlowEvent) -> bool {
4038    let FlowEvent::Assign {
4039        span: wanted_span,
4040        target: wanted_target,
4041        ..
4042    } = candidate
4043    else {
4044        return false;
4045    };
4046    events.iter().any(|event| match event {
4047        FlowEvent::Assign { span, target, .. } => span == wanted_span && target == wanted_target,
4048        FlowEvent::Branch {
4049            then_events,
4050            else_events,
4051            ..
4052        } => {
4053            event_list_contains_assign(then_events, candidate)
4054                || event_list_contains_assign(else_events, candidate)
4055        }
4056        FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
4057            event_list_contains_assign(body, candidate)
4058        }
4059        FlowEvent::Try {
4060            body,
4061            catch_events,
4062            finally_events,
4063            ..
4064        } => {
4065            event_list_contains_assign(body, candidate)
4066                || event_list_contains_assign(catch_events, candidate)
4067                || event_list_contains_assign(finally_events, candidate)
4068        }
4069        _ => false,
4070    })
4071}
4072
4073fn js_object_field_key(node: Node<'_>, src: &[u8]) -> Option<String> {
4074    let raw = node_text(&node, src).trim();
4075    let key = raw
4076        .strip_prefix('"')
4077        .and_then(|part| part.strip_suffix('"'))
4078        .or_else(|| raw.strip_prefix('\'').and_then(|part| part.strip_suffix('\'')))
4079        .or_else(|| raw.strip_prefix('`').and_then(|part| part.strip_suffix('`')))
4080        .unwrap_or(raw)
4081        .trim();
4082    if key.is_empty()
4083        || !key
4084            .chars()
4085            .all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric())
4086    {
4087        return None;
4088    }
4089    Some(key.to_string())
4090}
4091
4092fn js_value_source_names(node: Node<'_>, src: &[u8]) -> Vec<String> {
4093    let mut out = Vec::new();
4094    collect_js_value_source_names(node, src, &mut out);
4095    out.sort();
4096    out.dedup();
4097    out
4098}
4099
4100fn collect_js_value_source_names(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
4101    match node.kind() {
4102        "identifier" | "shorthand_property_identifier" => {
4103            push_js_source_name(out, node_text(&node, src).trim());
4104        }
4105        "member_expression" => {
4106            push_js_source_name(out, &normalize_js_member_text(node_text(&node, src)));
4107            if let Some(object) = node.child_by_field_name("object") {
4108                collect_js_value_source_names(object, src, out);
4109            }
4110            return;
4111        }
4112        "string" | "number" | "true" | "false" | "null" | "undefined" | "property_identifier" => return,
4113        _ => {}
4114    }
4115    let mut cursor = node.walk();
4116    for child in node.named_children(&mut cursor) {
4117        collect_js_value_source_names(child, src, out);
4118    }
4119}
4120
4121fn push_js_source_name(out: &mut Vec<String>, source: &str) {
4122    let source = source.trim();
4123    if source.is_empty()
4124        || source.chars().next().is_some_and(|ch| ch.is_ascii_digit())
4125        || source.contains(['"', '\'', '`', '{', '}'])
4126    {
4127        return;
4128    }
4129    out.push(source.to_string());
4130}
4131
4132fn normalize_js_member_text(text: &str) -> String {
4133    text.trim()
4134        .replace("?.", ".")
4135        .replace("?.[", ".[")
4136        .replace([' ', '\t', '\n', '\r'], "")
4137        .trim_end_matches(';')
4138        .to_string()
4139}
4140
4141fn span_contains_or_equal(outer: bonsai_common::Span, inner: bonsai_common::Span) -> bool {
4142    outer.file == inner.file && outer.start <= inner.start && outer.end >= inner.end
4143}
4144
4145fn spans_overlap_or_contain(left: bonsai_common::Span, right: bonsai_common::Span) -> bool {
4146    left.file == right.file
4147        && (span_contains_or_equal(left, right)
4148            || span_contains_or_equal(right, left)
4149            || (left.start <= right.end && right.start <= left.end))
4150}
4151
4152/// CommonJS `const x = require("y")` / `const { a } = require("y")` /
4153/// `const { a: b } = require("y")`. Walks `call_expression` nodes whose
4154/// function name is `require`.
4155/// Normalize a Node.js builtin-module specifier by dropping the
4156/// explicit `node:` scheme. `require("node:child_process")` and
4157/// `import "node:fs/promises"` name the exact same builtins as their
4158/// bare forms (`child_process`, `fs/promises`); rules and the resolver
4159/// key on the bare name, so canonicalizing here keeps the prefixed
4160/// form from silently bypassing package gates and alias-based callee
4161/// rewrites. Non-builtin specifiers (relative paths, scoped packages)
4162/// are returned unchanged.
4163fn normalize_node_builtin_scheme(module: &str) -> String {
4164    module.strip_prefix("node:").unwrap_or(module).to_string()
4165}
4166
4167pub fn js_ts_require_calls(file: FileId, tree: &tree_sitter::Tree, src: &[u8]) -> Vec<ImportSpec> {
4168    let mut imports = Vec::new();
4169    for call_node in collect_kinds(tree, &["call_expression"]) {
4170        let Some(callee) = call_node.child_by_field_name("function") else {
4171            continue;
4172        };
4173        // Syntactic gate: only bare `require(...)` calls. Member calls
4174        // (`foo.require(...)`) and other shapes are not module imports.
4175        if node_text(&callee, src) != "require" {
4176            continue;
4177        }
4178        let Some(arguments) = call_node.child_by_field_name("arguments") else {
4179            continue;
4180        };
4181        // Module path comes from the first string literal argument.
4182        let module = first_named_child_of_kind(&arguments, "string")
4183            .and_then(|string_node| first_named_child_of_kind(&string_node, "string_fragment"))
4184            .map(|fragment| node_text(&fragment, src).to_string())
4185            .unwrap_or_default();
4186        let module = normalize_node_builtin_scheme(&module);
4187        if module.is_empty() {
4188            continue;
4189        }
4190        // Anchor the import to the LHS binding: this is what makes a
4191        // bare `require()` participate in module-resolution.
4192        let declarator = call_node
4193            .parent()
4194            .filter(|parent| parent.kind() == "variable_declarator");
4195        let lhs_name_node = declarator.and_then(|vd| vd.child_by_field_name("name"));
4196        // Case 1: `const x = require("y")` — simple identifier binding.
4197        let simple_alias = lhs_name_node
4198            .filter(|name| name.kind() == "identifier")
4199            .map(|name| node_text(&name, src).to_string());
4200        // Case 2: `const { a: b, c } = require("y")` — destructured object pattern.
4201        //   - `{ a: b }` (rename) is Module-scope: `b` is a distinct local binding.
4202        //   - `{ a }` (shorthand) is Local-scope: bare `a(...)` expands to `module.a`,
4203        //     but default `imports` browse hides these to reduce noise.
4204        let mut rename_entries: Vec<(String, String)> = Vec::new();
4205        let mut shorthand_entries: Vec<String> = Vec::new();
4206        if let Some(object_pattern) = lhs_name_node.filter(|name| name.kind() == "object_pattern") {
4207            let mut pattern_cursor = object_pattern.walk();
4208            for pattern_child in object_pattern.named_children(&mut pattern_cursor) {
4209                match pattern_child.kind() {
4210                    "pair_pattern" => {
4211                        let key_node = pattern_child.child_by_field_name("key");
4212                        let value_node = pattern_child.child_by_field_name("value");
4213                        if let (Some(key), Some(value)) = (key_node, value_node) {
4214                            let original_name = node_text(&key, src).to_string();
4215                            let local_name = node_text(&value, src).to_string();
4216                            // Skip self-renames — same name on both sides is a shorthand.
4217                            if !original_name.is_empty()
4218                                && !local_name.is_empty()
4219                                && original_name != local_name
4220                            {
4221                                rename_entries.push((original_name, local_name));
4222                            }
4223                        }
4224                    }
4225                    "shorthand_property_identifier_pattern" => {
4226                        let name = node_text(&pattern_child, src).to_string();
4227                        if !name.is_empty() {
4228                            shorthand_entries.push(name);
4229                        }
4230                    }
4231                    "object_assignment_pattern" => {
4232                        // `{ exec = noop }` — destructure with default; treat the LHS as a shorthand.
4233                        if let Some(left) = pattern_child.child_by_field_name("left") {
4234                            let name = node_text(&left, src).to_string();
4235                            if !name.is_empty() {
4236                                shorthand_entries.push(name);
4237                            }
4238                        }
4239                    }
4240                    _ => {}
4241                }
4242            }
4243        }
4244        // Base entry: always one per `require()` call, carrying the simple binding alias if any.
4245        imports.push(ImportSpec {
4246            span: span_of(file, &call_node),
4247            module: module.clone(),
4248            alias: simple_alias,
4249            is_wildcard: false,
4250            original_name: None,
4251            scope: ImportScope::Module,
4252        });
4253        // Rename entries: one per `{ orig: local }` pair, surfaced at module scope.
4254        for (original_name, local_name) in rename_entries {
4255            imports.push(ImportSpec {
4256                span: span_of(file, &call_node),
4257                module: module.clone(),
4258                alias: Some(local_name),
4259                is_wildcard: false,
4260                original_name: Some(original_name),
4261                scope: ImportScope::Module,
4262            });
4263        }
4264        // Shorthand entries: one per `{ a }` binding, kept at local scope (browse hides these).
4265        for shorthand_name in shorthand_entries {
4266            imports.push(ImportSpec {
4267                span: span_of(file, &call_node),
4268                module: module.clone(),
4269                alias: Some(shorthand_name.clone()),
4270                is_wildcard: false,
4271                original_name: Some(shorthand_name),
4272                scope: ImportScope::Local,
4273            });
4274        }
4275    }
4276    imports
4277}
4278
4279/// Walk every `class_declaration` and `class` node, harvest its
4280/// `class_heritage > extends_clause` base names. JS has only single
4281/// inheritance, but the result shape mirrors the TypeScript adapter
4282/// for a consistent `Decl.bases` contract.
4283fn collect_javascript_class_bases(
4284    tree: &Tree,
4285    file: FileId,
4286    src: &[u8],
4287) -> Vec<(bonsai_common::Span, Vec<String>)> {
4288    let mut bases_by_class = Vec::new();
4289    for class_node in collect_kinds(tree, &["class_declaration", "class"]) {
4290        let mut bases: Vec<String> = Vec::new();
4291        let mut class_cursor = class_node.walk();
4292        for class_child in class_node.named_children(&mut class_cursor) {
4293            // Both wrapper and extends_clause shapes are accepted — grammar revisions vary.
4294            if matches!(class_child.kind(), "class_heritage" | "extends_clause") {
4295                collect_js_extends_names(class_child, src, &mut bases);
4296            }
4297        }
4298        if !bases.is_empty() {
4299            bases_by_class.push((span_of(file, &class_node), bases));
4300        }
4301    }
4302    bases_by_class
4303}
4304
4305/// Recursively walk a JS heritage subtree and append any base name we
4306/// encounter (deduped). Member expressions like `mod.Base` collapse to
4307/// the right-most segment so `Base` matches a class declaration.
4308fn collect_js_extends_names(node: Node<'_>, src: &[u8], collected_bases: &mut Vec<String>) {
4309    let mut stack = vec![node];
4310    while let Some(current) = stack.pop() {
4311        match current.kind() {
4312            "class_heritage" | "extends_clause" => {
4313                // Wrapper node — descend into its children to find the actual base name.
4314                let mut cursor = current.walk();
4315                for child in current.named_children(&mut cursor) {
4316                    stack.push(child);
4317                }
4318            }
4319            "identifier" | "member_expression" => {
4320                let raw_text = node_text(&current, src).to_string();
4321                // `a.b.Base` -> `Base`. The resolver matches on bare names.
4322                let canonical = raw_text
4323                    .rsplit('.')
4324                    .next()
4325                    .unwrap_or(raw_text.as_str())
4326                    .to_string();
4327                if !canonical.is_empty() && !collected_bases.iter().any(|b| b == &canonical) {
4328                    collected_bases.push(canonical);
4329                }
4330            }
4331            _ => {
4332                // Unknown wrapper (e.g. parenthesized expression) — keep descending.
4333                let mut cursor = current.walk();
4334                for child in current.named_children(&mut cursor) {
4335                    stack.push(child);
4336                }
4337            }
4338        }
4339    }
4340}
4341
4342fn rewrite_javascript_super_constructor_invocations(index: &mut DeclIndex) {
4343    let class_info: HashMap<SymbolId, (String, Vec<String>)> = index
4344        .defs
4345        .iter()
4346        .filter(|decl| matches!(decl.kind, DeclKind::Class))
4347        .map(|decl| (decl.symbol, (decl.name.clone(), decl.bases.clone())))
4348        .collect();
4349
4350    for decl in &mut index.defs {
4351        if !matches!(decl.kind, DeclKind::Constructor) {
4352            continue;
4353        }
4354        let Some(parent) = decl.parent else {
4355            continue;
4356        };
4357        let Some((_, bases)) = class_info.get(&parent) else {
4358            continue;
4359        };
4360        rewrite_javascript_super_constructor_invocations_in_events(
4361            &mut decl.flow_events,
4362            bases.first().map(String::as_str),
4363        );
4364    }
4365}
4366
4367fn rewrite_javascript_super_constructor_invocations_in_events(
4368    events: &mut [FlowEvent],
4369    super_ctor: Option<&str>,
4370) {
4371    for event in events {
4372        match event {
4373            FlowEvent::Call {
4374                name,
4375                receiver,
4376                receiver_types,
4377                call_kind,
4378                ..
4379            } => {
4380                if let Some(super_ctor) = super_ctor.filter(|ctor| !ctor.is_empty()) {
4381                    if name.trim() == "super" {
4382                        name.clear();
4383                        name.push_str(super_ctor);
4384                        *receiver = Some("super".to_string());
4385                        receiver_types.clear();
4386                        *call_kind = bonsai_lang_api::CallKind::Constructor;
4387                    }
4388                }
4389            }
4390            FlowEvent::Branch {
4391                then_events,
4392                else_events,
4393                ..
4394            } => {
4395                rewrite_javascript_super_constructor_invocations_in_events(then_events, super_ctor);
4396                rewrite_javascript_super_constructor_invocations_in_events(else_events, super_ctor);
4397            }
4398            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
4399                rewrite_javascript_super_constructor_invocations_in_events(body, super_ctor);
4400            }
4401            FlowEvent::Try {
4402                body,
4403                catch_events,
4404                finally_events,
4405                ..
4406            } => {
4407                rewrite_javascript_super_constructor_invocations_in_events(body, super_ctor);
4408                rewrite_javascript_super_constructor_invocations_in_events(catch_events, super_ctor);
4409                rewrite_javascript_super_constructor_invocations_in_events(finally_events, super_ctor);
4410            }
4411            FlowEvent::Assign { .. }
4412            | FlowEvent::AggregateAssign { .. }
4413            | FlowEvent::Return { .. }
4414            | FlowEvent::Throw { .. }
4415            | FlowEvent::Break { .. }
4416            | FlowEvent::Continue { .. }
4417            | FlowEvent::Yield { .. }
4418            | FlowEvent::Await { .. }
4419            | FlowEvent::Lifecycle { .. } => {}
4420        }
4421    }
4422}
4423
4424#[derive(Clone, Debug, PartialEq, Eq)]
4425struct JsGetterProjection {
4426    property: String,
4427    projected_source: String,
4428}
4429
4430pub fn apply_javascript_getter_property_sources(
4431    decl_index: &mut DeclIndex,
4432    tree: &Tree,
4433    src: &[u8],
4434    file: FileId,
4435) {
4436    let own_getters = collect_javascript_getter_projections(decl_index, tree, src, file);
4437    if own_getters.is_empty() {
4438        return;
4439    }
4440
4441    let mut class_symbols = Vec::new();
4442    let mut class_symbol_by_name: HashMap<String, SymbolId> = HashMap::new();
4443    let mut base_symbols_by_class: HashMap<SymbolId, Vec<SymbolId>> = HashMap::new();
4444    for decl in &decl_index.defs {
4445        if !matches!(decl.kind, DeclKind::Class) {
4446            continue;
4447        }
4448        class_symbols.push(decl.symbol);
4449        class_symbol_by_name.insert(canonical_js_class_name(&decl.name), decl.symbol);
4450        if let Some(qualified) = &decl.qualified_name {
4451            class_symbol_by_name.insert(canonical_js_class_name(qualified), decl.symbol);
4452        }
4453    }
4454    for decl in &decl_index.defs {
4455        if !matches!(decl.kind, DeclKind::Class) {
4456            continue;
4457        }
4458        let bases = decl
4459            .bases
4460            .iter()
4461            .filter_map(|base| class_symbol_by_name.get(&canonical_js_class_name(base)).copied())
4462            .collect::<Vec<_>>();
4463        if !bases.is_empty() {
4464            base_symbols_by_class.insert(decl.symbol, bases);
4465        }
4466    }
4467
4468    let mut getters_by_class: HashMap<SymbolId, Vec<JsGetterProjection>> = HashMap::new();
4469    for class_symbol in class_symbols {
4470        let mut projections = Vec::new();
4471        let mut seen_properties = HashSet::new();
4472        let mut visiting = HashSet::new();
4473        collect_getters_for_class(
4474            class_symbol,
4475            &own_getters,
4476            &base_symbols_by_class,
4477            &mut seen_properties,
4478            &mut visiting,
4479            &mut projections,
4480        );
4481        if !projections.is_empty() {
4482            getters_by_class.insert(class_symbol, projections);
4483        }
4484    }
4485
4486    for decl in &mut decl_index.defs {
4487        if !matches!(decl.kind, DeclKind::Method | DeclKind::Constructor) {
4488            continue;
4489        }
4490        let Some(parent) = decl.parent else {
4491            continue;
4492        };
4493        let Some(projections) = getters_by_class.get(&parent) else {
4494            continue;
4495        };
4496        enrich_getter_property_sources_in_events(&mut decl.flow_events, projections);
4497    }
4498}
4499
4500fn collect_javascript_getter_projections(
4501    decl_index: &DeclIndex,
4502    tree: &Tree,
4503    src: &[u8],
4504    file: FileId,
4505) -> HashMap<SymbolId, Vec<JsGetterProjection>> {
4506    let mut by_class: HashMap<SymbolId, Vec<JsGetterProjection>> = HashMap::new();
4507    for method in collect_kinds(tree, &["method_definition"]) {
4508        if !is_javascript_getter_method(method, src) {
4509            continue;
4510        }
4511        let method_span = span_of(file, &method);
4512        let Some(decl) = decl_index.defs.iter().find(|decl| {
4513            decl.span == method_span && matches!(decl.kind, DeclKind::Method) && decl.parent.is_some()
4514        }) else {
4515            continue;
4516        };
4517        let Some(parent) = decl.parent else {
4518            continue;
4519        };
4520        let Some(projected_source) = first_simple_js_getter_return_projection(&decl.flow_events) else {
4521            continue;
4522        };
4523        let projection = JsGetterProjection {
4524            property: decl.name.clone(),
4525            projected_source,
4526        };
4527        let entries = by_class.entry(parent).or_default();
4528        if !entries.iter().any(|existing| existing == &projection) {
4529            entries.push(projection);
4530        }
4531    }
4532    by_class
4533}
4534
4535fn is_javascript_getter_method(method: Node<'_>, src: &[u8]) -> bool {
4536    if method.kind() != "method_definition" {
4537        return false;
4538    }
4539    let Some(name) = method.child_by_field_name("name") else {
4540        return false;
4541    };
4542    let Some(prefix) = src.get(method.start_byte()..name.start_byte()) else {
4543        return false;
4544    };
4545    let Ok(prefix) = std::str::from_utf8(prefix) else {
4546        return false;
4547    };
4548    prefix
4549        .split(|ch: char| !ch.is_ascii_alphabetic())
4550        .any(|token| token == "get")
4551}
4552
4553fn first_simple_js_getter_return_projection(events: &[FlowEvent]) -> Option<String> {
4554    for event in events {
4555        match event {
4556            FlowEvent::Return { value_flow, .. } => {
4557                if let Some(projected) = value_flow.projection.as_ref().and_then(|projection| {
4558                    matches!(projection.base.as_str(), "this" | "super").then(|| projection.canonical_place())
4559                }) {
4560                    return Some(projected);
4561                }
4562            }
4563            FlowEvent::Branch {
4564                then_events,
4565                else_events,
4566                ..
4567            } => {
4568                if let Some(projected) = first_simple_js_getter_return_projection(then_events)
4569                    .or_else(|| first_simple_js_getter_return_projection(else_events))
4570                {
4571                    return Some(projected);
4572                }
4573            }
4574            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
4575                if let Some(projected) = first_simple_js_getter_return_projection(body) {
4576                    return Some(projected);
4577                }
4578            }
4579            FlowEvent::Try {
4580                body,
4581                catch_events,
4582                finally_events,
4583                ..
4584            } => {
4585                if let Some(projected) = first_simple_js_getter_return_projection(body)
4586                    .or_else(|| first_simple_js_getter_return_projection(catch_events))
4587                    .or_else(|| first_simple_js_getter_return_projection(finally_events))
4588                {
4589                    return Some(projected);
4590                }
4591            }
4592            _ => {}
4593        }
4594    }
4595    None
4596}
4597
4598fn collect_getters_for_class(
4599    class_symbol: SymbolId,
4600    own_getters: &HashMap<SymbolId, Vec<JsGetterProjection>>,
4601    base_symbols_by_class: &HashMap<SymbolId, Vec<SymbolId>>,
4602    seen_properties: &mut HashSet<String>,
4603    visiting: &mut HashSet<SymbolId>,
4604    out: &mut Vec<JsGetterProjection>,
4605) {
4606    if !visiting.insert(class_symbol) {
4607        return;
4608    }
4609    if let Some(getters) = own_getters.get(&class_symbol) {
4610        for getter in getters {
4611            if seen_properties.insert(getter.property.clone()) {
4612                out.push(getter.clone());
4613            }
4614        }
4615    }
4616    if let Some(bases) = base_symbols_by_class.get(&class_symbol) {
4617        for base in bases {
4618            collect_getters_for_class(
4619                *base,
4620                own_getters,
4621                base_symbols_by_class,
4622                seen_properties,
4623                visiting,
4624                out,
4625            );
4626        }
4627    }
4628    visiting.remove(&class_symbol);
4629}
4630
4631fn enrich_getter_property_sources_in_events(events: &mut [FlowEvent], projections: &[JsGetterProjection]) {
4632    for event in events {
4633        match event {
4634            FlowEvent::Assign {
4635                source_name,
4636                source_names,
4637                ..
4638            } => {
4639                if let Some(projected) = source_name
4640                    .as_deref()
4641                    .and_then(|source| projected_js_getter_source(source, projections))
4642                {
4643                    push_unique_source(source_names, projected);
4644                }
4645                enrich_getter_source_names(source_names, projections);
4646            }
4647            FlowEvent::Call { args, .. } => {
4648                for arg in args {
4649                    enrich_getter_sources_in_call_arg(arg, projections);
4650                }
4651            }
4652            FlowEvent::Branch {
4653                then_events,
4654                else_events,
4655                ..
4656            } => {
4657                enrich_getter_property_sources_in_events(then_events, projections);
4658                enrich_getter_property_sources_in_events(else_events, projections);
4659            }
4660            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
4661                enrich_getter_property_sources_in_events(body, projections);
4662            }
4663            FlowEvent::Try {
4664                body,
4665                catch_events,
4666                finally_events,
4667                ..
4668            } => {
4669                enrich_getter_property_sources_in_events(body, projections);
4670                enrich_getter_property_sources_in_events(catch_events, projections);
4671                enrich_getter_property_sources_in_events(finally_events, projections);
4672            }
4673            _ => {}
4674        }
4675    }
4676}
4677
4678fn enrich_getter_sources_in_call_arg(arg: &mut CallArg, projections: &[JsGetterProjection]) {
4679    let mut candidates = Vec::new();
4680    candidates.push(arg.value_text.clone());
4681    if let Some(place) = arg.place.as_deref() {
4682        candidates.push(place.to_string());
4683    }
4684    for source in &arg.source_names {
4685        candidates.push(source.clone());
4686    }
4687    for candidate in candidates {
4688        if let Some(projected) = projected_js_getter_source(&candidate, projections) {
4689            push_unique_source(&mut arg.source_names, projected);
4690        }
4691    }
4692    enrich_getter_source_names(&mut arg.source_names, projections);
4693}
4694
4695fn enrich_getter_source_names(source_names: &mut Vec<String>, projections: &[JsGetterProjection]) {
4696    let existing = source_names.clone();
4697    for source in existing {
4698        if let Some(projected) = projected_js_getter_source(&source, projections) {
4699            push_unique_source(source_names, projected);
4700        }
4701    }
4702}
4703
4704fn projected_js_getter_source(source: &str, projections: &[JsGetterProjection]) -> Option<String> {
4705    let source = source.trim();
4706    for projection in projections {
4707        for receiver in ["this", "super"] {
4708            let property_read = format!("{receiver}.{}", projection.property);
4709            if source != property_read {
4710                continue;
4711            }
4712            if receiver == "this" {
4713                return Some(projection.projected_source.clone());
4714            }
4715            if let Some(rest) = projection.projected_source.strip_prefix("this.") {
4716                return Some(format!("super.{rest}"));
4717            }
4718            return Some(projection.projected_source.clone());
4719        }
4720    }
4721    None
4722}
4723
4724fn push_unique_source(source_names: &mut Vec<String>, source: String) {
4725    if !source.trim().is_empty() && !source_names.iter().any(|existing| existing == &source) {
4726        source_names.push(source);
4727    }
4728}
4729
4730fn canonical_js_class_name(name: &str) -> String {
4731    name.rsplit('.').next().unwrap_or(name).trim().to_string()
4732}
4733
4734/// Split a workspace-relative JS/TS path into module-identity segments.
4735/// The trailing source extension is stripped so `src/utils/log.ts` becomes
4736/// `["src", "utils", "log"]`. Skips path roots and parent (`..`) components.
4737pub fn js_ts_module_segments(path: &std::path::Path) -> Vec<String> {
4738    let mut segments: Vec<String> = path
4739        .components()
4740        .filter_map(|component| match component {
4741            std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
4742            _ => None,
4743        })
4744        .collect();
4745    if let Some(last_segment) = segments.last_mut() {
4746        // Strip exactly one source extension; `.tsx` is checked before `.ts`.
4747        for extension in [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs"] {
4748            if last_segment.ends_with(extension) {
4749                *last_segment = last_segment.trim_end_matches(extension).to_string();
4750                break;
4751            }
4752        }
4753    }
4754    segments.retain(|segment| !segment.is_empty());
4755    segments
4756}
4757
4758#[cfg(test)]
4759mod syntax_tests {
4760    use super::{collect_kinds, export_statement_has_default_modifier, language_from_pack, PACK_NAME};
4761
4762    fn export_has_default_modifier(source: &str) -> bool {
4763        let language = language_from_pack(PACK_NAME).expect("javascript grammar");
4764        let mut parser = tree_sitter::Parser::new();
4765        parser.set_language(&language).expect("set javascript grammar");
4766        let tree = parser.parse(source, None).expect("parse javascript");
4767        let exports = collect_kinds(&tree, &["export_statement"]);
4768        assert_eq!(exports.len(), 1, "expected one parsed export statement");
4769        export_statement_has_default_modifier(exports[0])
4770    }
4771
4772    #[test]
4773    fn default_export_modifier_comes_from_the_syntax_tree() {
4774        assert!(export_has_default_modifier("export default app;"));
4775        assert!(!export_has_default_modifier("export { app as default };"));
4776        assert!(!export_has_default_modifier("export const app = 1;"));
4777    }
4778}