From f42e0c8780f082251b286ba33ef2ffa39266dc7f Mon Sep 17 00:00:00 2001 From: Allen Woods Date: Sat, 13 Jun 2026 16:17:45 +0800 Subject: [PATCH 1/2] feat(extraction): Elixir language support (.ex/.exs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Elixir to CodeGraph's tree-sitter extraction. The grammar (tree-sitter-elixir.wasm) already ships in tree-sitter-wasms; this wires it up and adds the extractor. tree-sitter-elixir is metaprogramming-first: defmodule, def, defp, alias, import, if, case — everything parses as the same `(call target:(identifier) (arguments) (do_block)?)` shape. So extraction runs through the visitNode hook and dispatches on the macro identifier rather than node types. Extracted: - modules (defmodule, nested) and protocols (defprotocol -> interface) - functions (def/defp/defmacro/defmacrop/defguard/defguardp/defdelegate) with public/private visibility; multi-clause defs fold into one symbol (same qualifiedName) so the resolver isn't left with duplicates - dependencies (alias/import/require/use), including multi-alias `alias A.{B, C}` expansion - defimpl with an `implements` edge to the protocol - defstruct/defexception as struct nodes - call edges for qualified `Mod.fun` and local calls, descending through control-flow special forms (if/case/with/for/...) while not recording them as calls; module attributes (@doc/@spec/...) are skipped Tested against real Phoenix/OTP source (1900+ LOC modules) — correct module names, visibility, and hundreds of call edges. 16 new unit tests; full suite (1506 tests) green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + README.md | 3 +- __tests__/extraction.test.ts | 204 +++++++++++++++++++++ src/extraction/grammars.ts | 5 + src/extraction/languages/elixir.ts | 285 +++++++++++++++++++++++++++++ src/extraction/languages/index.ts | 2 + src/types.ts | 1 + 7 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 src/extraction/languages/elixir.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 43c4c1e1c..cc17a1cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- **CodeGraph now indexes Elixir** (`.ex` / `.exs`) — modules (including nested), `def`/`defp`/`defmacro`/`defmacrop`/`defguard`/`defdelegate` with public/private visibility, multi-clause functions folded into one symbol, `alias`/`import`/`require`/`use` dependencies (including multi-alias `alias A.{B, C}` expansion), `defprotocol`/`defimpl` (with `implements` edges), `defstruct`/`defexception`, and call edges (qualified `Mod.fun` and local calls, including inside `if`/`case`/`with`/pipe bodies). Because tree-sitter-elixir parses every construct as a generic `call`, extraction dispatches on the macro identifier rather than node types; Phoenix/OTP codebases get the full explore / impact / callers surface. + ### Fixes - `codegraph upgrade` now also refreshes what previous versions installed into your agents — the CodeGraph section in CLAUDE.md / AGENTS.md / GEMINI.md and the MCP entry — so upgrading no longer leaves agents following instructions written for tools that have since been renamed or removed. Refresh-only: agents you never configured are not touched, and your permission and hook choices are preserved. Also available manually as `codegraph install --refresh`, and skippable with `CODEGRAPH_NO_INSTALL_REFRESH=1`. (#1238) diff --git a/README.md b/README.md index 0b23cc48f..7640b83ef 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, Elixir, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -804,6 +804,7 @@ is written): | COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) | | Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) | | Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) | +| Elixir | `.ex`, `.exs` | Full support (modules incl. nested, `def`/`defp`/`defmacro`/`defguard`/`defdelegate` with visibility, multi-clause folding, `alias`/`import`/`require`/`use` deps incl. multi-alias `A.{B, C}`, `defprotocol`/`defimpl` with implements edges, `defstruct`/`defexception`, call edges) | | Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) | | Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) | | Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) | diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 8619d12d7..d195d938f 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -10939,3 +10939,207 @@ import DataStore from '../data/DataStore'; }); }); }); + +describe('Elixir Extraction', () => { + describe('Language detection', () => { + it('should detect .ex and .exs files', () => { + expect(detectLanguage('lib/my_app/accounts.ex')).toBe('elixir'); + expect(detectLanguage('test/accounts_test.exs')).toBe('elixir'); + }); + }); + + describe('Module extraction', () => { + it('should extract a defmodule as a module node', () => { + const code = ` +defmodule MyApp.Accounts do + @moduledoc "Accounts context" +end +`; + const result = extractFromSource('lib/accounts.ex', code); + const mod = result.nodes.find((n) => n.kind === 'module'); + expect(mod).toMatchObject({ kind: 'module', name: 'MyApp.Accounts', language: 'elixir' }); + }); + + it('should extract nested modules', () => { + const code = ` +defmodule A do + defmodule B do + end +end +`; + const result = extractFromSource('lib/a.ex', code); + const names = result.nodes.filter((n) => n.kind === 'module').map((n) => n.name); + expect(names).toContain('A'); + expect(names).toContain('B'); + }); + }); + + describe('Function extraction', () => { + it('should extract def as a public function', () => { + const code = ` +defmodule M do + def get_user(id), do: id +end +`; + const result = extractFromSource('lib/m.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'get_user'); + expect(fn).toBeDefined(); + expect(fn?.visibility).toBe('public'); + }); + + it('should extract defp as a private function', () => { + const code = ` +defmodule M do + defp helper(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'helper'); + expect(fn).toBeDefined(); + expect(fn?.visibility).toBe('private'); + }); + + it('should extract defmacro', () => { + const code = ` +defmodule M do + defmacro mymacro(x) do + quote do: unquote(x) + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'mymacro'); + expect(fn).toBeDefined(); + }); + + it('should extract a function with a guard', () => { + const code = ` +defmodule M do + def bar(x) when is_integer(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'bar'); + expect(fn).toBeDefined(); + }); + + it('should dedupe multiple clauses of the same name/arity into one node', () => { + const code = ` +defmodule M do + def get(id) when is_integer(id), do: id + def get(_), do: nil +end +`; + const result = extractFromSource('lib/m.ex', code); + const gets = result.nodes.filter((n) => n.kind === 'function' && n.name === 'get'); + expect(gets.length).toBe(1); + }); + }); + + describe('Imports / dependencies', () => { + it('should extract alias', () => { + const code = ` +defmodule M do + alias MyApp.Repo +end +`; + const result = extractFromSource('lib/m.ex', code); + const imp = result.nodes.find((n) => n.kind === 'import'); + expect(imp?.name).toBe('MyApp.Repo'); + }); + + it('should expand multi-alias into one import each', () => { + const code = ` +defmodule M do + alias MyApp.Accounts.{User, Profile} +end +`; + const result = extractFromSource('lib/m.ex', code); + const names = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(names).toContain('MyApp.Accounts.User'); + expect(names).toContain('MyApp.Accounts.Profile'); + }); + + it('should extract import, require, and use', () => { + const code = ` +defmodule M do + import Ecto.Query + require Logger + use GenServer +end +`; + const result = extractFromSource('lib/m.ex', code); + const names = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(names).toContain('Ecto.Query'); + expect(names).toContain('Logger'); + expect(names).toContain('GenServer'); + }); + }); + + describe('Structural macros', () => { + it('should extract defprotocol as an interface and its callbacks', () => { + const code = ` +defprotocol Sizeable do + def size(data) +end +`; + const result = extractFromSource('lib/sizeable.ex', code); + const proto = result.nodes.find((n) => n.kind === 'interface' && n.name === 'Sizeable'); + expect(proto).toBeDefined(); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'size'); + expect(fn).toBeDefined(); + }); + + it('should extract defstruct as a struct node', () => { + const code = ` +defmodule User do + defstruct [:id, :name] +end +`; + const result = extractFromSource('lib/user.ex', code); + const st = result.nodes.find((n) => n.kind === 'struct'); + expect(st).toBeDefined(); + }); + + it('should extract defimpl with an implements reference', () => { + const code = ` +defimpl Sizeable, for: List do + def size(list), do: length(list) +end +`; + const result = extractFromSource('lib/sizeable_list.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'size'); + expect(fn).toBeDefined(); + const impl = result.unresolvedReferences.find( + (r) => r.referenceKind === 'implements' && r.referenceName === 'Sizeable' + ); + expect(impl).toBeDefined(); + }); + + it('should extract defdelegate as a function', () => { + const code = ` +defmodule M do + defdelegate len(list), to: List, as: :length +end +`; + const result = extractFromSource('lib/m.ex', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'len'); + expect(fn).toBeDefined(); + }); + }); + + describe('Call edges', () => { + it('should record a qualified call inside a function body', () => { + const code = ` +defmodule M do + def clean(name), do: String.trim(name) +end +`; + const result = extractFromSource('lib/m.ex', code); + const call = result.unresolvedReferences.find( + (r) => r.referenceKind === 'calls' && r.referenceName === 'String.trim' + ); + expect(call).toBeDefined(); + }); + }); +}); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index a26d232ae..67b43589c 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -46,6 +46,7 @@ const WASM_GRAMMAR_FILES: Record = { cobol: 'tree-sitter-cobol.wasm', vbnet: 'tree-sitter-vbnet.wasm', erlang: 'tree-sitter-erlang.wasm', + elixir: 'tree-sitter-elixir.wasm', solidity: 'tree-sitter-solidity.wasm', terraform: 'tree-sitter-terraform.wasm', arkts: 'tree-sitter-arkts.wasm', @@ -111,6 +112,9 @@ export const EXTENSION_MAP: Record = { '.vue': 'vue', '.astro': 'astro', '.r': 'r', + // Elixir source (.ex) and scripts (.exs) + '.ex': 'elixir', + '.exs': 'elixir', '.pas': 'pascal', '.dpr': 'pascal', '.dpk': 'pascal', @@ -559,6 +563,7 @@ export function getLanguageDisplayName(language: Language): string { go: 'Go', rust: 'Rust', r: 'R', + elixir: 'Elixir', java: 'Java', c: 'C', cpp: 'C++', diff --git a/src/extraction/languages/elixir.ts b/src/extraction/languages/elixir.ts new file mode 100644 index 000000000..7a3341ec3 --- /dev/null +++ b/src/extraction/languages/elixir.ts @@ -0,0 +1,285 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText } from '../tree-sitter-helpers'; +import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types'; + +/** + * Elixir extraction. + * + * tree-sitter-elixir is metaprogramming-first: there are almost no dedicated + * declaration node types. `defmodule`, `def`, `defp`, `alias`, `import`, `if`, + * `case` … all parse as the SAME shape — a `call` node: + * + * (call target: (identifier "") (arguments …) (do_block …)?) + * + * So the whole language is handled through the `visitNode` hook, which inspects + * each `call`'s target identifier and dispatches on the macro name. Real + * function calls (target not a known macro) become `calls` edges; control-flow + * special forms (`if`/`case`/…) are descended into but not recorded as calls. + */ + +/** Macros that introduce a callable definition. */ +const DEF_MACROS = new Set([ + 'def', 'defp', 'defmacro', 'defmacrop', 'defguard', 'defguardp', 'defdelegate', +]); +/** The subset of DEF_MACROS that are private. */ +const PRIVATE_DEFS = new Set(['defp', 'defmacrop', 'defguardp']); +/** Dependency macros — each records an `import` node (module dependency). */ +const DEP_MACROS = new Set(['alias', 'import', 'require', 'use']); +/** Macros that define the enclosing module's data shape. */ +const STRUCT_MACROS = new Set(['defstruct', 'defexception']); +/** + * Kernel special forms / control-flow macros. They parse as `call`s but are not + * meaningful call edges; we still descend into their bodies for nested calls. + */ +const SKIP_CALL = new Set([ + 'if', 'unless', 'case', 'cond', 'with', 'for', 'try', 'receive', + 'quote', 'unquote', 'unquote_splicing', 'fn', 'super', +]); + +/** The `target` of a call: the macro/function identifier (or a `dot` for `Mod.fun`). */ +function callTarget(node: SyntaxNode): SyntaxNode | null { + return node.childForFieldName('target') ?? node.namedChild(0); +} + +/** The `arguments` node of a call, if any. */ +function callArgs(node: SyntaxNode): SyntaxNode | null { + return ( + node.childForFieldName('arguments') ?? + node.namedChildren.find((c) => c.type === 'arguments') ?? + null + ); +} + +/** The trailing `do … end` block of a call, if present. */ +function doBlock(node: SyntaxNode): SyntaxNode | null { + return node.namedChildren.find((c) => c.type === 'do_block') ?? null; +} + +/** + * Extract a definition's simple name from a def-macro's arguments. Handles: + * def foo(a) → call(target: foo) + * def foo(a) when guard → binary_operator(when){ left: call(target: foo) } + * def foo → identifier(foo) (zero-arg, no parens) + */ +function defName(args: SyntaxNode | null): string | null { + if (!args) return null; + let head = args.namedChild(0); + if (!head) return null; + // `when` guard wraps the head: take its left operand. + if (head.type === 'binary_operator') { + head = head.childForFieldName('left') ?? head; + } + if (head.type === 'call') { + const t = callTarget(head); + // A simple identifier target — its own text is the name. + return t && t.type === 'identifier' ? t.text : (t ? t.text : null); + } + if (head.type === 'identifier') { + return head.text; + } + return null; +} + +/** + * Predict the qualifiedName the core will assign (scope node names joined by + * `::`). Used to fold a multi-clause function — Elixir lists each clause as its + * own `def`, but they are one symbol; a second node with the same qualifiedName + * only creates resolver ambiguity. + */ +function qualifiedNameFor(ctx: ExtractorContext, name: string): string { + const parts: string[] = []; + for (const id of ctx.nodeStack) { + const n = ctx.nodes.find((x) => x.id === id); + if (n && n.kind !== 'file') parts.push(n.name); + } + parts.push(name); + return parts.join('::'); +} + +/** Visit every body a def can carry — a `do … end` block and/or a `do:` keyword. */ +function visitDefBody(node: SyntaxNode, ctx: ExtractorContext): void { + const block = doBlock(node); + if (block) { + for (let i = 0; i < block.namedChildCount; i++) { + const child = block.namedChild(i); + if (child) ctx.visitNode(child); + } + } + const args = callArgs(node); + const kw = args?.namedChildren.find((c) => c.type === 'keywords'); + if (kw) { + for (let i = 0; i < kw.namedChildCount; i++) { + const pair = kw.namedChild(i); + if (pair?.type === 'pair') { + const val = pair.childForFieldName('value') ?? pair.namedChild(1); + if (val) ctx.visitNode(val); + } + } + } +} + +export const elixirExtractor: LanguageExtractor = { + // Elixir has no dedicated declaration node types — everything is dispatched + // through visitNode below. `callTypes` is empty because the hook records + // calls itself (the core's extractCall keys off a `function` field Elixir + // lacks). + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], + callTypes: [], + variableTypes: [], + nameField: 'target', + bodyField: 'do_block', + paramsField: 'arguments', + + visitNode: (node, ctx) => { + // Module attributes: `@doc …`, `@spec …`, `@moduledoc …`, `@my_attr …` + // parse as `unary_operator` over a call. Not symbols in v1 — consume so the + // inner call isn't mis-recorded as a call to "doc"/"spec". + if (node.type === 'unary_operator') { + const txt = getNodeText(node, ctx.source); + if (txt.trimStart().startsWith('@')) return true; + return false; // -x / !flag / ^pin — let the core descend for nested calls + } + + if (node.type !== 'call') return false; + + const target = callTarget(node); + if (!target) return false; + // A qualified target (`Mod.fun`) is always a real call, never a macro. + const macro = target.type === 'identifier' ? target.text : null; + const args = callArgs(node); + + // --- defmodule / defprotocol --- + if (macro === 'defmodule' || macro === 'defprotocol') { + const alias = args?.namedChildren.find((c) => c.type === 'alias'); + const name = alias ? getNodeText(alias, ctx.source) : ''; + const kind = macro === 'defprotocol' ? 'interface' : 'module'; + const created = ctx.createNode(kind, name, node); + const block = doBlock(node); + if (created && block) { + ctx.pushScope(created.id); + for (let i = 0; i < block.namedChildCount; i++) { + const child = block.namedChild(i); + if (child) ctx.visitNode(child); + } + ctx.popScope(); + } + return true; + } + + // --- defimpl Protocol, for: Type --- + if (macro === 'defimpl') { + const aliases = args?.namedChildren.filter((c) => c.type === 'alias') ?? []; + const protocol = aliases[0] ? getNodeText(aliases[0], ctx.source) : ''; + // `for:` target type, if present + const kw = args?.namedChildren.find((c) => c.type === 'keywords'); + const forPair = kw?.namedChildren.find( + (p) => p.type === 'pair' && (p.childForFieldName('key')?.text ?? '').replace(/:$/, '') === 'for' + ); + const forVal = forPair?.childForFieldName('value'); + const forType = forVal ? getNodeText(forVal, ctx.source) : 'Any'; + const created = ctx.createNode('module', `${protocol}.${forType}`, node); + if (created) { + ctx.addUnresolvedReference({ + fromNodeId: created.id, + referenceName: protocol, + referenceKind: 'implements', + filePath: ctx.filePath, + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + const block = doBlock(node); + if (block) { + ctx.pushScope(created.id); + for (let i = 0; i < block.namedChildCount; i++) { + const child = block.namedChild(i); + if (child) ctx.visitNode(child); + } + ctx.popScope(); + } + } + return true; + } + + // --- def / defp / defmacro / defguard / defdelegate --- + if (macro && DEF_MACROS.has(macro)) { + const name = defName(args); + if (name) { + // Fold additional clauses of the same function into the first node, but + // still walk each clause body so all its calls are recorded. + const qn = qualifiedNameFor(ctx, name); + const existing = ctx.nodes.find((n) => n.kind === 'function' && n.qualifiedName === qn); + const fnNode = existing ?? ctx.createNode('function', name, node, { + visibility: PRIVATE_DEFS.has(macro) ? 'private' : 'public', + signature: (getNodeText(node, ctx.source).split('\n')[0] ?? '').trim(), + }); + if (fnNode) { + ctx.pushScope(fnNode.id); + visitDefBody(node, ctx); + ctx.popScope(); + } + } + return true; + } + + // --- defstruct / defexception --- + if (macro && STRUCT_MACROS.has(macro)) { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + const parent = parentId ? ctx.nodes.find((n) => n.id === parentId) : undefined; + ctx.createNode('struct', parent?.name ?? macro, node); + return true; + } + + // --- alias / import / require / use --- + if (macro && DEP_MACROS.has(macro)) { + const signature = (getNodeText(node, ctx.source).split('\n')[0] ?? '').trim(); + const first = args?.namedChild(0); + if (first?.type === 'alias') { + ctx.createNode('import', getNodeText(first, ctx.source), node, { signature }); + } else if (first?.type === 'dot') { + // Multi-alias: `alias A.B.{X, Y}` → dot(left: alias "A.B", right: tuple) + const base = getNodeText(first.childForFieldName('left') ?? first, ctx.source); + const tuple = first.childForFieldName('right') ?? first.namedChildren.find((c) => c.type === 'tuple'); + if (tuple) { + for (let i = 0; i < tuple.namedChildCount; i++) { + const member = tuple.namedChild(i); + if (member?.type === 'alias') { + ctx.createNode('import', `${base}.${getNodeText(member, ctx.source)}`, node, { signature }); + } + } + } + } + return true; + } + + // --- real function call --- + let callee: string; + if (target.type === 'identifier') callee = target.text; + else callee = getNodeText(target, ctx.source); // dot → "Mod.fun" + + const callerId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (callerId && !SKIP_CALL.has(callee)) { + ctx.addUnresolvedReference({ + fromNodeId: callerId, + referenceName: callee, + referenceKind: 'calls', + filePath: ctx.filePath, + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + // Descend into every child except the target so nested calls in arguments, + // do-blocks (control-flow macros), and keyword bodies are still recorded. + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child !== target) ctx.visitNode(child); + } + return true; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..19df78dc2 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -32,6 +32,7 @@ import { cfqueryExtractor } from './cfquery'; import { cobolExtractor } from './cobol'; import { vbnetExtractor } from './vbnet'; import { erlangExtractor } from './erlang'; +import { elixirExtractor } from './elixir'; import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; @@ -65,6 +66,7 @@ export const EXTRACTORS: Partial> = { cobol: cobolExtractor, vbnet: vbnetExtractor, erlang: erlangExtractor, + elixir: elixirExtractor, solidity: solidityExtractor, terraform: terraformExtractor, arkts: arktsExtractor, diff --git a/src/types.ts b/src/types.ts index d160d044e..bb7b3833d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,6 +94,7 @@ export const LANGUAGES = [ 'r', 'solidity', 'nix', + 'elixir', 'yaml', 'twig', 'xml', From 7510211ac638c514a9f074a71c857c386838c8a4 Mon Sep 17 00:00:00 2001 From: Liz Baker Date: Fri, 10 Jul 2026 15:01:46 -0400 Subject: [PATCH 2/2] fix(elixir): resolve calls through alias X, as: Y bindings alias X, as: Y previously recorded the import under X's canonical name, so a later qualified call through the alias (Y.foo()) shared no substring with the callee's real qualifiedName (X::foo) and never resolved. Now records the import under the local alias name (matching how TS/JS import { A as C } records C) with the canonical path kept in signature, and import-resolver.ts rewrites Y.foo() back to X::foo before falling through to qualified-name matching. Also fixes a pre-existing defimpl ... for: bug where the keyword's trailing space caused the for: target type to never match, always falling back to the 'Any' default. Co-Authored-By: Claude Sonnet 5 --- __tests__/extraction.test.ts | 63 ++++++++++++++++++++++++++++++ src/extraction/languages/elixir.ts | 36 ++++++++++++++++- src/resolution/import-resolver.ts | 55 ++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index d195d938f..7ca07ad9d 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -11074,6 +11074,64 @@ end expect(names).toContain('Logger'); expect(names).toContain('GenServer'); }); + + it('should record an `as:` alias under the local name', () => { + const code = ` +defmodule M do + alias DistributorSSO.JitController, as: JitController +end +`; + const result = extractFromSource('lib/m.ex', code); + const imp = result.nodes.find((n) => n.kind === 'import'); + // Recorded under the LOCAL binding (the alias), matching how a TS/JS + // `import { A as C }` records `C` — not the canonical module path. + expect(imp?.name).toBe('JitController'); + expect(imp?.signature).toContain('DistributorSSO.JitController'); + expect(imp?.signature).toContain('as: JitController'); + }); + + it('resolves a call through an `alias X, as: Y` binding back to X (aliasing)', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'lib', 'distributor_sso'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'lib', 'distributor_sso', 'jit_controller.ex'), + ` +defmodule DistributorSSO.JitController do + def foo(conn) do + conn + end +end +` + ); + fs.writeFileSync( + path.join(dir, 'lib', 'caller.ex'), + ` +defmodule Caller do + alias DistributorSSO.JitController, as: JitController + + def handle(conn) do + JitController.foo(conn) + end +end +` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ex'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + + const foo = cg + .getNodesByName('foo') + .find((n) => n.kind === 'function' && n.qualifiedName === 'DistributorSSO.JitController::foo'); + expect(foo).toBeDefined(); + + const callers = cg.getCallers(foo!.id).map((c) => c.node); + expect(callers.some((n) => n.kind === 'function' && n.name === 'handle')).toBe(true); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); }); describe('Structural macros', () => { @@ -11114,6 +11172,11 @@ end (r) => r.referenceKind === 'implements' && r.referenceName === 'Sizeable' ); expect(impl).toBeDefined(); + // The `for:` target type must be read correctly (not fall back to the + // 'Any' default) — the keyword node's text carries a trailing space + // ("for: ") that has to be trimmed before comparing against 'for'. + const mod = result.nodes.find((n) => n.kind === 'module'); + expect(mod?.name).toBe('Sizeable.List'); }); it('should extract defdelegate as a function', () => { diff --git a/src/extraction/languages/elixir.ts b/src/extraction/languages/elixir.ts index 7a3341ec3..13911812d 100644 --- a/src/extraction/languages/elixir.ts +++ b/src/extraction/languages/elixir.ts @@ -55,6 +55,28 @@ function doBlock(node: SyntaxNode): SyntaxNode | null { return node.namedChildren.find((c) => c.type === 'do_block') ?? null; } +/** + * The `as:` value of `alias X, as: Y` — the arguments node holds + * `(alias "X") (keywords (pair key: (keyword "as:") value: (alias "Y")))`. + * Returns null when there is no `as:` keyword (the common case, where the + * local binding is X's own last segment). + */ +function elixirAsAlias(args: SyntaxNode | null, source: string): string | null { + const kw = args?.namedChildren.find((c) => c.type === 'keywords'); + if (!kw) return null; + for (let i = 0; i < kw.namedChildCount; i++) { + const pair = kw.namedChild(i); + if (pair?.type !== 'pair') continue; + const key = pair.childForFieldName('key') ?? pair.namedChild(0); + const keyText = key ? getNodeText(key, source).trim().replace(/:$/, '') : ''; + if (keyText === 'as') { + const value = pair.childForFieldName('value') ?? pair.namedChild(1); + return value ? getNodeText(value, source) : null; + } + } + return null; +} + /** * Extract a definition's simple name from a def-macro's arguments. Handles: * def foo(a) → call(target: foo) @@ -180,7 +202,7 @@ export const elixirExtractor: LanguageExtractor = { // `for:` target type, if present const kw = args?.namedChildren.find((c) => c.type === 'keywords'); const forPair = kw?.namedChildren.find( - (p) => p.type === 'pair' && (p.childForFieldName('key')?.text ?? '').replace(/:$/, '') === 'for' + (p) => p.type === 'pair' && (p.childForFieldName('key')?.text ?? '').trim().replace(/:$/, '') === 'for' ); const forVal = forPair?.childForFieldName('value'); const forType = forVal ? getNodeText(forVal, ctx.source) : 'Any'; @@ -241,7 +263,17 @@ export const elixirExtractor: LanguageExtractor = { const signature = (getNodeText(node, ctx.source).split('\n')[0] ?? '').trim(); const first = args?.namedChild(0); if (first?.type === 'alias') { - ctx.createNode('import', getNodeText(first, ctx.source), node, { signature }); + const canonical = getNodeText(first, ctx.source); + // `alias X, as: Y` — the `as:` keyword renames the local binding: later + // code refers to X only as Y (e.g. `Y.some_function()`). Elixir's own + // default-alias rule (`alias Foo.Bar` binds the LAST segment, `Bar`, + // with no `as:` needed) means an explicit `as:` is the only case where + // the local name actually diverges from the canonical module path, + // so record the import under the alias name and keep the canonical + // path in `signature` for the resolver to recover (import-resolver.ts + // resolveElixirAlias reads it back out). + const asAlias = elixirAsAlias(args, ctx.source); + ctx.createNode('import', asAlias ?? canonical, node, { signature }); } else if (first?.type === 'dot') { // Multi-alias: `alias A.B.{X, Y}` → dot(left: alias "A.B", right: tuple) const base = getNodeText(first.childForFieldName('left') ?? first, ctx.source); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index bbb1303b3..ad6f28378 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -10,6 +10,7 @@ import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types'; import { applyAliases } from './path-aliases'; import { resolveWorkspaceImport } from './workspace-packages'; +import { preferCallSiteFile } from './name-matcher'; /** * Extension resolution order by language @@ -1385,6 +1386,16 @@ export function resolveViaImport( if (luaResult) return luaResult; } + // Elixir qualified call through an `alias X, as: Y` binding: `Y.fun()` + // extracts to a `calls` ref named `Y.fun`, which shares no substring with + // the callee's real qualifiedName (`X::fun`) — plain qualified-name + // matching can't bridge the renamed receiver. Rewrite through the calling + // file's alias map before falling through to name-matching. + if (ref.language === 'elixir' && ref.referenceKind === 'calls') { + const elixirResult = resolveElixirAlias(ref, context); + if (elixirResult) return elixirResult; + } + // Whole-module / namespace imports → link the importing file to the module // file. Python `from . import certs` / `import mod`, and TS/JS `import * as ns // from './x'` (so a namespace touched only via a value-member read still @@ -1685,6 +1696,50 @@ function resolvePythonAbsoluteModule( return hit ? { original: ref, targetNodeId: hit.id, confidence: 0.9, resolvedBy: 'import' } : null; } +/** + * Resolve an Elixir qualified call made through an `alias X, as: Y` binding — + * `Y.some_function()` extracts to a `calls` ref named `Y.some_function`, which + * shares no substring with the callee's real qualifiedName + * (`X::some_function`), so plain qualified-name matching (which only ever + * strips/matches on `.`/`::` boundaries of the SAME path) never connects it. + * + * The extractor records the alias's `import` node under the LOCAL name (`Y`, + * matching how TS/JS `import { A as C }` records `C`) with the full `alias …` + * statement text in `signature`, since Elixir's own default-alias rule (a bare + * `alias Foo.Bar` binds the last segment, `Bar`, needing no `as:`) means an + * explicit `as:` is the only case where the local name actually diverges from + * the canonical module path. This walks the calling file's `import` nodes for + * one named like the reference's first segment, recovers X from its + * `signature`, and rewrites the reference to `X.some_function` before handing + * back to the generic qualified-name matcher. + */ +function resolveElixirAlias(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const dot = ref.referenceName.indexOf('.'); + if (dot <= 0) return null; + const aliasName = ref.referenceName.slice(0, dot); + const rest = ref.referenceName.slice(dot + 1); + if (!rest) return null; + + const importNode = context + .getNodesInFile(ref.filePath) + .find((n) => n.kind === 'import' && n.name === aliasName); + if (!importNode?.signature) return null; + + // `signature` holds the full statement, e.g. + // "alias DistributorSSO.JitController, as: JitController". + const asMatch = importNode.signature.match(/as:\s*([\w.]+)\s*$/); + if (!asMatch || asMatch[1] !== aliasName) return null; + const canonicalMatch = importNode.signature.match(/alias\s+([\w.]+)\s*,/); + const canonical = canonicalMatch?.[1]; + if (!canonical) return null; + + const qualifiedName = `${canonical}::${rest}`; + const candidates = context.getNodesByQualifiedName(qualifiedName); + const chosen = preferCallSiteFile(candidates, ref.filePath)[0]; + if (!chosen) return null; + return { original: ref, targetNodeId: chosen.id, confidence: 0.9, resolvedBy: 'import' }; +} + /** * Resolve a Rust qualified reference `A::B::C` by mapping the MODULE prefix * (`A::B`) to a file and finding the leaf symbol (`C`) in it. This is the Rust