Files
codegraph/codegraph-kernel/src/rlang.rs
T
Colby MchenryandGitHub 838006c947 fix(kernel): guard the native walkers against stack overflow and defer deep files to wasm (#1581) (#1600)
Fixes #1581.

## What was wrong

`codegraph init` / `codegraph index` died with `Segmentation fault` — the whole CLI
process, not a parse worker — on a C/C++ file with very deep brace nesting (llvm's
`clang/test/Parser/parser_overflow.c`, 16,384 nested `{`). The reporter's diagnosis is
exactly right: tree-sitter's parser is iterative, so the file parses fine, and then the
native kernel's **recursive walker** (`visit_node` → `visit_for_calls_and_structure` → …,
one frame per AST level) overflowed the thread's stack. A native overflow can't be caught
the way a wasm abort can, and a parse worker is a thread of the `codegraph` process, so
the SIGSEGV took the entire indexer down — no message, no per-file fallback, no partial
index.

Two things made "just give the worker a bigger stack" the wrong fix:

- it only moves the cliff — reproduced here: the reporter's 16,384-deep file kills a
  default 4 MiB worker (rc=132 on macOS / 139 on Linux), and a 100k-deep file kills the
  8 MiB **main** thread too;
- the walkers are shared by every kernel-routed language (20 of them), and each has
  several recursion points with different frame sizes, so no single stack size is a
  provable bound.

Meanwhile the wasm path already handles this shape gracefully: its JS walker catches its
own `RangeError` per file and stores a partial result with a `parse_error`. The kernel
just needed a way to get there instead of dying.

## What this does

**The kernel guards its own recursion against the calling thread's real stack bounds and
defers a too-deep file to wasm** — the same `defer:` routing signal it already uses for
files with parse errors, which `src/extraction/kernel/index.ts` treats as "take the wasm
path for this file", silently.

- `codegraph-kernel/src/stack.rs`: per-thread stack bounds from the OS, computed once per
  thread and cached — glibc/musl `pthread_getattr_np` + `pthread_attr_getstack`, macOS
  `pthread_get_stackaddr_np` + `pthread_get_stacksize_np`, Win32
  `GetCurrentThreadStackLimits` (a hand-declared `kernel32` extern; no `windows-sys`).
  `exhausted()` is one thread-local load and one compare: true once the stack pointer is
  within a 256 KiB red zone of the limit, and it latches a flag. Where the OS can't report
  bounds it falls back to a fixed 1 MiB descent budget measured from the entry stack
  pointer — safe on anything from Node's 4 MiB worker default up. So the guard is exact on
  the 4 MiB worker, the 8 MiB main thread, and any `resourceLimits.stackSizeMb` alike.
- `stack_guard!()` (defined in `lib.rs`) is the first statement of every recursive walker
  function — all **150** self-recursive or on-cycle functions across the 15 walker modules,
  found by script (every cycle in the call graph, not just direct self-calls). It returns
  `Default::default()` (`()`, `false`, `None`, `""`) so an exhausted walk simply stops
  descending; a hook returning `false` sends its caller down the generic child walk, whose
  own guard returns at once.
- `extract_file` runs the whole walk under `stack::run_guarded`: if the flag is set
  afterwards the (truncated) result is discarded and replaced by
  `defer: nesting too deep for the native walker — wasm recovery handles it`.
- `parse-pool.ts`: a comment at `new Worker(scriptPath)` records why there is deliberately
  no `resourceLimits.stackSizeMb` bump.
- No new crates beyond `libc` as a direct unix dependency (already in `Cargo.lock`
  transitively). No wire/ABI change.

Net effect for the reporter's repo: `deep.c` goes to the wasm path, lands as
`function foo` plus a recorded parse warning, and the other 31,607 files index normally.
`CODEGRAPH_KERNEL=0` and the `exclude` workaround are no longer needed.

## Tests

**Rust unit tests** (`cargo test`, 21 passed — 7 new in `stack.rs`): the walkers for
C, C++, Rust, TypeScript and Python are driven on a **1 MiB** thread (a quarter of Node's
worker default) with 30k-deep nesting and must return `defer:` instead of crashing;
shallow files are untouched; the latch resets between runs; the OS bounds are sane on the
main thread and describe a small thread's own stack.

**`__tests__/kernel-deep-nesting.test.ts`** (new, 8 tests — skips without a staged `.node`,
fails under `CODEGRAPH_KERNEL_EXPECT=1` if the kernel is missing, like the other kernel
suites):
- every default-routed language (all 20) survives a 60k-deep expression on the main thread
  — clean result or the wasm fallback's partial result, never a crash;
- the reporter's exact 16,384-brace C file is indexed (partial) on the main thread;
- 200-deep expressions in every language still take the kernel path clean (the guard never
  trips on normal code);
- inside a **default-sized 4 MiB `worker_threads` Worker** through `dist/`: the reporter's
  `deep.c` and a 60k-deep expression in every language come back `deferred` with exit 0,
  and a normal file still extracts natively;
- end-to-end through the built CLI: `codegraph init` on a repo holding `deep.c` + `ok.c`
  exits 0 and records both files, with both functions.

**Existing kernel suites**: all 15 (`kernel-*-parity`, `kernel-scaffold`,
`kernel-retry-materialize`, `kernel-grammar-parity`) pass unchanged, 147 tests — the guard
never fires on the parity fixtures.

**Reporter's probes** (`one.js` from the issue, default 4 MiB worker, this build):
`deep.c` → `deferred`, exitCode=0 (was rc=132/139); `deep100k.c` → `deferred`, exitCode=0.
Main thread: `deep.c` / `deep100k.c` → wasm partial with
`Parse error: Maximum call stack size exceeded`; a 6,000-term binary expression and a
3,000-branch `else if` chain stay on the kernel path with clean results.

**Perf** (same `dist/`, only the `.node` swapped via `CODEGRAPH_KERNEL_PATH`; interleaved
main/new ×3, `codegraph init`, macOS arm64):

| repo | main (median) | guarded (median) | nodes / edges |
|---|---|---|---|
| express (141 files) | 0.60 s (0.58–0.65) | 0.61 s (0.58–0.61) | 1,084 / identical |
| redis (786 C/H files) | 4.44 s (4.39–4.66) | 4.49 s (4.41–4.70) | 19,942 / 76,446 identical |

Within run-to-run noise, as expected for one TLS load + compare per recursion entry.

**Linux (Docker, `node:22-bookworm`, kernel built in-container, `docker run --rm --init`)** —
the reporter's platform and the glibc `pthread_getattr_np` bounds path:

```
=== platform ===
Linux efe3cc86947b 6.12.54-linuxkit #1 SMP Tue Nov  4 21:21:47 UTC 2025 aarch64 GNU/Linux
v22.22.3
-rwxr-xr-x 1 root root 35332288 Aug 22 18:02 codegraph-kernel/prebuilds/linux-arm64/codegraph-kernel.node

=== reporter repro (issue #1581): 16,384-brace deep.c, codegraph init ===
│
└  Done

init exit code: 0
  file: deep.c
  file: deep100k.c
  file: ok.c
  function: add
  function: bar
  function: foo

=== worker probe: kernel raw extract in a default 4 MiB worker ===
deep.c: deferred
deep.c: worker exitCode=0
deep100k.c: deferred
deep100k.c: worker exitCode=0
ok.c: kernel nodes=2
ok.c: worker exitCode=0

=== cargo test stack:: (glibc pthread_getattr_np bounds path) ===
test stack::tests::os_bounds_are_sane_on_this_platform ... ok
test stack::tests::small_stack_reports_its_own_bounds ... ok
test stack::tests::normal_files_are_untouched_by_the_guard ... ok
test stack::tests::deep_braces_c_defer_instead_of_crashing ... ok
test stack::tests::latch_resets_between_runs ... ok
test stack::tests::deep_parens_cpp_rust_ts_python_defer_instead_of_crashing ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 15 filtered out; finished in 0.23s

=== vitest: kernel-deep-nesting + kernel-scaffold ===
✓ __tests__/kernel-scaffold.test.ts (10 tests) 30ms
✓ __tests__/kernel-deep-nesting.test.ts (8 tests) 36989ms
Test Files  2 passed (2)
Tests  18 passed (18)
```

(The pre-fix crash was reproduced on macOS — rc=132 in a default worker, rc=139 on the main thread at 100k depth — not re-run inside this container; the reporter's Linux x86_64 trace is the SIGSEGV form of the same overflow.)

**Windows (Parallels ARM64 VM, MSVC 14.44, `cargo 1.97`, kernel built on the VM,
`GetCurrentThreadStackLimits` path)**:

```
head: cbf8485 fix(kernel): guard the native walkers against stack overflow and defer deep files to wasm (#1581)
=== cargo build --release (win32-arm64) ===
    Finished `release` profile [optimized] target(s) in 2m 04s
staged: 35086848 bytes
=== cargo test (stack guard unit tests) ===
test stack::tests::normal_files_are_untouched_by_the_guard ... ok
test stack::tests::os_bounds_are_sane_on_this_platform ... ok
test stack::tests::small_stack_reports_its_own_bounds ... ok
test stack::tests::deep_braces_c_defer_instead_of_crashing ... ok
test stack::tests::latch_resets_between_runs ... ok
test stack::tests::deep_parens_cpp_rust_ts_python_defer_instead_of_crashing ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 15 filtered out; finished in 0.49s
=== reporter repro: codegraph init on a 16,384-brace deep.c ===
└  Done
init exit code: 0
=== vitest: deep-nesting + scaffold (CODEGRAPH_KERNEL_EXPECT=1) ===
✓ __tests__/kernel-scaffold.test.ts (10 tests) 55ms
✓ __tests__/kernel-deep-nesting.test.ts (8 tests) 67239ms
   ✓ every default-routed language survives a 60k-deep expression on the main thread 52801ms
   ✓ inside a default-sized (4 MiB) parse worker, through dist/ > defers a 60k-deep expression in every default-routed language 13050ms
   ✓ end-to-end: codegraph init on a repo holding the deep file > exits 0 and records deep.c alongside the normal files 936ms
Test Files  2 passed (2)
Tests  18 passed (18)
```

(The end-to-end test is what reads the Windows index back through `node:sqlite` — `files` = `deep.c`, `ok.c`; functions `add`, `foo`.)

Full `npm test` on this branch (macOS arm64, kernel staged): **190 files passed, 3,185 tests passed, 10 skipped, 0 failed.**

Clippy note: `cargo clippy` on the current toolchain (1.92) reports 18 pre-existing lints
(`manual_contains`, `unnecessary_to_owned`, …) in walker code this PR only touched by
inserting guard lines; none are in `stack.rs`/`lib.rs`. Left alone to keep the diff
reviewable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
2026-08-26 10:38:29 -05:00

602 lines
24 KiB
Rust

//! R extraction — a faithful Rust port of the R paths of `TreeSitterExtractor`
//! (src/extraction/tree-sitter.ts) plus languages/r.ts.
//!
//! Same porting contract as the other walkers: behavior parity, bug-for-bug.
//! The authoritative quirk list is docs/design/r-kernel-port-checklist.md.
//! R is the lightest shared-surface port and the heaviest hook port: r.ts has
//! every type list empty except `callTypes: ['call']`, so the whole shared
//! extraction machine (extractFunction/Class/Import/Variable, docstrings,
//! decorators, inheritance, visitFunctionBody, value-refs, fn-refs,
//! static-member reads, type annotations) never runs — the walker is a file
//! node + the visitNode hook + the generic extractCall + pre-order recursion.
//! Load-bearing oddities preserved on purpose: `calls "return"` on every
//! `return(x)` (return/next/break are named nodes in v1.2.0), silent
//! consumption of imports with dynamic/missing/empty first args (subtree
//! never visited) vs class/generic calls FALLING THROUGH on the same shapes
//! (generic call + file-scope body leak), `library(help = pkg)` importing the
//! named arg's value, class-idiom variable suppression checking only the
//! callee NAME, chained `a <- b <- 5` minting only `a`, `env$fn <- function`
//! minting nothing while its body calls leak to file scope, raw-text callees
//! verbatim (`pkg::fn`, `obj$meth`, `"strfn"` quotes kept), and duplicate
//! same-(kind,name,line) ids emitted twice. Positions in UTF-16 code units.
//! Files with parse errors defer to wasm (~0% incidence on real repos).
use crate::buffers::{
build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
RefRow, Tables, FLAG_IS_EXPORTED, NONE, NONE_STR,
};
use crate::ids;
use crate::textutil as util;
use regex::Regex;
use std::sync::OnceLock;
use tree_sitter::{Node, Parser};
/// CONSTANT_NAME (r.ts:43) — ALL_CAPS or DOTTED.CAPS top-level assignment.
fn constant_name_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[A-Z][A-Z0-9._]*$").unwrap())
}
/// ASSIGN_LEFT / ASSIGN_RIGHT (r.ts:37-38).
fn is_assign_left(op: &str) -> bool {
matches!(op, "<-" | "<<-" | "=")
}
fn is_assign_right(op: &str) -> bool {
matches!(op, "->" | "->>")
}
/// IMPORT_FNS (r.ts:39) — `source` is checked alongside (r.ts:189).
fn is_import_fn(name: &str) -> bool {
matches!(name, "library" | "require" | "requireNamespace" | "loadNamespace")
}
/// CLASS_FNS (r.ts:40).
fn is_class_fn(name: &str) -> bool {
matches!(name, "setClass" | "setRefClass" | "R6Class" | "ggproto")
}
/// GENERIC_FNS (r.ts:41).
fn is_generic_fn(name: &str) -> bool {
matches!(name, "setGeneric" | "setMethod")
}
struct Scope {
row: u32,
kind: &'static str,
name: String,
}
pub struct Walker<'t> {
src: &'t str,
file_path: &'t str,
line_starts: Vec<usize>,
arena: Arena,
tables: Tables,
stack: Vec<Scope>,
}
pub fn extract(file_path: &str, source: &str) -> Result<EmitOut, String> {
let grammar = crate::langs::grammar_for("r").ok_or("no r grammar")?;
let t0 = std::time::Instant::now();
let mut parser = Parser::new();
parser
.set_language(&grammar)
.map_err(|e| format!("set_language(r) failed: {e}"))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| "parser returned null tree".to_string())?;
if tree.root_node().has_error() {
return Err("defer: parse tree contains errors — wasm recovery is canonical".to_string());
}
let mut w = Walker {
src: source,
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
tables: Tables::default(),
stack: Vec::new(),
};
// File node (tree-sitter.ts:508-521) — the only node with isExported set.
let line_count = source.bytes().filter(|b| *b == b'\n').count() as u32 + 1;
let base_name = file_path.rsplit(['/', '\\']).next().unwrap_or(file_path);
let mut flags = BoolFlags::default();
flags.set(FLAG_IS_EXPORTED, false);
let file_id = w.arena.put(&ids::file_node_id(file_path));
let name_ref = w.arena.put(base_name);
let qn_ref = w.arena.put(file_path);
w.tables.push_node(&NodeRow {
kind: node_kind_index("file").unwrap(),
visibility: 0,
flags,
start_line: 1,
end_line: line_count,
start_column: 0,
end_column: 0,
name: name_ref,
qualified_name: qn_ref,
id: file_id,
docstring: NONE_STR,
signature: NONE_STR,
decorators: NONE_STR,
type_parameters: NONE_STR,
return_type: NONE_STR,
extra_json: NONE_STR,
});
w.stack.push(Scope { row: 0, kind: "file", name: base_name.to_string() });
// extractFilePackage returns null (no packageTypes); both end-of-file
// flushes are no-ops for R (no fnRefSpec, VALUE_REF_LANGS gate).
w.visit(tree.root_node());
w.stack.pop();
let duration_ms = t0.elapsed().as_secs_f64() * 1000.0;
let meta = build_meta(&w.tables, w.arena.len(), NONE_STR, duration_ms);
Ok(EmitOut {
meta,
nodes: w.tables.nodes,
edges: w.tables.edges,
refs: w.tables.refs,
arena: w.arena.into_vec(),
})
}
impl<'t> Walker<'t> {
fn text(&self, node: Node) -> &'t str {
&self.src[node.byte_range()]
}
fn line_of(&self, node: Node) -> u32 {
node.start_position().row as u32 + 1
}
fn col_of(&self, node: Node) -> u32 {
util::col16(self.src, &self.line_starts, node.start_position().row, node.start_byte())
}
fn end_col_of(&self, node: Node) -> u32 {
util::col16(self.src, &self.line_starts, node.end_position().row, node.end_byte())
}
fn top_row(&self) -> u32 {
self.stack.last().map(|s| s.row).unwrap_or(0)
}
fn push_ref_at(&mut self, from_row: u32, name: &str, kind: &str, node: Node) {
let name_ref = self.arena.put(name);
self.tables.push_ref(&RefRow {
from_idx: from_row,
kind: edge_kind_index(kind).unwrap(),
line: self.line_of(node),
column: self.col_of(node),
reference_name: name_ref,
candidates: NONE_STR,
from_id_str: NONE_STR,
});
}
// --- createNode (tree-sitter.ts:1308) ---------------------------------
// R extras carry only `signature` (or nothing): no docstring, visibility,
// isStatic/isAsync/isExported, returnType, decorators — ever. The endLine
// body-extension is dead (no resolveBody hook).
fn create_node(&mut self, kind: &'static str, name: &str, node: Node<'t>, signature: Option<&str>) -> Option<u32> {
if name.is_empty() {
return None;
}
let start_line = self.line_of(node);
let id = ids::node_id(self.file_path, kind, name, start_line);
// buildQualifiedName (tree-sitter.ts:1447-1460): non-file stack names
// joined `::` (namespacePrefix is always empty for R).
let qualified = {
let mut parts: Vec<&str> = Vec::new();
for s in &self.stack {
if s.kind != "file" {
parts.push(&s.name);
}
}
let mut qn = parts.join("::");
if !qn.is_empty() {
qn.push_str("::");
}
qn.push_str(name);
qn
};
let name_ref = self.arena.put(name);
let qn_ref = self.arena.put(&qualified);
let id_ref = self.arena.put(&id);
let sig_ref = match signature {
Some(s) => self.arena.put(s),
None => NONE_STR,
};
let row = self.tables.push_node(&NodeRow {
kind: node_kind_index(kind).unwrap(),
visibility: 0,
flags: BoolFlags::default(),
start_line,
end_line: node.end_position().row as u32 + 1,
start_column: self.col_of(node),
end_column: self.end_col_of(node),
name: name_ref,
qualified_name: qn_ref,
id: id_ref,
docstring: NONE_STR,
signature: sig_ref,
decorators: NONE_STR,
type_parameters: NONE_STR,
return_type: NONE_STR,
extra_json: NONE_STR,
});
// Containment edge from the stack top (always non-empty — file node).
let parent_row = self.top_row();
self.tables.push_edge(&EdgeRow {
source_idx: parent_row,
target_idx: row,
kind: edge_kind_index("contains").unwrap(),
provenance: 0,
line: NONE,
column: NONE,
metadata_json: NONE_STR,
source_id_str: NONE_STR,
target_id_str: NONE_STR,
});
Some(row)
}
// --- r.ts helper transcriptions ---------------------------------------
/// calleeName (r.ts:46-55): bare identifier text, or a
/// namespace_operator's `rhs` field text (`pkg::fn` → `fn`) — so
/// `methods::setClass` etc. trigger the special branches. Everything else
/// (extract_operator, subset2, string, call, return…) → None.
fn callee_name(&self, call: Node<'t>) -> Option<&'t str> {
let f = call.child_by_field_name("function")?;
match f.kind() {
"identifier" => Some(self.text(f)),
"namespace_operator" => f.child_by_field_name("rhs").map(|rhs| self.text(rhs)),
_ => None,
}
}
/// firstArgValue (r.ts:58-67): the FIRST `argument`-typed named child of
/// the `arguments` field → its `value` field (named arguments are NOT
/// skipped — `library(help = docpkg)` imports "docpkg", preserved bug).
fn first_arg_value(&self, call: Node<'t>) -> Option<Node<'t>> {
let args = call.child_by_field_name("arguments")?;
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
if arg.kind() != "argument" {
continue;
}
return arg.child_by_field_name("value");
}
None
}
/// literalOrIdentifier (r.ts:70-81): identifier text (backticks kept), a
/// string's first string_content text, `Some("")` for an empty string
/// literal (falsy downstream, like null), None otherwise.
fn literal_or_identifier(&self, node: Option<Node<'t>>) -> Option<&'t str> {
let node = node?;
match node.kind() {
"identifier" => Some(self.text(node)),
"string" => {
let mut cursor = node.walk();
for c in node.named_children(&mut cursor) {
if c.kind() == "string_content" {
return Some(self.text(c));
}
}
Some("")
}
_ => None,
}
}
// --- the visitNode walk (tree-sitter.ts:936-953, 1248, 1295-1301) -----
// Hook first (consumed → return; scanFnRefSubtree is a no-op for R), then
// the ladder — only callTypes can match, with children still recursed —
// else plain recursion over namedChildren in order.
fn visit(&mut self, node: Node<'t>) {
stack_guard!();
if self.hook(node) {
return;
}
if node.kind() == "call" {
self.extract_call(node);
}
let mut cursor = node.walk();
let children: Vec<Node<'t>> = node.named_children(&mut cursor).collect();
for child in children {
self.visit(child);
}
}
/// The visitNode hook (r.ts:180-309). Returns true when consumed.
fn hook(&mut self, node: Node<'t>) -> bool {
stack_guard!();
match node.kind() {
"call" => self.hook_call(node),
"binary_operator" => self.hook_binary_operator(node),
_ => false,
}
}
fn hook_call(&mut self, node: Node<'t>) -> bool {
stack_guard!();
let fname = match self.callee_name(node) {
Some(f) => f,
None => return false,
};
// library(dplyr) / require(stats) / requireNamespace("jsonlite") /
// source("helpers.R") (r.ts:189-208). A dynamic/missing/empty first
// arg is consumed SILENTLY — nothing recorded, subtree never visited.
if is_import_fn(fname) || fname == "source" {
let module = match self.literal_or_identifier(self.first_arg_value(node)) {
Some(m) if !m.is_empty() => m,
_ => return true,
};
// signature: whole call text .trim().slice(0, 100) — UTF-16 slice.
// (A call node's text starts at the callee and ends at `)`, so
// trim() never has anything to strip on reachable inputs.)
let (sig, _) = util::slice_utf16(self.text(node).trim(), 100);
let module = module.to_string();
let imp = self.create_node("import", &module, node, Some(&sig));
if imp.is_some() && !self.stack.is_empty() {
let parent_row = self.top_row();
self.push_ref_at(parent_row, &module, "imports", node);
}
return true;
}
// setClass("Patient", …) / setRefClass / R6Class / ggproto
// (r.ts:211-221). A falsy name FALLS THROUGH to the generic call —
// `ggproto(NULL, Geom, …)` emits `calls ggproto` + file-scope body
// leak (asymmetric with imports, preserved).
if is_class_fn(fname) {
let name = match self.literal_or_identifier(self.first_arg_value(node)) {
Some(n) if !n.is_empty() => n.to_string(),
_ => return false,
};
if let Some(cls_row) = self.create_node("class", &name, node, None) {
self.stack.push(Scope { row: cls_row, kind: "class", name });
self.extract_class_members(node, cls_row);
self.stack.pop();
}
return true;
}
// setGeneric("describe", …) / setMethod("describe", "Patient", fn)
// (r.ts:224-249): function node named by the first arg; signature and
// body from the FIRST argument (any position) whose value is a
// function_definition.
if is_generic_fn(fname) {
let name = match self.literal_or_identifier(self.first_arg_value(node)) {
Some(n) if !n.is_empty() => n.to_string(),
_ => return false,
};
let mut impl_node: Option<Node<'t>> = None;
if let Some(args) = node.child_by_field_name("arguments") {
let mut cursor = args.walk();
for a in args.named_children(&mut cursor) {
if a.kind() != "argument" {
continue;
}
if let Some(v) = a.child_by_field_name("value") {
if v.kind() == "function_definition" {
impl_node = Some(v);
break;
}
}
}
}
let params_text = impl_node
.and_then(|i| i.child_by_field_name("parameters"))
.map(|p| self.text(p));
let fn_row = self.create_node("function", &name, node, params_text);
let body = impl_node.and_then(|i| i.child_by_field_name("body"));
if let (Some(row), Some(body)) = (fn_row, body) {
self.stack.push(Scope { row, kind: "function", name });
self.visit(body);
self.stack.pop();
}
return true;
}
false // ordinary call — generic extraction records the edge
}
fn hook_binary_operator(&mut self, node: Node<'t>) -> bool {
stack_guard!();
let op = match node.child_by_field_name("operator") {
Some(o) => self.text(o),
None => return false,
};
let lhs = node.child_by_field_name("lhs");
let rhs = node.child_by_field_name("rhs");
// name <- function(…) — ANY scope (r.ts:267-279). Body walked through
// the hook-aware visit (visitFunctionBody never runs for R).
if is_assign_left(op) {
if let (Some(lhs), Some(rhs)) = (lhs, rhs) {
if lhs.kind() == "identifier" && rhs.kind() == "function_definition" {
let params_text = rhs.child_by_field_name("parameters").map(|p| self.text(p));
let name = self.text(lhs).to_string();
let fn_row = self.create_node("function", &name, node, params_text);
let body = rhs.child_by_field_name("body");
if let (Some(row), Some(body)) = (fn_row, body) {
self.stack.push(Scope { row, kind: "function", name });
self.visit(body);
self.stack.pop();
}
return true;
}
}
}
let top_level = node.parent().map(|p| p.kind() == "program").unwrap_or(false);
// Top-level value assignments → variable/constant (r.ts:284-296);
// the class-definition idiom suppresses the twin variable node but the
// rhs is ALWAYS visited.
if top_level && is_assign_left(op) {
if let (Some(lhs), Some(rhs)) = (lhs, rhs) {
if lhs.kind() == "identifier" {
let rhs_callee = if rhs.kind() == "call" { self.callee_name(rhs) } else { None };
let suppressed = rhs_callee
.map(|c| is_class_fn(c) || is_generic_fn(c))
.unwrap_or(false);
if !suppressed {
let name = self.text(lhs);
let kind = if constant_name_re().is_match(name) { "constant" } else { "variable" };
self.create_node(kind, name, node, None);
}
self.visit(rhs);
return true;
}
}
}
// value -> name / value ->> name (r.ts:298-303).
if top_level && is_assign_right(op) {
if let (Some(lhs), Some(rhs)) = (lhs, rhs) {
if rhs.kind() == "identifier" {
let name = self.text(rhs);
let kind = if constant_name_re().is_match(name) { "constant" } else { "variable" };
self.create_node(kind, name, node, None);
self.visit(lhs);
return true;
}
}
}
false
}
/// extractClassMembers (r.ts:110-163): arguments in source order with a
/// positional counter — ggproto's 2nd positional identifier and
/// `inherit`/`contains` named args become `extends` refs (from the CLASS
/// row, positioned at the VALUE node); named function_definition args and
/// `list(…)` entries become methods. Non-method argument subtrees are
/// NEVER visited (`representation(…)`, `signature(…)` invisible).
fn extract_class_members(&mut self, class_call: Node<'t>, class_row: u32) {
stack_guard!();
let args = match class_call.child_by_field_name("arguments") {
Some(a) => a,
None => return,
};
let mut positional = 0u32;
let mut cursor = args.walk();
let arg_nodes: Vec<Node<'t>> = args.named_children(&mut cursor).collect();
for arg in arg_nodes {
if arg.kind() != "argument" {
continue;
}
let arg_name = arg.child_by_field_name("name");
let value = arg.child_by_field_name("value");
let arg_name = match arg_name {
None => {
positional += 1;
if positional == 2 {
if let Some(v) = value {
if v.kind() == "identifier" {
let parent = self.text(v).to_string();
self.push_ref_at(class_row, &parent, "extends", v);
}
}
}
continue;
}
Some(n) => n,
};
let arg_name_text = self.text(arg_name);
// R6 `inherit = Parent` / S4 `contains = "Parent"` — a falsy
// resolution (`inherit = pkg::Parent`, empty string) emits nothing.
if (arg_name_text == "inherit" || arg_name_text == "contains") && value.is_some() {
if let Some(parent) = self.literal_or_identifier(value) {
if !parent.is_empty() {
let parent = parent.to_string();
self.push_ref_at(class_row, &parent, "extends", value.unwrap());
}
}
continue;
}
// Direct named function argument (ggproto methods).
if let Some(v) = value {
if v.kind() == "function_definition" {
self.emit_method_arg(arg);
continue;
}
// list(…) of named function arguments (R5/R6 methods).
if v.kind() == "call" && self.callee_name(v) == Some("list") {
if let Some(list_args) = v.child_by_field_name("arguments") {
let mut lc = list_args.walk();
let entries: Vec<Node<'t>> = list_args.named_children(&mut lc).collect();
for entry in entries {
if entry.kind() == "argument" {
self.emit_method_arg(entry);
}
}
}
}
}
}
}
/// emitMethodArg (r.ts:84-98): `name = function(…)` argument entry → a
/// `method` node positioned at the ARGUMENT node, signature from the raw
/// parameters text, body walked hook-aware inside the method scope.
fn emit_method_arg(&mut self, entry: Node<'t>) {
stack_guard!();
let entry_name = match entry.child_by_field_name("name") {
Some(n) => n,
None => return,
};
let entry_value = match entry.child_by_field_name("value") {
Some(v) if v.kind() == "function_definition" => v,
_ => return,
};
let params_text = entry_value.child_by_field_name("parameters").map(|p| self.text(p));
let name = self.text(entry_name).to_string();
let method_row = self.create_node("method", &name, entry, params_text);
let body = entry_value.child_by_field_name("body");
if let (Some(row), Some(body)) = (method_row, body) {
self.stack.push(Scope { row, kind: "method", name });
self.visit(body);
self.stack.pop();
}
}
// --- extractCall (tree-sitter.ts:3684, 4313, 4518-4532, 4572-4580) ----
// R call nodes reach the generic tail: callee = RAW `function`-field text
// verbatim (member branch unreachable, cpp recoveries language-gated),
// then the parenthesized-conversion regex. No skipChildren — inner calls
// are visited by the ladder's recursion afterward.
fn extract_call(&mut self, node: Node<'t>) {
if self.stack.is_empty() {
return;
}
let caller_row = self.top_row();
let func = node
.child_by_field_name("function")
.or_else(|| node.named_child(0));
let func = match func {
Some(f) => f,
None => return,
};
let mut callee: &str = self.text(func);
if let Some(caps) = util::paren_conversion().captures(callee) {
if let Some(inner) = caps.get(1) {
callee = &callee[inner.range()];
}
}
if callee.is_empty() {
return;
}
let callee = callee.to_string();
self.push_ref_at(caller_row, &callee, "calls", node);
}
}