Skip to content

feat(extraction): Elixir language support (.ex/.exs)#1250

Open
liz-baker wants to merge 2 commits into
colbymchenry:mainfrom
prokeep:feat/elixir-support
Open

feat(extraction): Elixir language support (.ex/.exs)#1250
liz-baker wants to merge 2 commits into
colbymchenry:mainfrom
prokeep:feat/elixir-support

Conversation

@liz-baker

Copy link
Copy Markdown

What

Adds Elixir (.ex / .exs) to CodeGraph's tree-sitter extraction, rebased on current main and 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 its visitNode-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:

(call target: (identifier "<macro>") (arguments …) (do_block …)?)

So extraction runs through the visitNode hook and dispatches on the macro identifier instead of node types (similar in spirit to Ruby's module handling and Pascal's custom visitor). callTypes is empty because the core's extractCall keys off a function field Elixir lacks — the hook records calls itself.

Extracted

  • Modules (defmodule, nested) and protocols (defprotocolinterface)
  • Functionsdef / defp / defmacro / defmacrop / defguard / defguardp / defdelegate, with public/private visibility. Multi-clause definitions fold into a single symbol (same qualifiedName) so the resolver isn't left with duplicate nodes.
  • Dependenciesalias / import / require / use, including multi-alias alias A.{B, C} expansion into one import each
  • defimpl with an implements edge to the protocol
  • defstruct / defexception as struct nodes
  • Call edges — qualified Mod.fun and local calls, including through pipe chains and inside if/case/with/cond bodies (via the core's default fallback traversal for unhandled node types)

What this PR adds on top of #871

  • Resolves calls made through alias X, as: Y bindings. feat(extraction): Elixir language support (.ex/.exs) #871 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 the import is recorded under the local alias name (matching how TS/JS import { A as C } records C), with the canonical path preserved in signature; import-resolver.ts rewrites Y.foo() back to X::foo before falling through to qualified-name matching. This is a real pattern in production Phoenix code (SSO/OAuth controllers commonly alias controllers with as:).
  • Fixes a pre-existing defimpl ... for: bug: the keyword node's text carries a trailing space ("for: "), which meant the for: target type comparison never matched and silently fell back to the 'Any' default.
  • Rebased cleanly onto current main (resolving conflicts in CHANGELOG.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 on main since feat(extraction): Elixir language support (.ex/.exs) #871 was opened).

Validation

Indexed a real internal Elixir/Phoenix umbrella app (~3,400 .ex files, Absinthe GraphQL + Ecto + Oban): 31,576 nodes / 51,699 edges in ~12s. Spot-checked callers/node/explore against real controllers — correctly disambiguates same-named public/private functions across files, resolves calls through as: aliases, and traces multi-hop call chains through Ecto query blocks without choking on them.

Test plan

  • All existing + new Elixir extraction tests pass (npx vitest run __tests__/extraction.test.ts -t "Elixir")
  • Full test suite passes after rebase
  • npm run build succeeds
  • Manually indexed and queried a real production Elixir codebase (see Validation above)

Co-authored-by: allenwoods (original #871 author)

allenwoods and others added 2 commits July 10, 2026 14:38
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants