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
+25 -1
View File
@@ -10,7 +10,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getExploreOutputBudget, getExploreBudget, ToolHandler } from '../src/mcp/tools';
import { getExploreOutputBudget, getExploreBudget, normalizeQuerySpelling, ToolHandler } from '../src/mcp/tools';
import CodeGraph from '../src/index';
describe('getExploreOutputBudget', () => {
@@ -254,3 +254,27 @@ describe('codegraph_explore output respects the adaptive budget', () => {
expect(hasMethodBody).toBe(true);
});
});
describe('normalizeQuerySpelling (Erlang mod:fn/arity)', () => {
it('rewrites Erlang-native symbol spellings to pipeline shapes', () => {
expect(normalizeQuerySpelling('cowboy_stream_h:request_process/3'))
.toBe('cowboy_stream_h.request_process');
expect(normalizeQuerySpelling('ejabberd_router:route/1 do_route/1 session'))
.toBe('ejabberd_router.route do_route session');
expect(normalizeQuerySpelling('init/2 handle_call/3')).toBe('init handle_call');
});
it('leaves query-language field prefixes and other spellings alone', () => {
expect(normalizeQuerySpelling('kind:function lang:erlang route'))
.toBe('kind:function lang:erlang route');
expect(normalizeQuerySpelling('path:src/api name:auth')).toBe('path:src/api name:auth');
expect(normalizeQuerySpelling('Foo::bar baz')).toBe('Foo::bar baz');
expect(normalizeQuerySpelling('https://example.com/docs')).toBe('https://example.com/docs');
expect(normalizeQuerySpelling('meeting at 12:30')).toBe('meeting at 12:30');
expect(normalizeQuerySpelling('src/2fa/handler.ts')).toBe('src/2fa/handler.ts');
});
it('maps Lua colon-method spelling onto the qualified form', () => {
expect(normalizeQuerySpelling('logger:log message')).toBe('logger.log message');
});
});
+384
View File
@@ -107,6 +107,11 @@ describe('Language Detection', () => {
expect(isSourceFile('Renderer/Shaders.metal')).toBe(true);
});
it('should detect Erlang files', () => {
expect(detectLanguage('src/my_server.erl')).toBe('erlang');
expect(detectLanguage('include/records.hrl')).toBe('erlang');
});
it('should return unknown for unsupported extensions', () => {
expect(detectLanguage('styles.css')).toBe('unknown');
expect(detectLanguage('data.json')).toBe('unknown');
@@ -8753,3 +8758,382 @@ End Class
expect(calls).toContain('Parser.GetSteamNameAndID');
});
});
// =============================================================================
// Erlang (vendored WhatsApp/tree-sitter-erlang grammar — the ELP grammar)
// =============================================================================
describe('Erlang Extraction', () => {
describe('Language detection', () => {
it('should report Erlang as supported', () => {
expect(isLanguageSupported('erlang')).toBe(true);
expect(getSupportedLanguages()).toContain('erlang');
expect(isSourceFile('apps/app/src/foo.erl')).toBe(true);
expect(isSourceFile('include/foo.hrl')).toBe(true);
});
});
describe('Function extraction', () => {
it('should merge multi-clause functions into one node spanning all clauses', () => {
const code = `-module(m).
-export([classify/1]).
classify(X) when is_atom(X) ->
atom;
classify(X) when is_binary(X) ->
binary;
classify(_X) ->
other.
`;
const result = extractFromSource('src/m.erl', code);
const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'classify');
expect(fns).toHaveLength(1);
expect(fns[0]!.startLine).toBe(4);
expect(fns[0]!.endLine).toBe(9);
expect(fns[0]!.language).toBe('erlang');
});
it('should qualify functions with the module namespace', () => {
const code = `-module(my_server).
-export([start/0]).
start() -> ok.
helper() -> ok.
`;
const result = extractFromSource('src/my_server.erl', code);
const ns = result.nodes.find((n) => n.kind === 'namespace');
expect(ns?.name).toBe('my_server');
const start = result.nodes.find((n) => n.kind === 'function' && n.name === 'start');
expect(start?.qualifiedName).toBe('my_server::start');
});
it('should flag exported functions and honor -compile(export_all)', () => {
const code = `-module(m).
-export([api/0]).
api() -> internal().
internal() -> ok.
`;
const result = extractFromSource('src/m.erl', code);
const api = result.nodes.find((n) => n.name === 'api');
const internal = result.nodes.find((n) => n.name === 'internal');
expect(api?.isExported).toBe(true);
expect(internal?.isExported).toBe(false);
const all = extractFromSource('src/all.erl', `-module(all).
-compile(export_all).
anything() -> ok.
`);
expect(all.nodes.find((n) => n.name === 'anything')?.isExported).toBe(true);
});
it('should use the preceding -spec as the signature and capture doc comments', () => {
const code = `-module(m).
%% Fetches a value by key.
-spec fetch(binary()) -> {ok, term()} | not_found.
fetch(Key) ->
lookup(Key).
lookup(_K) -> not_found.
`;
const result = extractFromSource('src/m.erl', code);
const fetch = result.nodes.find((n) => n.name === 'fetch');
expect(fetch?.signature).toBe('-spec fetch(binary()) -> {ok, term()} | not_found.');
expect(fetch?.docstring).toBe('Fetches a value by key.');
});
it('should fall back to the clause header as the signature', () => {
const code = `-module(m).
resize(W, H) when W > 0, H > 0 ->
{W, H}.
`;
const result = extractFromSource('src/m.erl', code);
const resize = result.nodes.find((n) => n.name === 'resize');
expect(resize?.signature).toBe('resize(W, H) when W > 0, H > 0');
});
});
describe('Record and type extraction', () => {
it('should extract records as structs with fields', () => {
const code = `-module(m).
-record(state, {
store = #{} :: map(),
counter = 0 :: non_neg_integer()
}).
`;
const result = extractFromSource('src/m.erl', code);
const rec = result.nodes.find((n) => n.kind === 'struct');
expect(rec?.name).toBe('state');
const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
expect(fields).toContain('store');
expect(fields).toContain('counter');
});
it('should extract -type and -opaque as type aliases, without bogus type-call refs', () => {
const code = `-module(m).
-type key() :: atom() | binary().
-opaque handle() :: reference().
-spec noop(key()) -> ok.
noop(_K) -> ok.
`;
const result = extractFromSource('src/m.erl', code);
const aliases = result.nodes.filter((n) => n.kind === 'type_alias').map((n) => n.name);
expect(aliases).toContain('key');
expect(aliases).toContain('handle');
// Type-position expressions parse as `call` nodes — the spec/type subtrees
// must not leak `calls` refs to type names like atom()/binary().
const bogus = result.unresolvedReferences.filter(
(r) => r.referenceKind === 'calls' && ['atom', 'binary', 'reference', 'key'].includes(r.referenceName)
);
expect(bogus).toHaveLength(0);
});
it('should extract -define macros as constants', () => {
const code = `-module(m).
-define(TIMEOUT, 5000).
-define(WRAP(X), {ok, X}).
`;
const result = extractFromSource('src/m.erl', code);
const consts = result.nodes.filter((n) => n.kind === 'constant').map((n) => n.name);
expect(consts).toContain('TIMEOUT');
expect(consts).toContain('WRAP');
});
});
describe('Import extraction', () => {
it('should extract -include/-include_lib and -import', () => {
const code = `-module(m).
-include("records.hrl").
-include_lib("kernel/include/logger.hrl").
-import(lists, [map/2]).
`;
const result = extractFromSource('src/m.erl', code);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('records.hrl');
expect(imports).toContain('kernel/include/logger.hrl');
expect(imports).toContain('lists');
const ref = result.unresolvedReferences.find(
(r) => r.referenceKind === 'imports' && r.referenceName === 'records.hrl'
);
expect(ref).toBeDefined();
});
});
describe('Call extraction', () => {
it('should record local calls bare and remote calls module-qualified', () => {
const code = `-module(m).
-export([run/1]).
run(X) ->
Y = prepare(X),
other_mod:process(Y).
prepare(X) -> X.
`;
const result = extractFromSource('src/m.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls).toContain('prepare');
// `mod:fn(...)` is emitted as `mod::fn` — the same shape the module
// namespace gives every function's qualifiedName, so it resolves via
// the qualified-name matcher.
expect(calls).toContain('other_mod::process');
});
it('should not emit calls for dynamic dispatch (var module / var fun)', () => {
const code = `-module(m).
-export([run/2]).
run(Mod, F) ->
Mod:handle(x),
F(y).
`;
const result = extractFromSource('src/m.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls).not.toContain('handle');
expect(calls).not.toContain('Mod::handle');
expect(calls).not.toContain('F');
});
it('should connect gen_server self-calls to the module handlers', () => {
const code = `-module(kv_store).
-behaviour(gen_server).
-export([get/1, put/2, drop/1]).
-export([init/1, handle_call/3, handle_cast/2]).
-define(SERVER, ?MODULE).
get(Key) ->
gen_server:call(?SERVER, {get, Key}).
put(Key, Value) ->
gen_server:cast(?MODULE, {put, Key, Value}).
drop(Key) ->
gen_server:call(kv_store, {drop, Key}).
init(_) -> {ok, #{}}.
handle_call({get, K}, _From, S) -> {reply, maps:find(K, S), S};
handle_call({drop, K}, _From, S) -> {reply, ok, maps:remove(K, S)}.
handle_cast({put, K, V}, S) -> {noreply, maps:put(K, V, S)}.
`;
const result = extractFromSource('src/kv_store.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
// ?SERVER (defined as ?MODULE), ?MODULE, and the module's own atom all
// count as self — public API wrappers connect to their handlers.
expect(calls.filter((c) => c === 'kv_store::handle_call')).toHaveLength(2);
expect(calls).toContain('kv_store::handle_cast');
});
it('should not connect gen_server calls to other processes', () => {
const code = `-module(m).
-export([go/2]).
go(Pid, Msg) ->
gen_server:call(Pid, Msg),
gen_server:call(other_registered_name, Msg),
gen_server:cast({global, some_name}, Msg).
`;
const result = extractFromSource('src/m.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls.filter((c) => c.includes('handle_call') || c.includes('handle_cast'))).toHaveLength(0);
});
it('should lift static MFA arguments of the spawn/apply family into call refs', () => {
const code = `-module(m).
-export([boot/2]).
boot(Req, Env) ->
Pid = proc_lib:spawn_link(?MODULE, request_process, [Req, Env]),
spawn(?MODULE, monitor_loop, [Pid]),
apply(other_mod, handle, [Req]),
timer:apply_after(500, other_mod, tick, []),
Pid.
request_process(_R, _E) -> ok.
monitor_loop(_P) -> ok.
`;
const result = extractFromSource('src/m.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls).toContain('request_process'); // ?MODULE → bare, same-file resolution
expect(calls).toContain('monitor_loop');
expect(calls).toContain('other_mod::handle');
expect(calls).toContain('other_mod::tick');
});
it('should stay silent on dynamic spawn/apply (var module, fun value, or plain fun)', () => {
const code = `-module(m).
-export([go/3]).
go(M, F, A) ->
spawn(M, F, A),
spawn(fun() -> helper() end),
apply(M, F, A).
helper() -> ok.
`;
const result = extractFromSource('src/m.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
// The fun body's call is still walked; no phantom MFA targets appear.
expect(calls).toContain('helper');
expect(calls.filter((c) => c !== 'spawn' && c !== 'apply' && c !== 'helper')).toHaveLength(0);
});
it('should treat ?MODULE:fn calls as local calls', () => {
const code = `-module(m).
-export([kick/0]).
kick() ->
?MODULE:work().
work() -> ok.
`;
const result = extractFromSource('src/m.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls).toContain('work');
});
it('should capture fun name/arity values as function references', () => {
const code = `-module(m).
-export([wire/1]).
wire(Pids) ->
lists:foreach(fun notify/1, Pids),
lists:map(fun m:notify/1, Pids).
notify(_P) -> ok.
`;
const result = extractFromSource('src/m.erl', code);
const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName);
expect(refs).toContain('notify');
expect(refs).toContain('m::notify');
});
it('should reference records used in bodies and argument patterns', () => {
const code = `-module(m).
-export([mk/1, get_id/1]).
-record(req, {id, payload}).
mk(Id) -> #req{id = Id}.
get_id(#req{id = Id}) -> Id.
`;
const result = extractFromSource('src/m.erl', code);
const refs = result.unresolvedReferences.filter(
(r) => r.referenceKind === 'references' && r.referenceName === 'req'
);
expect(refs.length).toBeGreaterThanOrEqual(2);
});
it('should attribute calls from every clause of a multi-clause function', () => {
const code = `-module(m).
-export([handle/1]).
handle({a, X}) ->
first(X);
handle({b, X}) ->
second(X).
first(X) -> X.
second(X) -> X.
`;
const result = extractFromSource('src/m.erl', code);
const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle');
const calls = result.unresolvedReferences.filter(
(r) => r.referenceKind === 'calls' && r.fromNodeId === handle?.id
).map((r) => r.referenceName);
expect(calls).toContain('first');
expect(calls).toContain('second');
});
});
describe('Behaviour extraction', () => {
it('should emit an implements reference for -behaviour', () => {
const code = `-module(m).
-behaviour(gen_server).
init(_) -> {ok, #{}}.
`;
const result = extractFromSource('src/m.erl', code);
const impl = result.unresolvedReferences.find((r) => r.referenceKind === 'implements');
expect(impl?.referenceName).toBe('gen_server');
});
it('should not create symbols from -callback declarations', () => {
const code = `-module(b).
-callback handle_thing(term()) -> ok.
-callback init(list()) -> {ok, term()}.
`;
const result = extractFromSource('src/b.erl', code);
const fns = result.nodes.filter((n) => n.kind === 'function');
expect(fns).toHaveLength(0);
});
});
});
+61
View File
@@ -83,6 +83,67 @@ describe('Resolution Module', () => {
expect(result?.resolvedBy).toBe('exact-match');
});
it('should resolve Erlang -behaviour refs only to module namespaces', () => {
// On emqx, `-behaviour(supervisor)` (OTP behaviour, not in the repo)
// fell through to bare-name matching and resolved to a
// `-define(supervisor, ...)` macro constant in an unrelated app.
const macroConstant: Node = {
id: 'constant:apps/bridge/src/impl.erl:supervisor:61',
kind: 'constant',
name: 'supervisor',
qualifiedName: 'impl::supervisor',
filePath: 'apps/bridge/src/impl.erl',
language: 'erlang',
startLine: 61,
endLine: 61,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
const behaviourModule: Node = {
id: 'namespace:src/my_behaviour.erl:my_behaviour:1',
kind: 'namespace',
name: 'my_behaviour',
qualifiedName: 'my_behaviour',
filePath: 'src/my_behaviour.erl',
language: 'erlang',
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
const nodes = [macroConstant, behaviourModule];
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: (name) => nodes.filter((n) => n.name === name),
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => false,
readFile: () => null,
getProjectRoot: () => '/test',
getAllFiles: () => [],
getNodesByLowerName: () => [],
getImportMappings: () => [],
};
const mkRef = (name: string) => ({
fromNodeId: 'namespace:src/worker.erl:worker:1',
referenceName: name,
referenceKind: 'implements' as const,
line: 2,
column: 0,
filePath: 'src/worker.erl',
language: 'erlang' as const,
});
// Out-of-repo behaviour whose name collides with a macro constant:
// stays unresolved instead of linking the constant.
expect(matchReference(mkRef('supervisor'), context)).toBeNull();
// In-repo behaviour module resolves to its namespace.
const resolved = matchReference(mkRef('my_behaviour'), context);
expect(resolved?.targetNodeId).toBe(behaviourModule.id);
});
it('should prefer same-module candidates over cross-module matches', () => {
// Simulates a Python monorepo where multiple apps define navigate()
const candidateA: Node = {