Files
2026-07-13 12:42:18 +08:00

6958 lines
283 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 .custom_languages import CustomLanguage, load_custom_languages
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": [],
}
def _builtin_language_names() -> frozenset[str]:
"""All built-in language identifiers.
Used to stop config-driven custom languages (languages.toml) from
shadowing a built-in language name — built-ins always win.
"""
return (
frozenset(EXTENSION_TO_LANGUAGE.values())
| frozenset(_CLASS_TYPES)
| frozenset(_FUNCTION_TYPES)
| frozenset(_IMPORT_TYPES)
| frozenset(_CALL_TYPES)
)
# 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, repo_root: Optional[Path] = None) -> 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]] = {}
# Config-driven custom languages (.code-review-graph/languages.toml).
# The built-in tables stay shared module-level constants; only when a
# repo defines custom languages does this parser switch to merged
# copies, so other CodeParser instances (multi-repo registry, worker
# processes for other repos) are never affected. See #320.
self._extension_map: dict[str, str] = EXTENSION_TO_LANGUAGE
self._class_types: dict[str, list[str]] = _CLASS_TYPES
self._function_types: dict[str, list[str]] = _FUNCTION_TYPES
self._import_types: dict[str, list[str]] = _IMPORT_TYPES
self._call_types: dict[str, list[str]] = _CALL_TYPES
self._custom_languages: dict[str, CustomLanguage] = {}
if repo_root is not None:
self._custom_languages = load_custom_languages(
Path(repo_root),
builtin_extensions=EXTENSION_TO_LANGUAGE,
builtin_languages=_builtin_language_names(),
)
if self._custom_languages:
self._extension_map = dict(EXTENSION_TO_LANGUAGE)
self._class_types = dict(_CLASS_TYPES)
self._function_types = dict(_FUNCTION_TYPES)
self._import_types = dict(_IMPORT_TYPES)
self._call_types = dict(_CALL_TYPES)
for custom in self._custom_languages.values():
for ext in custom.extensions:
self._extension_map[ext] = custom.name
self._class_types[custom.name] = list(custom.class_node_types)
self._function_types[custom.name] = list(custom.function_node_types)
self._import_types[custom.name] = list(custom.import_node_types)
self._call_types[custom.name] = list(custom.call_node_types)
def _get_parser(self, language: str): # type: ignore[arg-type]
if language not in self._parsers:
# Custom languages map their name onto a packaged grammar.
custom = self._custom_languages.get(language)
grammar = custom.grammar if custom is not None else language
try:
self._parsers[language] = tslp.get_parser(grammar) # 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 = self._extension_map.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(self._class_types.get(language, []))
func_types = set(self._function_types.get(language, []))
import_types = set(self._import_types.get(language, []))
call_types = set(self._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(self._class_types.get(language, []))
func_types = set(self._function_types.get(language, []))
import_types = set(self._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)
# Custom languages (languages.toml): grammars often expose the
# definition name through a ``name`` field whose child type is
# grammar-specific (Erlang ``atom``, Haskell ``variable``) and thus
# not in the generic child-type list above.
if language in self._custom_languages:
name_child = node.child_by_field_name("name")
if name_child is not None:
return name_child.text.decode("utf-8", errors="replace")
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)
elif language in self._custom_languages:
# Custom languages (languages.toml): prefer the grammar's
# module-ish field over the raw statement text (e.g. Erlang
# ``-import(lists, [map/2]).`` → ``lists``; Haskell
# ``import Data.List`` → ``Data.List``).
for field_name in ("module", "name", "path", "source"):
target = node.child_by_field_name(field_name)
if target is None:
continue
val = target.text.decode("utf-8", errors="replace").strip().strip("'\"")
if val:
imports.append(val)
break
else:
imports.append(text)
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")
# Custom languages (languages.toml): probe common callee field names
# (Erlang ``call`` uses ``expr``; Haskell ``apply`` uses ``function``).
if language in self._custom_languages:
return self._get_custom_call_name(node)
return None
# Callee field names probed for config-driven custom languages, in order.
_CUSTOM_CALLEE_FIELDS = ("function", "callee", "expr", "name")
_MAX_CUSTOM_CALLEE_DESCENT = 32 # Curried-application depth guard
def _get_custom_call_name(self, node) -> Optional[str]:
"""Generic callee extraction for config-driven custom languages.
Tries common tree-sitter field names for the callee. When the field
child is itself an application node carrying the same field (curried
calls, e.g. Haskell ``f x y`` = ``apply(apply(f, x), y)``), descend
to the leaf callee. Multi-line or oversized callee text (a lambda
being invoked, for instance) is rejected rather than recorded as a
garbage call target.
"""
for field_name in self._CUSTOM_CALLEE_FIELDS:
callee = node.child_by_field_name(field_name)
if callee is None:
continue
for _ in range(self._MAX_CUSTOM_CALLEE_DESCENT):
inner = callee.child_by_field_name(field_name)
if inner is None:
break
callee = inner
text = callee.text.decode("utf-8", errors="replace").strip()
if text and len(text) <= 256 and "\n" not in text:
return text
return None
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,
)