"""Tree-sitter based multi-language code parser.

Extracts structural nodes (classes, functions, imports, types) and edges
(calls, inheritance, contains) from source files.
"""

from __future__ import annotations

import hashlib
import json
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import NamedTuple, Optional

import tree_sitter_language_pack as tslp

from .tsconfig_resolver import TsconfigResolver


class CellInfo(NamedTuple):
    """Represents a single cell in a notebook with its language."""
    cell_index: int
    language: str
    source: str


_SQL_TABLE_RE = re.compile(
    r"(?:FROM|JOIN|INTO|CREATE\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW)|INSERT\s+OVERWRITE)"
    r"\s+((?:`[^`]+`|\w+)(?:\.(?:`[^`]+`|\w+))*)",
    re.IGNORECASE,
)

# SQL keywords that can appear after FROM/JOIN but are NOT table names.
_SQL_KEYWORDS: frozenset[str] = frozenset({
    "SELECT", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET",
    "UNION", "INTERSECT", "EXCEPT", "AS", "ON", "USING", "SET",
    "VALUES", "DEFAULT", "NULL", "TRUE", "FALSE",
    "INNER", "OUTER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL",
    "LATERAL", "RECURSIVE", "ONLY", "WITH",
})

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Data models for extracted entities
# ---------------------------------------------------------------------------


@dataclass
class NodeInfo:
    kind: str  # File, Class, Function, Type, Test
    name: str
    file_path: str
    line_start: int
    line_end: int
    language: str = ""
    parent_name: Optional[str] = None  # enclosing class/module
    params: Optional[str] = None
    return_type: Optional[str] = None
    modifiers: Optional[str] = None
    is_test: bool = False
    extra: dict = field(default_factory=dict)


@dataclass
class EdgeInfo:
    # CALLS, IMPORTS_FROM, INHERITS, IMPLEMENTS, CONTAINS,
    # TESTED_BY, DEPENDS_ON, REFERENCES
    kind: str
    source: str  # qualified name or path
    target: str  # qualified name or path
    file_path: str
    line: int = 0
    extra: dict = field(default_factory=dict)


# ---------------------------------------------------------------------------
# Language extension mapping
# ---------------------------------------------------------------------------

EXTENSION_TO_LANGUAGE: dict[str, str] = {
    ".py": "python",
    ".js": "javascript",
    ".jsx": "javascript",
    ".ts": "typescript",
    ".tsx": "tsx",
    ".go": "go",
    ".rs": "rust",
    ".java": "java",
    ".cs": "csharp",
    ".rb": "ruby",
    ".cpp": "cpp",
    ".cc": "cpp",
    ".cxx": "cpp",
    ".c": "c",
    ".h": "c",
    ".hpp": "cpp",
    ".hh": "cpp",
    ".kt": "kotlin",
    ".swift": "swift",
    ".php": "php",
    ".scala": "scala",
    ".sol": "solidity",
    ".vue": "vue",
    ".dart": "dart",
    ".r": "r",  # .lower() in detect_language handles .R → .r
    ".mjs": "javascript",
    ".astro": "typescript",
    ".pl": "perl",
    ".pm": "perl",
    ".t": "perl",
    ".xs": "c",  # Perl XS: parsed as C to capture functions/structs/includes
    ".lua": "lua",
    ".luau": "luau",
    ".m": "objc",  # Objective-C (.h still maps to C; .mm defers to C++ for simplicity)
    ".sh": "bash",
    ".bash": "bash",
    ".zsh": "bash",
    ".ksh": "bash",  # Korn shell — close enough to bash for tree-sitter-bash (#235)
    ".ex": "elixir",
    ".exs": "elixir",
    ".ipynb": "notebook",
    ".zig": "zig",
    ".ps1": "powershell",
    ".psm1": "powershell",
    ".psd1": "powershell",
    ".svelte": "svelte",
    ".jl": "julia",
    # ReScript: .res is implementation, .resi is interface. Both share one
    # language label; the parser flags interface files via extra metadata.
    # No tree-sitter grammar is bundled in tree_sitter_language_pack, so
    # extraction is regex-based (see _parse_rescript).
    ".res": "rescript",
    ".resi": "rescript",
    ".gd": "gdscript",
    ".nix": "nix",
    # SystemVerilog/Verilog
    ".sv": "verilog",
    ".svh": "verilog",
    ".v": "verilog",
    ".vh": "verilog",
    ".sql": "sql",
}

# Shebang interpreter → language mapping for extension-less Unix scripts.
# Each key is the **basename** of the interpreter path as it appears after
# ``#!`` (or after ``#!/usr/bin/env``).  Only languages already registered
# above are listed — this file strictly routes extension-less scripts, it
# does NOT introduce new languages on its own.  See issue #237.
SHEBANG_INTERPRETER_TO_LANGUAGE: dict[str, str] = {
    # POSIX / bash-compatible shells — all routed through tree-sitter-bash
    "bash": "bash",
    "sh": "bash",
    "zsh": "bash",
    "ksh": "bash",
    "dash": "bash",
    "ash": "bash",
    # Python (every common variant)
    "python": "python",
    "python2": "python",
    "python3": "python",
    "pypy": "python",
    "pypy3": "python",
    # JavaScript via Node
    "node": "javascript",
    "nodejs": "javascript",
    # Ruby / Perl / Lua / R / PHP
    "ruby": "ruby",
    "perl": "perl",
    "lua": "lua",
    "Rscript": "r",
    "php": "php",
}

# Maximum bytes to read from the head of a file when probing for a shebang.
# 256 is enough for any reasonable shebang line (``#!/usr/bin/env python3 -u\n``
# is ~30 chars) while keeping the worst-case read tiny even on fat binaries.
_SHEBANG_PROBE_BYTES = 256

# Tree-sitter node type mappings per language
# Maps (language) -> dict of semantic role -> list of TS node types
_CLASS_TYPES: dict[str, list[str]] = {
    "python": ["class_definition"],
    "javascript": ["class_declaration", "class"],
    "typescript": ["class_declaration", "class"],
    "tsx": ["class_declaration", "class"],
    "go": ["type_declaration"],
    "rust": ["struct_item", "enum_item", "impl_item"],
    "java": ["class_declaration", "interface_declaration", "enum_declaration"],
    "c": ["struct_specifier", "type_definition"],
    "cpp": ["class_specifier", "struct_specifier"],
    "csharp": [
        "class_declaration", "interface_declaration",
        "enum_declaration", "struct_declaration",
    ],
    "ruby": ["class", "module"],
    "r": [],  # Classes detected via call pattern-matching, not AST node types
    "perl": ["package_statement", "class_statement", "role_statement"],
    "kotlin": ["class_declaration", "object_declaration"],
    "swift": ["class_declaration", "struct_declaration", "protocol_declaration"],
    "php": ["class_declaration", "interface_declaration"],
    "scala": [
        "class_definition", "trait_definition", "object_definition", "enum_definition",
    ],
    "solidity": [
        "contract_declaration", "interface_declaration", "library_declaration",
        "struct_declaration", "enum_declaration", "error_declaration",
        "user_defined_type_definition",
    ],
    "dart": ["class_definition", "mixin_declaration", "enum_declaration"],
    "lua": [],  # Lua has no class keyword; table-based OOP handled via constructs handler
    "luau": ["type_definition"],  # Luau type aliases; table-based OOP via constructs handler
    "objc": [
        "class_interface", "class_implementation",
        "category_interface", "protocol_declaration",
    ],
    "bash": [],  # Shell has no classes
    # Elixir: `defmodule Name do ... end` is a ``call`` node whose first
    # identifier is literally "defmodule". Dispatched via
    # _extract_elixir_constructs to avoid matching every ``call`` here.
    "elixir": [],
    # Nix: attrset bindings aren't "classes"; dispatched via
    # _extract_nix_constructs.
    "nix": [],
    "zig": ["container_declaration"],
    "powershell": ["class_statement"],
    "julia": [
        "struct_definition", "abstract_definition", "module_definition",
    ],
    "verilog": ["module_declaration", "interface_declaration", "class_declaration"],
    # GDScript: inner classes use ``class Name:`` (class_definition); the
    # file-level ``class_name Name`` gives the script itself an identity.
    "gdscript": ["class_definition", "class_name_statement"],
    # SQL: CREATE TABLE / CREATE VIEW are handled via _parse_sql dispatch.
    "sql": [],
}

_FUNCTION_TYPES: dict[str, list[str]] = {
    "python": ["function_definition"],
    "javascript": ["function_declaration", "method_definition", "arrow_function"],
    "typescript": ["function_declaration", "method_definition", "arrow_function"],
    "tsx": ["function_declaration", "method_definition", "arrow_function"],
    "go": ["function_declaration", "method_declaration"],
    "rust": ["function_item"],
    "java": ["method_declaration", "constructor_declaration"],
    "c": ["function_definition"],
    "cpp": ["function_definition"],
    "csharp": ["method_declaration", "constructor_declaration"],
    "ruby": ["method", "singleton_method"],
    "r": ["function_definition"],
    "perl": ["subroutine_declaration_statement", "method_declaration_statement"],
    "kotlin": ["function_declaration"],
    "swift": ["function_declaration"],
    "php": ["function_definition", "method_declaration"],
    "scala": ["function_definition", "function_declaration"],
    # Solidity: events and modifiers use kind="Function" because the graph
    # schema has no dedicated kind for them.  State variables are also modeled
    # as Function nodes (public ones auto-generate getters) and distinguished
    # via extra["solidity_kind"].
    "solidity": [
        "function_definition", "constructor_definition", "modifier_definition",
        "event_definition", "fallback_receive_definition",
    ],
    # Dart: function_signature covers both top-level functions and class methods
    # (class methods appear as method_signature > function_signature pairs;
    # the parser recurses into method_signature generically and then matches
    # function_signature inside it).
    "dart": ["function_signature"],
    "lua": ["function_declaration"],
    "luau": ["function_declaration"],
    # Objective-C: method_definition lives inside implementation_definition
    # inside class_implementation. C-style function_definition is also present
    # for main() and helper functions.
    "objc": ["method_definition", "function_definition"],
    # Bash: only function_definition; everything else is a command.
    "bash": ["function_definition"],
    # Elixir: def/defp/defmacro are all ``call`` nodes whose first
    # identifier matches. Dispatched via _extract_elixir_constructs.
    "elixir": [],
    # Nix: `attrpath = expr;` bindings become Function nodes —
    # handled in _extract_nix_constructs.
    "nix": [],
    "zig": ["fn_proto", "fn_decl"],
    "powershell": ["function_statement"],
    # Julia: short-form functions `f(x) = expr` parse as `assignment` nodes
    # (not a dedicated definition node) and are handled in
    # _extract_julia_constructs.
    "julia": [
        "function_definition",
        "macro_definition",
    ],
    "verilog": ["task_declaration", "function_declaration", "always_construct"],
    # GDScript: ``func name(args) -> ReturnType:`` — includes ``static func``.
    "gdscript": ["function_definition"],
    # SQL: CREATE FUNCTION / CREATE PROCEDURE handled via _parse_sql dispatch.
    "sql": [],
}

_IMPORT_TYPES: dict[str, list[str]] = {
    "python": ["import_statement", "import_from_statement"],
    "javascript": ["import_statement"],
    "typescript": ["import_statement"],
    "tsx": ["import_statement"],
    "go": ["import_declaration"],
    "rust": ["use_declaration"],
    "java": ["import_declaration"],
    "c": ["preproc_include"],
    "cpp": ["preproc_include"],
    "csharp": ["using_directive"],
    "ruby": ["call"],  # require/require_relative
    "r": ["call"],  # library(), require(), source() — filtered downstream
    "perl": ["use_statement", "require_expression"],
    "kotlin": ["import_header"],
    "swift": ["import_declaration"],
    "php": ["namespace_use_declaration"],
    "scala": ["import_declaration"],
    "solidity": ["import_directive"],
    # Dart: import_or_export wraps library_import > import_specification > configurable_uri
    "dart": ["import_or_export"],
    # Lua/Luau: require() is a function_call, handled via _extract_lua_constructs
    "lua": [],
    "luau": [],
    # Objective-C: #import "..." and #include "..." both arrive as preproc_include
    # (tree-sitter-objc doesn't distinguish via a separate preproc_import node).
    "objc": ["preproc_include"],
    # Bash: source / . <file> is a command — handled in _extract_bash_source below.
    "bash": [],
    # Elixir: alias/import/require/use are all ``call`` nodes —
    # handled in _extract_elixir_constructs.
    "elixir": [],
    # Nix: `import ./x.nix`, `callPackage ./y.nix {}`, and flake
    # `inputs.*.url` strings become IMPORTS_FROM edges —
    # handled in _extract_nix_constructs.
    "nix": [],
    # Zig: @import("...") is a builtin_call_expr — handled
    # generically via call types below.
    "zig": [],
    "powershell": [],
    # Julia: import/using are import_statement nodes.
    "julia": ["import_statement", "using_statement"],
    "verilog": ["package_import_declaration"],
    # GDScript has no ``import`` keyword. The closest analogue is
    # ``extends OtherClass`` / ``extends "res://path.gd"``, which establishes
    # a hard dependency on the parent script. preload()/load() calls remain
    # as ordinary CALLS edges.
    "gdscript": ["extends_statement"],
    # SQL: table references extracted as IMPORTS_FROM via _parse_sql dispatch.
    "sql": [],
}

_CALL_TYPES: dict[str, list[str]] = {
    "python": ["call"],
    "javascript": ["call_expression", "new_expression"],
    "typescript": ["call_expression", "new_expression"],
    "tsx": ["call_expression", "new_expression"],
    "go": ["call_expression"],
    "rust": ["call_expression", "macro_invocation"],
    "java": ["method_invocation", "object_creation_expression"],
    "c": ["call_expression"],
    "cpp": ["call_expression"],
    "csharp": ["invocation_expression", "object_creation_expression"],
    "ruby": ["call", "method_call"],
    "r": ["call"],
    "perl": [
        "function_call_expression", "method_call_expression",
        "ambiguous_function_call_expression",
    ],
    "kotlin": ["call_expression"],
    "swift": ["call_expression"],
    "php": [
        "function_call_expression",
        "member_call_expression",
        "scoped_call_expression",
        "nullsafe_member_call_expression",
    ],
    "scala": ["call_expression", "instance_expression", "generic_function"],
    "solidity": ["call_expression"],
    "lua": ["function_call"],
    "luau": ["function_call"],
    # Objective-C: [receiver message:args] produces message_expression;
    # C-style foo(x) produces call_expression.
    "objc": ["message_expression", "call_expression"],
    # Bash: every command invocation is a "command" node.
    "bash": ["command"],
    # Elixir: everything is a ``call`` node — dispatched via
    # _extract_elixir_constructs which filters out def/defmodule/alias/etc.
    # before treating what's left as a real call.
    "elixir": [],
    # Nix: function application is ubiquitous; only import/callPackage
    # produce edges, in _extract_nix_constructs.
    "nix": [],
    "zig": ["call_expression", "builtin_call_expr"],
    "powershell": ["command_expression"],
    "julia": [
        "call_expression",
        "broadcast_call_expression",
        "macrocall_expression",
    ],
    "verilog": [
        "module_instantiation", "function_subroutine_call", "subroutine_call", "system_tf_call"
        ],
    # GDScript: bare calls produce ``call``; ``obj.method()`` is an
    # ``attribute`` node whose right-hand side is an ``attribute_call``.
    "gdscript": ["call", "attribute_call"],
    # SQL: no call edges extracted (grammar too unreliable for procedure calls).
    "sql": [],
}

# Patterns that indicate a test function
_TEST_PATTERNS = [
    re.compile(r"^test_"),
    re.compile(r"^Test"),
    re.compile(r"_test$"),
    re.compile(r"\.test\."),
    re.compile(r"\.spec\."),
    re.compile(r"_spec$"),
]

_TEST_FILE_PATTERNS = [
    re.compile(r"test_.*\.py$"),
    re.compile(r".*_test\.py$"),
    re.compile(r".*\.test\.[jt]sx?$"),
    re.compile(r".*\.spec\.[jt]sx?$"),
    re.compile(r".*_test\.go$"),
    re.compile(r"tests?/"),
    re.compile(r"[\\/]__tests__[\\/]"),
    re.compile(r".*_test\.dart$"),
    re.compile(r"test[_-].*\.[rR]$"),
    re.compile(r"tests/testthat/"),
    re.compile(r".*Test\.kt$"),
    re.compile(r".*Test\.java$"),
    re.compile(r".*_test\.resi?$"),
    re.compile(r".*\.test\.resi?$"),
    re.compile(r"test/runtests\.jl$"),
    re.compile(r"test/.*\.jl$"),
]

_TEST_RUNNER_NAMES = frozenset({
    "describe", "it", "test", "beforeEach", "afterEach",
    "beforeAll", "afterAll",
    # Mocha TDD interface: `suite` is the describe-equivalent.
    # `test`, the it-equivalent, is already covered above.
    "suite",
})

# Annotations/decorators that mark test methods (JUnit, TestNG, etc.)
_TEST_ANNOTATIONS = frozenset({
    "Test", "ParameterizedTest", "RepeatedTest", "TestFactory",
    "org.junit.Test", "org.junit.jupiter.api.Test",
    # Rust: built-in `#[test]` plus common async-runtime + framework
    # variants. Stripped of the `#[ ]` wrapper before lookup.
    "test", "tokio::test", "async_std::test",
    "rstest", "rstest::rstest", "proptest",
})

# Spring stereotype annotations that mark classes as managed beans
_SPRING_STEREOTYPE_ANNOTATIONS = frozenset({
    "Component", "Service", "Repository", "Controller", "RestController",
    "Configuration", "Indexed", "ControllerAdvice", "RestControllerAdvice",
    "EventListener",
})

# Spring DI injection annotations (field/setter/constructor-level)
_SPRING_INJECT_ANNOTATIONS = frozenset({
    "Autowired", "Inject", "Resource",
})

# Lombok annotations that trigger constructor injection of final fields
_LOMBOK_CONSTRUCTOR_ANNOTATIONS = frozenset({
    "RequiredArgsConstructor", "AllArgsConstructor",
})

# Temporal workflow/activity interface markers
_TEMPORAL_INTERFACE_ANNOTATIONS = frozenset({
    "WorkflowInterface", "ActivityInterface",
})

# Temporal method-level markers
_TEMPORAL_METHOD_ANNOTATIONS = frozenset({
    "WorkflowMethod", "ActivityMethod", "SignalMethod", "QueryMethod",
})

# Kafka consumer annotations (annotation-based pattern)
_KAFKA_LISTENER_ANNOTATIONS = frozenset({"KafkaListener", "KafkaHandler"})

# Kafka consumer field types (reactive / imperative)
_KAFKA_CONSUMER_TYPES = frozenset({
    "KafkaReceiver",
    "ReactiveKafkaConsumerTemplate",
    "MessageListenerContainer",
    "ConcurrentMessageListenerContainer",
})

# Kafka producer field types
_KAFKA_PRODUCER_TYPES = frozenset({
    "KafkaTemplate",
    "KafkaOperations",
    "ReactiveKafkaProducerTemplate",
    "KafkaSender",
})


# ---------------------------------------------------------------------------
# ReScript regex patterns and helpers (no tree-sitter grammar bundled)
# ---------------------------------------------------------------------------

_RESCRIPT_IDENT = r"[A-Za-z_][A-Za-z0-9_']*"

# `module Name =`, `module type Name =`, `module Name: {`, `module Name: (Sig) => {`
_RESCRIPT_MODULE_RE = re.compile(
    r"^\s*module\s+(?:type\s+)?([A-Z][A-Za-z0-9_']*)\s*[:=]",
    re.MULTILINE,
)

# Optional leading decorator block on the same line, e.g. `@deriving(foo)`.
_RESCRIPT_DECORATOR_PREFIX = r"(?:@[A-Za-z_][A-Za-z0-9_']*(?:\([^)]*\))?\s+)*"

# `let [rec] name` / `and name` — captures binding name. Multi-line decorators
# on prior lines don't interfere (they end with a newline and the anchor
# restarts on the next line); same-line decorators are tolerated.
_RESCRIPT_LET_RE = re.compile(
    rf"^\s*{_RESCRIPT_DECORATOR_PREFIX}"
    rf"(?:let\s+(?:rec\s+)?|and\s+)({_RESCRIPT_IDENT})\b",
    re.MULTILINE,
)

# `external name: sig = "..."`
_RESCRIPT_EXTERNAL_RE = re.compile(
    rf"^\s*{_RESCRIPT_DECORATOR_PREFIX}external\s+({_RESCRIPT_IDENT})\s*:",
    re.MULTILINE,
)

# `type name` / `type rec name` / `type name<'a>`
_RESCRIPT_TYPE_RE = re.compile(
    rf"^\s*{_RESCRIPT_DECORATOR_PREFIX}type\s+(?:rec\s+)?({_RESCRIPT_IDENT})\b",
    re.MULTILINE,
)

# `open Foo` / `include Foo.Bar`
_RESCRIPT_OPEN_RE = re.compile(
    r"^\s*(open|include)\s+([A-Z][A-Za-z0-9_'.]*)",
    re.MULTILINE,
)

# `module X = Foo.Bar` with no `{` body — a module alias/re-export. Distinct
# from `module X = { ... }` (handled by _RESCRIPT_MODULE_RE + brace scan).
_RESCRIPT_MODULE_ALIAS_RE = re.compile(
    r"^\s*module\s+([A-Z][A-Za-z0-9_']*)\s*=\s*"
    r"([A-Z][A-Za-z0-9_']*(?:\.[A-Za-z_][A-Za-z0-9_']*)*)\s*$",
    re.MULTILINE,
)

# JSX opening tag: `<Foo`, `<Foo.Bar`, `<Foo.Bar.Baz`. First segment must be
# Capitalized (lowercase tags are HTML elements, not ReScript components).
# The leading `<` must NOT be part of `=>`, `<=`, `<-`, or a generic-type
# parameter (we approximate by requiring the char before `<` to be space,
# newline, `{`, `(`, `,`, `>`, `}`, or BOF).
_RESCRIPT_JSX_RE = re.compile(
    r"(?:^|(?<=[\s{(,>}]))"
    r"<([A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)*)\b",
    re.MULTILINE,
)

# `@module("path")` — source module for an external binding
_RESCRIPT_MODULE_ATTR_RE = re.compile(
    r'@module\(\s*"([^"]+)"\s*\)',
)

# `Ident(`, `Mod.fn(` — anything that looks like a call site. Preceded by a
# non-identifier char to avoid matching suffixes of identifiers.
_RESCRIPT_CALL_RE = re.compile(
    rf"(?<![A-Za-z0-9_']){_RESCRIPT_IDENT}(?:\.{_RESCRIPT_IDENT})*\s*\(",
)

# Recompiled to grab the captured identifier sequence. We need a different
# regex with a capture group for matching:
_RESCRIPT_CALL_RE = re.compile(
    r"(?<![A-Za-z0-9_'])"
    r"([A-Za-z_][A-Za-z0-9_']*(?:\.[A-Za-z_][A-Za-z0-9_']*)*)"
    r"\s*\(",
)

# Reserved words + syntactic noise that should never be treated as names
# or as call targets.
_RESCRIPT_KEYWORDS = frozenset({
    "let", "rec", "and", "type", "module", "open", "include", "external",
    "if", "else", "switch", "when", "match", "fun", "true", "false",
    "for", "while", "mutable", "try", "catch", "throw", "assert",
    "lazy", "do", "in", "of", "as", "exception", "private",
    "constraint", "with", "downto", "to", "unpack", "async", "await",
})


def _strip_rescript_noise(text: str) -> str:
    """Replace ReScript comments and string/backtick content with spaces.

    Newlines are preserved so absolute offsets still map back to accurate
    line numbers. ReScript block comments may nest, so we track depth.
    """
    out: list[str] = []
    i = 0
    n = len(text)
    while i < n:
        c = text[i]
        nxt = text[i + 1] if i + 1 < n else ""
        # Line comment
        if c == "/" and nxt == "/":
            while i < n and text[i] != "\n":
                out.append(" ")
                i += 1
            continue
        # Nestable block comment
        if c == "/" and nxt == "*":
            depth = 1
            out.append("  ")
            i += 2
            while i < n and depth > 0:
                if i + 1 < n and text[i] == "/" and text[i + 1] == "*":
                    depth += 1
                    out.append("  ")
                    i += 2
                elif i + 1 < n and text[i] == "*" and text[i + 1] == "/":
                    depth -= 1
                    out.append("  ")
                    i += 2
                else:
                    out.append("\n" if text[i] == "\n" else " ")
                    i += 1
            continue
        # Double-quoted string — blank content, keep quotes + newlines.
        if c == '"':
            out.append('"')
            i += 1
            while i < n and text[i] != '"':
                if text[i] == "\\" and i + 1 < n:
                    out.append("  ")
                    i += 2
                    continue
                out.append("\n" if text[i] == "\n" else " ")
                i += 1
            if i < n:
                out.append('"')
                i += 1
            continue
        # Backtick template string — blank content, preserve newlines.
        if c == "`":
            out.append("`")
            i += 1
            while i < n and text[i] != "`":
                out.append("\n" if text[i] == "\n" else " ")
                i += 1
            if i < n:
                out.append("`")
                i += 1
            continue
        out.append(c)
        i += 1
    return "".join(out)


def _rescript_brace_depth_array(cleaned: str) -> list[int]:
    """Compute brace depth at every offset in `cleaned` (comment/string-stripped).

    Returned array has length len(cleaned); `depth[i]` is the depth
    immediately before the character at position i.
    """
    depth = [0] * (len(cleaned) + 1)
    d = 0
    for i, c in enumerate(cleaned):
        depth[i] = d
        if c == "{":
            d += 1
        elif c == "}":
            d = max(0, d - 1)
    depth[len(cleaned)] = d
    return depth


def _scan_rescript_modules(cleaned: str, offset_to_line) -> list[dict]:
    """Find `module Name = { ... }` blocks and their offset/line ranges.

    Returns dicts with name, start/end offsets, start/end lines, and parent
    module name (or None for top-level).
    """
    modules: list[dict] = []
    n = len(cleaned)
    # Module aliases (`module X = Foo.Bar`) also match _RESCRIPT_MODULE_RE but
    # have no brace body — skip them here to avoid the greedy `{`-scanner
    # swallowing the next unrelated block (e.g. a `let` body).
    alias_starts = {
        m.start() for m in _RESCRIPT_MODULE_ALIAS_RE.finditer(cleaned)
    }
    for match in _RESCRIPT_MODULE_RE.finditer(cleaned):
        if match.start() in alias_starts:
            continue
        name = match.group(1)
        header_start = match.start()
        # Find the first `{` after the header's `:` or `=`. To avoid grabbing
        # a `{` from an unrelated following statement, require that the chars
        # between `match.end()` and `brace_open` contain no definition-starting
        # keywords (`let`, `type`, `module`, `external`).
        brace_open = cleaned.find("{", match.end())
        if brace_open == -1:
            continue
        between = cleaned[match.end():brace_open]
        if re.search(
            r"(?:^|\s)(?:let|type|module|external|and)\s",
            between,
        ):
            continue
        # Walk braces to find the matching close.
        depth = 1
        j = brace_open + 1
        while j < n and depth > 0:
            c = cleaned[j]
            if c == "{":
                depth += 1
            elif c == "}":
                depth -= 1
            j += 1
        brace_close = j - 1 if depth == 0 else n - 1
        modules.append({
            "name": name,
            "start_off": header_start,
            "end_off": brace_close,
            "body_start_off": brace_open + 1,
            "start_line": offset_to_line(header_start),
            "end_line": offset_to_line(brace_close),
            "parent": None,
        })

    # Parent = innermost strictly-containing module.
    for i, m in enumerate(modules):
        parent_name = None
        parent_start = -1
        for j, other in enumerate(modules):
            if i == j:
                continue
            if (
                other["start_off"] < m["start_off"]
                and other["end_off"] > m["end_off"]
                and other["start_off"] > parent_start
            ):
                parent_name = other["name"]
                parent_start = other["start_off"]
        m["parent"] = parent_name
    return modules


def _is_test_file(path: str) -> bool:
    return any(p.search(path) for p in _TEST_FILE_PATTERNS)


def _is_test_function(
    name: str, file_path: str, decorators: tuple[str, ...] = (),
) -> bool:
    """A function is a test if its name matches test patterns, it lives
    in a test file and has a test-runner name, or it has a @Test annotation.
    """
    if any(p.search(name) for p in _TEST_PATTERNS):
        return True
    if _is_test_file(file_path) and name in _TEST_RUNNER_NAMES:
        return True
    if decorators and any(d in _TEST_ANNOTATIONS for d in decorators):
        return True
    return False


def file_hash(path: Path) -> str:
    """SHA-256 hash of file contents."""
    return hashlib.sha256(path.read_bytes()).hexdigest()


# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------


class CodeParser:
    """Parses source files using Tree-sitter and extracts structural information."""

    _MODULE_CACHE_MAX = 15_000  # Evict cache to cap memory on huge monorepos

    def __init__(self) -> None:
        self._parsers: dict[str, object] = {}
        self._module_file_cache: dict[str, Optional[str]] = {}
        self._export_symbol_cache: dict[str, Optional[str]] = {}
        self._tsconfig_resolver = TsconfigResolver()
        # Per-parse cache of Dart pubspec root lookups; see #87
        self._dart_pubspec_cache: dict[tuple[str, str], Optional[Path]] = {}

    def _get_parser(self, language: str):  # type: ignore[arg-type]
        if language not in self._parsers:
            try:
                self._parsers[language] = tslp.get_parser(language)  # type: ignore[arg-type]
            except (LookupError, ValueError, ImportError) as exc:
                # language not packaged, or grammar load failed
                logger.debug("tree-sitter parser unavailable for %s: %s", language, exc)
                return None
        return self._parsers[language]

    def detect_language(self, path: Path) -> Optional[str]:
        """Map a file path to its language name.

        Extension-based lookup is tried first.  For extension-less files
        (typical for Unix scripts like ``bin/myapp`` or ``.git/hooks/pre-commit``)
        we fall back to reading the first line for a shebang.  Files that
        already have a known extension are never re-read — shebang probing
        only runs when the extension lookup returns ``None`` **and** the path
        has no suffix at all.  See issue #237.
        """
        suffix = path.suffix.lower()
        lang = EXTENSION_TO_LANGUAGE.get(suffix)
        if lang is not None:
            return lang
        # Only probe shebang for files without any extension — "README", "LICENSE",
        # and other extension-less text files also fall here, but the probe is a
        # cheap 256-byte read that returns None when no shebang is found.
        if suffix == "":
            return self._detect_language_from_shebang(path)
        return None

    @staticmethod
    def _detect_language_from_shebang(path: Path) -> Optional[str]:
        """Inspect the first line of ``path`` for a shebang interpreter.

        Returns the mapped language name or ``None`` if the file has no
        shebang, is unreadable, or names an interpreter we don't map.

        Accepted shapes::

            #!/bin/bash
            #!/usr/bin/env python3
            #!/usr/bin/env -S node --experimental-vm-modules
            #!/usr/bin/bash -e

        Only the basename of the interpreter is consulted.  Trailing flags
        after the interpreter are ignored.  Windows-style ``\r\n`` line
        endings are handled.  Binary files read as garbage bytes simply
        fail the ``#!`` prefix check and return ``None``.
        """
        try:
            with path.open("rb") as fh:
                head = fh.read(_SHEBANG_PROBE_BYTES)
        except (OSError, PermissionError):
            return None
        if not head.startswith(b"#!"):
            return None

        # Take just the first line, stripped of leading "#!" and any
        # surrounding whitespace.  Split on NUL to defend against accidental
        # binary content following a ``#!`` prefix.
        first_line = head.split(b"\n", 1)[0].split(b"\0", 1)[0]
        try:
            line = first_line[2:].decode("utf-8", errors="strict").strip()
        except UnicodeDecodeError:
            return None
        if not line:
            return None

        tokens = line.split()
        if not tokens:
            return None

        first = tokens[0]
        # `/usr/bin/env` indirection: the interpreter is the next token.
        # `/usr/bin/env -S node --flag` is also valid — skip any leading
        # ``-`` options after env.
        if first.endswith("/env") or first == "env":
            interpreter_token: Optional[str] = None
            for tok in tokens[1:]:
                if tok.startswith("-"):
                    # ``-S`` takes no argument in most envs; skip and continue.
                    continue
                interpreter_token = tok
                break
            if interpreter_token is None:
                return None
            interpreter = interpreter_token.rsplit("/", 1)[-1]
        else:
            # Direct form: ``#!/bin/bash`` or ``#!/usr/local/bin/python3``.
            interpreter = first.rsplit("/", 1)[-1]

        return SHEBANG_INTERPRETER_TO_LANGUAGE.get(interpreter)

    def parse_file(self, path: Path) -> tuple[list[NodeInfo], list[EdgeInfo]]:
        """Parse a single file and return extracted nodes and edges."""
        try:
            source = path.read_bytes()
        except (OSError, PermissionError):
            return [], []
        return self.parse_bytes(path, source)

    def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[EdgeInfo]]:
        """Parse pre-read bytes and return extracted nodes and edges.

        This avoids re-reading the file from disk, eliminating TOCTOU gaps
        when the caller has already read the bytes (e.g. for hashing).
        """
        language = self.detect_language(path)
        if not language:
            return [], []

        # Vue SFCs: parse with vue parser, then delegate script blocks to JS/TS
        if language == "vue":
            return self._parse_vue(path, source)

        # Svelte SFCs: same approach as Vue — extract <script> blocks
        if language == "svelte":
            return self._parse_svelte(path, source)

        # Jupyter notebooks: extract code cells and parse as Python
        if language == "notebook":
            return self._parse_notebook(path, source)

        # Databricks .py notebook exports.  The header is ALWAYS the very
        # first line, but the file may have CRLF line endings on Windows
        # (git's core.autocrlf=true default).  Match the first line robustly
        # after stripping any trailing ``\r`` so the detection works on both
        # platforms.  See issue #239.
        if language == "python":
            first_newline = source.find(b"\n")
            first_line = (
                source[:first_newline].rstrip(b"\r")
                if first_newline != -1
                else source.rstrip(b"\r")
            )
            if first_line == b"# Databricks notebook source":
                return self._parse_databricks_py_notebook(path, source)

        # ReScript: regex-based parser (no tree-sitter grammar bundled).
        if language == "rescript":
            return self._parse_rescript(path, source)

        # SQL: dedicated parser — tree-sitter for tables/views/functions +
        # regex fallback for CREATE PROCEDURE (unsupported by the grammar).
        if language == "sql":
            return self._parse_sql(path, source)

        parser = self._get_parser(language)
        if not parser:
            return [], []

        tree = parser.parse(source)
        nodes: list[NodeInfo] = []
        edges: list[EdgeInfo] = []
        file_path_str = str(path)

        # File node
        test_file = _is_test_file(file_path_str)
        nodes.append(NodeInfo(
            kind="File",
            name=file_path_str,
            file_path=file_path_str,
            line_start=1,
            line_end=source.count(b"\n") + 1,
            language=language,
            is_test=test_file,
        ))

        # Pre-scan for import mappings and defined names
        import_map, defined_names = self._collect_file_scope(
            tree.root_node, language, source,
        )

        # Walk the tree
        self._extract_from_tree(
            tree.root_node, source, language, file_path_str, nodes, edges,
            import_map=import_map, defined_names=defined_names,
        )

        # Resolve bare call targets to qualified names using same-file definitions
        edges = self._resolve_call_targets(nodes, edges, file_path_str)

        # Generate TESTED_BY edges: when a test function calls a production
        # function, create an edge from the production function back to the test.
        if test_file:
            test_qnames = set()
            for n in nodes:
                if n.is_test:
                    qn = self._qualify(n.name, n.file_path, n.parent_name)
                    test_qnames.add(qn)
            for edge in list(edges):
                if edge.kind == "CALLS" and edge.source in test_qnames:
                    edges.append(EdgeInfo(
                        kind="TESTED_BY",
                        source=edge.target,
                        target=edge.source,
                        file_path=edge.file_path,
                        line=edge.line,
                    ))

        return nodes, edges

    def _parse_vue(
        self, path: Path, source: bytes,
    ) -> tuple[list[NodeInfo], list[EdgeInfo]]:
        """Parse a Vue SFC by extracting <script> blocks and delegating to JS/TS."""
        vue_parser = self._get_parser("vue")
        if not vue_parser:
            return [], []

        tree = vue_parser.parse(source)
        file_path_str = str(path)
        test_file = _is_test_file(file_path_str)

        all_nodes: list[NodeInfo] = [NodeInfo(
            kind="File",
            name=file_path_str,
            file_path=file_path_str,
            line_start=1,
            line_end=source.count(b"\n") + 1,
            language="vue",
            is_test=test_file,
        )]
        all_edges: list[EdgeInfo] = []

        # Find script_element blocks in the Vue AST
        for child in tree.root_node.children:
            if child.type != "script_element":
                continue

            # Detect language from lang="ts" attribute
            script_lang = "javascript"
            start_tag = None
            raw_text_node = None
            for sub in child.children:
                if sub.type == "start_tag":
                    start_tag = sub
                elif sub.type == "raw_text":
                    raw_text_node = sub

            if start_tag:
                for attr in start_tag.children:
                    if attr.type == "attribute":
                        attr_name = None
                        attr_value = None
                        for a in attr.children:
                            if a.type == "attribute_name":
                                attr_name = a.text.decode("utf-8", errors="replace")
                            elif a.type == "quoted_attribute_value":
                                for v in a.children:
                                    if v.type == "attribute_value":
                                        attr_value = v.text.decode(
                                            "utf-8", errors="replace",
                                        )
                        if attr_name == "lang" and attr_value in ("ts", "typescript"):
                            script_lang = "typescript"

            if not raw_text_node:
                continue

            script_source = raw_text_node.text
            line_offset = raw_text_node.start_point[0]  # 0-based line of raw_text start

            # Parse the script block with the appropriate JS/TS parser
            script_parser = self._get_parser(script_lang)
            if not script_parser:
                continue

            script_tree = script_parser.parse(script_source)

            # Collect imports and defined names from the script block
            import_map, defined_names = self._collect_file_scope(
                script_tree.root_node, script_lang, script_source,
            )

            nodes: list[NodeInfo] = []
            edges: list[EdgeInfo] = []
            self._extract_from_tree(
                script_tree.root_node, script_source, script_lang,
                file_path_str, nodes, edges,
                import_map=import_map, defined_names=defined_names,
            )

            # Adjust line numbers to account for position within the .vue file
            for node in nodes:
                node.line_start += line_offset
                node.line_end += line_offset
                node.language = "vue"
            for edge in edges:
                edge.line += line_offset

            all_nodes.extend(nodes)
            all_edges.extend(edges)

        # Generate TESTED_BY edges
        if test_file:
            test_qnames = set()
            for n in all_nodes:
                if n.is_test:
                    qn = self._qualify(n.name, n.file_path, n.parent_name)
                    test_qnames.add(qn)
            for edge in list(all_edges):
                if edge.kind == "CALLS" and edge.source in test_qnames:
                    all_edges.append(EdgeInfo(
                        kind="TESTED_BY",
                        source=edge.target,
                        target=edge.source,
                        file_path=edge.file_path,
                        line=edge.line,
                    ))

        return all_nodes, all_edges

    def _parse_svelte(
        self, path: Path, source: bytes,
    ) -> tuple[list[NodeInfo], list[EdgeInfo]]:
        """Parse a Svelte SFC by extracting <script> blocks.

        Uses the same approach as Vue: parse the outer HTML structure,
        locate ``<script>`` blocks, detect ``lang="ts"`` for TypeScript,
        and delegate each block to the appropriate JS/TS parser.
        """
        # Svelte uses HTML-like structure; reuse the vue grammar which
        # also handles generic HTML with <script> elements.
        svelte_parser = self._get_parser("svelte")
        # Fall back to the vue grammar if a dedicated svelte grammar
        # is not available in the installed tree-sitter language pack.
        if not svelte_parser:
            svelte_parser = self._get_parser("vue")
        if not svelte_parser:
            return [], []

        tree = svelte_parser.parse(source)
        file_path_str = str(path)
        test_file = _is_test_file(file_path_str)

        all_nodes: list[NodeInfo] = [NodeInfo(
            kind="File",
            name=file_path_str,
            file_path=file_path_str,
            line_start=1,
            line_end=source.count(b"\n") + 1,
            language="svelte",
            is_test=test_file,
        )]
        all_edges: list[EdgeInfo] = []

        # Walk root children looking for script_element blocks
        for child in tree.root_node.children:
            if child.type != "script_element":
                continue

            script_lang = "javascript"
            start_tag = None
            raw_text_node = None
            for sub in child.children:
                if sub.type == "start_tag":
                    start_tag = sub
                elif sub.type == "raw_text":
                    raw_text_node = sub

            if start_tag:
                for attr in start_tag.children:
                    if attr.type == "attribute":
                        attr_name = None
                        attr_value = None
                        for a in attr.children:
                            if a.type == "attribute_name":
                                attr_name = a.text.decode(
                                    "utf-8", errors="replace",
                                )
                            elif a.type == "quoted_attribute_value":
                                for v in a.children:
                                    if v.type == "attribute_value":
                                        attr_value = v.text.decode(
                                            "utf-8",
                                            errors="replace",
                                        )
                        if (
                            attr_name == "lang"
                            and attr_value
                            in ("ts", "typescript")
                        ):
                            script_lang = "typescript"

            if not raw_text_node:
                continue

            script_source = raw_text_node.text
            line_offset = raw_text_node.start_point[0]

            script_parser = self._get_parser(script_lang)
            if not script_parser:
                continue

            script_tree = script_parser.parse(script_source)
            import_map, defined_names = self._collect_file_scope(
                script_tree.root_node, script_lang, script_source,
            )

            nodes: list[NodeInfo] = []
            edges: list[EdgeInfo] = []
            self._extract_from_tree(
                script_tree.root_node, script_source,
                script_lang, file_path_str, nodes, edges,
                import_map=import_map,
                defined_names=defined_names,
            )

            for node in nodes:
                node.line_start += line_offset
                node.line_end += line_offset
                node.language = "svelte"
            for edge in edges:
                edge.line += line_offset

            all_nodes.extend(nodes)
            all_edges.extend(edges)

        # Generate TESTED_BY edges
        if test_file:
            test_qnames = set()
            for n in all_nodes:
                if n.is_test:
                    qn = self._qualify(
                        n.name, n.file_path, n.parent_name,
                    )
                    test_qnames.add(qn)
            for edge in list(all_edges):
                if (
                    edge.kind == "CALLS"
                    and edge.source in test_qnames
                ):
                    all_edges.append(EdgeInfo(
                        kind="TESTED_BY",
                        source=edge.target,
                        target=edge.source,
                        file_path=edge.file_path,
                        line=edge.line,
                    ))

        return all_nodes, all_edges

    def _parse_notebook(
        self, path: Path, source: bytes,
    ) -> tuple[list[NodeInfo], list[EdgeInfo]]:
        """Parse a Jupyter notebook by extracting code cells."""
        try:
            nb = json.loads(source)
        except (json.JSONDecodeError, UnicodeDecodeError):
            return [], []

        # Determine kernel language
        kernel_lang = (
            nb.get("metadata", {}).get("kernelspec", {}).get("language")
            or nb.get("metadata", {}).get("language_info", {}).get("name")
            or "python"
        ).lower()

        # Only parse supported languages
        supported = {"python", "r"}
        if kernel_lang not in supported:
            return [], []

        # Build CellInfo list from code cells
        cells: list[CellInfo] = []
        magic_lang_map = {
            "%python": "python",
            "%sql": "sql",
            "%r": "r",
        }
        skip_magics = {"%scala", "%md", "%sh"}

        for cell_idx, cell in enumerate(nb.get("cells", [])):
            if cell.get("cell_type") != "code":
                continue
            lines = cell.get("source", [])
            if isinstance(lines, str):
                lines = lines.splitlines(keepends=True)
            if not lines:
                continue

            # Check first line for language-switching magic
            first_line = lines[0].strip()
            cell_lang = kernel_lang
            cell_lines = lines

            for magic, lang in magic_lang_map.items():
                if first_line == magic or first_line.startswith(magic + " "):
                    cell_lang = lang
                    cell_lines = lines[1:]  # strip magic line
                    break
            else:
                # Check for skip magics
                for skip in skip_magics:
                    if first_line == skip or first_line.startswith(skip + " "):
                        cell_lines = []
                        break

            # Filter %pip, ! lines from Python/R content (not SQL)
            if cell_lang in ("python", "r"):
                filtered = [
                    ln for ln in cell_lines
                    if not ln.lstrip().startswith(("%", "!"))
                ]
            else:
                filtered = cell_lines
            if not filtered:
                continue

            cell_source = "".join(filtered)
            cells.append(CellInfo(cell_index=cell_idx, language=cell_lang, source=cell_source))

        if not cells:
            file_path_str = str(path)
            return [NodeInfo(
                kind="File",
                name=file_path_str,
                file_path=file_path_str,
                line_start=1,
                line_end=1,
                language=kernel_lang,
                is_test=_is_test_file(file_path_str),
            )], []

        return self._parse_notebook_cells(path, cells, kernel_lang)

    def _parse_notebook_cells(
        self,
        path: Path,
        cells: list[CellInfo],
        default_language: str,
    ) -> tuple[list[NodeInfo], list[EdgeInfo]]:
        """Parse notebook cells grouped by language.

        Args:
            path: Notebook file path.
            cells: List of CellInfo with index, language, and source.
            default_language: Default language for the File node.
        """
        file_path_str = str(path)
        test_file = _is_test_file(file_path_str)

        # Group cells by language
        lang_cells: dict[str, list[CellInfo]] = {}
        for cell in cells:
            lang_cells.setdefault(cell.language, []).append(cell)

        all_nodes: list[NodeInfo] = []
        all_edges: list[EdgeInfo] = []

        # Track offsets per language for cell_index tagging.
        # Each language group is parsed independently by Tree-sitter,
        # so line numbers restart at 1 for each group.
        all_cell_offsets: list[tuple[int, int, int]] = []
        max_line = 1

        for lang, lang_group in lang_cells.items():
            if lang == "sql":
                # SQL: regex-based table extraction
                for cell in lang_group:
                    for match in _SQL_TABLE_RE.finditer(cell.source):
                        table_name = match.group(1).replace("`", "")
                        all_edges.append(EdgeInfo(
                            kind="IMPORTS_FROM",
                            source=file_path_str,
                            target=table_name,
                            file_path=file_path_str,
                            line=1,
                        ))
                continue

            if lang not in ("python", "r"):
                continue

            ts_parser = self._get_parser(lang)
            if not ts_parser:
                continue

            # Concatenate cells of this language.
            # Line numbers start at 1 for each language group because
            # Tree-sitter parses each concatenation independently.
            code_chunks: list[str] = []
            cell_offsets: list[tuple[int, int, int]] = []
            current_line = 1

            for cell in lang_group:
                cell_line_count = cell.source.count("\n") + (
                    1 if not cell.source.endswith("\n") else 0
                )
                cell_offsets.append((
                    cell.cell_index, current_line, current_line + cell_line_count - 1,
                ))
                code_chunks.append(cell.source)
                current_line += cell_line_count + 1

            concatenated = "\n".join(code_chunks)
            concat_bytes = concatenated.encode("utf-8")

            tree = ts_parser.parse(concat_bytes)

            import_map, defined_names = self._collect_file_scope(
                tree.root_node, lang, concat_bytes,
            )
            self._extract_from_tree(
                tree.root_node, concat_bytes, lang,
                file_path_str, all_nodes, all_edges,
                import_map=import_map, defined_names=defined_names,
            )

            all_cell_offsets.extend(cell_offsets)
            max_line = max(max_line, current_line)

        # Create File node
        file_node = NodeInfo(
            kind="File",
            name=file_path_str,
            file_path=file_path_str,
            line_start=1,
            line_end=max_line,
            language=default_language,
            is_test=test_file,
        )
        all_nodes.insert(0, file_node)

        # Resolve call targets
        all_edges = self._resolve_call_targets(
            all_nodes, all_edges, file_path_str,
        )

        # Tag nodes with cell_index
        for node in all_nodes:
            if node.kind == "File":
                continue
            for cell_idx, start, end in all_cell_offsets:
                if start <= node.line_start <= end:
                    node.extra["cell_index"] = cell_idx
                    break

        # Generate TESTED_BY edges
        if test_file:
            test_qnames = set()
            for n in all_nodes:
                if n.is_test:
                    qn = self._qualify(n.name, n.file_path, n.parent_name)
                    test_qnames.add(qn)
            for edge in list(all_edges):
                if edge.kind == "CALLS" and edge.source in test_qnames:
                    all_edges.append(EdgeInfo(
                        kind="TESTED_BY",
                        source=edge.target,
                        target=edge.source,
                        file_path=edge.file_path,
                        line=edge.line,
                    ))

        return all_nodes, all_edges

    def _parse_databricks_py_notebook(
        self, path: Path, source: bytes,
    ) -> tuple[list[NodeInfo], list[EdgeInfo]]:
        """Parse a Databricks .py notebook export."""
        text = source.decode("utf-8", errors="replace")

        # Strip the header line
        lines = text.split("\n")
        if lines and lines[0].strip() == "# Databricks notebook source":
            lines = lines[1:]

        # Split on COMMAND delimiters
        cell_chunks: list[list[str]] = [[]]
        for line in lines:
            if re.match(r"^# COMMAND\s*-+\s*$", line):
                cell_chunks.append([])
            else:
                cell_chunks[-1].append(line)

        # Classify each cell
        cells: list[CellInfo] = []
        magic_lang_map = {
            "# MAGIC %sql": "sql",
            "# MAGIC %r": "r",
        }
        skip_prefixes = ("# MAGIC %md", "# MAGIC %sh")

        for cell_idx, chunk in enumerate(cell_chunks):
            non_empty = [ln for ln in chunk if ln.strip()]
            if not non_empty:
                continue

            first_line = non_empty[0]

            # Check if all non-empty lines are MAGIC lines
            all_magic = all(ln.startswith("# MAGIC ") for ln in non_empty)

            # Detect language from the first MAGIC line (e.g. "# MAGIC %sql")
            cell_lang = None
            if all_magic:
                for prefix, lang in magic_lang_map.items():
                    if first_line.startswith(prefix):
                        cell_lang = lang
                        break

            if cell_lang:
                # Strip "# MAGIC " prefix (8 chars) then skip the %lang directive line
                stripped = [
                    ln[8:] if ln.startswith("# MAGIC ") else ln
                    for ln in chunk
                ]
                # Remove the first non-empty line if it's just the %lang directive
                stripped_non_empty = [ln for ln in stripped if ln.strip()]
                if stripped_non_empty and stripped_non_empty[0].strip().startswith("%"):
                    # Drop the directive line from the source
                    first_directive = stripped_non_empty[0]
                    stripped = [ln for ln in stripped if ln != first_directive]
                cell_source = "\n".join(stripped)
                cells.append(CellInfo(
                    cell_index=cell_idx, language=cell_lang, source=cell_source,
                ))
                continue

            # Check for skip prefixes (md, sh)
            if all_magic and first_line.startswith(skip_prefixes):
                continue

            # Default: Python cell (mixed or no MAGIC)
            py_lines = [ln for ln in chunk if not ln.startswith("# MAGIC ")]
            cell_source = "\n".join(py_lines)
            cells.append(CellInfo(
                cell_index=cell_idx, language="python", source=cell_source,
            ))

        if not cells:
            file_path_str = str(path)
            file_node = NodeInfo(
                kind="File",
                name=file_path_str,
                file_path=file_path_str,
                line_start=1,
                line_end=1,
                language="python",
                is_test=_is_test_file(file_path_str),
            )
            file_node.extra["notebook_format"] = "databricks_py"
            return [file_node], []

        nodes, edges = self._parse_notebook_cells(path, cells, "python")

        # Tag File node with notebook_format
        for node in nodes:
            if node.kind == "File":
                node.extra["notebook_format"] = "databricks_py"
                break

        return nodes, edges

    # ------------------------------------------------------------------
    # ReScript: regex-based structural parser (no tree-sitter grammar
    # is bundled for ReScript, so we extract best-effort structure via
    # comment-stripping + line-anchored regex + brace-counted module scan).
    # ------------------------------------------------------------------

    def _parse_rescript(
        self, path: Path, source: bytes,
    ) -> tuple[list[NodeInfo], list[EdgeInfo]]:
        """Parse a ReScript `.res` or `.resi` file.

        Extracts modules, let bindings, types, external bindings, open/include
        imports, and function calls. Interface files (`.resi`) are flagged via
        ``File`` node ``extra["rescript_interface"]=True`` and skip call
        extraction since signatures have no call sites.
        """
        text = source.decode("utf-8", errors="replace")
        file_path_str = str(path)
        test_file = _is_test_file(file_path_str)
        is_interface = path.suffix.lower() == ".resi"

        # Strip comments and string/backtick literal content so downstream
        # regex matches are not fooled by code-looking text inside strings.
        # Newlines are preserved so offset→line mapping stays accurate.
        cleaned = _strip_rescript_noise(text)

        # Build offset → line index (1-based).
        line_starts = [0]
        for i, ch in enumerate(cleaned):
            if ch == "\n":
                line_starts.append(i + 1)

        def offset_to_line(off: int) -> int:
            lo, hi = 0, len(line_starts) - 1
            while lo < hi:
                mid = (lo + hi + 1) // 2
                if line_starts[mid] <= off:
                    lo = mid
                else:
                    hi = mid - 1
            return lo + 1

        nodes: list[NodeInfo] = []
        edges: list[EdgeInfo] = []

        file_extra: dict = {}
        if is_interface:
            file_extra["rescript_interface"] = True
        nodes.append(NodeInfo(
            kind="File",
            name=file_path_str,
            file_path=file_path_str,
            line_start=1,
            line_end=text.count("\n") + 1,
            language="rescript",
            is_test=test_file,
            extra=file_extra,
        ))

        # Modules with brace-matched offset ranges.
        modules = _scan_rescript_modules(cleaned, offset_to_line)
        depth_arr = _rescript_brace_depth_array(cleaned)

        def is_top_level(off: int, parent_mod: Optional[str]) -> bool:
            """True if offset is at file scope (depth 0) or directly inside
            `parent_mod`'s body (depth = module body depth)."""
            d = depth_arr[off] if off < len(depth_arr) else 0
            if parent_mod is None:
                return d == 0
            for m in modules:
                if m["name"] == parent_mod and m["start_off"] <= off <= m["end_off"]:
                    expected = depth_arr[m["body_start_off"]]
                    return d == expected
            return False
        for m in modules:
            nodes.append(NodeInfo(
                kind="Class",
                name=m["name"],
                file_path=file_path_str,
                line_start=m["start_line"],
                line_end=m["end_line"],
                language="rescript",
                parent_name=m["parent"],
                extra={"rescript_kind": "module"},
            ))

        def enclosing_module(off: int) -> Optional[str]:
            innermost_name = None
            innermost_start = -1
            for m in modules:
                if (
                    m["start_off"] <= off <= m["end_off"]
                    and m["start_off"] > innermost_start
                ):
                    innermost_name = m["name"]
                    innermost_start = m["start_off"]
            return innermost_name

        # First: let/and bindings — collect offsets so we can later compute
        # end offsets for call attribution.
        let_entries: list[dict] = []
        for match in _RESCRIPT_LET_RE.finditer(cleaned):
            name = match.group(1)
            if name in _RESCRIPT_KEYWORDS:
                continue
            off = match.start(1)
            parent = enclosing_module(off)
            if not is_top_level(off, parent):
                continue  # nested local `let` — not a structural node
            line_start = offset_to_line(off)
            is_test_fn = _is_test_function(name, file_path_str)
            let_entries.append({
                "name": name,
                "start_off": off,
                "line_start": line_start,
                "parent": parent,
                "is_test": is_test_fn,
            })

        # Sort by start_off, compute end_off as next same-or-outer-scope let start
        # or the closing brace of the enclosing module, or end of file.
        let_entries.sort(key=lambda e: e["start_off"])
        for i, entry in enumerate(let_entries):
            nxt = len(cleaned)
            for later in let_entries[i + 1:]:
                nxt = later["start_off"]
                break
            # Clamp by enclosing module end if any
            if entry["parent"]:
                for m in modules:
                    if (
                        m["name"] == entry["parent"]
                        and m["start_off"] <= entry["start_off"] <= m["end_off"]
                    ):
                        nxt = min(nxt, m["end_off"])
                        break
            entry["end_off"] = max(nxt, entry["start_off"] + 1)
            entry["line_end"] = offset_to_line(entry["end_off"] - 1)

        for entry in let_entries:
            nodes.append(NodeInfo(
                kind="Test" if entry["is_test"] else "Function",
                name=entry["name"],
                file_path=file_path_str,
                line_start=entry["line_start"],
                line_end=entry["line_end"],
                language="rescript",
                parent_name=entry["parent"],
                is_test=entry["is_test"],
            ))

        # External bindings (also create IMPORTS_FROM edges for @module attrs).
        for match in _RESCRIPT_EXTERNAL_RE.finditer(cleaned):
            name = match.group(1)
            if name in _RESCRIPT_KEYWORDS:
                continue
            off = match.start(1)
            parent = enclosing_module(off)
            if not is_top_level(off, parent):
                continue
            line_start = offset_to_line(off)
            nodes.append(NodeInfo(
                kind="Function",
                name=name,
                file_path=file_path_str,
                line_start=line_start,
                line_end=line_start,
                language="rescript",
                parent_name=parent,
                extra={"rescript_external": True},
            ))
            # Look back up to 200 chars for a nearby @module("...") attr.
            # Read from the ORIGINAL text (not `cleaned`) so string literal
            # content like "fs" is preserved. Offsets are length-equivalent
            # because `_strip_rescript_noise` replaces with spaces/newlines.
            look_start = max(0, off - 200)
            snippet = text[look_start:off]
            for attr in _RESCRIPT_MODULE_ATTR_RE.finditer(snippet):
                edges.append(EdgeInfo(
                    kind="IMPORTS_FROM",
                    source=file_path_str,
                    target=attr.group(1),
                    file_path=file_path_str,
                    line=line_start,
                    extra={"rescript_import_kind": "external_module"},
                ))

        # Type definitions.
        for match in _RESCRIPT_TYPE_RE.finditer(cleaned):
            name = match.group(1)
            if name in _RESCRIPT_KEYWORDS:
                continue
            off = match.start(1)
            parent = enclosing_module(off)
            if not is_top_level(off, parent):
                continue
            line_start = offset_to_line(off)
            nodes.append(NodeInfo(
                kind="Type",
                name=name,
                file_path=file_path_str,
                line_start=line_start,
                line_end=line_start,
                language="rescript",
                parent_name=parent,
            ))

        # open / include statements.
        for match in _RESCRIPT_OPEN_RE.finditer(cleaned):
            kind = match.group(1)
            target = match.group(2)
            off = match.start()
            line = offset_to_line(off)
            edges.append(EdgeInfo(
                kind="IMPORTS_FROM",
                source=file_path_str,
                target=target,
                file_path=file_path_str,
                line=line,
                extra={"rescript_import_kind": kind},
            ))

        # Module aliases: `module X = Foo.Bar` (no brace body). These
        # re-export another module and are the second most common way ReScript
        # files reference each other (after JSX).
        for match in _RESCRIPT_MODULE_ALIAS_RE.finditer(cleaned):
            alias_name = match.group(1)
            target = match.group(2)
            off = match.start()
            # Skip if the alias was actually the header of a `module X = { ... }`
            # block already captured by `modules`. That scanner requires `{` to
            # follow, so a trailing-dot form like `module X = Foo.Bar` at EOL
            # never gets mistaken for a block.
            if any(m["start_off"] == off for m in modules):
                continue
            line = offset_to_line(off)
            edges.append(EdgeInfo(
                kind="IMPORTS_FROM",
                source=file_path_str,
                target=target,
                file_path=file_path_str,
                line=line,
                extra={
                    "rescript_import_kind": "module_alias",
                    "alias_name": alias_name,
                },
            ))

        # JSX component usage: `<Foo />`, `<Foo.Bar />`. The root module is
        # what matters for cross-file dependency tracking (importers_of);
        # the specific component is the CALLS target for finer queries.
        if not is_interface:
            for match in _RESCRIPT_JSX_RE.finditer(cleaned):
                target = match.group(1)
                off = match.start(1)
                root = target.split(".", 1)[0]
                line = offset_to_line(off)
                edges.append(EdgeInfo(
                    kind="IMPORTS_FROM",
                    source=file_path_str,
                    target=root,
                    file_path=file_path_str,
                    line=line,
                    extra={"rescript_import_kind": "jsx"},
                ))
                # Attribute a CALLS edge to the enclosing let, so
                # callers_of(<Foo.Bar />) can find the caller.
                caller = None
                caller_parent = None
                for entry in let_entries:
                    if entry["start_off"] <= off < entry["end_off"]:
                        caller = entry["name"]
                        caller_parent = entry["parent"]
                    elif entry["start_off"] > off:
                        break
                if caller is not None:
                    edges.append(EdgeInfo(
                        kind="CALLS",
                        source=self._qualify(
                            caller, file_path_str, caller_parent,
                        ),
                        target=target,
                        file_path=file_path_str,
                        line=line,
                        extra={"rescript_call_kind": "jsx"},
                    ))

        # Calls — interface files have no call sites, skip.
        if not is_interface and let_entries:
            for match in _RESCRIPT_CALL_RE.finditer(cleaned):
                target = match.group(1)
                off = match.start(1)
                top = target.split(".", 1)[0]
                if top in _RESCRIPT_KEYWORDS or target in _RESCRIPT_KEYWORDS:
                    continue
                # Find enclosing let by offset range.
                caller = None
                caller_parent = None
                for entry in let_entries:
                    if entry["start_off"] <= off < entry["end_off"]:
                        caller = entry["name"]
                        caller_parent = entry["parent"]
                    elif entry["start_off"] > off:
                        break
                if caller is None:
                    continue
                # Skip the definition site itself: `let name = ...` where
                # name(x) is actually the definition header, not a call.
                if caller == target and off == next(
                    (e["start_off"] for e in let_entries if e["name"] == caller),
                    -1,
                ):
                    continue
                line = offset_to_line(off)
                source_qn = self._qualify(caller, file_path_str, caller_parent)
                edges.append(EdgeInfo(
                    kind="CALLS",
                    source=source_qn,
                    target=target,
                    file_path=file_path_str,
                    line=line,
                ))

        # CONTAINS edges: each module node contains its members.
        for n in nodes:
            if n.kind in ("Function", "Type", "Test") and n.parent_name:
                edges.append(EdgeInfo(
                    kind="CONTAINS",
                    source=self._qualify(n.parent_name, file_path_str, None),
                    target=self._qualify(n.name, file_path_str, n.parent_name),
                    file_path=file_path_str,
                    line=n.line_start,
                ))

        # Tag modules whose member functions are all externals as JS bindings.
        # (e.g. `module TextEncoder = { type encoder; @new external ... }`)
        member_funcs: dict[str, list[NodeInfo]] = {}
        for n in nodes:
            if n.kind == "Function" and n.parent_name:
                member_funcs.setdefault(n.parent_name, []).append(n)
        for mod_node in nodes:
            if mod_node.kind != "Class":
                continue
            members = member_funcs.get(mod_node.name, [])
            if members and all(
                m.extra.get("rescript_external") for m in members
            ):
                mod_node.extra["rescript_kind"] = "js_binding"

        # Dedupe IMPORTS_FROM edges by (source, target). The same `open X`
        # can appear multiple times legitimately (e.g. reopened within
        # different scopes), and include+open of the same module produces
        # two edges; collapse them.
        seen_imports: set[tuple[str, str]] = set()
        deduped_edges: list[EdgeInfo] = []
        for e in edges:
            if e.kind == "IMPORTS_FROM":
                key = (e.source, e.target)
                if key in seen_imports:
                    continue
                seen_imports.add(key)
            deduped_edges.append(e)
        edges = deduped_edges

        edges = self._resolve_call_targets(nodes, edges, file_path_str)

        if test_file:
            test_qnames = set()
            for n in nodes:
                if n.is_test:
                    qn = self._qualify(n.name, n.file_path, n.parent_name)
                    test_qnames.add(qn)
            for edge in list(edges):
                if edge.kind == "CALLS" and edge.source in test_qnames:
                    edges.append(EdgeInfo(
                        kind="TESTED_BY",
                        source=edge.target,
                        target=edge.source,
                        file_path=edge.file_path,
                        line=edge.line,
                    ))

        return nodes, edges

    # ------------------------------------------------------------------
    # SQL parser
    # ------------------------------------------------------------------

    # Regex for CREATE PROCEDURE — tree-sitter SQL grammar emits an ERROR node
    # for this statement, so we fall back to a regex scan.
    _SQL_PROC_RE = re.compile(
        r"CREATE\s+(?:OR\s+REPLACE\s+)?PROCEDURE\s+(\w+(?:\.\w+)*)",
        re.IGNORECASE,
    )

    # Named DDL statements supported by tree-sitter-sql.
    _SQL_DDL_NODE_TYPES = frozenset({
        "create_table",
        "create_view",
        "create_function",
    })

    def _parse_sql(
        self, path: Path, source: bytes,
    ) -> tuple[list[NodeInfo], list[EdgeInfo]]:
        """Parse a `.sql` file.

        Extracts:
        - Tables (CREATE TABLE) → Class nodes with extra["sql_kind"]="table"
        - Views  (CREATE VIEW)  → Class nodes with extra["sql_kind"]="view"
        - Functions (CREATE FUNCTION) → Function nodes with extra["sql_kind"]="function"
        - Procedures (CREATE PROCEDURE, regex fallback) → Function nodes with
          extra["sql_kind"]="procedure"

        Data dependencies (FROM/JOIN table references) are recorded as
        IMPORTS_FROM edges so the impact-radius query can follow them.
        """
        text = source.decode("utf-8", errors="replace")
        file_path_str = str(path)
        test_file = _is_test_file(file_path_str)

        nodes: list[NodeInfo] = []
        edges: list[EdgeInfo] = []

        nodes.append(NodeInfo(
            kind="File",
            name=file_path_str,
            file_path=file_path_str,
            line_start=1,
            line_end=text.count("\n") + 1,
            language="sql",
            is_test=test_file,
        ))

        # --- tree-sitter pass ---
        parser = self._get_parser("sql")
        if parser:
            tree = parser.parse(source)
            self._walk_sql_tree(
                tree.root_node, source, file_path_str, nodes, edges,
            )

        # --- regex fallback for CREATE PROCEDURE ---
        for m in self._SQL_PROC_RE.finditer(text):
            raw_name = m.group(1)
            name = raw_name.split(".")[-1]  # strip schema prefix
            line = text[: m.start()].count("\n") + 1
            qualified = f"{file_path_str}::{name}"
            nodes.append(NodeInfo(
                kind="Function",
                name=name,
                file_path=file_path_str,
                line_start=line,
                line_end=line,
                language="sql",
                extra={"sql_kind": "procedure"},
            ))
            edges.append(EdgeInfo(
                kind="CONTAINS",
                source=file_path_str,
                target=qualified,
                file_path=file_path_str,
                line=line,
            ))

        # --- table-reference pass (FROM / JOIN targets) ---
        seen_refs: set[str] = set()
        for m in _SQL_TABLE_RE.finditer(text):
            raw_ref = m.group(1).strip("`")
            ref = raw_ref.split(".")[-1]  # strip schema/db prefix
            if ref and ref.upper() not in _SQL_KEYWORDS and ref not in seen_refs:
                seen_refs.add(ref)
                line = text[: m.start()].count("\n") + 1
                edges.append(EdgeInfo(
                    kind="IMPORTS_FROM",
                    source=file_path_str,
                    target=ref,
                    file_path=file_path_str,
                    line=line,
                ))

        return nodes, edges

    def _walk_sql_tree(
        self,
        node,
        source: bytes,
        file_path_str: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
    ) -> None:
        """Recursively walk a tree-sitter SQL AST and extract DDL entities."""
        if node.type in self._SQL_DDL_NODE_TYPES:
            self._extract_sql_ddl(node, source, file_path_str, nodes, edges)
            return  # don't recurse into the DDL body — no nested DDL expected
        for child in node.children:
            self._walk_sql_tree(child, source, file_path_str, nodes, edges)

    def _extract_sql_ddl(
        self,
        node,
        source: bytes,
        file_path_str: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
    ) -> None:
        """Extract a single CREATE TABLE / VIEW / FUNCTION DDL node."""
        node_type = node.type
        line_start = node.start_point[0] + 1
        line_end = node.end_point[0] + 1

        # Locate the identifier / object_reference child that holds the name.
        name: Optional[str] = None
        for child in node.children:
            if child.type in ("identifier", "object_reference", "dotted_name"):
                raw = source[child.start_byte: child.end_byte].decode("utf-8", errors="replace")
                # Strip schema prefix (schema.name → name)
                name = raw.strip("`\"").split(".")[-1]
                break
            # Some grammars nest: relation > object_reference > identifier
            if child.type == "relation":
                for gc in child.children:
                    if gc.type in ("object_reference", "identifier"):
                        raw = source[gc.start_byte: gc.end_byte].decode(
                            "utf-8", errors="replace",
                        )
                        name = raw.strip("`\"").split(".")[-1]
                        break
                if name:
                    break

        if not name:
            return

        if node_type == "create_table":
            kind = "Class"
            sql_kind = "table"
        elif node_type == "create_view":
            kind = "Class"
            sql_kind = "view"
        else:  # create_function
            kind = "Function"
            sql_kind = "function"

        qualified = f"{file_path_str}::{name}"
        nodes.append(NodeInfo(
            kind=kind,
            name=name,
            file_path=file_path_str,
            line_start=line_start,
            line_end=line_end,
            language="sql",
            extra={"sql_kind": sql_kind},
        ))
        edges.append(EdgeInfo(
            kind="CONTAINS",
            source=file_path_str,
            target=qualified,
            file_path=file_path_str,
            line=line_start,
        ))

    def _resolve_call_targets(
        self,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        file_path: str,
    ) -> list[EdgeInfo]:
        """Resolve bare call targets to qualified names using same-file definitions.

        After parsing, CALLS edges store bare function names (e.g. ``FirebaseAuth``)
        as targets. This method builds a symbol table from the parsed nodes and
        qualifies any bare target that matches a local definition, so that
        ``callers_of`` / ``callees_of`` queries produce correct results.

        External calls (names not defined in this file) remain bare.
        """
        # Build symbol table: bare_name -> qualified_name
        symbols: dict[str, str] = {}
        for node in nodes:
            if node.kind in ("Function", "Class", "Type", "Test"):
                bare = node.name
                qualified = self._qualify(bare, file_path, node.parent_name)
                if bare not in symbols:
                    symbols[bare] = qualified

        resolved: list[EdgeInfo] = []
        for edge in edges:
            if edge.kind in ("CALLS", "REFERENCES") and "::" not in edge.target:
                if edge.target in symbols:
                    edge = EdgeInfo(
                        kind=edge.kind,
                        source=edge.source,
                        target=symbols[edge.target],
                        file_path=edge.file_path,
                        line=edge.line,
                        extra=edge.extra,
                    )
            resolved.append(edge)
        return resolved

    _MAX_AST_DEPTH = 180  # Guard against pathologically nested source files
    _MAX_TEST_DESCRIPTION_LEN = 200  # Cap test description length in node names

    def _get_test_description(self, call_node, source: bytes) -> Optional[str]:
        """Extract the first string argument from a test runner call node."""
        for child in call_node.children:
            if child.type == "arguments":
                for arg in child.children:
                    if arg.type in ("string", "template_string"):
                        raw = arg.text.decode("utf-8", errors="replace")
                        stripped = raw.strip("'\"`")
                        normalized = re.sub(r"\s+", " ", stripped).strip()
                        if len(normalized) > self._MAX_TEST_DESCRIPTION_LEN:
                            normalized = normalized[: self._MAX_TEST_DESCRIPTION_LEN]
                        return normalized
        return None

    def _extract_from_tree(
        self,
        root,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str] = None,
        enclosing_func: Optional[str] = None,
        import_map: Optional[dict[str, str]] = None,
        defined_names: Optional[set[str]] = None,
        _depth: int = 0,
    ) -> None:
        """Recursively walk the AST and extract nodes/edges."""
        if _depth > self._MAX_AST_DEPTH:
            return
        class_types = set(_CLASS_TYPES.get(language, []))
        func_types = set(_FUNCTION_TYPES.get(language, []))
        import_types = set(_IMPORT_TYPES.get(language, []))
        call_types = set(_CALL_TYPES.get(language, []))

        for child in root.children:
            node_type = child.type

            # --- R-specific constructs ---
            if language == "r" and self._extract_r_constructs(
                child, node_type, source, language, file_path,
                nodes, edges, enclosing_class, enclosing_func,
                import_map, defined_names,
            ):
                continue

            # --- Lua/Luau-specific constructs ---
            if language in ("lua", "luau") and self._extract_lua_constructs(
                child, node_type, source, language, file_path,
                nodes, edges, enclosing_class, enclosing_func,
                import_map, defined_names, _depth,
            ):
                continue

            # --- Bash-specific constructs ---
            # ``source ./foo.sh`` and ``. ./foo.sh`` are commands in
            # tree-sitter-bash; re-interpret them as IMPORTS_FROM edges so
            # cross-script wiring works the same as in other languages.
            if language == "bash" and node_type == "command":
                if self._extract_bash_source_command(
                    child, file_path, edges,
                ):
                    continue

            # --- Elixir-specific constructs ---
            # Every top-level construct in Elixir is a ``call`` node:
            # defmodule, def/defp/defmacro, alias/import/require/use, and
            # ordinary function invocations all share the same node type.
            # Dispatch via _extract_elixir_constructs so we can tell them
            # apart by the first-identifier text and still recurse into
            # bodies with the correct enclosing scope. See: #112
            if language == "elixir" and node_type == "call":
                if self._extract_elixir_constructs(
                    child, source, language, file_path, nodes, edges,
                    enclosing_class, enclosing_func,
                    import_map, defined_names, _depth,
                ):
                    continue

            # --- Nix-specific constructs ---
            # Nix bindings (``attrpath = expr;``) are the graph's addressable
            # things; dispatch via _extract_nix_constructs to flatten dotted
            # attrpaths into Function nodes and to emit IMPORTS_FROM edges for
            # flake ``inputs.*.url`` strings and ``import``/``callPackage``
            # applications. See: #366 follow-up (flake-aware Nix support).
            if language == "nix" and node_type == "binding":
                if self._extract_nix_constructs(
                    child, source, language, file_path, nodes, edges,
                    enclosing_class, enclosing_func,
                    import_map, defined_names, _depth,
                ):
                    continue

            # --- Julia-specific constructs ---
            # Short-form functions (`f(x) = expr`) parse as ``assignment``,
            # ``include("file.jl")`` as a call_expression, exports as
            # ``export_statement``, and macrocalls (including ``@testset``)
            # need recursion into bodies that may themselves contain
            # function definitions (e.g. ``@inline function f ... end``).
            if language == "julia" and self._extract_julia_constructs(
                child, node_type, source, language, file_path,
                nodes, edges, enclosing_class, enclosing_func,
                import_map, defined_names, _depth,
            ):
                continue

            # --- Dart call detection (see #87) ---
            # tree-sitter-dart does not wrap calls in a single
            # ``call_expression`` node; instead the pattern is
            # ``identifier + selector > argument_part`` as siblings inside
            # the parent.  Scan child's children here and emit CALLS edges
            # for any we find; nested calls are handled by the main recursion.
            if language == "dart":
                self._extract_dart_calls_from_children(
                    child, source, file_path, edges,
                    enclosing_class, enclosing_func,
                )

            # --- JS/TS variable-assigned functions (const foo = () => {}) ---
            if (
                language in ("javascript", "typescript", "tsx")
                and node_type in ("lexical_declaration", "variable_declaration")
                and self._extract_js_var_functions(
                    child, source, language, file_path, nodes, edges,
                    enclosing_class, enclosing_func,
                    import_map, defined_names, _depth,
                )
            ):
                continue

            # --- Classes ---
            if node_type in class_types and self._extract_classes(
                child, source, language, file_path, nodes, edges,
                enclosing_class, import_map, defined_names,
                _depth,
            ):
                continue

            # --- JS/TS class field arrow functions (handler = () => {}) ---
            if (
                language in ("javascript", "typescript", "tsx")
                and node_type == "public_field_definition"
                and self._extract_js_field_function(
                    child, source, language, file_path, nodes, edges,
                    enclosing_class, enclosing_func,
                    import_map, defined_names, _depth,
                )
            ):
                continue

            # --- Functions ---
            if node_type in func_types and self._extract_functions(
                child, source, language, file_path, nodes, edges,
                enclosing_class, import_map, defined_names,
                _depth, enclosing_func,
            ):
                continue

            # --- Imports ---
            if node_type in import_types:
                self._extract_imports(
                    child, language, source, file_path, edges,
                )
                continue

            # --- Calls ---
            if node_type in call_types:
                if self._extract_calls(
                    child, source, language, file_path, nodes, edges,
                    enclosing_class, enclosing_func,
                    import_map, defined_names, _depth,
                ):
                    continue

            # --- JSX component invocations ---
            if (
                language in ("javascript", "typescript", "tsx")
                and node_type in ("jsx_opening_element", "jsx_self_closing_element")
            ):
                self._extract_jsx_component_call(
                    child, language, file_path, edges,
                    enclosing_class, enclosing_func,
                    import_map, defined_names,
                )

            # --- Value references (function-as-value in maps, arrays, args) ---
            self._extract_value_references(
                child, node_type, source, language, file_path, edges,
                enclosing_class, enclosing_func,
                import_map, defined_names,
            )

            # --- Solidity-specific constructs ---
            if language == "solidity" and self._extract_solidity_constructs(
                child, node_type, source, file_path, nodes, edges,
                enclosing_class, enclosing_func,
            ):
                continue

            # Recurse for other node types
            self._extract_from_tree(
                child, source, language, file_path, nodes, edges,
                enclosing_class=enclosing_class,
                enclosing_func=enclosing_func,
                import_map=import_map, defined_names=defined_names,
                _depth=_depth + 1,
            )

    def _elixir_call_identifier(self, node) -> Optional[str]:
        """Return the leading identifier of an Elixir ``call`` node.

        For ``def add(a, b)`` returns ``"def"``; for ``defmodule Calc``
        returns ``"defmodule"``; for ``IO.puts(msg)`` returns the dotted
        path's final identifier (``"puts"``); for ``alias Calculator``
        returns ``"alias"``.
        """
        if not node.children:
            return None
        first = node.children[0]
        if first.type == "identifier":
            return first.text.decode("utf-8", errors="replace")
        # Dotted calls: dot > left: alias "IO", right: identifier "puts"
        if first.type == "dot":
            for child in reversed(first.children):
                if child.type == "identifier":
                    return child.text.decode("utf-8", errors="replace")
        return None

    def _elixir_module_name(self, arguments) -> Optional[str]:
        """Extract a module name from a ``defmodule`` / ``alias`` / etc.
        arguments node. Supports ``Calc`` (single alias) and ``Foo.Bar``
        (dotted alias inside a `dot` node).
        """
        for child in arguments.children:
            if child.type == "alias":
                return child.text.decode("utf-8", errors="replace")
            if child.type == "dot":
                return child.text.decode("utf-8", errors="replace")
        return None

    def _elixir_function_name_and_params(
        self, arguments, source: bytes,
    ) -> tuple[Optional[str], Optional[str]]:
        """Extract the function name and parameter list from a ``def``/
        ``defp``/``defmacro`` arguments node.

        The ``arguments`` of a ``def`` call wraps another ``call`` whose
        first child is the function's identifier and whose children
        (past the parens) are the parameters.
        """
        for child in arguments.children:
            if child.type == "call":
                name: Optional[str] = None
                for sub in child.children:
                    if sub.type == "identifier" and name is None:
                        name = sub.text.decode("utf-8", errors="replace")
                # Parameter text is everything between the parens of
                # the inner call; source slice is simplest.
                params_text = child.text.decode("utf-8", errors="replace")
                # Strip the function name off the front.
                if name and params_text.startswith(name):
                    params_text = params_text[len(name):]
                return name, params_text
            if child.type == "identifier":
                # Zero-arity def like `def reset, do: ...` has no inner
                # call; just the identifier.
                return child.text.decode("utf-8", errors="replace"), None
        return None, None

    def _extract_elixir_constructs(
        self,
        node,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Handle every Elixir ``call`` node by dispatching on the leading
        identifier. See: #112

        Returns True if the node was fully handled (and the main loop
        should skip generic recursion); False to let the default dispatch
        continue (never used here — Elixir has no other node types).
        """
        ident = self._elixir_call_identifier(node)
        if ident is None:
            return False

        # ---- defmodule Name do ... end ----------------------------------
        if ident == "defmodule":
            arguments = None
            do_block = None
            for sub in node.children:
                if sub.type == "arguments":
                    arguments = sub
                elif sub.type == "do_block":
                    do_block = sub
            if arguments is None:
                return False
            mod_name = self._elixir_module_name(arguments)
            if mod_name is None:
                return False
            qualified = self._qualify(mod_name, file_path, None)
            nodes.append(NodeInfo(
                kind="Class",
                name=mod_name,
                file_path=file_path,
                line_start=node.start_point[0] + 1,
                line_end=node.end_point[0] + 1,
                language=language,
                parent_name=None,
            ))
            # CONTAINS file -> module
            edges.append(EdgeInfo(
                kind="CONTAINS",
                source=file_path,
                target=qualified,
                file_path=file_path,
                line=node.start_point[0] + 1,
            ))
            if do_block is not None:
                self._extract_from_tree(
                    do_block, source, language, file_path, nodes, edges,
                    enclosing_class=mod_name,
                    enclosing_func=None,
                    import_map=import_map, defined_names=defined_names,
                    _depth=_depth + 1,
                )
            return True

        # ---- def / defp / defmacro / defmacrop -------------------------
        if ident in ("def", "defp", "defmacro", "defmacrop"):
            arguments = None
            do_block = None
            for sub in node.children:
                if sub.type == "arguments":
                    arguments = sub
                elif sub.type == "do_block":
                    do_block = sub
            if arguments is None:
                return False
            fn_name, params = self._elixir_function_name_and_params(
                arguments, source,
            )
            if fn_name is None:
                return False
            is_test = _is_test_function(fn_name, file_path)
            kind = "Test" if is_test else "Function"
            qualified = self._qualify(fn_name, file_path, enclosing_class)
            nodes.append(NodeInfo(
                kind=kind,
                name=fn_name,
                file_path=file_path,
                line_start=node.start_point[0] + 1,
                line_end=node.end_point[0] + 1,
                language=language,
                parent_name=enclosing_class,
                params=params,
                is_test=is_test,
            ))
            container = (
                self._qualify(enclosing_class, file_path, None)
                if enclosing_class else file_path
            )
            edges.append(EdgeInfo(
                kind="CONTAINS",
                source=container,
                target=qualified,
                file_path=file_path,
                line=node.start_point[0] + 1,
            ))
            if do_block is not None:
                self._extract_from_tree(
                    do_block, source, language, file_path, nodes, edges,
                    enclosing_class=enclosing_class,
                    enclosing_func=fn_name,
                    import_map=import_map, defined_names=defined_names,
                    _depth=_depth + 1,
                )
            return True

        # ---- alias / import / require / use ----------------------------
        if ident in ("alias", "import", "require", "use"):
            for sub in node.children:
                if sub.type == "arguments":
                    mod = self._elixir_module_name(sub)
                    if mod is not None:
                        edges.append(EdgeInfo(
                            kind="IMPORTS_FROM",
                            source=file_path,
                            target=mod,
                            file_path=file_path,
                            line=node.start_point[0] + 1,
                        ))
                    break
            return True

        # ---- Everything else = a regular function/method call ----------
        # Module-scope calls attribute to the File node (same rule as the
        # generic _extract_calls path).
        # For dotted calls like `IO.puts(msg)`, prefer the dotted
        # identifier; for bare calls use the first identifier.
        call_name = ident
        caller = (
            self._qualify(enclosing_func, file_path, enclosing_class)
            if enclosing_func
            else file_path
        )
        target = self._resolve_call_target(
            call_name, file_path, language,
            import_map or {}, defined_names or set(),
        )
        edges.append(EdgeInfo(
            kind="CALLS",
            source=caller,
            target=target,
            file_path=file_path,
            line=node.start_point[0] + 1,
        ))
        # Recurse into arguments + do_block so nested calls are caught.
        for sub in node.children:
            if sub.type in ("arguments", "do_block"):
                self._extract_from_tree(
                    sub, source, language, file_path, nodes, edges,
                    enclosing_class=enclosing_class,
                    enclosing_func=enclosing_func,
                    import_map=import_map, defined_names=defined_names,
                    _depth=_depth + 1,
                )
        return True

    @staticmethod
    def _is_nix_flake_file(file_path: str) -> bool:
        """Return True for files whose basename is ``flake.nix``."""
        return Path(file_path).name == "flake.nix"

    def _nix_attrpath_parts(self, attrpath_node) -> list[str]:
        """Flatten a Nix ``attrpath`` node into a list of identifier parts.

        ``packages.default`` → ``["packages", "default"]``;
        ``inputs.nixpkgs.url`` → ``["inputs", "nixpkgs", "url"]``. Dotted
        attrpaths have ``identifier`` children separated by ``.`` tokens.
        """
        parts: list[str] = []
        for child in attrpath_node.children:
            if child.type == "identifier":
                parts.append(child.text.decode("utf-8", errors="replace"))
        return parts

    def _extract_nix_flake_input_urls(
        self, attrset_node,
    ) -> list[tuple[str, int]]:
        """Walk a Nix ``attrset_expression`` looking for ``*.url = "..."``
        bindings whose RHS is a literal string. Returns ``(url, line)``
        tuples. Used when the enclosing attrpath is ``inputs`` so that both
        the nested form

            inputs = { nixpkgs.url = "..."; flake-utils.url = "..."; };

        and the mixed form (an inner input with its own nested attrset)
        surface the URL strings as IMPORTS_FROM targets.
        """
        results: list[tuple[str, int]] = []

        def visit(n) -> None:
            if n is None:
                return
            if n.type == "binding":
                inner_path = None
                inner_rhs = None
                for sub in n.children:
                    if sub.type == "attrpath":
                        inner_path = sub
                    elif sub.type not in ("=", ";") and inner_path is not None:
                        if inner_rhs is None:
                            inner_rhs = sub
                if inner_path is not None and inner_rhs is not None:
                    parts = self._nix_attrpath_parts(inner_path)
                    if (
                        parts
                        and parts[-1] == "url"
                        and inner_rhs.type == "string_expression"
                    ):
                        for c in inner_rhs.children:
                            if c.type == "string_fragment":
                                url = c.text.decode("utf-8", errors="replace")
                                results.append((url, n.start_point[0] + 1))
                                break
                        return  # leaf binding — no children to recurse into
                    # Non-url binding: still recurse so a deeper url survives
                    if inner_rhs.type == "attrset_expression":
                        visit(inner_rhs)
                        return
            for c in n.children:
                visit(c)

        visit(attrset_node)
        return results

    def _extract_nix_import_targets(self, rhs_node) -> list[tuple[str, int]]:
        """Walk an expression looking for ``import <path>`` and
        ``callPackage <path> <args>`` applications. Returns a list of
        ``(target_path, line)`` tuples for each match.

        Recurses through ``apply_expression`` (so ``import ./x.nix { ... }``
        and ``pkgs.callPackage ./y.nix { }`` are both caught) and descends
        into bodies of ``let_expression`` / ``parenthesized_expression`` /
        ``function_expression`` / ``attrset_expression`` / ``list_expression``
        so a ``let pkgs = import nixpkgs; in { ... }`` body is scanned too.
        """
        results: list[tuple[str, int]] = []

        def head_call_name(apply) -> Optional[str]:
            """Drill down the left-most side of nested apply_expressions to
            the callee identifier. ``import ./x`` → ``"import"``;
            ``pkgs.callPackage ./y { }`` → ``"callPackage"`` (last dotted
            segment of the select_expression)."""
            cur = apply
            while cur is not None and cur.type == "apply_expression":
                cur = cur.children[0] if cur.children else None
            if cur is None:
                return None
            if cur.type == "variable_expression":
                for c in cur.children:
                    if c.type == "identifier":
                        return c.text.decode("utf-8", errors="replace")
            if cur.type == "select_expression":
                # Last identifier in the attrpath portion.
                last: Optional[str] = None
                for c in cur.children:
                    if c.type == "attrpath":
                        for ac in c.children:
                            if ac.type == "identifier":
                                last = ac.text.decode("utf-8", errors="replace")
                    elif c.type == "identifier":
                        last = c.text.decode("utf-8", errors="replace")
                return last
            if cur.type == "identifier":
                return cur.text.decode("utf-8", errors="replace")
            return None

        def first_path_arg(apply) -> Optional[str]:
            """For nested apply_expressions like ``import ./x.nix { }``, walk
            down collecting arguments; return the first ``path_expression``
            we find."""
            # Descend left spine collecting right-hand args in outer→inner order
            stack: list = []
            cur = apply
            while cur is not None and cur.type == "apply_expression":
                if len(cur.children) >= 2:
                    stack.append(cur.children[1])
                cur = cur.children[0] if cur.children else None
            # Args closest to the callee come last in stack; try them in
            # that order (innermost first) so ``import ./x { }`` picks
            # ``./x`` not ``{ }``.
            for arg in reversed(stack):
                if arg.type == "path_expression":
                    return arg.text.decode("utf-8", errors="replace").strip()
            return None

        def visit(n) -> None:
            if n is None:
                return
            if n.type == "apply_expression":
                name = head_call_name(n)
                if name in ("import", "callPackage"):
                    path = first_path_arg(n)
                    if path:
                        results.append((path, n.start_point[0] + 1))
                # Still recurse into children so nested imports inside
                # argument attrsets/lets are caught.
            for c in n.children:
                visit(c)

        visit(rhs_node)
        return results

    def _extract_nix_constructs(
        self,
        node,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Handle a Nix ``binding`` node (``attrpath = expr;``).

        - Flattens dotted attrpaths into a single dotted node name
          (``packages.default``).
        - In ``flake.nix``, ``inputs.<name>.url = "..."`` bindings emit an
          ``IMPORTS_FROM`` edge with target = the URL string, and no node.
        - All other bindings become ``Function`` nodes (matching the
          Bash/Elixir convention for "the graph's addressable things") with a
          CONTAINS edge from the File.
        - The RHS is scanned for ``import <path>`` / ``callPackage <path> ...``
          applications; each emits an ``IMPORTS_FROM`` edge (relative paths
          are resolved against the caller's directory when possible).
        - Recurses into the RHS so nested bindings (e.g. inside
          ``let ... in { ... }`` or ``outputs = { ... }: { ... }``) are
          discovered and flattened as their own top-level nodes.

        Returns True (Nix has no other node-type dispatches in the walker).
        """
        attrpath_node = None
        rhs_node = None
        for sub in node.children:
            if sub.type == "attrpath":
                attrpath_node = sub
            elif sub.type not in ("=", ";") and attrpath_node is not None:
                # First non-attrpath, non-punctuation child is the RHS.
                if rhs_node is None:
                    rhs_node = sub
        if attrpath_node is None or rhs_node is None:
            return False

        parts = self._nix_attrpath_parts(attrpath_node)
        if not parts:
            return False
        name = ".".join(parts)
        line = node.start_point[0] + 1

        # --- Flake input URL: inputs.<name>.url = "..." ------------------
        # Flat form: ``inputs.nixpkgs.url = "github:...";`` — emit one edge,
        # skip node creation (this is metadata, not a graph "thing").
        if (
            self._is_nix_flake_file(file_path)
            and len(parts) >= 2
            and parts[0] == "inputs"
            and parts[-1] == "url"
            and rhs_node.type == "string_expression"
        ):
            url: Optional[str] = None
            for c in rhs_node.children:
                if c.type == "string_fragment":
                    url = c.text.decode("utf-8", errors="replace")
                    break
            if url:
                edges.append(EdgeInfo(
                    kind="IMPORTS_FROM",
                    source=file_path,
                    target=url,
                    file_path=file_path,
                    line=line,
                ))
                return True

        # Nested form: ``inputs = { nixpkgs.url = "..."; ... };`` — emit an
        # edge per inner url string. Still fall through so the ``inputs``
        # binding itself becomes a Function node and the default recursion
        # continues (the recursion won't re-emit these urls as separate
        # Function nodes because the flat form above short-circuits).
        if (
            self._is_nix_flake_file(file_path)
            and parts == ["inputs"]
            and rhs_node.type == "attrset_expression"
        ):
            for url, uline in self._extract_nix_flake_input_urls(rhs_node):
                edges.append(EdgeInfo(
                    kind="IMPORTS_FROM",
                    source=file_path,
                    target=url,
                    file_path=file_path,
                    line=uline,
                ))

        # --- Regular binding → Function node -----------------------------
        qualified = self._qualify(name, file_path, enclosing_class)
        nodes.append(NodeInfo(
            kind="Function",
            name=name,
            file_path=file_path,
            line_start=line,
            line_end=node.end_point[0] + 1,
            language=language,
            parent_name=enclosing_class,
        ))
        container = (
            self._qualify(enclosing_class, file_path, None)
            if enclosing_class else file_path
        )
        edges.append(EdgeInfo(
            kind="CONTAINS",
            source=container,
            target=qualified,
            file_path=file_path,
            line=line,
        ))

        # --- IMPORTS_FROM edges for import / callPackage inside the RHS --
        for target, tline in self._extract_nix_import_targets(rhs_node):
            resolved = self._resolve_module_to_file(target, file_path, "nix")
            edges.append(EdgeInfo(
                kind="IMPORTS_FROM",
                source=file_path,
                target=resolved if resolved else target,
                file_path=file_path,
                line=tline,
            ))

        # Recurse into the RHS so nested bindings become their own nodes
        # (e.g. ``outputs = ...: { packages.default = ...; }`` surfaces
        # ``packages.default`` as a top-level-named Function node too).
        self._extract_from_tree(
            rhs_node, source, language, file_path, nodes, edges,
            enclosing_class=enclosing_class,
            enclosing_func=enclosing_func,
            import_map=import_map, defined_names=defined_names,
            _depth=_depth + 1,
        )
        return True

    def _extract_bash_source_command(
        self,
        node,
        file_path: str,
        edges: list[EdgeInfo],
    ) -> bool:
        """Detect ``source foo.sh`` / ``. foo.sh`` and emit an IMPORTS_FROM
        edge. Returns True if handled (so the main loop skips recursing
        into this command). See: #197
        """
        command_name: Optional[str] = None
        args: list[str] = []
        for sub in node.children:
            if sub.type == "command_name":
                command_name = sub.text.decode("utf-8", errors="replace").strip()
            elif sub.type in ("word", "string", "raw_string") and command_name:
                txt = sub.text.decode("utf-8", errors="replace").strip()
                # Strip surrounding quotes if present
                if len(txt) >= 2 and txt[0] in ("'", '"') and txt[-1] == txt[0]:
                    txt = txt[1:-1]
                if txt:
                    args.append(txt)
        if command_name in ("source", ".") and args:
            target = args[0]
            # Try to resolve relative paths to real files
            resolved = self._resolve_module_to_file(target, file_path, "bash")
            edges.append(EdgeInfo(
                kind="IMPORTS_FROM",
                source=file_path,
                target=resolved if resolved else target,
                file_path=file_path,
                line=node.start_point[0] + 1,
            ))
            return True
        return False

    def _extract_dart_calls_from_children(
        self,
        parent,
        source: bytes,
        file_path: str,
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
    ) -> None:
        """Detect Dart call sites from a parent node's children (#87 bug 1).

        tree-sitter-dart does not emit a single ``call_expression`` node for
        Dart calls.  Instead it produces ``identifier`` / method-selector
        siblings followed by a ``selector`` whose child is ``argument_part``:

            identifier "print"
            selector
              argument_part

        And for method calls like ``obj.foo()`` the middle selector is a
        ``unconditional_assignable_selector`` holding the method name:

            identifier "obj"
            selector
              unconditional_assignable_selector "."
                identifier "foo"
            selector
              argument_part

        This walker scans the immediate children of ``parent`` for either
        shape and emits a ``CALLS`` edge.  Nested calls are picked up as
        ``_extract_from_tree`` recurses into child nodes.
        """
        call_name: Optional[str] = None
        for sub in parent.children:
            if sub.type == "identifier":
                call_name = sub.text.decode("utf-8", errors="replace")
                continue
            if sub.type == "selector":
                # Case A: selector > unconditional_assignable_selector > identifier
                # (updates call_name to the method name)
                method_name: Optional[str] = None
                has_arguments = False
                for ssub in sub.children:
                    if ssub.type == "unconditional_assignable_selector":
                        for ident in ssub.children:
                            if ident.type == "identifier":
                                method_name = ident.text.decode(
                                    "utf-8", errors="replace"
                                )
                                break
                    elif ssub.type == "argument_part":
                        has_arguments = True
                if method_name is not None:
                    call_name = method_name
                if has_arguments and call_name:
                    src_qn = (
                        self._qualify(enclosing_func, file_path, enclosing_class)
                        if enclosing_func else file_path
                    )
                    edges.append(EdgeInfo(
                        kind="CALLS",
                        source=src_qn,
                        target=call_name,
                        file_path=file_path,
                        line=parent.start_point[0] + 1,
                    ))
                    # After emitting for this call, clear call_name so we
                    # don't re-emit on any trailing chained selector.
                    call_name = None
                continue
            # Non-identifier, non-selector children don't change the
            # pending call name (``return``, ``await``, ``yield``, etc.)
            # but anything unexpected should reset it to avoid spurious
            # edges across unrelated siblings.
            if sub.type not in ("return", "await", "yield", "this", "const", "new"):
                call_name = None

    def _extract_r_constructs(
        self,
        child,
        node_type: str,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
    ) -> bool:
        """Handle R-specific AST nodes (assignments and class-defining calls).

        Returns True if the child was fully handled and should be skipped
        by the main loop.
        """
        # R: function definitions via assignment
        if node_type == "binary_operator":
            handled = self._handle_r_binary_operator(
                child, source, language, file_path, nodes, edges,
                enclosing_class, enclosing_func,
                import_map, defined_names,
            )
            if handled:
                return True

        # R: setClass/setRefClass/setGeneric calls and imports
        if node_type == "call":
            handled = self._handle_r_call(
                child, source, language, file_path, nodes, edges,
                enclosing_class, enclosing_func,
                import_map, defined_names,
            )
            if handled:
                return True

        return False

    # ------------------------------------------------------------------
    # Julia-specific helpers
    # ------------------------------------------------------------------

    def _julia_short_func_name(self, call_expr) -> Optional[str]:
        """Extract the name from a ``call_expression`` that is the LHS of
        a short-form function ``f(x) = expr`` or ``Base.f(x) = expr`` or
        ``Foo{T}(x) = expr``.
        """
        for child in call_expr.children:
            if child.type == "identifier":
                return child.text.decode("utf-8", errors="replace")
            if child.type == "field_expression":
                for ident in reversed(child.children):
                    if ident.type == "identifier":
                        return ident.text.decode("utf-8", errors="replace")
                return None
            if child.type == "parametrized_type_expression":
                for ident in child.children:
                    if ident.type == "identifier":
                        return ident.text.decode("utf-8", errors="replace")
                return None
        return None

    def _julia_string_arg(self, call_expr) -> Optional[str]:
        """Return the first string literal argument of a call_expression."""
        for child in call_expr.children:
            if child.type != "argument_list":
                continue
            for arg in child.children:
                if arg.type == "string_literal":
                    for sub in arg.children:
                        if sub.type == "content":
                            return sub.text.decode("utf-8", errors="replace")
                    raw = arg.text.decode("utf-8", errors="replace")
                    return raw.strip('"').strip("'")
        return None

    def _julia_call_first_identifier(self, call_expr) -> Optional[str]:
        """First identifier of a ``call_expression`` (the function being
        called). Used to detect ``include("...")``.
        """
        for child in call_expr.children:
            if child.type == "identifier":
                return child.text.decode("utf-8", errors="replace")
        return None

    def _extract_julia_constructs(
        self,
        child,
        node_type: str,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Handle Julia-specific constructs the type tables can't cover.

        Returns True if the child was fully handled and should be skipped
        by the main dispatch loop.
        """
        # --- Short-form function: assignment with call_expression LHS ---
        # ``f(x) = expr`` or ``Base.f(x) = expr``.  Anything else with an
        # ``=`` (plain variable, const) is left to the generic path.
        if node_type == "assignment":
            lhs = child.children[0] if child.children else None
            # Unwrap typed LHS: ``f(x)::RetT = expr`` parses as
            # ``assignment > typed_expression > call_expression``.
            if lhs is not None and lhs.type == "typed_expression":
                for sub in lhs.children:
                    if sub.type == "call_expression":
                        lhs = sub
                        break
            if lhs is not None and lhs.type == "call_expression":
                name = self._julia_short_func_name(lhs)
                if name:
                    is_test = _is_test_function(name, file_path, ())
                    kind = "Test" if is_test else "Function"
                    qualified = self._qualify(
                        name, file_path, enclosing_class,
                    )
                    nodes.append(NodeInfo(
                        kind=kind,
                        name=name,
                        file_path=file_path,
                        line_start=child.start_point[0] + 1,
                        line_end=child.end_point[0] + 1,
                        language=language,
                        parent_name=enclosing_class,
                        is_test=is_test,
                    ))
                    container = (
                        self._qualify(enclosing_class, file_path, None)
                        if enclosing_class
                        else file_path
                    )
                    edges.append(EdgeInfo(
                        kind="CONTAINS",
                        source=container,
                        target=qualified,
                        file_path=file_path,
                        line=child.start_point[0] + 1,
                    ))
                    # Recurse into the RHS only (children after the ``=``
                    # operator) with this function as the enclosing scope
                    # so internal calls wire up correctly. Visiting the
                    # whole assignment would re-treat the LHS
                    # ``call_expression`` as a self-call.
                    seen_op = False
                    for sub in child.children:
                        if not seen_op:
                            if sub.type == "operator":
                                seen_op = True
                            continue
                        self._extract_from_tree(
                            sub, source, language, file_path, nodes, edges,
                            enclosing_class=enclosing_class,
                            enclosing_func=name,
                            import_map=import_map,
                            defined_names=defined_names,
                            _depth=_depth + 1,
                        )
                    return True

        # --- Skip call_expression nodes that are actually function
        # signatures (``function foo(x) ... end`` has a ``signature >
        # call_expression`` that describes the definition, not a call).
        if node_type == "call_expression":
            parent = child.parent
            if parent is not None and parent.type == "signature":
                return True

        # --- include("file.jl") -> IMPORTS_FROM edge ---
        if node_type == "call_expression":
            if self._julia_call_first_identifier(child) == "include":
                path_arg = self._julia_string_arg(child)
                if path_arg:
                    resolved = self._resolve_module_to_file(
                        path_arg, file_path, language,
                    )
                    edges.append(EdgeInfo(
                        kind="IMPORTS_FROM",
                        source=file_path,
                        target=resolved if resolved else path_arg,
                        file_path=file_path,
                        line=child.start_point[0] + 1,
                    ))
                    # Fall through - let generic call dispatch also record
                    # the CALLS edge and recurse for nested calls.
                    return False

        # --- export_statement / public_statement -> REFERENCES edges ---
        # ``public`` (1.11+) is a softer variant of ``export`` — symbols
        # are part of the public API but not brought into scope by
        # ``using``. Track both so review tools can answer "what's the
        # public surface of this module?".
        if node_type in ("export_statement", "public_statement"):
            source_qual = (
                self._qualify(enclosing_class, file_path, None)
                if enclosing_class
                else file_path
            )
            marker = (
                "julia_export"
                if node_type == "export_statement"
                else "julia_public"
            )
            for sub in child.children:
                if sub.type == "identifier":
                    name = sub.text.decode("utf-8", errors="replace")
                    edges.append(EdgeInfo(
                        kind="REFERENCES",
                        source=source_qual,
                        target=name,
                        file_path=file_path,
                        line=child.start_point[0] + 1,
                        extra={marker: True},
                    ))
            return True

        # --- macrocall_expression ---
        if node_type == "macrocall_expression":
            macro_name = None
            for sub in child.children:
                if sub.type == "macro_identifier":
                    for ident in sub.children:
                        if ident.type == "identifier":
                            macro_name = ident.text.decode(
                                "utf-8", errors="replace",
                            )
                            break
                    break

            if macro_name == "enum":
                # @enum Color RED BLUE GREEN
                # First argument is the enum type name; the rest are
                # variant names. Model the type as a Class and each
                # variant as a Function child, so callers referencing a
                # variant resolve to something in the graph.
                type_name: Optional[str] = None
                variant_identifiers: list = []
                for sub in child.children:
                    if sub.type != "macro_argument_list":
                        continue
                    for arg in sub.children:
                        if arg.type != "identifier":
                            continue
                        if type_name is None:
                            type_name = arg.text.decode(
                                "utf-8", errors="replace",
                            )
                        else:
                            variant_identifiers.append(arg)
                    break
                if type_name:
                    line_start = child.start_point[0] + 1
                    line_end = child.end_point[0] + 1
                    qualified_type = self._qualify(
                        type_name, file_path, enclosing_class,
                    )
                    nodes.append(NodeInfo(
                        kind="Class",
                        name=type_name,
                        file_path=file_path,
                        line_start=line_start,
                        line_end=line_end,
                        language=language,
                        parent_name=enclosing_class,
                        extra={"julia_kind": "enum"},
                    ))
                    container = (
                        self._qualify(enclosing_class, file_path, None)
                        if enclosing_class
                        else file_path
                    )
                    edges.append(EdgeInfo(
                        kind="CONTAINS",
                        source=container,
                        target=qualified_type,
                        file_path=file_path,
                        line=line_start,
                    ))
                    for variant in variant_identifiers:
                        vname = variant.text.decode(
                            "utf-8", errors="replace",
                        )
                        qualified_v = self._qualify(
                            vname, file_path, type_name,
                        )
                        nodes.append(NodeInfo(
                            kind="Function",
                            name=vname,
                            file_path=file_path,
                            line_start=variant.start_point[0] + 1,
                            line_end=variant.end_point[0] + 1,
                            language=language,
                            parent_name=type_name,
                            extra={"julia_kind": "enum_variant"},
                        ))
                        edges.append(EdgeInfo(
                            kind="CONTAINS",
                            source=qualified_type,
                            target=qualified_v,
                            file_path=file_path,
                            line=variant.start_point[0] + 1,
                        ))
                return True

            if macro_name == "testset":
                # @testset "desc" begin ... end
                desc = None
                body_parent = None
                for sub in child.children:
                    if sub.type != "macro_argument_list":
                        continue
                    body_parent = sub
                    for arg in sub.children:
                        if arg.type == "string_literal":
                            for c in arg.children:
                                if c.type == "content":
                                    desc = c.text.decode(
                                        "utf-8", errors="replace",
                                    )
                                    break
                            break
                line_no = child.start_point[0] + 1
                synth_base = f"testset:{desc}" if desc else "testset"
                synth_name = f"{synth_base}@L{line_no}"
                qualified = self._qualify(
                    synth_name, file_path, enclosing_class,
                )
                nodes.append(NodeInfo(
                    kind="Test",
                    name=synth_name,
                    file_path=file_path,
                    line_start=child.start_point[0] + 1,
                    line_end=child.end_point[0] + 1,
                    language=language,
                    parent_name=enclosing_class,
                    is_test=True,
                ))
                container = (
                    self._qualify(
                        enclosing_func, file_path, enclosing_class,
                    )
                    if enclosing_func
                    else file_path
                )
                edges.append(EdgeInfo(
                    kind="CONTAINS",
                    source=container,
                    target=qualified,
                    file_path=file_path,
                    line=child.start_point[0] + 1,
                ))
                if body_parent is not None:
                    self._extract_from_tree(
                        body_parent, source, language, file_path, nodes, edges,
                        enclosing_class=enclosing_class,
                        enclosing_func=synth_name,
                        import_map=import_map, defined_names=defined_names,
                        _depth=_depth + 1,
                    )
                return True

            # Other macrocalls: let the generic CALLS path emit the edge,
            # but also recurse into the macro_argument_list so that any
            # function defs nested under @inline / @generated / etc. get
            # captured. We return False so the generic dispatcher still
            # runs for the CALLS edge.
            return False

        return False

    # ------------------------------------------------------------------
    # Lua-specific helpers
    # ------------------------------------------------------------------

    def _extract_lua_constructs(
        self,
        child,
        node_type: str,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Handle Lua-specific AST constructs.

        Returns True if the child was fully handled and should be skipped
        by the main loop.

        Handles:
        - variable_declaration with require() -> IMPORTS_FROM edge
        - variable_declaration with function_definition -> named Function node
        - function_declaration with dot/method name -> Function with table parent
        - top-level require() call -> IMPORTS_FROM edge
        """
        # --- variable_declaration: require() or anonymous function ---
        if node_type == "variable_declaration":
            return self._handle_lua_variable_declaration(
                child, source, language, file_path, nodes, edges,
                enclosing_class, enclosing_func,
                import_map, defined_names, _depth,
            )

        # --- function_declaration with dot/method table name ---
        if node_type == "function_declaration":
            return self._handle_lua_table_function(
                child, source, language, file_path, nodes, edges,
                enclosing_class, enclosing_func,
                import_map, defined_names, _depth,
            )

        # --- Top-level require() not wrapped in variable_declaration ---
        if node_type == "function_call" and not enclosing_func:
            req_target = self._lua_get_require_target(child)
            if req_target is not None:
                resolved = self._resolve_module_to_file(
                    req_target, file_path, language,
                )
                edges.append(EdgeInfo(
                    kind="IMPORTS_FROM",
                    source=file_path,
                    target=resolved if resolved else req_target,
                    file_path=file_path,
                    line=child.start_point[0] + 1,
                ))
                return True

        return False

    def _handle_lua_variable_declaration(
        self,
        child,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Handle Lua variable declarations that contain require() or
        anonymous function definitions.

        ``local json = require("json")``  -> IMPORTS_FROM edge
        ``local fn = function(x) ... end`` -> Function node named "fn"
        """
        # Walk into: variable_declaration > assignment_statement
        assign = None
        for sub in child.children:
            if sub.type == "assignment_statement":
                assign = sub
                break
        if not assign:
            return False

        # Get variable name from variable_list
        var_name = None
        for sub in assign.children:
            if sub.type == "variable_list":
                for ident in sub.children:
                    if ident.type == "identifier":
                        var_name = ident.text.decode("utf-8", errors="replace")
                        break
                break

        # Get value from expression_list
        expr_list = None
        for sub in assign.children:
            if sub.type == "expression_list":
                expr_list = sub
                break

        if not var_name or not expr_list:
            return False

        # Check for require() call
        for expr in expr_list.children:
            if expr.type == "function_call":
                req_target = self._lua_get_require_target(expr)
                if req_target is not None:
                    resolved = self._resolve_module_to_file(
                        req_target, file_path, language,
                    )
                    edges.append(EdgeInfo(
                        kind="IMPORTS_FROM",
                        source=file_path,
                        target=resolved if resolved else req_target,
                        file_path=file_path,
                        line=child.start_point[0] + 1,
                    ))
                    return True

        # Check for anonymous function: local foo = function(...) end
        for expr in expr_list.children:
            if expr.type == "function_definition":
                is_test = _is_test_function(var_name, file_path)
                kind = "Test" if is_test else "Function"
                qualified = self._qualify(var_name, file_path, enclosing_class)
                params = self._get_params(expr, language, source)

                nodes.append(NodeInfo(
                    kind=kind,
                    name=var_name,
                    file_path=file_path,
                    line_start=child.start_point[0] + 1,
                    line_end=child.end_point[0] + 1,
                    language=language,
                    parent_name=enclosing_class,
                    params=params,
                    is_test=is_test,
                ))
                container = (
                    self._qualify(enclosing_class, file_path, None)
                    if enclosing_class else file_path
                )
                edges.append(EdgeInfo(
                    kind="CONTAINS",
                    source=container,
                    target=qualified,
                    file_path=file_path,
                    line=child.start_point[0] + 1,
                ))
                # Recurse into the function body for calls
                self._extract_from_tree(
                    expr, source, language, file_path, nodes, edges,
                    enclosing_class=enclosing_class,
                    enclosing_func=var_name,
                    import_map=import_map,
                    defined_names=defined_names,
                    _depth=_depth + 1,
                )
                return True

        return False

    def _handle_lua_table_function(
        self,
        child,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Handle Lua function declarations with table-qualified names.

        ``function Animal.new(name)``  -> Function "new", parent "Animal"
        ``function Animal:speak()``    -> Function "speak", parent "Animal"

        Plain ``function foo()`` is NOT handled here (returns False).
        """
        table_name = None
        method_name = None

        for sub in child.children:
            if sub.type in ("dot_index_expression", "method_index_expression"):
                identifiers = [
                    c for c in sub.children if c.type == "identifier"
                ]
                if len(identifiers) >= 2:
                    table_name = identifiers[0].text.decode(
                        "utf-8", errors="replace",
                    )
                    method_name = identifiers[-1].text.decode(
                        "utf-8", errors="replace",
                    )
                break

        if not table_name or not method_name:
            return False

        is_test = _is_test_function(method_name, file_path)
        kind = "Test" if is_test else "Function"
        qualified = self._qualify(method_name, file_path, table_name)
        params = self._get_params(child, language, source)

        nodes.append(NodeInfo(
            kind=kind,
            name=method_name,
            file_path=file_path,
            line_start=child.start_point[0] + 1,
            line_end=child.end_point[0] + 1,
            language=language,
            parent_name=table_name,
            params=params,
            is_test=is_test,
        ))
        # CONTAINS: table -> method
        container = self._qualify(table_name, file_path, None)
        edges.append(EdgeInfo(
            kind="CONTAINS",
            source=container,
            target=qualified,
            file_path=file_path,
            line=child.start_point[0] + 1,
        ))
        # Recurse into function body for calls
        self._extract_from_tree(
            child, source, language, file_path, nodes, edges,
            enclosing_class=table_name,
            enclosing_func=method_name,
            import_map=import_map,
            defined_names=defined_names,
            _depth=_depth + 1,
        )
        return True

    @staticmethod
    def _lua_get_require_target(call_node) -> Optional[str]:
        """Extract the module path from a Lua require() call.

        Returns the string argument or None if this is not a require() call.
        """
        # Structure: function_call > identifier("require") > arguments > string
        first_child = call_node.children[0] if call_node.children else None
        if (
            not first_child
            or first_child.type != "identifier"
            or first_child.text != b"require"
        ):
            return None
        for child in call_node.children:
            if child.type == "arguments":
                for arg in child.children:
                    if arg.type == "string":
                        # String node has string_content child
                        for sub in arg.children:
                            if sub.type == "string_content":
                                return sub.text.decode(
                                    "utf-8", errors="replace",
                                )
                        # Fallback: strip quotes from full text
                        raw = arg.text.decode("utf-8", errors="replace")
                        return raw.strip("'\"")
        return None

    # ------------------------------------------------------------------
    # JS/TS: variable-assigned functions  (const foo = () => {})
    # ------------------------------------------------------------------

    _JS_FUNC_VALUE_TYPES = frozenset(
        {"arrow_function", "function_expression", "function"},
    )

    def _extract_js_var_functions(
        self,
        child,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Handle JS/TS variable declarations that assign functions.

        Patterns handled:
          const foo = () => {}
          let bar = function() {}
          export const baz = (x: number): string => x.toString()

        Returns True if at least one function was extracted from the
        declaration, so the caller can skip generic recursion.
        """
        handled = False
        for declarator in child.children:
            if declarator.type != "variable_declarator":
                continue

            # Find identifier and function value
            var_name = None
            func_node = None
            for sub in declarator.children:
                if sub.type == "identifier" and var_name is None:
                    var_name = sub.text.decode("utf-8", errors="replace")
                elif sub.type in self._JS_FUNC_VALUE_TYPES:
                    func_node = sub

            if not var_name or not func_node:
                continue

            is_test = _is_test_function(var_name, file_path)
            kind = "Test" if is_test else "Function"
            qualified = self._qualify(var_name, file_path, enclosing_class)
            params = self._get_params(func_node, language, source)
            ret_type = self._get_return_type(func_node, language, source)

            nodes.append(NodeInfo(
                kind=kind,
                name=var_name,
                file_path=file_path,
                line_start=child.start_point[0] + 1,
                line_end=child.end_point[0] + 1,
                language=language,
                parent_name=enclosing_class,
                params=params,
                return_type=ret_type,
                is_test=is_test,
            ))
            container = (
                self._qualify(enclosing_class, file_path, None)
                if enclosing_class else file_path
            )
            edges.append(EdgeInfo(
                kind="CONTAINS",
                source=container,
                target=qualified,
                file_path=file_path,
                line=child.start_point[0] + 1,
            ))

            # Recurse into the function body for calls
            self._extract_from_tree(
                func_node, source, language, file_path, nodes, edges,
                enclosing_class=enclosing_class,
                enclosing_func=var_name,
                import_map=import_map,
                defined_names=defined_names,
                _depth=_depth + 1,
            )
            handled = True

        if not handled:
            # Not a function assignment — let generic recursion handle it
            return False
        return True

    def _extract_js_field_function(
        self,
        child,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Handle class field arrow functions: handler = (e) => { ... }"""
        prop_name = None
        func_node = None
        for sub in child.children:
            if sub.type == "property_identifier" and prop_name is None:
                prop_name = sub.text.decode("utf-8", errors="replace")
            elif sub.type in self._JS_FUNC_VALUE_TYPES:
                func_node = sub

        if not prop_name or not func_node:
            return False

        is_test = _is_test_function(prop_name, file_path)
        kind = "Test" if is_test else "Function"
        qualified = self._qualify(prop_name, file_path, enclosing_class)
        params = self._get_params(func_node, language, source)

        nodes.append(NodeInfo(
            kind=kind,
            name=prop_name,
            file_path=file_path,
            line_start=child.start_point[0] + 1,
            line_end=child.end_point[0] + 1,
            language=language,
            parent_name=enclosing_class,
            params=params,
            is_test=is_test,
        ))
        container = (
            self._qualify(enclosing_class, file_path, None)
            if enclosing_class else file_path
        )
        edges.append(EdgeInfo(
            kind="CONTAINS",
            source=container,
            target=qualified,
            file_path=file_path,
            line=child.start_point[0] + 1,
        ))

        self._extract_from_tree(
            func_node, source, language, file_path, nodes, edges,
            enclosing_class=enclosing_class,
            enclosing_func=prop_name,
            import_map=import_map,
            defined_names=defined_names,
            _depth=_depth + 1,
        )
        return True

    @staticmethod
    def _get_java_annotations(class_node) -> list[str]:
        """Return annotation names from the modifiers child of a Java class/method node."""
        names: list[str] = []
        for child in class_node.children:
            if child.type != "modifiers":
                continue
            for mod in child.children:
                if mod.type in ("marker_annotation", "annotation"):
                    for sub in mod.children:
                        if sub.type == "identifier":
                            names.append(sub.text.decode("utf-8", errors="replace"))
                            break
        return names

    def _emit_spring_injections(
        self,
        class_node,
        class_name: str,
        class_annotations: list[str],
        language: str,
        file_path: str,
        edges: list[EdgeInfo],
    ) -> None:
        """Emit INJECTS edges for Spring DI injection points in a Java class.

        Handles three patterns:
        - @Autowired / @Inject / @Resource field injection
        - @Autowired constructor injection
        - Lombok @RequiredArgsConstructor / @AllArgsConstructor with final fields
        """
        if language != "java":
            return

        has_lombok_constructor = any(
            a in _LOMBOK_CONSTRUCTOR_ANNOTATIONS for a in class_annotations
        )
        qualified_source = self._qualify(class_name, file_path, None)

        # Find the class body
        for node in class_node.children:
            if node.type != "class_body":
                continue
            for member in node.children:
                if member.type == "field_declaration":
                    self._emit_spring_field_injection(
                        member, qualified_source, file_path,
                        edges, has_lombok_constructor,
                    )
                elif member.type == "constructor_declaration":
                    self._emit_spring_constructor_injection(
                        member, qualified_source, file_path, edges,
                    )

    def _emit_spring_field_injection(
        self,
        field_node,
        qualified_source: str,
        file_path: str,
        edges: list[EdgeInfo],
        has_lombok_constructor: bool,
    ) -> None:
        """Emit an INJECTS edge for a single field_declaration if injection applies."""
        field_annotations: list[str] = []
        has_final = False
        has_static = False
        field_type: Optional[str] = None
        field_name: Optional[str] = None

        for child in field_node.children:
            if child.type == "modifiers":
                for mod in child.children:
                    text = mod.text.decode("utf-8", errors="replace")
                    if text == "final":
                        has_final = True
                    elif text == "static":
                        has_static = True
                    elif mod.type in ("marker_annotation", "annotation"):
                        for sub in mod.children:
                            if sub.type == "identifier":
                                field_annotations.append(
                                    sub.text.decode("utf-8", errors="replace")
                                )
                                break
            elif child.type in ("type_identifier", "generic_type", "array_type"):
                # Use outermost type name for generic types like List<Foo>
                if child.type == "type_identifier":
                    field_type = child.text.decode("utf-8", errors="replace")
                elif child.type == "generic_type":
                    for sub in child.children:
                        if sub.type == "type_identifier":
                            field_type = sub.text.decode("utf-8", errors="replace")
                            break
                elif child.type == "array_type":
                    for sub in child.children:
                        if sub.type == "type_identifier":
                            field_type = sub.text.decode("utf-8", errors="replace")
                            break
            elif child.type == "variable_declarator":
                for sub in child.children:
                    if sub.type == "identifier":
                        field_name = sub.text.decode("utf-8", errors="replace")
                        break

        if not field_type or has_static:
            return

        has_inject_annotation = any(a in _SPRING_INJECT_ANNOTATIONS for a in field_annotations)
        is_lombok_injected = has_lombok_constructor and has_final

        if not has_inject_annotation and not is_lombok_injected:
            return

        injection_type = "field" if has_inject_annotation else "constructor_lombok"
        extra: dict = {"injection_type": injection_type}
        if field_name:
            extra["field_name"] = field_name
        edges.append(EdgeInfo(
            kind="INJECTS",
            source=qualified_source,
            target=field_type,
            file_path=file_path,
            line=field_node.start_point[0] + 1,
            extra=extra,
        ))

    def _emit_spring_constructor_injection(
        self,
        ctor_node,
        qualified_source: str,
        file_path: str,
        edges: list[EdgeInfo],
    ) -> None:
        """Emit INJECTS edges for @Autowired constructor parameters."""
        ctor_annotations = self._get_java_annotations(ctor_node)
        if not any(a in _SPRING_INJECT_ANNOTATIONS for a in ctor_annotations):
            return

        for child in ctor_node.children:
            if child.type != "formal_parameters":
                continue
            for param in child.children:
                if param.type != "formal_parameter":
                    continue
                param_type: Optional[str] = None
                param_name: Optional[str] = None
                for sub in param.children:
                    if sub.type == "type_identifier" and param_type is None:
                        param_type = sub.text.decode("utf-8", errors="replace")
                    elif sub.type == "identifier":
                        param_name = sub.text.decode("utf-8", errors="replace")
                if param_type:
                    extra: dict = {"injection_type": "constructor"}
                    if param_name:
                        extra["field_name"] = param_name
                    edges.append(EdgeInfo(
                        kind="INJECTS",
                        source=qualified_source,
                        target=param_type,
                        file_path=file_path,
                        line=param.start_point[0] + 1,
                        extra=extra,
                    ))

    def _emit_temporal_stub_fields(
        self,
        class_node,
        class_name: str,
        file_path: str,
        edges: list[EdgeInfo],
    ) -> None:
        """Emit TEMPORAL_STUB edges for Temporal activity/workflow stub fields.

        Detects fields whose type name ends with 'Activity' or 'Workflow' —
        the universal naming convention for Temporal interfaces. The temporal
        resolver validates these against nodes that have temporal_role in extra.
        Static fields are skipped (e.g. logger, constants).
        """
        qualified_source = self._qualify(class_name, file_path, None)

        for node in class_node.children:
            if node.type != "class_body":
                continue
            for member in node.children:
                if member.type != "field_declaration":
                    continue
                has_static = False
                field_type: Optional[str] = None
                field_name: Optional[str] = None

                for ch in member.children:
                    if ch.type == "modifiers":
                        for mod in ch.children:
                            if mod.text and mod.text.decode("utf-8", errors="replace") == "static":
                                has_static = True
                    elif ch.type == "type_identifier":
                        field_type = ch.text.decode("utf-8", errors="replace")
                    elif ch.type == "variable_declarator":
                        for sub in ch.children:
                            if sub.type == "identifier":
                                field_name = sub.text.decode("utf-8", errors="replace")
                                break

                if has_static or not field_type or not field_name:
                    continue

                # Only emit for types following the Temporal naming convention
                if not (field_type.endswith("Activity") or field_type.endswith("Workflow")):
                    continue

                edges.append(EdgeInfo(
                    kind="TEMPORAL_STUB",
                    source=qualified_source,
                    target=field_type,
                    file_path=file_path,
                    line=member.start_point[0] + 1,
                    extra={"field_name": field_name, "stub_type": (
                        "activity" if field_type.endswith("Activity") else "workflow"
                    )},
                ))

    @staticmethod
    def _get_kafka_annotation_topics(annotation_node) -> list[str]:
        """Extract topic strings from @KafkaListener(topics = "...") or topics = {"a","b"}."""
        topics: list[str] = []
        for child in annotation_node.children:
            if child.type != "annotation_argument_list":
                continue
            for pair in child.children:
                if pair.type != "element_value_pair":
                    continue
                key_node = next((c for c in pair.children if c.type == "identifier"), None)
                if key_node is None:
                    continue
                key = key_node.text.decode("utf-8", errors="replace")
                if key not in ("topics", "topicPattern", "value"):
                    continue
                # value can be string_literal or element_value_array_initializer
                for val in pair.children:
                    if val.type == "string_literal":
                        raw = val.text.decode("utf-8", errors="replace").strip('"').strip("'")
                        if raw:
                            topics.append(raw)
                    elif val.type in ("array_initializer", "element_value_array_initializer"):
                        for item in val.children:
                            if item.type == "string_literal":
                                txt = item.text.decode("utf-8", errors="replace")
                                raw = txt.strip('"').strip("'")
                                if raw:
                                    topics.append(raw)
        return topics

    def _emit_kafka_edges_from_class(
        self,
        class_node,
        class_name: str,
        file_path: str,
        edges: list[EdgeInfo],
    ) -> None:
        """Emit CONSUMES/PRODUCES edges for Kafka field declarations.

        Handles:
        - KafkaReceiver / ReactiveKafkaConsumerTemplate → CONSUMES
        - KafkaTemplate / KafkaOperations / ReactiveKafkaProducerTemplate → PRODUCES
        Generic value type (e.g. KafkaReceiver<String, EquipmentMove>) is
        stored in extra.message_type for traceability.
        """
        qualified_source = self._qualify(class_name, file_path, None)

        for node in class_node.children:
            if node.type != "class_body":
                continue
            for member in node.children:
                if member.type != "field_declaration":
                    continue
                has_static = False
                outer_type: Optional[str] = None
                value_type: Optional[str] = None   # second generic param
                field_name: Optional[str] = None

                for ch in member.children:
                    if ch.type == "modifiers":
                        for mod in ch.children:
                            if mod.text and mod.text.decode("utf-8", errors="replace") == "static":
                                has_static = True
                    elif ch.type == "type_identifier":
                        outer_type = ch.text.decode("utf-8", errors="replace")
                    elif ch.type == "generic_type":
                        # KafkaReceiver<String, EquipmentMove>
                        type_args: list[str] = []
                        for sub in ch.children:
                            if sub.type == "type_identifier":
                                if outer_type is None:
                                    outer_type = sub.text.decode("utf-8", errors="replace")
                            elif sub.type == "type_arguments":
                                for arg in sub.children:
                                    if arg.type == "type_identifier":
                                        type_args.append(arg.text.decode("utf-8", errors="replace"))
                        if len(type_args) >= 2:
                            value_type = type_args[-1]  # last param is the value/message type
                    elif ch.type == "variable_declarator":
                        for sub in ch.children:
                            if sub.type == "identifier":
                                field_name = sub.text.decode("utf-8", errors="replace")
                                break

                if has_static or not outer_type or not field_name:
                    continue

                extra: dict = {"field_name": field_name}
                if value_type:
                    extra["message_type"] = value_type

                if outer_type in _KAFKA_CONSUMER_TYPES:
                    extra["kafka_type"] = outer_type
                    edges.append(EdgeInfo(
                        kind="CONSUMES",
                        source=qualified_source,
                        target="kafka:config",
                        file_path=file_path,
                        line=member.start_point[0] + 1,
                        extra=extra,
                    ))
                elif outer_type in _KAFKA_PRODUCER_TYPES:
                    extra["kafka_type"] = outer_type
                    edges.append(EdgeInfo(
                        kind="PRODUCES",
                        source=qualified_source,
                        target="kafka:config",
                        file_path=file_path,
                        line=member.start_point[0] + 1,
                        extra=extra,
                    ))

    def _emit_kafka_edges_from_method(
        self,
        method_node,
        method_name: str,
        class_name: Optional[str],
        file_path: str,
        edges: list[EdgeInfo],
    ) -> None:
        """Emit CONSUMES edges for @KafkaListener / @KafkaHandler annotated methods."""
        qualified_source = self._qualify(method_name, file_path, class_name)

        for child in method_node.children:
            if child.type != "modifiers":
                continue
            for mod in child.children:
                if mod.type not in ("annotation", "marker_annotation"):
                    continue
                ann_name: Optional[str] = None
                for sub in mod.children:
                    if sub.type == "identifier":
                        ann_name = sub.text.decode("utf-8", errors="replace")
                        break
                if ann_name not in _KAFKA_LISTENER_ANNOTATIONS:
                    continue
                # Extract topics from annotation arguments
                topics = self._get_kafka_annotation_topics(mod)
                if topics:
                    for topic in topics:
                        edges.append(EdgeInfo(
                            kind="CONSUMES",
                            source=qualified_source,
                            target=f"kafka:{topic}",
                            file_path=file_path,
                            line=method_node.start_point[0] + 1,
                            extra={"topic": topic, "kafka_type": "KafkaListener"},
                        ))
                else:
                    # @KafkaListener without resolvable topic (config placeholder)
                    edges.append(EdgeInfo(
                        kind="CONSUMES",
                        source=qualified_source,
                        target="kafka:config",
                        file_path=file_path,
                        line=method_node.start_point[0] + 1,
                        extra={"kafka_type": ann_name},
                    ))

    def _extract_classes(
        self,
        child,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Extract a class definition node and its inheritance edges.

        Returns True if the child was handled (class with a name found).
        """
        name = self._get_name(child, language, "class")
        if not name:
            return False

        # Swift: detect the actual type keyword (class/struct/enum/actor/extension)
        # and store it in extra["swift_kind"] for richer downstream analysis.
        # Tree-sitter maps struct/enum/actor/extension all to class_declaration;
        # protocol uses its own protocol_declaration node type.
        extra: dict = {}
        if language == "swift":
            if child.type == "class_declaration":
                _swift_keywords = {"class", "struct", "enum", "actor", "extension"}
                for kw_child in child.children:
                    kw_text = kw_child.text.decode("utf-8", errors="replace")
                    if kw_text in _swift_keywords:
                        extra["swift_kind"] = kw_text
                        break
            elif child.type == "protocol_declaration":
                extra["swift_kind"] = "protocol"

        # Java: detect Spring stereotype annotations and store as metadata
        class_annotations: list[str] = []
        if language == "java":
            class_annotations = self._get_java_annotations(child)
            spring_stereotypes = [
                a for a in class_annotations if a in _SPRING_STEREOTYPE_ANNOTATIONS
            ]
            if spring_stereotypes:
                extra["spring_stereotype"] = spring_stereotypes[0]
            if class_annotations:
                extra["spring_annotations"] = class_annotations
            temporal_roles = [
                a for a in class_annotations if a in _TEMPORAL_INTERFACE_ANNOTATIONS
            ]
            if temporal_roles:
                is_wf = "WorkflowInterface" in temporal_roles
                role = "workflow_interface" if is_wf else "activity_interface"
                extra["temporal_role"] = role

        node = NodeInfo(
            kind="Class",
            name=name,
            file_path=file_path,
            line_start=child.start_point[0] + 1,
            line_end=child.end_point[0] + 1,
            language=language,
            parent_name=enclosing_class,
            extra=extra,
        )
        nodes.append(node)

        # CONTAINS edge
        edges.append(EdgeInfo(
            kind="CONTAINS",
            source=file_path,
            target=self._qualify(name, file_path, enclosing_class),
            file_path=file_path,
            line=child.start_point[0] + 1,
        ))

        # Inheritance edges
        bases = self._get_bases(child, language, source)
        for base in bases:
            edges.append(EdgeInfo(
                kind="INHERITS",
                source=self._qualify(
                    name, file_path, enclosing_class,
                ),
                target=base,
                file_path=file_path,
                line=child.start_point[0] + 1,
            ))

        # Spring DI: emit INJECTS edges for injected dependencies
        if language == "java":
            self._emit_spring_injections(
                child, name, class_annotations, language, file_path, edges,
            )
            # Temporal: emit TEMPORAL_STUB edges for activity/workflow stub fields
            self._emit_temporal_stub_fields(child, name, file_path, edges)
            # Kafka: emit CONSUMES/PRODUCES edges for Kafka field declarations
            self._emit_kafka_edges_from_class(child, name, file_path, edges)

        # Recurse into class body
        self._extract_from_tree(
            child, source, language, file_path, nodes, edges,
            enclosing_class=name, enclosing_func=None,
            import_map=import_map, defined_names=defined_names,
            _depth=_depth + 1,
        )
        return True

    def _extract_functions(
        self,
        child,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
        enclosing_func: Optional[str] = None,
    ) -> bool:
        """Extract a function/method definition node.

        Returns True if the child was handled (function with a name found).
        """
        name = self._get_name(child, language, "function")
        if not name:
            return False

        # Go methods: attach to their receiver type as the enclosing class,
        # so `func (s *T) Foo()` becomes a member of T rather than a
        # top-level function. See: #190
        if language == "go" and child.type == "method_declaration":
            receiver_type = self._get_go_receiver_type(child)
            if receiver_type:
                enclosing_class = receiver_type

        # Extract annotations/decorators for test detection
        decorators: tuple[str, ...] = ()
        deco_list: list[str] = []
        for sub in child.children:
            # Java/Kotlin/C#: annotations inside a modifiers child
            if sub.type == "modifiers":
                for mod in sub.children:
                    if mod.type in ("annotation", "marker_annotation"):
                        text = mod.text.decode("utf-8", errors="replace")
                        deco_list.append(text.lstrip("@").strip())
        # Python: check parent decorated_definition for decorator siblings
        if child.parent and child.parent.type == "decorated_definition":
            for sib in child.parent.children:
                if sib.type == "decorator":
                    text = sib.text.decode("utf-8", errors="replace")
                    deco_list.append(text.lstrip("@").strip())
        # Rust: attributes are preceding siblings of function_item, not
        # children. Walk back through `attribute_item` nodes and strip the
        # `#[ ]` (or `#![ ]`) wrapper.
        if language == "rust":
            sib = child.prev_sibling
            while sib is not None and sib.type == "attribute_item":
                text = sib.text.decode("utf-8", errors="replace").strip()
                if text.startswith("#!["):
                    inner = text[3:].rstrip()
                elif text.startswith("#["):
                    inner = text[2:].rstrip()
                else:
                    inner = text
                if inner.endswith("]"):
                    inner = inner[:-1]
                deco_list.append(inner.strip())
                sib = sib.prev_sibling
        if deco_list:
            decorators = tuple(deco_list)

        is_test = _is_test_function(name, file_path, decorators)
        kind = "Test" if is_test else "Function"

        # Julia: nested functions (``function inner`` inside another
        # ``function outer``) should wire up to their enclosing function,
        # not skip past it to the enclosing class/module.
        parent_name = enclosing_class
        container_scope = enclosing_class
        if language == "julia" and enclosing_func:
            parent_name = enclosing_func
            container_scope = enclosing_func

        qualified = self._qualify(name, file_path, parent_name)
        params = self._get_params(child, language, source)
        ret_type = self._get_return_type(child, language, source)

        # Java: detect Temporal method-level annotations and Kafka listeners
        method_extra: dict = {}
        if language == "java" and deco_list:
            temporal_method_annots = [
                a for a in deco_list if a in _TEMPORAL_METHOD_ANNOTATIONS
            ]
            if temporal_method_annots:
                method_extra["temporal_role"] = temporal_method_annots[0].lower()
            if any(a.split("(")[0] in _KAFKA_LISTENER_ANNOTATIONS for a in deco_list):
                method_extra["kafka_listener"] = True
                self._emit_kafka_edges_from_method(
                    child, name, enclosing_class, file_path, edges,
                )

        node = NodeInfo(
            kind=kind,
            name=name,
            file_path=file_path,
            line_start=child.start_point[0] + 1,
            line_end=child.end_point[0] + 1,
            language=language,
            parent_name=parent_name,
            params=params,
            return_type=ret_type,
            is_test=is_test,
            extra=method_extra,
        )
        nodes.append(node)

        # CONTAINS edge
        container = (
            self._qualify(container_scope, file_path, None)
            if container_scope
            else file_path
        )
        edges.append(EdgeInfo(
            kind="CONTAINS",
            source=container,
            target=qualified,
            file_path=file_path,
            line=child.start_point[0] + 1,
        ))

        # Julia: ``function Base.show(io, x)`` extends a foreign module's
        # method. Record a REFERENCES edge from the function to the
        # qualifier module so cross-module links stay visible even though
        # the function's local name is just the method name.
        if language == "julia" and child.type == "function_definition":
            for sub in child.children:
                if sub.type != "signature":
                    continue
                call_expr = None
                scope = sub
                # Peel where_expression / typed_expression wrappers so we
                # land on the inner call_expression regardless of
                # ``func(x) where T`` or ``func(x)::T`` sugar.
                for _ in range(2):
                    found_wrapper = False
                    for inner in scope.children:
                        if inner.type in (
                            "where_expression", "typed_expression",
                        ):
                            scope = inner
                            found_wrapper = True
                            break
                    if not found_wrapper:
                        break
                for inner in scope.children:
                    if inner.type == "call_expression":
                        call_expr = inner
                        break
                if call_expr is None:
                    break
                if call_expr.children and call_expr.children[0].type == "field_expression":
                    field_expr = call_expr.children[0]
                    parts: list[str] = []
                    for ident in field_expr.children:
                        if ident.type == "identifier":
                            parts.append(
                                ident.text.decode("utf-8", errors="replace"),
                            )
                    # Module qualifier = everything except the final method
                    # name.
                    if len(parts) >= 2:
                        qualifier = ".".join(parts[:-1])
                        edges.append(EdgeInfo(
                            kind="REFERENCES",
                            source=qualified,
                            target=qualifier,
                            file_path=file_path,
                            line=child.start_point[0] + 1,
                            extra={"julia_qualified_def": True},
                        ))
                break

        # Solidity: modifier invocations on functions -> CALLS edges
        if language == "solidity":
            for sub in child.children:
                if sub.type == "modifier_invocation":
                    for ident in sub.children:
                        if ident.type == "identifier":
                            edges.append(EdgeInfo(
                                kind="CALLS",
                                source=qualified,
                                target=ident.text.decode(
                                    "utf-8", errors="replace",
                                ),
                                file_path=file_path,
                                line=sub.start_point[0] + 1,
                            ))
                            break

        # Recurse to find calls inside the function
        self._extract_from_tree(
            child, source, language, file_path, nodes, edges,
            enclosing_class=enclosing_class, enclosing_func=name,
            import_map=import_map, defined_names=defined_names,
            _depth=_depth + 1,
        )
        return True

    def _extract_imports(
        self,
        child,
        language: str,
        source: bytes,
        file_path: str,
        edges: list[EdgeInfo],
    ) -> None:
        """Extract import edges from an import statement node."""
        imports = self._extract_import(child, language, source)
        for imp_target in imports:
            resolved = self._resolve_module_to_file(
                imp_target, file_path, language,
            )
            edges.append(EdgeInfo(
                kind="IMPORTS_FROM",
                source=file_path,
                target=resolved if resolved else imp_target,
                file_path=file_path,
                line=child.start_point[0] + 1,
            ))

    def _extract_calls(
        self,
        child,
        source: bytes,
        language: str,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        _depth: int,
    ) -> bool:
        """Extract call expressions, including test runner special cases.

        Returns True if the child was fully handled (test runner call that
        should skip default recursion). Returns False if the caller should
        continue to Solidity handling and default recursion.
        """
        call_name = self._get_call_name(child, language, source)

        # For member expressions like describe.only / it.skip / test.each,
        # resolve the base call name so those are treated as test runner
        # calls.
        effective_call_name = call_name
        if (
            call_name
            and language in ("javascript", "typescript", "tsx")
            and _is_test_file(file_path)
            and call_name not in _TEST_RUNNER_NAMES
        ):
            effective_call_name = (
                self._get_base_call_name(child, source) or call_name
            )

        # Special handling: test runner calls in test files -> Test nodes
        if (
            effective_call_name
            and language in ("javascript", "typescript", "tsx")
            and _is_test_file(file_path)
            and effective_call_name in _TEST_RUNNER_NAMES
        ):
            test_desc = self._get_test_description(child, source)
            line_no = child.start_point[0] + 1
            synthetic_base = (
                f"{effective_call_name}:{test_desc}"
                if test_desc else effective_call_name
            )
            synthetic_name = f"{synthetic_base}@L{line_no}"
            qualified = self._qualify(
                synthetic_name, file_path, enclosing_class,
            )

            nodes.append(NodeInfo(
                kind="Test",
                name=synthetic_name,
                file_path=file_path,
                line_start=child.start_point[0] + 1,
                line_end=child.end_point[0] + 1,
                language=language,
                parent_name=enclosing_class,
                is_test=True,
            ))

            # CONTAINS edge: parent -> this test
            container = (
                self._qualify(
                    enclosing_func, file_path, enclosing_class,
                )
                if enclosing_func
                else file_path
            )
            edges.append(EdgeInfo(
                kind="CONTAINS",
                source=container,
                target=qualified,
                file_path=file_path,
                line=child.start_point[0] + 1,
            ))

            # Recurse into the call's children (the arrow function body)
            self._extract_from_tree(
                child, source, language, file_path, nodes, edges,
                enclosing_class=enclosing_class,
                enclosing_func=synthetic_name,
                import_map=import_map, defined_names=defined_names,
                _depth=_depth + 1,
            )
            return True

        if call_name:
            # Module-scope calls (no enclosing function) are attributed to
            # the File node. Matches the existing convention for CONTAINS
            # edges and _extract_value_references. Without this fallback,
            # any function called only from top-level script glue, CLI
            # entrypoints, or Jupyter/Databricks notebook cells is flagged
            # as dead by find_dead_code.
            # For Verilog module instantiations, create CALLS edges from the
            # enclosing module (enclosing_class), not just from functions.
            if enclosing_func:
                caller = self._qualify(
                    enclosing_func, file_path, enclosing_class,
                )
            elif language == "verilog" and enclosing_class:
                # Verilog module instantiation happen at module level
                caller = self._qualify(
                    enclosing_class, file_path, None
                )
            else:
                caller = file_path

            # Java method_invocation: extract actual method name and receiver
            # separately so the Spring DI resolver can rewrite the target.
            call_extra: dict = {}
            if language == "java" and child.type == "method_invocation":
                method_name, receiver = self._get_java_method_and_receiver(child)
                if method_name:
                    call_name = method_name
                if receiver:
                    call_extra["receiver"] = receiver

            # When a receiver is present, skip scope-based resolution: the method
            # lives on the receiver's type, not in the current file's scope.
            # The spring_resolver post-pass will do the correct cross-type lookup.
            if call_extra.get("receiver"):
                target = call_name
            else:
                target = self._resolve_call_target(
                    call_name, file_path, language,
                    import_map or {}, defined_names or set(),
                )
            edges.append(EdgeInfo(
                kind="CALLS",
                source=caller,
                target=target,
                file_path=file_path,
                line=child.start_point[0] + 1,
                extra=call_extra,
            ))

        return False

    @staticmethod
    def _get_java_method_and_receiver(node) -> tuple[Optional[str], Optional[str]]:
        """For a Java method_invocation node, return (method_name, receiver_name).

        Pattern: [receiver_identifier, '.', method_identifier, argument_list]
        Chained: [inner_method_invocation, '.', method_identifier, argument_list]

        Returns (None, None) for unrecognised shapes.
        """
        children = node.children
        if len(children) < 3:
            return None, None

        # method_identifier is always the last identifier before argument_list
        method_name: Optional[str] = None
        receiver_name: Optional[str] = None

        # Scan backwards for the method identifier
        for i in range(len(children) - 1, -1, -1):
            ch = children[i]
            if ch.type == "argument_list":
                continue
            if ch.type == "identifier":
                if method_name is None:
                    method_name = ch.text.decode("utf-8", errors="replace")
                else:
                    # Second identifier scanning backwards = receiver
                    receiver_name = ch.text.decode("utf-8", errors="replace")
                break
            if ch.type == "." :
                continue
            # Chained call or complex expression as receiver — no simple receiver
            break

        # Receiver is the first child if it's a plain identifier
        if method_name and children[0].type == "identifier":
            first_text = children[0].text.decode("utf-8", errors="replace")
            if first_text != method_name:
                receiver_name = first_text

        return method_name, receiver_name

    def _extract_jsx_component_call(
        self,
        child,
        language: str,
        file_path: str,
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
    ) -> None:
        """Emit a synthetic CALLS edge for JSX component usage.

        React-style component invocations use JSX rather than ``call_expression``.
        Treat uppercase component tags such as ``<MarkdownMsg />`` as call-like
        edges so caller/impact queries can cross the JSX boundary. Intrinsic DOM
        tags (``<div>``) are ignored.

        Module-scope JSX (e.g. a top-level ``<App />`` render call) attributes
        to the File node.
        """
        target = self._resolve_jsx_component_target(
            child, language, file_path, import_map or {}, defined_names or set(),
        )
        if not target:
            return

        caller = (
            self._qualify(enclosing_func, file_path, enclosing_class)
            if enclosing_func
            else file_path
        )
        edges.append(EdgeInfo(
            kind="CALLS",
            source=caller,
            target=target,
            file_path=file_path,
            line=child.start_point[0] + 1,
        ))

    def _resolve_jsx_component_target(
        self,
        node,
        language: str,
        file_path: str,
        import_map: dict[str, str],
        defined_names: set[str],
    ) -> Optional[str]:
        """Resolve a JSX component element to a call target."""
        component_ref = self._get_jsx_component_reference(node)
        if component_ref is None:
            return None

        base_name, component_name = component_ref
        if base_name is None:
            return self._resolve_call_target(
                component_name, file_path, language, import_map, defined_names,
            )

        if base_name in import_map:
            resolved = self._resolve_imported_symbol(
                component_name, import_map[base_name], file_path, language,
            )
            if resolved:
                return resolved

        return component_name

    # ------------------------------------------------------------------
    # Value-reference extraction (function-as-value patterns)
    # ------------------------------------------------------------------

    # AST node types that represent object literal key-value pairs.
    _PAIR_TYPES = frozenset({"pair"})

    # AST node types for array/list containers.
    _ARRAY_TYPES = frozenset({"array", "list"})

    # AST node types for call argument containers. JS/TS uses ``arguments``;
    # Python uses ``argument_list``. Both share the same identifier-child shape
    # for bare-identifier callbacks like ``executor.submit(my_handler)``.
    _ARGUMENTS_TYPES = frozenset({"arguments", "argument_list"})

    # Names that are almost certainly not function references (constants,
    # common primitives).  All-uppercase identifiers and very short names
    # are excluded by a length/casing heuristic in the method itself.
    _VALUE_REF_SKIP_NAMES = frozenset({
        "true", "false", "null", "undefined", "None", "True", "False",
        "self", "this", "cls", "super",
    })

    def _extract_value_references(
        self,
        child,
        node_type: str,
        source: bytes,
        language: str,
        file_path: str,
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
    ) -> None:
        """Emit ``REFERENCES`` edges for function-as-value patterns.

        Detects identifiers in value positions that likely refer to
        functions — object literal values, map property assignments,
        array elements, and callback arguments.  This reduces false
        positives in dead-code detection for dispatch-map patterns
        like ``Record<string, Handler>``.

        Only emits edges when the identifier matches a locally defined
        name or an imported symbol, avoiding noise from arbitrary
        variable references.
        """
        imap = import_map or {}
        dnames = defined_names or set()

        # Use enclosing function as source, or the file path for module-scope code.
        if enclosing_func:
            caller = self._qualify(enclosing_func, file_path, enclosing_class)
        else:
            caller = file_path

        # --- JS/TS/Python: object literal pair values  { key: fnRef } ---
        if node_type in self._PAIR_TYPES:
            self._ref_from_pair(child, source, language, file_path, caller, edges, imap, dnames)
            return

        # --- JS/TS: shorthand property identifiers  { fnRef } ---
        if (
            node_type == "shorthand_property_identifier"
            and language in ("javascript", "typescript", "tsx")
        ):
            name = child.text.decode("utf-8", errors="replace")
            self._emit_reference_if_known(
                name, language, file_path, caller, edges, imap, dnames,
                line=child.start_point[0] + 1,
            )
            return

        # --- JS/TS/Python: assignment with member/subscript LHS ---
        if node_type in ("assignment_expression", "augmented_assignment", "assignment"):
            self._ref_from_assignment(
                child, source, language, file_path, caller, edges, imap, dnames,
            )
            return

        # --- JS/TS/Python: array / list elements ---
        if node_type in self._ARRAY_TYPES:
            self._ref_from_array(child, source, language, file_path, caller, edges, imap, dnames)
            return

        # --- Callback arguments (identifier args inside call_expression) ---
        if node_type in self._ARGUMENTS_TYPES:
            self._ref_from_arguments(
                child, source, language, file_path, caller, edges, imap, dnames,
            )

    def _emit_reference_if_known(
        self,
        name: str,
        language: str,
        file_path: str,
        caller: str,
        edges: list[EdgeInfo],
        import_map: dict[str, str],
        defined_names: set[str],
        line: int = 0,
    ) -> None:
        """Emit a ``REFERENCES`` edge if *name* is a known function/import."""
        if not name or name in self._VALUE_REF_SKIP_NAMES:
            return
        # Skip all-uppercase names (likely constants) and single-char names.
        if name.isupper() or len(name) <= 1:
            return
        # Must be a known local definition or import to be worth tracking.
        if name not in defined_names and name not in import_map:
            return

        target = self._resolve_call_target(
            name, file_path, language, import_map, defined_names,
        )
        edges.append(EdgeInfo(
            kind="REFERENCES",
            source=caller,
            target=target,
            file_path=file_path,
            line=line,
        ))

    def _ref_from_pair(
        self,
        pair_node,
        source: bytes,
        language: str,
        file_path: str,
        caller: str,
        edges: list[EdgeInfo],
        import_map: dict[str, str],
        defined_names: set[str],
    ) -> None:
        """Extract a REFERENCES edge from an object/dict literal pair value."""
        # pair children: key, ":", value
        children = pair_node.children
        # Find the value — it's the last meaningful child.
        value_node = None
        for ch in reversed(children):
            if ch.type not in (":", ",", "comment"):
                value_node = ch
                break
        if value_node is None:
            return
        if value_node.type == "identifier":
            name = value_node.text.decode("utf-8", errors="replace")
            self._emit_reference_if_known(
                name, language, file_path, caller, edges,
                import_map, defined_names,
                line=value_node.start_point[0] + 1,
            )

    def _ref_from_assignment(
        self,
        assign_node,
        source: bytes,
        language: str,
        file_path: str,
        caller: str,
        edges: list[EdgeInfo],
        import_map: dict[str, str],
        defined_names: set[str],
    ) -> None:
        """Extract REFERENCES from ``obj.key = fnRef`` or ``obj['key'] = fnRef``."""
        children = assign_node.children
        if len(children) < 3:
            return
        lhs = children[0]
        # LHS must be a member_expression or subscript_expression (map assignment).
        if lhs.type not in (
            "member_expression", "subscript_expression",
            "attribute", "subscript",
        ):
            return
        # RHS is the last non-punctuation child.
        rhs = None
        for ch in reversed(children):
            if ch.type not in ("=", ":", ",", "comment", "type_annotation"):
                rhs = ch
                break
        if rhs is None or rhs.type != "identifier":
            return
        name = rhs.text.decode("utf-8", errors="replace")
        self._emit_reference_if_known(
            name, language, file_path, caller, edges,
            import_map, defined_names,
            line=rhs.start_point[0] + 1,
        )

    def _ref_from_array(
        self,
        array_node,
        source: bytes,
        language: str,
        file_path: str,
        caller: str,
        edges: list[EdgeInfo],
        import_map: dict[str, str],
        defined_names: set[str],
    ) -> None:
        """Extract REFERENCES from array/list elements that are identifiers."""
        for ch in array_node.children:
            if ch.type == "identifier":
                name = ch.text.decode("utf-8", errors="replace")
                self._emit_reference_if_known(
                    name, language, file_path, caller, edges,
                    import_map, defined_names,
                    line=ch.start_point[0] + 1,
                )

    def _ref_from_arguments(
        self,
        args_node,
        source: bytes,
        language: str,
        file_path: str,
        caller: str,
        edges: list[EdgeInfo],
        import_map: dict[str, str],
        defined_names: set[str],
    ) -> None:
        """Extract REFERENCES from identifier arguments (callbacks)."""
        for ch in args_node.children:
            if ch.type == "identifier":
                name = ch.text.decode("utf-8", errors="replace")
                self._emit_reference_if_known(
                    name, language, file_path, caller, edges,
                    import_map, defined_names,
                    line=ch.start_point[0] + 1,
                )

    def _extract_solidity_constructs(
        self,
        child,
        node_type: str,
        source: bytes,
        file_path: str,
        nodes: list[NodeInfo],
        edges: list[EdgeInfo],
        enclosing_class: Optional[str],
        enclosing_func: Optional[str],
    ) -> bool:
        """Handle Solidity-specific AST constructs (emit, state vars, etc.).

        Returns True if the child was fully handled and should skip
        default recursion.
        """
        # Emit statements: emit EventName(...) -> CALLS edge.
        # Module-scope emits attribute to the File node.
        if node_type == "emit_statement":
            for sub in child.children:
                if sub.type == "expression":
                    for ident in sub.children:
                        if ident.type == "identifier":
                            caller = (
                                self._qualify(
                                    enclosing_func, file_path,
                                    enclosing_class,
                                )
                                if enclosing_func
                                else file_path
                            )
                            edges.append(EdgeInfo(
                                kind="CALLS",
                                source=caller,
                                target=ident.text.decode(
                                    "utf-8", errors="replace",
                                ),
                                file_path=file_path,
                                line=child.start_point[0] + 1,
                            ))
            # emit_statement falls through to default recursion
            return False

        # State variable declarations -> Function nodes (public ones
        # auto-generate getters, and all are critical for reviews)
        if node_type == "state_variable_declaration" and enclosing_class:
            var_name = None
            var_visibility = None
            var_mutability = None
            var_type = None
            for sub in child.children:
                if sub.type == "identifier":
                    var_name = sub.text.decode(
                        "utf-8", errors="replace",
                    )
                elif sub.type == "visibility":
                    var_visibility = sub.text.decode(
                        "utf-8", errors="replace",
                    )
                elif sub.type == "type_name":
                    var_type = sub.text.decode(
                        "utf-8", errors="replace",
                    )
                elif sub.type in ("constant", "immutable"):
                    var_mutability = sub.type
            if var_name:
                qualified = self._qualify(
                    var_name, file_path, enclosing_class,
                )
                nodes.append(NodeInfo(
                    kind="Function",
                    name=var_name,
                    file_path=file_path,
                    line_start=child.start_point[0] + 1,
                    line_end=child.end_point[0] + 1,
                    language="solidity",
                    parent_name=enclosing_class,
                    return_type=var_type,
                    modifiers=var_visibility,
                    extra={
                        "solidity_kind": "state_variable",
                        "mutability": var_mutability,
                    },
                ))
                edges.append(EdgeInfo(
                    kind="CONTAINS",
                    source=self._qualify(
                        enclosing_class, file_path, None,
                    ),
                    target=qualified,
                    file_path=file_path,
                    line=child.start_point[0] + 1,
                ))
                return True
            return False

        # File-level and contract-level constant declarations
        if node_type == "constant_variable_declaration":
            var_name = None
            var_type = None
            for sub in child.children:
                if sub.type == "identifier":
                    var_name = sub.text.decode(
                        "utf-8", errors="replace",
                    )
                elif sub.type == "type_name":
                    var_type = sub.text.decode(
                        "utf-8", errors="replace",
                    )
            if var_name:
                qualified = self._qualify(
                    var_name, file_path, enclosing_class,
                )
                nodes.append(NodeInfo(
                    kind="Function",
                    name=var_name,
                    file_path=file_path,
                    line_start=child.start_point[0] + 1,
                    line_end=child.end_point[0] + 1,
                    language="solidity",
                    parent_name=enclosing_class,
                    return_type=var_type,
                    extra={"solidity_kind": "constant"},
                ))
                container = (
                    self._qualify(enclosing_class, file_path, None)
                    if enclosing_class
                    else file_path
                )
                edges.append(EdgeInfo(
                    kind="CONTAINS",
                    source=container,
                    target=qualified,
                    file_path=file_path,
                    line=child.start_point[0] + 1,
                ))
                return True
            return False

        # Using directives: using LibName for Type -> DEPENDS_ON edge
        if node_type == "using_directive":
            lib_name = None
            for sub in child.children:
                if sub.type == "type_alias":
                    for ident in sub.children:
                        if ident.type == "identifier":
                            lib_name = ident.text.decode(
                                "utf-8", errors="replace",
                            )
            if lib_name:
                source_name = (
                    self._qualify(
                        enclosing_class, file_path, None,
                    )
                    if enclosing_class
                    else file_path
                )
                edges.append(EdgeInfo(
                    kind="DEPENDS_ON",
                    source=source_name,
                    target=lib_name,
                    file_path=file_path,
                    line=child.start_point[0] + 1,
                ))
            return True

        return False

    def _collect_file_scope(
        self, root, language: str, source: bytes,
    ) -> tuple[dict[str, str], set[str]]:
        """Pre-scan top-level AST to collect import mappings and defined names.

        Returns:
            (import_map, defined_names) where import_map maps imported names
            to their source module/path, and defined_names is the set of
            function/class names defined at file scope.
        """
        import_map: dict[str, str] = {}
        defined_names: set[str] = set()

        class_types = set(_CLASS_TYPES.get(language, []))
        func_types = set(_FUNCTION_TYPES.get(language, []))
        import_types = set(_IMPORT_TYPES.get(language, []))

        # Node types that wrap a class/function with decorators/annotations
        decorator_wrappers = {"decorated_definition", "decorator"}

        for child in root.children:
            node_type = child.type

            # Unwrap decorator wrappers to reach the inner definition
            target = child
            if node_type in decorator_wrappers:
                for inner in child.children:
                    if inner.type in func_types or inner.type in class_types:
                        target = inner
                        break
            elif (
                language in ("javascript", "typescript", "tsx")
                and node_type == "export_statement"
            ):
                for inner in child.children:
                    if inner.type in func_types or inner.type in class_types:
                        target = inner
                        break

            target_type = target.type

            # R: function names live on the left side of binary_operator
            if language == "r" and target_type == "binary_operator":
                r_children = target.children
                if (
                    len(r_children) >= 3
                    and r_children[0].type == "identifier"
                    and r_children[2].type == "function_definition"
                ):
                    name = r_children[0].text.decode("utf-8", errors="replace")
                    defined_names.add(name)
                    continue

            # Collect defined function/class names
            if target_type in func_types or target_type in class_types:
                name = self._get_name(target, language,
                                      "class" if target_type in class_types else "function")
                if name:
                    defined_names.add(name)
                    continue

            if (
                language in ("javascript", "typescript", "tsx")
                and node_type == "export_statement"
            ):
                self._collect_js_exported_local_names(child, defined_names)

            # Collect import mappings: imported_name → module_path
            if node_type in import_types:
                self._collect_import_names(child, language, source, import_map)

        return import_map, defined_names

    def _collect_js_exported_local_names(
        self, node, defined_names: set[str],
    ) -> None:
        """Collect locally exported JS/TS names from export statements."""
        for child in node.children:
            if child.type in ("lexical_declaration", "variable_declaration"):
                for sub in child.children:
                    if sub.type == "variable_declarator":
                        for part in sub.children:
                            if part.type == "identifier":
                                defined_names.add(
                                    part.text.decode("utf-8", errors="replace"),
                                )
                                break

    def _collect_import_names(
        self, node, language: str, source: bytes, import_map: dict[str, str],
    ) -> None:
        """Extract imported names and their source modules into import_map."""
        if language == "python":
            if node.type == "import_from_statement":
                # from X.Y import A, B → {A: X.Y, B: X.Y}
                module = None
                seen_import_keyword = False
                for child in node.children:
                    if child.type == "dotted_name" and not seen_import_keyword:
                        module = child.text.decode("utf-8", errors="replace")
                    elif child.type == "import":
                        seen_import_keyword = True
                    elif seen_import_keyword and module:
                        if child.type in ("identifier", "dotted_name"):
                            name = child.text.decode("utf-8", errors="replace")
                            import_map[name] = module
                        elif child.type == "aliased_import":
                            # from X import A as B → {B: X}
                            names = [
                                sub.text.decode("utf-8", errors="replace")
                                for sub in child.children
                                if sub.type in ("identifier", "dotted_name")
                            ]
                            # Last name is the alias (local name)
                            if names:
                                import_map[names[-1]] = module

        elif language in ("javascript", "typescript", "tsx"):
            # import { A, B } from './path' → {A: ./path, B: ./path}
            module = None
            for child in node.children:
                if child.type == "string":
                    module = child.text.decode("utf-8", errors="replace").strip("'\"")
            if module:
                for child in node.children:
                    if child.type == "import_clause":
                        self._collect_js_import_names(child, module, import_map)

    def _collect_js_import_names(
        self, clause_node, module: str, import_map: dict[str, str],
    ) -> None:
        """Walk JS/TS import_clause to extract named and default imports."""
        for child in clause_node.children:
            if child.type == "identifier":
                # Default import
                import_map[child.text.decode("utf-8", errors="replace")] = module
            elif child.type == "namespace_import":
                for sub in child.children:
                    if sub.type == "identifier":
                        import_map[sub.text.decode("utf-8", errors="replace")] = module
                        break
            elif child.type == "named_imports":
                for spec in child.children:
                    if spec.type == "import_specifier":
                        # Could be: name or name as alias
                        names = [
                            s.text.decode("utf-8", errors="replace")
                            for s in spec.children
                            if s.type in ("identifier", "property_identifier")
                        ]
                        # Last identifier is the local name
                        if names:
                            import_map[names[-1]] = module

    def _resolve_module_to_file(
        self, module: str, file_path: str, language: str,
    ) -> Optional[str]:
        """Resolve a module/import path to an absolute file path.

        Uses self._module_file_cache to avoid repeated filesystem lookups.
        """
        caller_dir = str(Path(file_path).parent)
        cache_key = f"{language}:{caller_dir}:{module}"
        if cache_key in self._module_file_cache:
            return self._module_file_cache[cache_key]

        resolved = self._do_resolve_module(module, file_path, language)
        if len(self._module_file_cache) >= self._MODULE_CACHE_MAX:
            self._module_file_cache.clear()
        self._module_file_cache[cache_key] = resolved
        return resolved

    def _do_resolve_module(
        self, module: str, file_path: str, language: str,
    ) -> Optional[str]:
        """Language-aware module-to-file resolution."""
        caller_dir = Path(file_path).parent

        if language == "bash":
            # ``source ./lib.sh`` or ``source lib.sh`` — resolve relative
            # to the caller's directory. See: #197
            try:
                target = (caller_dir / module).resolve()
                if target.is_file():
                    return str(target)
            except (OSError, ValueError):
                pass
            return None

        if language == "nix":
            # ``import ./x.nix`` / ``callPackage ./x.nix { }`` — relative to
            # the caller's directory. Non-relative targets (URLs, bare
            # identifiers like ``nixpkgs``) are left unresolved.
            try:
                target = (caller_dir / module).resolve()
                if target.is_file():
                    return str(target)
            except (OSError, ValueError):
                pass
            return None

        if language == "python":
            rel_path = module.replace(".", "/")
            candidates = [rel_path + ".py", rel_path + "/__init__.py"]
            # Walk up from caller's directory to find the module file
            current = caller_dir
            while True:
                for candidate in candidates:
                    target = current / candidate
                    if target.is_file():
                        return str(target.resolve())
                if current == current.parent:
                    break
                current = current.parent

        elif language in ("javascript", "typescript", "tsx", "vue"):
            if module.startswith("."):
                # Relative import — resolve from caller's directory
                base = caller_dir / module
                extensions = [".ts", ".tsx", ".js", ".jsx", ".vue"]
                # Try exact path first (might already have extension)
                if base.is_file():
                    return str(base.resolve())
                # Try with extensions
                for ext in extensions:
                    target = base.with_suffix(ext)
                    if target.is_file():
                        return str(target.resolve())
                # Try index file in directory
                if base.is_dir():
                    for ext in extensions:
                        target = base / f"index{ext}"
                        if target.is_file():
                            return str(target.resolve())
            else:
                # Non-relative import — try tsconfig path alias resolution
                resolved = self._tsconfig_resolver.resolve_alias(module, file_path)
                if resolved:
                    return resolved

        elif language == "dart":
            if module.startswith("."):
                # Dart relative imports include the .dart extension
                base = caller_dir / module
                if base.is_file():
                    return str(base.resolve())
                # Fallback: try appending .dart
                target = base.with_suffix(".dart")
                if target.is_file():
                    return str(target.resolve())
            elif module.startswith("package:"):
                # ``package:<name>/<sub_path>`` — resolve to the current repo's
                # ``lib/<sub_path>`` iff a ``pubspec.yaml`` declaring that
                # package name is found in an ancestor directory. See: #87
                try:
                    uri_body = module[len("package:"):]
                    pkg_name, _, sub_path = uri_body.partition("/")
                    if not sub_path:
                        return None
                    pubspec_root = self._find_dart_pubspec_root(
                        caller_dir, pkg_name
                    )
                    if pubspec_root is not None:
                        target = pubspec_root / "lib" / sub_path
                        if target.is_file():
                            return str(target.resolve())
                except (OSError, ValueError):
                    return None
            # ``dart:core`` / ``dart:async`` etc. are SDK libraries we do
            # not track; fall through to return None.

        elif language == "java":
            # ``import com.example.pkg.ClassName;`` — convert dot-notation
            # to a relative path and walk up from the caller's directory to
            # find the source root.  Wildcards (``import pkg.*``) and static
            # member imports (``import static pkg.Class.member``) that don't
            # resolve as-is are retried after dropping the last segment
            # (the member name).
            if module.endswith(".*"):
                return None  # wildcard import — can't resolve to one file
            rel_path = module.replace(".", "/") + ".java"
            current = caller_dir
            while True:
                target = current / rel_path
                if target.is_file():
                    return str(target.resolve())
                if current == current.parent:
                    break
                current = current.parent
            # Static import: ``pkg.Class.member`` — strip member, try again
            dot = module.rfind(".")
            if dot > 0:
                class_module = module[:dot]
                rel_path2 = class_module.replace(".", "/") + ".java"
                current = caller_dir
                while True:
                    target = current / rel_path2
                    if target.is_file():
                        return str(target.resolve())
                    if current == current.parent:
                        break
                    current = current.parent

        return None

    def _find_dart_pubspec_root(
        self, start: Path, pkg_name: str,
    ) -> Optional[Path]:
        """Walk up from ``start`` to find a ``pubspec.yaml`` whose ``name:``
        matches ``pkg_name``. Returns the directory containing that pubspec,
        or None if no match is found. Result is cached per (start, pkg_name)
        pair so repeated lookups within one parse pass are cheap.
        """
        cache_key = (str(start), pkg_name)
        cached = self._dart_pubspec_cache.get(cache_key)
        if cached is not None or cache_key in self._dart_pubspec_cache:
            return cached
        current = start
        # Avoid infinite loops on weird symlinks.
        for _ in range(20):
            pubspec = current / "pubspec.yaml"
            if pubspec.is_file():
                try:
                    text = pubspec.read_text(encoding="utf-8", errors="replace")
                except OSError:
                    text = ""
                m = re.search(r"^name:\s*([\w-]+)", text, re.MULTILINE)
                if m and m.group(1) == pkg_name:
                    self._dart_pubspec_cache[cache_key] = current
                    return current
            if current.parent == current:
                break
            current = current.parent
        self._dart_pubspec_cache[cache_key] = None
        return None

    def _resolve_call_target(
        self,
        call_name: str,
        file_path: str,
        language: str,
        import_map: dict[str, str],
        defined_names: set[str],
    ) -> str:
        """Resolve a bare call name to a qualified target, with fallback."""
        if call_name in defined_names:
            return self._qualify(call_name, file_path, None)
        if call_name in import_map:
            resolved = self._resolve_imported_symbol(
                call_name, import_map[call_name], file_path, language,
            )
            if resolved:
                return resolved
        return call_name

    def _resolve_imported_symbol(
        self,
        symbol_name: str,
        module: str,
        file_path: str,
        language: str,
    ) -> Optional[str]:
        """Resolve an imported symbol to its defining qualified name when possible."""
        resolved = self._resolve_module_to_file(module, file_path, language)
        if not resolved:
            return None

        export_target = self._resolve_exported_symbol(resolved, symbol_name)
        if export_target:
            return export_target
        return self._qualify(symbol_name, resolved, None)

    def _resolve_exported_symbol(
        self,
        module_file: str,
        symbol_name: str,
        seen: Optional[set[tuple[str, str]]] = None,
    ) -> Optional[str]:
        """Resolve a JS/TS symbol through common re-export/barrel patterns."""
        cache_key = f"{module_file}::{symbol_name}"
        if cache_key in self._export_symbol_cache:
            return self._export_symbol_cache[cache_key]

        key = (module_file, symbol_name)
        if seen is None:
            seen = set()
        if key in seen:
            return None
        seen.add(key)

        path = Path(module_file)
        language = self.detect_language(path)
        if language not in ("javascript", "typescript", "tsx", "vue"):
            return None

        try:
            source = path.read_bytes()
        except (OSError, PermissionError):
            return None

        parser = self._get_parser(language)
        if not parser:
            return None

        tree = parser.parse(source)

        # Direct local definition/export in the module file.
        import_map, defined_names = self._collect_file_scope(
            tree.root_node, language, source,
        )
        if symbol_name in defined_names:
            result = self._qualify(symbol_name, module_file, None)
            self._export_symbol_cache[cache_key] = result
            return result

        for child in tree.root_node.children:
            if child.type != "export_statement":
                continue

            export_clause = None
            target_module = None
            has_star_export = False

            for sub in child.children:
                if sub.type == "export_clause":
                    export_clause = sub
                elif sub.type == "string":
                    target_module = sub.text.decode("utf-8", errors="replace").strip("'\"")
                elif sub.type == "*":
                    has_star_export = True

            # Re-exported names: export { Foo as Bar } from './x'
            if export_clause is not None:
                for spec in export_clause.children:
                    if spec.type != "export_specifier":
                        continue
                    names = [
                        part.text.decode("utf-8", errors="replace")
                        for part in spec.children
                        if part.type in ("identifier", "property_identifier")
                    ]
                    if not names:
                        continue
                    exported_name = names[-1]
                    original_name = names[0]
                    if exported_name != symbol_name:
                        continue
                    if target_module:
                        resolved_module = self._resolve_module_to_file(
                            target_module, module_file, language,
                        )
                        if resolved_module:
                            result = self._resolve_exported_symbol(
                                resolved_module, original_name, seen,
                            ) or self._qualify(original_name, resolved_module, None)
                            self._export_symbol_cache[cache_key] = result
                            return result
                    result = self._qualify(original_name, module_file, None)
                    self._export_symbol_cache[cache_key] = result
                    return result

            # Star re-export: export * from './x'
            if has_star_export and target_module:
                resolved_module = self._resolve_module_to_file(
                    target_module, module_file, language,
                )
                if resolved_module:
                    result = self._resolve_exported_symbol(
                        resolved_module, symbol_name, seen,
                    )
                    if result:
                        self._export_symbol_cache[cache_key] = result
                        return result

        self._export_symbol_cache[cache_key] = None
        return None

    def _qualify(self, name: str, file_path: str, enclosing_class: Optional[str]) -> str:
        """Create a qualified name: file_path::ClassName.name or file_path::name."""
        if enclosing_class:
            return f"{file_path}::{enclosing_class}.{name}"
        return f"{file_path}::{name}"

    def _get_name(self, node, language: str, kind: str) -> Optional[str]:
        """Extract the name from a class/function definition node."""
        # Dart: function_signature has a return-type node before the identifier;
        # search only for 'identifier' to avoid returning the return type name.
        if language == "dart" and node.type == "function_signature":
            for child in node.children:
                if child.type == "identifier":
                    return child.text.decode("utf-8", errors="replace")
            return None
        # Solidity: constructor and receive/fallback have no identifier child
        if language == "solidity":
            if node.type == "constructor_definition":
                return "constructor"
            if node.type == "fallback_receive_definition":
                for child in node.children:
                    if child.type in ("receive", "fallback"):
                        return child.text.decode("utf-8", errors="replace")
        # Lua/Luau: function_declaration names may be dot_index_expression or
        # method_index_expression (e.g. function Animal.new() / Animal:speak()).
        # Return only the method name; the table name is used as parent_name
        # in _extract_lua_constructs.
        if language in ("lua", "luau") and node.type == "function_declaration":
            for child in node.children:
                if child.type in ("dot_index_expression", "method_index_expression"):
                    # Last identifier child is the method name
                    for sub in reversed(child.children):
                        if sub.type == "identifier":
                            return sub.text.decode("utf-8", errors="replace")
                    return None
        # Perl: bareword for subroutine names, package for package names
        if language == "perl":
            for child in node.children:
                if child.type == "bareword":
                    return child.text.decode("utf-8", errors="replace")
                if child.type == "package" and child.text != b"package":
                    return child.text.decode("utf-8", errors="replace")
        # Java: method_declaration has return type_identifier before the method
        # identifier — skip straight to the first plain identifier child to
        # avoid returning the return type as the function name.
        if language == "java" and kind == "function" and node.type in (
            "method_declaration", "constructor_declaration",
        ):
            for child in node.children:
                if child.type == "identifier":
                    return child.text.decode("utf-8", errors="replace")
            return None

        # For C/C++/Objective-C: function names are inside
        # function_declarator / pointer_declarator. Check these first to
        # avoid matching the return type_identifier as the function name.
        if language in ("c", "cpp", "objc") and kind == "function":
            for child in node.children:
                if child.type in ("function_declarator", "pointer_declarator"):
                    # Scoped names like Foo::bar use qualified_identifier; take
                    # the rightmost identifier/field_identifier after the last ::.
                    for sub in child.children:
                        if sub.type == "qualified_identifier":
                            for qsub in reversed(sub.children):
                                if qsub.type in ("identifier", "field_identifier"):
                                    return qsub.text.decode("utf-8", errors="replace")
                    result = self._get_name(child, language, kind)
                    if result:
                        return result
            # C++: inside function_declarator, the name appears as
            # qualified_identifier (Class::method), destructor_name (~Class),
            # operator_name (operator==), or field_identifier. The generic
            # loop below only recognizes 'identifier'/'type_identifier',
            # so scoped method definitions would otherwise fall through and
            # match the outer return-type type_identifier as the function name.
            # Nested scopes (Outer::Inner::method) produce nested
            # qualified_identifier nodes — peel until we find the leaf name.
            if language == "cpp" and node.type == "function_declarator":
                def _leaf_name(qi):
                    # Walk right-to-left: the rightmost identifier/
                    # destructor_name/operator_name is the method name.
                    # If the rightmost child is itself a qualified_identifier
                    # (nested scope), recurse into it.
                    for sub in reversed(qi.children):
                        if sub.type in (
                            "identifier",
                            "destructor_name",
                            "operator_name",
                        ):
                            return sub.text.decode(
                                "utf-8", errors="replace")
                        if sub.type == "qualified_identifier":
                            inner = _leaf_name(sub)
                            if inner:
                                return inner
                    return None
                for child in node.children:
                    if child.type == "qualified_identifier":
                        name = _leaf_name(child)
                        if name:
                            return name
                    if child.type in (
                        "field_identifier",
                        "destructor_name",
                        "operator_name",
                    ):
                        return child.text.decode(
                            "utf-8", errors="replace")

        # Objective-C method_definition: the method name is the first
        # ``identifier`` child (first part of the selector). Multi-part
        # selectors like ``- (void)add:(int)a to:(int)b`` keep ``add`` as
        # the canonical method name; later parts are keyword arguments.
        if language == "objc" and node.type == "method_definition":
            for child in node.children:
                if child.type == "identifier":
                    return child.text.decode("utf-8", errors="replace")

        # Bash function_definition: ``foo() { ... }`` — tree-sitter-bash
        # stores the function name as a ``word`` child, which the generic
        # loop below doesn't recognize.
        if language == "bash" and node.type == "function_definition":
            for child in node.children:
                if child.type == "word":
                    return child.text.decode("utf-8", errors="replace")
        # Go methods: tree-sitter-go uses field_identifier for the name
        # (e.g. func (s *T) MethodName(...) { }). Must run before the generic
        # loop, which would match the result type's type_identifier (e.g. int64).
        if language == "go" and node.type == "method_declaration":
            for child in node.children:
                if child.type == "field_identifier":
                    return child.text.decode("utf-8", errors="replace")
        # Java methods: tree-sitter-java puts type_identifier or generic_type
        # (return type) before identifier (method name).  Must run before
        # the generic loop, which would match the return type's
        # type_identifier (e.g. "String", "ConfigBean").
        # Constructors are fine — they have no return type node.
        # Kotlin is unaffected: its syntax places the name before the type.
        if language == "java" and node.type == "method_declaration":
            for child in node.children:
                if child.type == "identifier":
                    return child.text.decode("utf-8", errors="replace")
        # Swift extensions: name is inside user_type > type_identifier
        # (e.g. `extension MyClass: Protocol { ... }`)
        if language == "swift" and node.type == "class_declaration":
            for child in node.children:
                if child.type == "user_type":
                    for sub in child.children:
                        if sub.type == "type_identifier":
                            return sub.text.decode("utf-8", errors="replace")
        # Verilog/SystemVerilog: names are nested differently per construct type.
        if language == "verilog":
            # module_declaration: name is in module_header > simple_identifier
            if node.type == "module_declaration":
                for child in node.children:
                    if child.type == "module_header":
                        for sub in child.children:
                            if sub.type == "simple_identifier":
                                return sub.text.decode("utf-8", errors="replace")
            # interface_declaration: name is in interface_ansi_header > interface_identifier
            if node.type == "interface_declaration":
                for child in node.children:
                    if child.type in ("interface_header", "interface_ansi_header"):
                        for sub in child.children:
                            if sub.type == "simple_identifier":
                                return sub.text.decode("utf-8", errors="replace")
                            if sub.type == "interface_identifier":
                                for ss in sub.children:
                                    if ss.type == "simple_identifier":
                                        return ss.text.decode("utf-8", errors="replace")
                                return sub.text.decode("utf-8", errors="replace")
            # task_declaration: name is in task_body_declaration > task_identifier
            if node.type == "task_declaration":
                for child in node.children:
                    if child.type == "task_body_declaration":
                        for sub in child.children:
                            if sub.type == "task_identifier":
                                return sub.text.decode("utf-8", errors="replace")
            # function_declaration: name is in function_body_declaration > function_identifier
            if node.type == "function_declaration":
                for child in node.children:
                    if child.type == "function_body_declaration":
                        for sub in child.children:
                            if sub.type == "function_identifier":
                                return sub.text.decode("utf-8", errors="replace")

        # Julia: functions / macros nest the name inside
        # ``signature > call_expression > identifier``. Qualified names
        # (``function Base.show``) store the method name as the last
        # identifier of a ``field_expression``. ``where`` clauses wrap the
        # call in a ``where_expression``.
        # Structs and abstract types put the name inside ``type_head``,
        # possibly wrapped in ``binary_expression`` (``<:``) or
        # ``parametrized_type_expression`` (``{T}``).
        if language == "julia":
            if node.type in ("function_definition", "macro_definition"):
                for child in node.children:
                    if child.type == "signature":
                        call = child
                        # Unwrap where_expression: signature > where_expression > call_expression
                        for sub in call.children:
                            if sub.type == "where_expression":
                                call = sub
                                break
                        # Unwrap typed_expression: signature > typed_expression > call_expression
                        # (``function foo(x)::ReturnType``)
                        for sub in call.children:
                            if sub.type == "typed_expression":
                                call = sub
                                break
                        for sub in call.children:
                            if sub.type == "call_expression":
                                for target in sub.children:
                                    if target.type == "identifier":
                                        return target.text.decode(
                                            "utf-8", errors="replace",
                                        )
                                    if target.type == "field_expression":
                                        # Qualified: last identifier is method name
                                        for ident in reversed(target.children):
                                            if ident.type == "identifier":
                                                return ident.text.decode(
                                                    "utf-8", errors="replace",
                                                )
                                    if target.type == "parametrized_type_expression":
                                        # Parametric constructor: Foo{T}(x) = ...
                                        for p in target.children:
                                            if p.type == "identifier":
                                                return p.text.decode(
                                                    "utf-8", errors="replace",
                                                )
                                return None
                return None
            if node.type in ("struct_definition", "abstract_definition"):
                for child in node.children:
                    if child.type == "type_head":
                        # Direct identifier: struct Foo ... end
                        for sub in child.children:
                            if sub.type == "identifier":
                                return sub.text.decode(
                                    "utf-8", errors="replace",
                                )
                        # Subtyped: type_head > binary_expression > identifier (first)
                        for sub in child.children:
                            if sub.type == "binary_expression":
                                for ident in sub.children:
                                    if ident.type == "identifier":
                                        return ident.text.decode(
                                            "utf-8", errors="replace",
                                        )
                                    if ident.type == "parametrized_type_expression":
                                        for p in ident.children:
                                            if p.type == "identifier":
                                                return p.text.decode(
                                                    "utf-8", errors="replace",
                                                )
                                        return None
                                return None
                        # Parametric (no <:): type_head > parametrized_type_expression
                        for sub in child.children:
                            if sub.type == "parametrized_type_expression":
                                for p in sub.children:
                                    if p.type == "identifier":
                                        return p.text.decode(
                                            "utf-8", errors="replace",
                                        )
                                return None
                return None

        # Most languages use a 'name' child.
        # field_identifier covers C++ class member function names inside
        # function_declarator (e.g. virtual std::string get_name() = 0).
        for child in node.children:
            if child.type in (
                "identifier", "name", "type_identifier", "property_identifier",
                "simple_identifier", "constant", "field_identifier",
            ):
                return child.text.decode("utf-8", errors="replace")
        # For Go type declarations, look for type_spec
        if language == "go" and node.type == "type_declaration":
            for child in node.children:
                if child.type == "type_spec":
                    return self._get_name(child, language, kind)
        return None

    def _get_go_receiver_type(self, node) -> Optional[str]:
        """Extract the receiver type from a Go method_declaration.

        For ``func (s *T) Foo() {...}`` returns ``"T"``. For ``func (T) Foo()``
        also returns ``"T"``. Returns None if no receiver is present.

        The receiver is always the first ``parameter_list`` child of a
        Go ``method_declaration`` and contains a single ``parameter_declaration``
        whose type is either a ``type_identifier`` or a ``pointer_type``
        wrapping one. See: #190
        """
        for child in node.children:
            if child.type != "parameter_list":
                continue
            for param in child.children:
                if param.type != "parameter_declaration":
                    continue
                for sub in param.children:
                    if sub.type == "type_identifier":
                        return sub.text.decode("utf-8", errors="replace")
                    if sub.type == "pointer_type":
                        for ptr_child in sub.children:
                            if ptr_child.type == "type_identifier":
                                return ptr_child.text.decode(
                                    "utf-8", errors="replace"
                                )
            # First parameter_list is always the receiver; stop searching.
            return None
        return None

    def _get_params(self, node, language: str, source: bytes) -> Optional[str]:
        """Extract parameter list as a string."""
        for child in node.children:
            param_types = (
                "parameters", "formal_parameters",
                "parameter_list", "formal_parameter_list",
            )
            if child.type in param_types:
                return child.text.decode("utf-8", errors="replace")
        # Solidity: parameters are direct children between ( and )
        if language == "solidity":
            params = [
                c.text.decode("utf-8", errors="replace")
                for c in node.children
                if c.type == "parameter"
            ]
            if params:
                return f"({', '.join(params)})"
        return None

    def _get_return_type(self, node, language: str, source: bytes) -> Optional[str]:
        """Extract return type annotation if present."""
        for child in node.children:
            if child.type in ("type", "return_type", "type_annotation", "return_type_definition"):
                return child.text.decode("utf-8", errors="replace")
        # Python: look for -> annotation
        if language == "python":
            for i, child in enumerate(node.children):
                if child.type == "->" and i + 1 < len(node.children):
                    return node.children[i + 1].text.decode("utf-8", errors="replace")
        return None

    def _get_bases(self, node, language: str, source: bytes) -> list[str]:
        """Extract base classes / implemented interfaces."""
        bases = []
        if language == "python":
            for child in node.children:
                if child.type == "argument_list":
                    for arg in child.children:
                        if arg.type in ("identifier", "attribute"):
                            bases.append(arg.text.decode("utf-8", errors="replace"))
        elif language == "java":
            # Java: superclass and super_interfaces wrap the keyword
            # (extends/implements) around type_identifier children.
            # Taking .text would include the keyword (e.g. "implements Foo").
            # Drill into the children to extract bare type names.
            for child in node.children:
                if child.type == "superclass":
                    for sub in child.children:
                        if sub.type in ("type_identifier", "generic_type"):
                            bases.append(sub.text.decode("utf-8", errors="replace"))
                elif child.type == "super_interfaces":
                    for sub in child.children:
                        if sub.type == "type_list":
                            for ident in sub.children:
                                if ident.type in ("type_identifier", "generic_type"):
                                    bases.append(ident.text.decode("utf-8", errors="replace"))
        elif language in ("csharp", "kotlin"):
            # Look for superclass/interfaces in extends/implements clauses
            for child in node.children:
                if child.type in (
                    "superclass", "super_interfaces", "extends_type",
                    "implements_type", "type_identifier", "supertype",
                    "delegation_specifier",
                ):
                    text = child.text.decode("utf-8", errors="replace")
                    bases.append(text)
        elif language == "scala":
            for child in node.children:
                if child.type == "extends_clause":
                    for sub in child.children:
                        if sub.type == "type_identifier":
                            bases.append(sub.text.decode("utf-8", errors="replace"))
                        elif sub.type == "generic_type":
                            for ident in sub.children:
                                if ident.type == "type_identifier":
                                    bases.append(
                                        ident.text.decode("utf-8", errors="replace")
                                    )
                                    break
        elif language == "cpp":
            # C++: base_class_clause contains type_identifiers
            for child in node.children:
                if child.type == "base_class_clause":
                    for sub in child.children:
                        if sub.type == "type_identifier":
                            bases.append(sub.text.decode("utf-8", errors="replace"))
        elif language in ("typescript", "javascript", "tsx"):
            # extends clause
            for child in node.children:
                if child.type in ("extends_clause", "implements_clause"):
                    for sub in child.children:
                        if sub.type in ("identifier", "type_identifier", "nested_identifier"):
                            bases.append(sub.text.decode("utf-8", errors="replace"))
        elif language == "solidity":
            # contract Foo is Bar, Baz { ... }
            for child in node.children:
                if child.type == "inheritance_specifier":
                    for sub in child.children:
                        if sub.type == "user_defined_type":
                            for ident in sub.children:
                                if ident.type == "identifier":
                                    bases.append(ident.text.decode("utf-8", errors="replace"))
        elif language == "go":
            # Embedded structs / interface composition
            for child in node.children:
                if child.type == "type_spec":
                    for sub in child.children:
                        if sub.type in ("struct_type", "interface_type"):
                            for field_node in sub.children:
                                if field_node.type == "field_declaration_list":
                                    for f in field_node.children:
                                        if f.type == "type_identifier":
                                            bases.append(f.text.decode("utf-8", errors="replace"))
        elif language == "dart":
            # class Foo extends Bar with Mixin implements Iface { ... }
            # AST: superclass contains type_identifier (base) and mixins (with clause);
            #      interfaces is a sibling of superclass.
            for child in node.children:
                if child.type == "superclass":
                    for sub in child.children:
                        if sub.type == "type_identifier":
                            bases.append(sub.text.decode("utf-8", errors="replace"))
                        elif sub.type == "mixins":
                            for m in sub.children:
                                if m.type == "type_identifier":
                                    bases.append(m.text.decode("utf-8", errors="replace"))
                elif child.type == "interfaces":
                    for sub in child.children:
                        if sub.type == "type_identifier":
                            bases.append(sub.text.decode("utf-8", errors="replace"))
        elif language == "swift":
            # Swift: class Foo: Bar, Baz { ... } / extension Foo: Protocol { ... }
            # AST: inheritance_specifier > user_type > type_identifier
            for child in node.children:
                if child.type == "inheritance_specifier":
                    for sub in child.children:
                        if sub.type == "user_type":
                            for ident in sub.children:
                                if ident.type == "type_identifier":
                                    bases.append(
                                        ident.text.decode("utf-8", errors="replace")
                                    )
                                    break
        elif language == "julia":
            # Julia: struct Foo <: Bar / abstract type Foo <: Bar end
            # AST: type_head > binary_expression with operator "<:" and
            # identifier children; the identifier AFTER the operator is the
            # supertype.
            if node.type in ("struct_definition", "abstract_definition"):
                for child in node.children:
                    if child.type != "type_head":
                        continue
                    for sub in child.children:
                        if sub.type != "binary_expression":
                            continue
                        has_subtype_op = False
                        for op_child in sub.children:
                            if (
                                op_child.type == "operator"
                                and op_child.text == b"<:"
                            ):
                                has_subtype_op = True
                                break
                        if not has_subtype_op:
                            continue
                        idents = [
                            c for c in sub.children if c.type == "identifier"
                        ]
                        # First identifier is the type being defined; the
                        # second (if present) is the supertype.
                        if len(idents) >= 2:
                            bases.append(
                                idents[1].text.decode("utf-8", errors="replace"),
                            )
                        elif len(idents) == 1:
                            # Could be `Parametric{T} <: Super` where the
                            # first side is parametrized_type_expression.
                            bases.append(
                                idents[0].text.decode("utf-8", errors="replace"),
                            )
        return bases

    def _extract_import(self, node, language: str, source: bytes) -> list[str]:
        """Extract import targets as module/path strings."""
        imports = []
        text = node.text.decode("utf-8", errors="replace").strip()

        if language == "python":
            # import x.y.z  or  from x.y import z
            if node.type == "import_from_statement":
                for child in node.children:
                    if child.type == "dotted_name":
                        imports.append(child.text.decode("utf-8", errors="replace"))
                        break
            else:
                for child in node.children:
                    if child.type == "dotted_name":
                        imports.append(child.text.decode("utf-8", errors="replace"))
        elif language in ("javascript", "typescript", "tsx"):
            # import ... from 'module'
            for child in node.children:
                if child.type == "string":
                    val = child.text.decode("utf-8", errors="replace").strip("'\"")
                    imports.append(val)
        elif language == "go":
            for child in node.children:
                if child.type == "import_spec_list":
                    for spec in child.children:
                        if spec.type == "import_spec":
                            for s in spec.children:
                                if s.type == "interpreted_string_literal":
                                    val = s.text.decode("utf-8", errors="replace")
                                    imports.append(val.strip('"'))
                elif child.type == "import_spec":
                    for s in child.children:
                        if s.type == "interpreted_string_literal":
                            val = s.text.decode("utf-8", errors="replace")
                            imports.append(val.strip('"'))
        elif language == "rust":
            # use crate::module::item
            imports.append(text.replace("use ", "").rstrip(";").strip())
        elif language in ("c", "cpp"):
            # #include <header> or #include "header"
            for child in node.children:
                if child.type in ("system_lib_string", "string_literal"):
                    val = child.text.decode("utf-8", errors="replace").strip("<>\"")
                    imports.append(val)
        elif language in ("java", "csharp"):
            # import/using package.Class
            parts = text.split()
            if len(parts) >= 2:
                imports.append(parts[-1].rstrip(";"))
        elif language == "solidity":
            # import "path/to/file.sol" or import {Symbol} from "path"
            for child in node.children:
                if child.type == "string":
                    val = child.text.decode("utf-8", errors="replace").strip('"')
                    if val:
                        imports.append(val)
        elif language == "scala":
            parts = []
            selectors = []
            is_wildcard = False
            for child in node.children:
                if child.type == "identifier":
                    parts.append(child.text.decode("utf-8", errors="replace"))
                elif child.type == "namespace_selectors":
                    for sub in child.children:
                        if sub.type == "identifier":
                            selectors.append(sub.text.decode("utf-8", errors="replace"))
                elif child.type == "namespace_wildcard":
                    is_wildcard = True
            base = ".".join(parts)
            if selectors:
                for name in selectors:
                    imports.append(f"{base}.{name}")
            elif is_wildcard:
                imports.append(f"{base}.*")
            elif base:
                imports.append(base)
        elif language == "r":
            # library(pkg), require(pkg), source("file.R")
            func_name = self._r_call_func_name(node)
            if func_name in ("library", "require", "source"):
                for _name, value in self._r_iter_args(node):
                    if value.type == "identifier":
                        imports.append(value.text.decode("utf-8", errors="replace"))
                    elif value.type == "string":
                        val = self._r_first_string_arg(node)
                        if val:
                            imports.append(val)
                    break  # Only first argument matters
        elif language == "ruby":
            # require 'module' or require_relative 'path'
            if "require" in text:
                match = re.search(r"""['"](.*?)['"]""", text)
                if match:
                    imports.append(match.group(1))
        elif language == "dart":
            # import 'dart:async' or import 'package:flutter/material.dart'
            # Node structure: import_or_export > library_import > import_specification
            #                 > configurable_uri > uri > string_literal
            def _find_string_literal(n) -> Optional[str]:
                if n.type == "string_literal":
                    return n.text.decode("utf-8", errors="replace").strip("'\"")
                for c in n.children:
                    result = _find_string_literal(c)
                    if result is not None:
                        return result
                return None
            val = _find_string_literal(node)
            if val:
                imports.append(val)
        elif language == "verilog":
            # import pkg::*; or import pkg::item;
            # Node structure: package_import_declaration > package_import_item > package_identifier
            for child in node.children:
                if child.type == "package_import_item":
                    for subchild in child.children:
                        if subchild.type == "package_identifier":
                            imports.append(subchild.text.decode("utf-8", errors="replace"))
        elif language == "julia":
            # using/import statements. Children can be:
            # - identifier (simple: `using Foo`)
            # - import_path (dotted: `using Foo.Bar`)
            # - selected_import (`using Foo: bar, baz` — first child is the
            #   module as identifier/import_path, remaining identifiers after
            #   the ':' are imported names to record as ``Module.name``)
            def _import_path_text(n) -> str:
                parts: list[str] = []
                for sub in n.children:
                    if sub.type == "identifier":
                        parts.append(sub.text.decode("utf-8", errors="replace"))
                return ".".join(parts)

            for child in node.children:
                if child.type == "identifier":
                    imports.append(
                        child.text.decode("utf-8", errors="replace"),
                    )
                elif child.type == "import_path":
                    path = _import_path_text(child)
                    if path:
                        imports.append(path)
                elif child.type == "selected_import":
                    module_name: Optional[str] = None
                    seen_colon = False
                    for sub in child.children:
                        if sub.type == ":":
                            seen_colon = True
                            continue
                        if not seen_colon:
                            if sub.type == "identifier":
                                module_name = sub.text.decode(
                                    "utf-8", errors="replace",
                                )
                            elif sub.type == "import_path":
                                path = _import_path_text(sub)
                                if path:
                                    module_name = path
                        else:
                            if sub.type == "identifier" and module_name:
                                imported = sub.text.decode(
                                    "utf-8", errors="replace",
                                )
                                imports.append(f"{module_name}.{imported}")
        elif language == "gdscript":
            # ``extends Node`` → type > identifier("Node")
            # ``extends "res://path.gd"`` → string literal
            # ``extends SomeClass.Nested`` → type node (keep full text)
            for child in node.children:
                if child.type == "type":
                    txt = child.text.decode("utf-8", errors="replace").strip()
                    if txt:
                        imports.append(txt)
                elif child.type == "string":
                    val = child.text.decode("utf-8", errors="replace").strip("'\"")
                    if val:
                        imports.append(val)
                elif child.type == "identifier":
                    # Fallback: some grammar variants expose the parent type as
                    # a bare identifier next to the ``extends`` keyword.
                    txt = child.text.decode("utf-8", errors="replace")
                    if txt and txt != "extends":
                        imports.append(txt)
        else:
            # Fallback: just record the text
            imports.append(text)

        return imports

    def _get_call_name(self, node, language: str, source: bytes) -> Optional[str]:
        """Extract the function/method name being called."""
        if not node.children:
            return None

        first = node.children[0]

        # Julia macrocall: ``@test expr`` — name is inside
        # ``macro_identifier > identifier``. Prefix with ``@`` to distinguish
        # from ordinary calls.
        if language == "julia" and node.type == "macrocall_expression":
            for child in node.children:
                if child.type == "macro_identifier":
                    for sub in child.children:
                        if sub.type == "identifier":
                            raw = sub.text.decode("utf-8", errors="replace")
                            return f"@{raw}"
                    return None
            return None

        # Julia broadcast call: ``sin.(x)`` — same structure as
        # call_expression (first child is identifier or field_expression)
        # so the generic paths below handle it.
        if language == "php":
            def _normalize_php_name(text: str) -> str:
                # PHP global/function names can be prefixed with '\\'.
                return text.lstrip("\\")

            if node.type == "function_call_expression":
                for child in node.children:
                    if child.type in ("name", "qualified_name"):
                        raw = child.text.decode("utf-8", errors="replace")
                        return _normalize_php_name(raw)
                return None

            if node.type in (
                "member_call_expression",
                "nullsafe_member_call_expression",
            ):
                for child in reversed(node.children):
                    if child.type == "name":
                        return child.text.decode("utf-8", errors="replace")
                return None

            if node.type == "scoped_call_expression":
                parts = []
                for child in node.children:
                    if child.type in ("name", "qualified_name"):
                        raw = child.text.decode("utf-8", errors="replace")
                        parts.append(_normalize_php_name(raw))
                if len(parts) >= 2:
                    return f"{parts[0]}::{parts[-1]}"
                if parts:
                    return parts[0]
                return None

        # Scala: instance_expression (new Foo(...)) – extract the type name
        if node.type == "instance_expression":
            for child in node.children:
                if child.type in ("type_identifier", "identifier"):
                    return child.text.decode("utf-8", errors="replace")
            return None

        # Objective-C: [receiver method:arg] — the method name is the
        # SECOND identifier-like child (the first is the receiver). For
        # multi-part selectors like `[obj add:a to:b]` we keep the first
        # part (`add`) as the call name; later parts are keyword arguments.
        if language == "objc" and node.type == "message_expression":
            receiver_skipped = False
            for child in node.children:
                if child.type in ("[", "]"):
                    continue
                if not receiver_skipped:
                    # First non-bracket child is the receiver (identifier,
                    # message_expression for chained calls, etc.)
                    receiver_skipped = True
                    continue
                if child.type == "identifier":
                    return child.text.decode("utf-8", errors="replace")
            return None

        # Bash: `command` node's first child is the command name.
        if language == "bash" and node.type == "command":
            for child in node.children:
                if child.type == "command_name":
                    # command_name wraps a word — get its text
                    txt = child.text.decode("utf-8", errors="replace").strip()
                    return txt or None
            return None

        # Verilog/SystemVerilog: module_instantiation's first child is the module name
        if language == "verilog" and node.type == "module_instantiation":
            if first.type == "simple_identifier":
                return first.text.decode("utf-8", errors="replace")
            return None

        # Solidity wraps call targets in an 'expression' node – unwrap it
        if language == "solidity" and first.type == "expression" and first.children:
            first = first.children[0]

        # Perl method_call_expression: $obj->method() — find the 'method' child
        if language == "perl" and node.type == "method_call_expression":
            for child in node.children:
                if child.type == "method":
                    return child.text.decode("utf-8", errors="replace")
            return None  # method child not found

        # Simple call: func_name(args)
        # Kotlin uses "simple_identifier" instead of "identifier".
        if first.type in ("identifier", "simple_identifier"):
            return first.text.decode("utf-8", errors="replace")

        # Perl: function_call_expression / ambiguous_function_call_expression
        if first.type == "function":
            return first.text.decode("utf-8", errors="replace")

        # Lua/Luau: dot_index_expression (obj.method) and method_index_expression
        # (obj:method) — extract the rightmost identifier as the call name.
        if language in ("lua", "luau") and first.type in (
            "dot_index_expression", "method_index_expression",
        ):
            for child in reversed(first.children):
                if child.type == "identifier":
                    return child.text.decode("utf-8", errors="replace")
            return None

        # Method call: obj.method(args)
        # Kotlin uses "navigation_expression" for member access (obj.method).
        member_types = (
            "attribute", "member_expression",
            "field_expression", "selector_expression",
            "navigation_expression",
        )
        if first.type in member_types:
            # Get the rightmost identifier (the method name)
            # Kotlin navigation_expression uses navigation_suffix > simple_identifier.
            for child in reversed(first.children):
                if child.type in (
                    "identifier", "property_identifier", "field_identifier",
                    "field_name", "simple_identifier",
                ):
                    return child.text.decode("utf-8", errors="replace")
                if child.type == "navigation_suffix":
                    for sub in child.children:
                        if sub.type == "simple_identifier":
                            return sub.text.decode("utf-8", errors="replace")
            return first.text.decode("utf-8", errors="replace")

        # Scoped call (e.g., Rust path::func())
        if first.type in ("scoped_identifier", "qualified_name"):
            return first.text.decode("utf-8", errors="replace")

        # R namespace-qualified call: dplyr::filter()
        if first.type == "namespace_operator":
            return first.text.decode("utf-8", errors="replace")

        return None

    def _get_jsx_component_reference(self, node) -> Optional[tuple[Optional[str], str]]:
        """Extract ``(base_name, component_name)`` for a JSX element.

        ``base_name`` is set for member-style elements such as
        ``<UI.MarkdownMsg />`` and ``None`` for plain component tags such as
        ``<MarkdownMsg />``.
        """
        for child in node.children:
            if child.type == "identifier":
                name = child.text.decode("utf-8", errors="replace")
                if self._looks_like_component_name(name):
                    return (None, name)
                return None
            if child.type == "member_expression":
                base_name = self._get_member_expression_root_name(child)
                component_name = None
                for sub in reversed(child.children):
                    if sub.type in ("identifier", "property_identifier"):
                        component_name = sub.text.decode("utf-8", errors="replace")
                        break
                if component_name and self._looks_like_component_name(component_name):
                    return (base_name, component_name)
                for sub in reversed(child.children):
                    if sub.type in ("identifier", "property_identifier"):
                        name = sub.text.decode("utf-8", errors="replace")
                        if self._looks_like_component_name(name):
                            return (None, name)
                        return None
                text = child.text.decode("utf-8", errors="replace")
                tail = text.split(".")[-1]
                if self._looks_like_component_name(tail):
                    return (None, tail)
                return None
        return None

    def _get_member_expression_root_name(self, node) -> Optional[str]:
        """Return the leftmost identifier for a nested member expression."""
        for child in node.children:
            if child.type == "identifier":
                return child.text.decode("utf-8", errors="replace")
            if child.type == "member_expression":
                return self._get_member_expression_root_name(child)
        return None

    @staticmethod
    def _looks_like_component_name(name: str) -> bool:
        """Return True for JSX names that look like user components."""
        return bool(name) and name[0].isupper()

    # Modifier suffixes used in JS/TS test runners
    _TEST_MODIFIER_SUFFIXES = frozenset({
        "only", "skip", "each", "todo", "concurrent", "failing",
    })

    def _get_base_call_name(self, node, source: bytes) -> Optional[str]:
        """Return the base object name for member-expression calls like describe.only()."""
        if not node.children:
            return None
        first = node.children[0]
        if first.type != "member_expression":
            return None
        rightmost: Optional[str] = None
        for child in reversed(first.children):
            if child.type in ("identifier", "property_identifier"):
                rightmost = child.text.decode("utf-8", errors="replace")
                break
        if rightmost not in self._TEST_MODIFIER_SUFFIXES:
            return None
        for child in first.children:
            if child.type == "identifier":
                return child.text.decode("utf-8", errors="replace")
            if child.type == "member_expression":
                for inner in child.children:
                    if inner.type == "identifier":
                        return inner.text.decode("utf-8", errors="replace")
        return None

    # ------------------------------------------------------------------
    # R-specific helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _r_call_func_name(call_node) -> Optional[str]:
        """Extract the function name from an R call node."""
        for child in call_node.children:
            if child.type in ("identifier", "namespace_operator"):
                return child.text.decode("utf-8", errors="replace")
        return None

    @staticmethod
    def _r_first_string_arg(call_node) -> Optional[str]:
        """Extract the first string argument value from an R call node."""
        for child in call_node.children:
            if child.type == "arguments":
                for arg in child.children:
                    if arg.type == "argument":
                        for sub in arg.children:
                            if sub.type == "string":
                                for sc in sub.children:
                                    if sc.type == "string_content":
                                        return sc.text.decode("utf-8", errors="replace")
                break
        return None

    @staticmethod
    def _r_iter_args(call_node):
        """Yield (name_str, value_node) pairs from an R call's arguments."""
        for child in call_node.children:
            if child.type != "arguments":
                continue
            for arg in child.children:
                if arg.type != "argument":
                    continue
                has_eq = any(sub.type == "=" for sub in arg.children)
                if has_eq:
                    name = None
                    value = None
                    for sub in arg.children:
                        if sub.type == "identifier" and name is None:
                            name = sub.text.decode("utf-8", errors="replace")
                        elif sub.type not in ("=", ","):
                            value = sub
                    yield (name, value)
                else:
                    for sub in arg.children:
                        if sub.type not in (",",):
                            yield (None, sub)
                            break
            break

    @classmethod
    def _r_find_named_arg(cls, call_node, arg_name: str):
        """Find a named argument's value node in an R call."""
        for name, value in cls._r_iter_args(call_node):
            if name == arg_name:
                return value
        return None

    # ------------------------------------------------------------------
    # R-specific handlers
    # ------------------------------------------------------------------

    def _handle_r_binary_operator(
        self, node, source: bytes, language: str, file_path: str,
        nodes: list[NodeInfo], edges: list[EdgeInfo],
        enclosing_class: Optional[str], enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
    ) -> bool:
        """Handle R binary_operator nodes: name <- function(...) { ... }."""
        children = node.children
        if len(children) < 3:
            return False

        left, op, right = children[0], children[1], children[2]
        if op.type not in ("<-", "="):
            return False

        if right.type == "function_definition" and left.type == "identifier":
            name = left.text.decode("utf-8", errors="replace")
            is_test = _is_test_function(name, file_path)
            kind = "Test" if is_test else "Function"
            qualified = self._qualify(name, file_path, enclosing_class)
            params = self._get_params(right, language, source)

            nodes.append(NodeInfo(
                kind=kind,
                name=name,
                file_path=file_path,
                line_start=right.start_point[0] + 1,
                line_end=right.end_point[0] + 1,
                language=language,
                parent_name=enclosing_class,
                params=params,
                is_test=is_test,
            ))

            container = (
                self._qualify(enclosing_class, file_path, None)
                if enclosing_class else file_path
            )
            edges.append(EdgeInfo(
                kind="CONTAINS",
                source=container,
                target=qualified,
                file_path=file_path,
                line=right.start_point[0] + 1,
            ))

            self._extract_from_tree(
                right, source, language, file_path, nodes, edges,
                enclosing_class=enclosing_class, enclosing_func=name,
                import_map=import_map, defined_names=defined_names,
            )
            return True

        if right.type == "call" and left.type == "identifier":
            call_func = self._r_call_func_name(right)
            if call_func in ("setRefClass", "setClass", "setGeneric"):
                assign_name = left.text.decode("utf-8", errors="replace")
                return self._handle_r_class_call(
                    right, source, language, file_path, nodes, edges,
                    enclosing_class, enclosing_func,
                    import_map, defined_names,
                    assign_name=assign_name,
                )

        return False

    def _handle_r_call(
        self, node, source: bytes, language: str, file_path: str,
        nodes: list[NodeInfo], edges: list[EdgeInfo],
        enclosing_class: Optional[str], enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
    ) -> bool:
        """Handle R call nodes for imports and class definitions."""
        func_name = self._r_call_func_name(node)
        if not func_name:
            return False

        if func_name in ("library", "require", "source"):
            imports = self._extract_import(node, language, source)
            for imp_target in imports:
                edges.append(EdgeInfo(
                    kind="IMPORTS_FROM",
                    source=file_path,
                    target=imp_target,
                    file_path=file_path,
                    line=node.start_point[0] + 1,
                ))
            return True

        if func_name in ("setRefClass", "setClass", "setGeneric"):
            return self._handle_r_class_call(
                node, source, language, file_path, nodes, edges,
                enclosing_class, enclosing_func,
                import_map, defined_names,
            )

        # Module-scope R calls attribute to the File node.
        call_name = self._get_call_name(node, language, source)
        if call_name:
            caller = (
                self._qualify(enclosing_func, file_path, enclosing_class)
                if enclosing_func
                else file_path
            )
            target = self._resolve_call_target(
                call_name, file_path, language,
                import_map or {}, defined_names or set(),
            )
            edges.append(EdgeInfo(
                kind="CALLS",
                source=caller,
                target=target,
                file_path=file_path,
                line=node.start_point[0] + 1,
            ))

        self._extract_from_tree(
            node, source, language, file_path, nodes, edges,
            enclosing_class=enclosing_class, enclosing_func=enclosing_func,
            import_map=import_map, defined_names=defined_names,
        )
        return True

    def _handle_r_class_call(
        self, node, source: bytes, language: str, file_path: str,
        nodes: list[NodeInfo], edges: list[EdgeInfo],
        enclosing_class: Optional[str], enclosing_func: Optional[str],
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
        assign_name: Optional[str] = None,
    ) -> bool:
        """Handle setClass/setRefClass/setGeneric calls -> Class nodes."""
        class_name = self._r_first_string_arg(node) or assign_name
        if not class_name:
            return False

        qualified = self._qualify(class_name, file_path, enclosing_class)
        nodes.append(NodeInfo(
            kind="Class",
            name=class_name,
            file_path=file_path,
            line_start=node.start_point[0] + 1,
            line_end=node.end_point[0] + 1,
            language=language,
            parent_name=enclosing_class,
        ))
        edges.append(EdgeInfo(
            kind="CONTAINS",
            source=file_path,
            target=qualified,
            file_path=file_path,
            line=node.start_point[0] + 1,
        ))

        methods_list = self._r_find_named_arg(node, "methods")
        if methods_list is not None:
            self._extract_r_methods(
                methods_list, source, language, file_path,
                nodes, edges, class_name,
                import_map, defined_names,
            )

        return True

    def _extract_r_methods(
        self, list_call, source: bytes, language: str, file_path: str,
        nodes: list[NodeInfo], edges: list[EdgeInfo],
        class_name: str,
        import_map: Optional[dict[str, str]],
        defined_names: Optional[set[str]],
    ) -> None:
        """Extract methods from a setRefClass methods = list(...) call."""
        for method_name, func_def in self._r_iter_args(list_call):
            if not method_name or func_def is None:
                continue
            if func_def.type != "function_definition":
                continue

            qualified = self._qualify(method_name, file_path, class_name)
            params = self._get_params(func_def, language, source)
            nodes.append(NodeInfo(
                kind="Function",
                name=method_name,
                file_path=file_path,
                line_start=func_def.start_point[0] + 1,
                line_end=func_def.end_point[0] + 1,
                language=language,
                parent_name=class_name,
                params=params,
            ))
            edges.append(EdgeInfo(
                kind="CONTAINS",
                source=self._qualify(class_name, file_path, None),
                target=qualified,
                file_path=file_path,
                line=func_def.start_point[0] + 1,
            ))
            self._extract_from_tree(
                func_def, source, language, file_path, nodes, edges,
                enclosing_class=class_name,
                enclosing_func=method_name,
                import_map=import_map,
                defined_names=defined_names,
            )
