Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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) |
Expand Down
267 changes: 267 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10939,3 +10939,270 @@ 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');
});

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', () => {
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();
// 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', () => {
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();
});
});
});
5 changes: 5 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
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',
Expand Down Expand Up @@ -111,6 +112,9 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.vue': 'vue',
'.astro': 'astro',
'.r': 'r',
// Elixir source (.ex) and scripts (.exs)
'.ex': 'elixir',
'.exs': 'elixir',
'.pas': 'pascal',
'.dpr': 'pascal',
'.dpk': 'pascal',
Expand Down Expand Up @@ -559,6 +563,7 @@ export function getLanguageDisplayName(language: Language): string {
go: 'Go',
rust: 'Rust',
r: 'R',
elixir: 'Elixir',
java: 'Java',
c: 'C',
cpp: 'C++',
Expand Down
Loading