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
+37 -4
View File
@@ -95,6 +95,36 @@ function lastQualifierPart(symbol: string): string {
return parts[parts.length - 1] ?? symbol;
}
/**
* Normalize Erlang-native symbol spellings in an explore query into the shapes
* the rest of the pipeline already understands. Agents working Erlang code
* name symbols the way the language spells them — `mod:fn/3`, `init/2` — and
* those tokens previously died in both consumers: the flow-builder's token
* filter rejects `:` and `/arity` outright, and the search-side field parser
* eats `mod:fn` as an unknown `field:value`. Measured on cowboy: the agent
* named `cowboy_stream_h:request_process/3` in two queries, got no body back
* either time, and fell back to Read.
*
* - `fn/3` → `fn` (arity tail after an identifier; a path segment like
* `src/2fa` doesn't match because the tail must be all digits)
* - `mod:fn` → `mod.fn` (exactly one colon between identifiers, so it rides
* the existing Class.method qualified handling; `::`, URLs, drive letters,
* and times don't match, and the query language's own field prefixes —
* kind:/lang:/language:/path:/name: — are left alone)
*
* Safe cross-language: Lua's `t:m` spelling maps to the same `t.m` its
* qualified names use, and no other supported spelling contains a bare
* single-colon identifier pair.
*/
export function normalizeQuerySpelling(query: string): string {
return query
.replace(/\b([A-Za-z_][\w@]*)\/(\d{1,3})(?=$|[\s,()[\]/])/g, '$1')
.replace(
/(^|[\s,()[\]])(?!(?:kind|lang|language|path|name):)([a-z_][\w@]*):([A-Za-z_][\w@]*)(?=$|[\s,()[\]])/g,
'$1$2.$3'
);
}
/**
* Calculate the recommended number of codegraph_explore calls based on project size.
* Larger codebases need more exploration calls to cover their surface area,
@@ -1854,7 +1884,7 @@ export class ToolHandler {
// names (Class.method / Class::method) — the agent's most precise input,
// resolved exactly by findAllSymbols. (The old strip mangled Class.method
// into Class, throwing the method away.)
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro)$/i;
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
const tokens = [...new Set(
query.split(/[\s,()[\]]+/)
.map((t) => t.replace(FILE_EXT, '').trim())
@@ -2457,8 +2487,11 @@ export class ToolHandler {
* tax on small projects while earning its keep on large ones.
*/
private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
const query = this.validateString(args.query, 'query');
if (typeof query !== 'string') return query;
const rawQuery = this.validateString(args.query, 'query');
if (typeof rawQuery !== 'string') return rawQuery;
// One normalization point so the flow-builder, relevance search, and
// ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
const query = normalizeQuerySpelling(rawQuery);
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const projectRoot = cg.getProjectRoot();
@@ -2539,7 +2572,7 @@ export class ToolHandler {
// overloads (the query also named the type) all earn it. (#1064)
const tierSeedIds = new Set<string>();
{
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro)$/i;
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
const isTestPath = (p: string) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
const bodyLines = (n: Node) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);