feat(extraction): Elixir language support (.ex/.exs)#1250
Open
liz-baker wants to merge 2 commits into
Open
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)