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.


## [1.0.0] - 2026-06-12

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ CodeGraph cuts **tokens, tool calls, and wall-clock time on every repo** — acr
| **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, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Elixir, 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 @@ -673,6 +673,7 @@ is written):
| Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) |
| R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) |
| Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |
| 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) |

## Measured cross-file coverage

Expand Down
204 changes: 204 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7161,3 +7161,207 @@ GeomPoint <- ggproto("GeomPoint", Geom,
});
});
});

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();
});
});
});
5 changes: 5 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
r: 'tree-sitter-r.wasm',
luau: 'tree-sitter-luau.wasm',
objc: 'tree-sitter-objc.wasm',
elixir: 'tree-sitter-elixir.wasm',
};

/**
Expand Down Expand Up @@ -96,6 +97,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 @@ -404,6 +408,7 @@ export function getLanguageDisplayName(language: Language): string {
go: 'Go',
rust: 'Rust',
r: 'R',
elixir: 'Elixir',
java: 'Java',
c: 'C',
cpp: 'C++',
Expand Down
Loading