feat(extraction): Elixir language support (.ex/.exs)#1250
Conversation
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
Closing this in favor of #1264. @w0lan's #1264 is an independent implementation that covers everything this PR does and more — default/multi-alias resolution ( It also caught a real correctness bug in this branch: this PR has no Elixir-specific rule in the name matcher, so bare (unqualified) calls fall through to repo-wide name matching with no same-file/same-module restriction. I verified this produces actual wrong edges on our own ~3,400-file production Elixir monorepo — a bare Given the overlap, no need to carry two competing implementations — appreciate the review either way. |
What
Adds Elixir (
.ex/.exs) to CodeGraph's tree-sitter extraction, rebased on currentmainand validated against a real production Elixir/Phoenix monorepo (~3,400 Elixir files).This builds directly on @allenwoods' work in #871, which had gone unreviewed for a few weeks and drifted out of sync with
main. Filed issue #1219 also flags #871 (and #228) as ready for attention — this PR is a rebased, extended version of #871 specifically, since itsvisitNode-dispatch approach to Elixir's metaprogramming-heavy grammar was the more surgical of the two.Why a custom approach
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:So extraction runs through the
visitNodehook and dispatches on the macro identifier instead of node types (similar in spirit to Ruby's module handling and Pascal's custom visitor).callTypesis empty because the core'sextractCallkeys off afunctionfield Elixir lacks — the hook records calls itself.Extracted
defmodule, nested) and protocols (defprotocol→interface)def/defp/defmacro/defmacrop/defguard/defguardp/defdelegate, with public/private visibility. Multi-clause definitions fold into a single symbol (samequalifiedName) so the resolver isn't left with duplicate nodes.alias/import/require/use, including multi-aliasalias A.{B, C}expansion into one import eachdefimplwith animplementsedge to the protocoldefstruct/defexceptionas struct nodesMod.funand local calls, including through pipe chains and insideif/case/with/condbodies (via the core's default fallback traversal for unhandled node types)What this PR adds on top of #871
alias X, as: Ybindings. feat(extraction): Elixir language support (.ex/.exs) #871 recorded the import underX's canonical name, so a later qualified call through the alias (Y.foo()) shared no substring with the callee's realqualifiedName(X::foo) and never resolved. Now the import is recorded under the local alias name (matching how TS/JSimport { A as C }recordsC), with the canonical path preserved insignature;import-resolver.tsrewritesY.foo()back toX::foobefore falling through to qualified-name matching. This is a real pattern in production Phoenix code (SSO/OAuth controllers commonly alias controllers withas:).defimpl ... for:bug: the keyword node's text carries a trailing space ("for: "), which meant thefor:target type comparison never matched and silently fell back to the'Any'default.main(resolving conflicts inCHANGELOG.md,README.md,__tests__/extraction.test.ts,src/extraction/grammars.ts,src/extraction/languages/index.ts,src/types.ts— all additive, since several other languages landed onmainsince feat(extraction): Elixir language support (.ex/.exs) #871 was opened).Validation
Indexed a real internal Elixir/Phoenix umbrella app (~3,400
.exfiles, Absinthe GraphQL + Ecto + Oban): 31,576 nodes / 51,699 edges in ~12s. Spot-checkedcallers/node/exploreagainst real controllers — correctly disambiguates same-named public/private functions across files, resolves calls throughas:aliases, and traces multi-hop call chains through Ecto query blocks without choking on them.Test plan
npx vitest run __tests__/extraction.test.ts -t "Elixir")npm run buildsucceedsCo-authored-by: allenwoods (original #871 author)