feat(extraction): add Erlang language support (.erl/.hrl) (#635, #648) (#1165)

Vendored WhatsApp/tree-sitter-erlang 0.19 (the ELP grammar, ABI 14) with an
Erlang-shaped extractor: multi-clause/multi-arity functions merged into one
symbol, -spec signatures, records with fields, -type/-opaque aliases, -define
macros, -include/-include_lib file edges, and -export-driven visibility.

Modules wrap in a namespace so remote mod:fn(...) calls resolve through the
existing qualified-name matcher as mod::fn with zero resolver changes.
-behaviour declarations link to the behaviour module — gated to namespace
targets only (bare-name fallthrough linked -behaviour(supervisor) to an
unrelated macro constant on emqx). OTP indirection with static targets is
followed: spawn/apply/proc_lib/timer/rpc MFA-argument callees, and
gen_server:call/cast(?MODULE | ?SERVER) to the module's own
handle_call/handle_cast. Var-module dispatch and message sends stay
deliberately unlinked. codegraph_explore also normalizes Erlang-native query
spelling (mod:fn/3, init/2) so named symbols resolve as typed.

Benchmarked on cowboy (189 files), ejabberd (414), emqx (2,447): extraction
PASS on all three; with-codegraph arms reached 2/2/0 file Reads vs 10/5+/19
without, fastest on the largest repo.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-03 14:32:20 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 63e1b5a23a
commit 6511722250
15 changed files with 1050 additions and 8 deletions
+7 -1
View File
@@ -44,6 +44,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
cfquery: 'tree-sitter-cfquery.wasm',
cobol: 'tree-sitter-cobol.wasm',
vbnet: 'tree-sitter-vbnet.wasm',
erlang: 'tree-sitter-erlang.wasm',
};
/**
@@ -135,6 +136,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
// VB.NET: vendored grammar (patched govindbanura/tree-sitter-vbnet) — classes,
// modules, interfaces, structures, properties, events, Handles clauses, LINQ.
'.vb': 'vbnet',
// Erlang: modules (.erl) and header files (.hrl). Vendored WhatsApp/
// tree-sitter-erlang grammar (the ELP grammar).
'.erl': 'erlang',
'.hrl': 'erlang',
// Spring config: `application.properties` / `application-*.properties`. Same
// shape as the `.yml` variants — the YAML/properties extractor emits one node
// per leaf key, and the Spring resolver links `@Value("${k}")` references.
@@ -253,7 +258,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// `class Foo(...)` as an ERROR that swallows the whole class (#237); we
// vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
// primary constructors natively.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet')
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
@@ -473,6 +478,7 @@ export function getLanguageDisplayName(language: Language): string {
cfquery: 'CFQuery (SQL)',
cobol: 'COBOL',
vbnet: 'Visual Basic .NET',
erlang: 'Erlang',
unknown: 'Unknown',
};
return names[language] || language;
+276
View File
@@ -0,0 +1,276 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField, getPrecedingDocstring } from '../tree-sitter-helpers';
import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types';
// Node names follow the vendored WhatsApp/tree-sitter-erlang grammar (0.19,
// ABI 14) — the grammar behind the Erlang Language Platform (ELP).
//
// Erlang is form-based, and three of its shapes don't fit the generic
// extractor, so every symbol-bearing top-level form is dispatched through the
// visitNode hook below instead:
// - a function's name lives on its CLAUSE, not the fun_decl, and the grammar
// emits one fun_decl PER CLAUSE — consecutive same-name fun_decl forms are
// merged into a single function node here;
// - type-position expressions (-spec/-type/-callback bodies, record field
// types) parse as `call` nodes, so descending into them would mint bogus
// call refs to type names (`pid()`, `term()`); the hook consumes those
// subtrees;
// - record_decl carries its fields as direct children (no body field), which
// the generic extractStruct would skip as a forward declaration.
// Calls (local `f(X)`, remote `mod:f(X)`, `fun f/1` references, and record
// usages) are handled by the erlang branch in extractCall — remote calls are
// emitted as `mod::f`, which matches the qualifiedName the module namespace
// produces (see packageTypes below), so cross-module resolution rides the
// standard qualified-name matcher.
/** Text of an atom with quoted-atom quotes stripped (`'EXIT'` → `EXIT`). */
function atomText(node: SyntaxNode, source: string): string {
return getNodeText(node, source).replace(/^'([\s\S]*)'$/, '$1');
}
function collapseWs(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
// --- Per-file memos. Extraction is file-sequential within a worker, so a
// single-entry memo keyed by filePath is safe (and resets naturally). ---
/** Exported function names for the current file ('all' for -compile(export_all)). */
let exportsFile = '';
let exportsMemo: Set<string> | 'all' = new Set();
/**
* Clause-merge state: the previous fun_decl's name and node id. A fun_decl
* whose clause repeats that name is a continuation clause (or a same-name
* different-arity definition — deliberately grouped under one node, the way
* overloads are elsewhere) and attaches to the existing node instead of
* creating a duplicate.
*/
let lastFnFile = '';
let lastFnName = '';
let lastFnId = '';
function moduleExports(node: SyntaxNode, source: string, filePath: string): Set<string> | 'all' {
if (filePath === exportsFile) return exportsMemo;
let root: SyntaxNode = node;
while (root.parent) root = root.parent;
let result: Set<string> | 'all' = new Set<string>();
for (let i = 0; i < root.namedChildCount; i++) {
const form = root.namedChild(i);
if (!form) continue;
if (
form.type === 'compile_options_attribute' &&
getNodeText(form, source).includes('export_all')
) {
result = 'all';
break;
}
if (form.type === 'export_attribute') {
for (const fa of form.namedChildren) {
if (fa.type !== 'fa') continue;
const fun = getChildByField(fa, 'fun');
if (fun) result.add(atomText(fun, source));
}
}
}
exportsFile = filePath;
exportsMemo = result;
return result;
}
/** The -spec directly above a function (comments may sit between), if it names it. */
function precedingSpec(node: SyntaxNode, name: string, source: string): SyntaxNode | null {
let prev = node.previousNamedSibling;
while (prev && prev.type === 'comment') prev = prev.previousNamedSibling;
if (prev?.type === 'spec') {
const specFun = getChildByField(prev, 'fun');
if (specFun && atomText(specFun, source) === name) return prev;
}
return null;
}
/** `name(Args) when Guard` — the clause text up to the `->`. */
function clauseHeader(clause: SyntaxNode, source: string): string | undefined {
const body = getChildByField(clause, 'body');
const end = body ? body.startIndex : clause.endIndex;
return collapseWs(source.substring(clause.startIndex, end)) || undefined;
}
function handleFunDecl(node: SyntaxNode, ctx: ExtractorContext): boolean {
const clauses = node.namedChildren.filter((c) => c.type === 'function_clause');
const first = clauses[0];
if (!first) return true; // macro-templated clause (`?M(...) -> ...`) — no static name
const nameNode = getChildByField(first, 'name');
if (!nameNode) return true;
const name = atomText(nameNode, ctx.source);
if (!name) return true;
// Continuation clause: extend the existing node's span and attribute this
// clause's calls to it.
if (ctx.filePath === lastFnFile && name === lastFnName && lastFnId) {
for (let i = ctx.nodes.length - 1; i >= 0; i--) {
const n = ctx.nodes[i];
if (n && n.id === lastFnId) {
if (node.endPosition.row + 1 > n.endLine) n.endLine = node.endPosition.row + 1;
break;
}
}
ctx.pushScope(lastFnId);
for (const clause of clauses) ctx.visitFunctionBody(clause, lastFnId);
ctx.popScope();
return true;
}
const spec = precedingSpec(node, name, ctx.source);
const exports = moduleExports(node, ctx.source, ctx.filePath);
const fn = ctx.createNode('function', name, node, {
docstring: getPrecedingDocstring(spec ?? node, ctx.source),
signature: spec
? collapseWs(getNodeText(spec, ctx.source)).slice(0, 300)
: clauseHeader(first, ctx.source),
isExported: exports === 'all' || exports.has(name),
});
if (!fn) return true;
ctx.pushScope(fn.id);
// The whole clause is walked (not just the body) so record patterns in the
// arguments and guard calls contribute references too.
for (const clause of clauses) ctx.visitFunctionBody(clause, fn.id);
ctx.popScope();
lastFnFile = ctx.filePath;
lastFnName = name;
lastFnId = fn.id;
return true;
}
function handleRecordDecl(node: SyntaxNode, ctx: ExtractorContext): boolean {
const nameNode = getChildByField(node, 'name');
if (!nameNode) return true;
const rec = ctx.createNode('struct', atomText(nameNode, ctx.source), node, {
docstring: getPrecedingDocstring(node, ctx.source),
signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 300),
});
if (rec) {
ctx.pushScope(rec.id);
for (const field of node.namedChildren) {
if (field.type !== 'record_field') continue;
const fieldName = getChildByField(field, 'name');
if (fieldName) ctx.createNode('field', atomText(fieldName, ctx.source), field);
}
ctx.popScope();
}
return true; // field types/defaults are type-position exprs — don't descend
}
function handleTypeAlias(node: SyntaxNode, ctx: ExtractorContext): boolean {
const typeName = getChildByField(node, 'name'); // type_name wrapper
const nameNode = typeName ? getChildByField(typeName, 'name') : null;
if (nameNode) {
ctx.createNode('type_alias', atomText(nameNode, ctx.source), node, {
signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
});
}
return true;
}
function handlePpDefine(node: SyntaxNode, ctx: ExtractorContext): boolean {
const lhs = getChildByField(node, 'lhs');
const nameNode = lhs ? getChildByField(lhs, 'name') : null;
if (nameNode) {
ctx.createNode('constant', getNodeText(nameNode, ctx.source), node, {
signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
});
}
return true; // the replacement's calls only exist at expansion sites
}
function handleBehaviour(node: SyntaxNode, ctx: ExtractorContext): boolean {
const nameNode = getChildByField(node, 'name');
const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
if (nameNode && parentId) {
// `-behaviour(x)` implements x's callback contract. Resolves when the
// behaviour module is in the repo; OTP behaviours (gen_server, …) simply
// stay unresolved.
ctx.addUnresolvedReference({
fromNodeId: parentId,
referenceName: atomText(nameNode, ctx.source),
referenceKind: 'implements',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
return true;
}
export const erlangExtractor: LanguageExtractor = {
functionTypes: ['fun_decl'], // dispatched via visitNode (name lives on the clause)
classTypes: [],
methodTypes: [],
interfaceTypes: [],
structTypes: ['record_decl'], // dispatched via visitNode (fields are direct children)
enumTypes: [],
typeAliasTypes: ['type_alias', 'opaque'], // dispatched via visitNode
importTypes: ['import_attribute', 'pp_include', 'pp_include_lib'],
callTypes: [
'call',
'internal_fun', // fun f/1
'external_fun', // fun mod:f/1
'record_expr', // #rec{...} construction
'record_update_expr', // X#rec{...}
'record_index_expr', // #rec.field
'record_field_expr', // X#rec.field
],
variableTypes: [],
nameField: 'name',
bodyField: 'body',
paramsField: 'args',
// `-module(m)` wraps the file's declarations in a namespace so every
// function's qualifiedName is `m::f` — which is exactly the reference shape
// the extractCall erlang branch emits for remote calls, so `mod:f(...)`
// resolves through matchByQualifiedName with no resolver changes.
packageTypes: ['module_attribute'],
extractPackage: (node, source) => {
const name = getChildByField(node, 'name');
return name ? atomText(name, source) : null;
},
extractImport: (node, source) => {
if (node.type === 'import_attribute') {
const mod = getChildByField(node, 'module');
if (!mod) return null;
return {
moduleName: atomText(mod, source),
signature: collapseWs(getNodeText(node, source)).slice(0, 200),
};
}
// pp_include / pp_include_lib — a C-include-style file dependency on a .hrl.
const file = getChildByField(node, 'file');
if (!file) return null;
const headerPath = getNodeText(file, source).replace(/^"/, '').replace(/"$/, '');
if (!headerPath) return null;
return { moduleName: headerPath, signature: getNodeText(node, source).trim() };
},
visitNode: (node, ctx) => {
switch (node.type) {
case 'fun_decl':
return handleFunDecl(node, ctx);
case 'record_decl':
return handleRecordDecl(node, ctx);
case 'type_alias':
case 'opaque':
return handleTypeAlias(node, ctx);
case 'pp_define':
return handlePpDefine(node, ctx);
case 'behaviour_attribute':
return handleBehaviour(node, ctx);
// -spec / -callback: their type expressions parse as `call` nodes;
// consume the subtree so the walker doesn't mint bogus call refs.
case 'spec':
case 'callback':
return true;
default:
return false;
}
},
};
+2
View File
@@ -31,6 +31,7 @@ import { cfscriptExtractor } from './cfscript';
import { cfqueryExtractor } from './cfquery';
import { cobolExtractor } from './cobol';
import { vbnetExtractor } from './vbnet';
import { erlangExtractor } from './erlang';
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
@@ -59,4 +60,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
cfquery: cfqueryExtractor,
cobol: cobolExtractor,
vbnet: vbnetExtractor,
erlang: erlangExtractor,
};
+1
View File
@@ -84,6 +84,7 @@ function cleanCommentMarkers(comment: string): string {
.replace(/^\/\/[/!]?\s?/gm, '') // // , and Rust/Swift doc lines /// //!
.replace(/^--\s?/gm, '') // Lua/Luau line comments
.replace(/^#\s?/gm, '') // Python/Ruby/shell line comments
.replace(/^%+\s?/gm, '') // Erlang line comments (% / %% / %%%)
.replace(/^\s*\*\s?/gm, '') // block-comment continuation (* foo)
.trim();
}
+209
View File
@@ -60,6 +60,22 @@ const VUE_STORE_FACTORY_CALLEES = new Set(['defineStore', 'createStore']);
* `const actions = {…}` as a store collection see looksLikeVueStoreFile). */
const VUE_STORE_FILE_SIGNAL = /\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b/g;
/**
* Erlang calls that take their real callee as (Module, Function, Args)
* ARGUMENTS the spawn/apply family. Keys are the callee as the call site
* spells it: bare for auto-imported BIFs, `module:function` for remote calls.
* Used by the erlang branch of extractCall to lift a static MFA pair into a
* call edge (the spawned/applied function is otherwise invisible to the graph).
*/
const ERLANG_MFA_CALLS = new Set([
'spawn', 'spawn_link', 'spawn_monitor', 'spawn_opt', 'apply',
'erlang:spawn', 'erlang:spawn_link', 'erlang:spawn_monitor', 'erlang:spawn_opt', 'erlang:apply',
'proc_lib:spawn', 'proc_lib:spawn_link', 'proc_lib:spawn_opt', 'proc_lib:start', 'proc_lib:start_link',
'timer:apply_after', 'timer:apply_interval',
'rpc:call', 'rpc:cast', 'rpc:async_call',
'erpc:call', 'erpc:cast',
]);
/**
* Extract the name from a node based on language
*/
@@ -3492,6 +3508,50 @@ export class TreeSitterExtractor {
/**
* Extract a function call
*/
/**
* Whether an Erlang gen_server target expression statically refers to the
* module it appears in: `?MODULE`, a macro the file defines as `?MODULE`
* (`-define(SERVER, ?MODULE)` the standard idiom), or the module's own
* name as an atom. The self-macro set is memoized per file (single entry
* extraction is file-sequential).
*/
private erlangSelfMacroFile = '';
private erlangSelfMacros = new Set<string>();
private isErlangSelfReference(target: SyntaxNode): boolean {
const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, '');
if (target.type === 'atom') {
return getNodeText(target, this.source) === ownModule;
}
if (target.type !== 'macro_call_expr') return false;
const nameNode = getChildByField(target, 'name');
if (!nameNode) return false;
const macroName = getNodeText(nameNode, this.source);
if (macroName === 'MODULE') return true;
if (this.erlangSelfMacroFile !== this.filePath) {
this.erlangSelfMacroFile = this.filePath;
this.erlangSelfMacros = new Set<string>();
let root: SyntaxNode = target;
while (root.parent) root = root.parent;
for (let i = 0; i < root.namedChildCount; i++) {
const form = root.namedChild(i);
if (form?.type !== 'pp_define') continue;
const lhs = getChildByField(form, 'lhs');
const defName = lhs ? getChildByField(lhs, 'name') : null;
const replacement = getChildByField(form, 'replacement');
if (
defName &&
replacement?.type === 'macro_call_expr' &&
getChildByField(replacement, 'name') &&
getNodeText(getChildByField(replacement, 'name')!, this.source) === 'MODULE'
) {
this.erlangSelfMacros.add(getNodeText(defName, this.source));
}
}
}
return this.erlangSelfMacros.has(macroName);
}
private extractCall(node: SyntaxNode): void {
if (this.nodeStack.length === 0) return;
@@ -3543,6 +3603,155 @@ export class TreeSitterExtractor {
return;
}
// Erlang: a local call is `call(expr: atom, args)`; a remote call nests it
// under `remote(module: remote_module, fun: call)` — the module qualifier
// lives on the PARENT. Remote calls are emitted as `mod::fn`, which is
// byte-identical to the qualifiedName the module namespace gives every
// function (see packageTypes in languages/erlang.ts), so they resolve via
// matchByQualifiedName. A var/macro callee or module (`F(X)`, `?M(X)`,
// `Mod:handle(X)`) has no static target — except `?MODULE:fn(X)`, which the
// bare name + same-file preference resolves correctly. `fun name/1` /
// `fun mod:name/1` values are function REFERENCES (callback registration),
// and record construction/update/index/field-access are `references` to the
// record's struct node.
if (this.language === 'erlang') {
const line = node.startPosition.row + 1;
const column = node.startPosition.column;
const erlAtom = (n: SyntaxNode): string => getNodeText(n, this.source).replace(/^'([\s\S]*)'$/, '$1');
if (node.type === 'call') {
let callee = getChildByField(node, 'expr');
let moduleNode: SyntaxNode | null = null;
// remote(module, fun: call) — the shape the grammar produces today; the
// node-types also permit call(expr: remote), so handle both nestings.
if (node.parent?.type === 'remote') {
moduleNode = getChildByField(node.parent, 'module');
} else if (callee?.type === 'remote') {
moduleNode = getChildByField(callee, 'module');
callee = getChildByField(callee, 'fun');
}
if (callee?.type === 'atom') {
const fnBare = erlAtom(callee);
let calleeName = fnBare;
const moduleExpr = moduleNode ? getChildByField(moduleNode, 'module') : null;
if (moduleExpr?.type === 'atom') {
calleeName = `${erlAtom(moduleExpr)}::${calleeName}`;
} else if (moduleExpr) {
// Non-atom module qualifier. `?MODULE:f(X)` targets THIS module —
// keep the bare name so same-file preference resolves it. Anything
// else (`Mod:f(X)`) is behaviour-style dynamic dispatch with no
// static target: emitting the bare name would link an arbitrary
// same-named function, so stay silent instead.
const macroName =
moduleExpr.type === 'macro_call_expr' ? getChildByField(moduleExpr, 'name') : null;
if (!macroName || getNodeText(macroName, this.source) !== 'MODULE') return;
}
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: calleeName,
referenceKind: 'calls',
line,
column,
});
// gen_server self-dispatch: `gen_server:call(?SERVER, Msg)` /
// `gen_server:cast(?MODULE, Msg)` — the OTP API-wrapper idiom (a
// module's public functions wrap gen_server requests to itself, and
// the real work happens in its own handle_call/handle_cast). The
// target is static when the first argument is ?MODULE, a macro the
// file defines as ?MODULE (the standard `-define(SERVER, ?MODULE)`),
// or the module's own name as an atom — emit the qualified callback
// ref so the module's public API connects to its handlers. Any other
// target (pid/var/registered name of another process) stays silent.
if (
moduleExpr?.type === 'atom' &&
erlAtom(moduleExpr) === 'gen_server' &&
(fnBare === 'call' || fnBare === 'cast' || fnBare === 'send_request')
) {
const argsNode = getChildByField(node, 'args');
const target = argsNode?.namedChild(0) ?? null;
if (target && this.isErlangSelfReference(target)) {
const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, '');
if (ownModule) {
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: `${ownModule}::${fnBare === 'cast' ? 'handle_cast' : 'handle_call'}`,
referenceKind: 'calls',
line,
column,
});
}
}
}
// MFA-in-argument dispatch: the spawn/apply family names its real
// callee in ARGUMENT position — `proc_lib:spawn_link(?MODULE,
// request_process, [Req, Env, Middlewares])` — so the walker above
// sees only the spawn itself and the spawned function ends up with
// zero callers (measured on cowboy: request_process had no incoming
// edges and the agent Read the file to find it). When the (Module,
// Function) pair is static, lift it as a call edge. The pair is
// found positionally-agnostically (first adjacent module-atom/
// ?MODULE + atom pair) so every arity variant works: spawn/3,
// spawn(Node,M,F,A)/4, timer:apply_after(Time,M,F,A),
// rpc:call(Node,M,F,A). A var module or fun stays silent.
const familyKey = moduleExpr?.type === 'atom' ? `${erlAtom(moduleExpr)}:${fnBare}` : fnBare;
if (ERLANG_MFA_CALLS.has(familyKey)) {
const argsNode = getChildByField(node, 'args');
const argExprs = argsNode ? argsNode.namedChildren : [];
for (let i = 0; i + 1 < argExprs.length; i++) {
const m = argExprs[i]!;
const f = argExprs[i + 1]!;
if (f.type !== 'atom') continue;
const isLocalModule =
m.type === 'macro_call_expr' &&
getChildByField(m, 'name') !== null &&
getNodeText(getChildByField(m, 'name')!, this.source) === 'MODULE';
if (m.type !== 'atom' && !isLocalModule) continue;
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: isLocalModule ? erlAtom(f) : `${erlAtom(m)}::${erlAtom(f)}`,
referenceKind: 'calls',
line: f.startPosition.row + 1,
column: f.startPosition.column,
});
break;
}
}
}
return;
}
if (node.type === 'internal_fun' || node.type === 'external_fun') {
const funNode = getChildByField(node, 'fun');
if (funNode?.type !== 'atom') return; // fun Mod:F/A with var parts — dynamic
let refName = erlAtom(funNode);
if (node.type === 'external_fun') {
const moduleWrapper = getChildByField(node, 'module');
const moduleAtom = moduleWrapper ? getChildByField(moduleWrapper, 'name') : null;
if (moduleAtom?.type !== 'atom') return;
refName = `${erlAtom(moduleAtom)}::${refName}`;
}
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: refName,
referenceKind: 'references',
line,
column,
});
return;
}
// record_expr / record_update_expr / record_index_expr / record_field_expr
const recordName = getChildByField(node, 'name');
const recordAtom = recordName?.type === 'record_name' ? getChildByField(recordName, 'name') : null;
if (recordAtom?.type === 'atom') {
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: erlAtom(recordAtom),
referenceKind: 'references',
line,
column,
});
}
return;
}
// Ruby `call` nodes use `receiver` + `method` fields (tree-sitter-ruby), not
// the `object`/`name`/`function` fields the branches below expect — so
// without this they fell through to the generic path, which took the
Binary file not shown.