diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 150b4a601..9b5a10e69 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -493,6 +493,29 @@ "question": "How does a PUBLISH packet from an MQTT client reach the sessions of matching subscribers? Trace the flow from the connection/channel layer through the broker's routing to session delivery." } ], + "Elixir": [ + { + "name": "plug", + "repo": "https://github.com/elixir-plug/plug", + "size": "Small", + "files": "~80", + "question": "How does Plug.Builder compile a plug pipeline so a Conn reaches each plug's call/2 in order? Trace from use Plug.Builder through the compiled call/2." + }, + { + "name": "phoenix", + "repo": "https://github.com/phoenixframework/phoenix", + "size": "Medium", + "files": "~240", + "question": "How does an HTTP request arriving at Phoenix.Endpoint reach a controller action through the router and the controller pipeline?" + }, + { + "name": "blockscout", + "repo": "https://github.com/blockscout/blockscout", + "size": "Large", + "files": "~2500", + "question": "How does BlockScout fetch a newly imported block from the Ethereum JSON-RPC client and persist it so the block page can serve it? Trace from the indexer fetcher through the import pipeline to the schema." + } + ], "Solidity": [ { "name": "solmate", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c4150..b37a69aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- CodeGraph now indexes **Elixir** (`.ex`, `.exs`) — modules, functions and macros (with clauses and arities of the same name grouped as one symbol), `defp` visibility, `@spec` signatures, `@doc` docs, structs with their fields, `@type`/`@opaque` aliases, protocols and implementations, plus `alias`/`import`/`require`/`use`. Cross-module `Mod.fun(...)` calls resolve through aliases, `|>` pipes and `&fun/arity` captures are followed, `apply`/`spawn` that name a module and function as arguments link to that function, `GenServer.call/cast` on this module reaches `handle_call`/`handle_cast`, and `@behaviour` names the callback module. Aliases apply in source order (including inside a function), a local call resolves to the enclosing module when a file holds more than one, an explicit `import Mod, only: [...]` no longer also binds the bare name, and each `defimpl` is its own scope so two implementations in one file no longer share callback names. Re-index Elixir projects after upgrading. + - **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps. - **Where the code chooses, the picture says so once.** A helper that ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'` sends the app to one of two screens, but the Steps picture drew that as two separate arrows, each carrying the whole condition with one of them negated and both cut off at the same forty characters — and before you clicked anything, neither arrow was labelled at all, so nothing said it was a choice. Now sibling arrows out of one box that are the arms of one `if`, `switch` or ternary are drawn as the choice they are: the condition is written once under the box that decides it, and each arrow out says only which way it is — `yes`, `no`, or a case's own value. They are the only arrows labelled before you select anything, so the picture reads at a glance without becoming a wall of text. A one-sided guard — an early exit, an `if` with only one side drawn — still carries its condition on the arrow, and an arrow that is reached whether or not the condition holds never claims a side. Nothing needs a re-index: the decision is read from the source at request time. diff --git a/README.md b/README.md index a73d3b2bc..ad983d673 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ Every language below gets the same treatment — full structural extraction and COBOL Visual Basic .NET Erlang + Elixir Solidity Terraform / OpenTofu Nix @@ -289,7 +290,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr | **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 | @@ -896,6 +897,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 with nested `defmodule`, functions and macros with multi-clause/multi-arity grouping, `defp` visibility, `@spec` signatures and `@doc`/`@moduledoc`, `defstruct` fields, `@type`/`@typep`/`@opaque` aliases, `defprotocol`/`defimpl`, `alias`/`import`/`require`/`use` edges with alias expansion on calls, local and `Mod.fun` remote call edges including `|>` pipes and `&fun/arity` captures, `apply`/`spawn` MFA-argument call edges, `GenServer.call/cast(__MODULE__)` → own `handle_call`/`handle_cast` links, `@behaviour` links, `defdelegate`) | | 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) | diff --git a/__tests__/elixir-arity-resolution.test.ts b/__tests__/elixir-arity-resolution.test.ts new file mode 100644 index 000000000..fcae7d1a9 --- /dev/null +++ b/__tests__/elixir-arity-resolution.test.ts @@ -0,0 +1,161 @@ +/** + * Elixir arity-aware resolution. + * + * Arity is part of a function's identity: `f/1` and `f/2` are unrelated + * definitions. Extraction gives each arity its own node (`Mod::f/1`) and + * stamps refs with the call-site arity; resolution must land each ref on the + * def of exactly that arity — except default-args (`def foo(a, b \\ 1)`), + * which index as the max arity and accept a shorter call when that file + * defines exactly one function of the name. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +describe('elixir arity-aware resolution', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'elixir-arity-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + async function callEdges(d: string): Promise> { + const cg = await CodeGraph.init(d, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT s.qualified_name sq, t.qualified_name tq + FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind IN ('calls','references') AND s.kind = 'function'` + ) + .all(); + cg.destroy(); + return rows; + } + + it('resolves the f/N -> f/N+1 delegation to a real edge, not a self-loop', async () => { + fs.mkdirSync(path.join(dir, 'lib'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'lib', 'deleg.ex'), + `defmodule Deleg do + def header(name, req) do + header(name, req, nil) + end + + def header(name, headers, default) do + Map.get(headers, name, default) + end +end +` + ); + const edges = await callEdges(dir); + expect(edges).toContainEqual({ sq: 'Deleg::header/2', tq: 'Deleg::header/3' }); + expect(edges.some((e) => e.sq === e.tq && e.sq.startsWith('Deleg::header'))).toBe(false); + }); + + it('resolves remote calls to the called arity and refuses a sibling arity', async () => { + fs.mkdirSync(path.join(dir, 'lib'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'lib', 'store.ex'), + `defmodule Store do + def get(k), do: get(k, nil) + def get(k, default), do: {k, default} +end +` + ); + fs.writeFileSync( + path.join(dir, 'lib', 'client.ex'), + `defmodule Client do + def fetch(k), do: Store.get(k, nil) + def broken(k), do: Store.get(k, nil, :extra) +end +` + ); + const edges = await callEdges(dir); + expect(edges).toContainEqual({ sq: 'Client::fetch/1', tq: 'Store::get/2' }); + expect(edges.some((e) => e.sq === 'Client::broken/1' && e.tq.startsWith('Store::get'))).toBe(false); + }); + + it('resolves a shorter call onto a default-args def when it is the only arity', async () => { + fs.mkdirSync(path.join(dir, 'lib'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'lib', 'opts.ex'), + `defmodule Opts do + def get(id, opts \\\\ []) do + {id, opts} + end + + def run(id), do: get(id) +end +` + ); + const edges = await callEdges(dir); + expect(edges).toContainEqual({ sq: 'Opts::run/1', tq: 'Opts::get/2' }); + }); + + it('resolves a local call to the enclosing module when the file has two modules', async () => { + fs.mkdirSync(path.join(dir, 'lib'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'lib', 'pair.ex'), + `defmodule First do + def work(x), do: {:first, x} + def run(x), do: work(x) +end + +defmodule Second do + def work(x), do: {:second, x} + def run(x), do: work(x) +end +` + ); + const edges = await callEdges(dir); + expect(edges).toContainEqual({ sq: 'First::run/1', tq: 'First::work/1' }); + expect(edges).toContainEqual({ sq: 'Second::run/1', tq: 'Second::work/1' }); + expect(edges.some((e) => e.sq === 'Second::run/1' && e.tq === 'First::work/1')).toBe(false); + expect(edges.some((e) => e.sq === 'First::run/1' && e.tq === 'Second::work/1')).toBe(false); + }); + + it('does not resolve an explicit import to an unrelated same-named function', async () => { + fs.mkdirSync(path.join(dir, 'lib'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'lib', 'other.ex'), + `defmodule Other do + def work(x), do: {:other, x} +end +` + ); + fs.writeFileSync( + path.join(dir, 'lib', 'client.ex'), + `defmodule Client do + import Foo, only: [work: 1] + def go(x), do: work(x) +end +` + ); + const edges = await callEdges(dir); + expect(edges.some((e) => e.sq === 'Client::go/1' && e.tq === 'Other::work/1')).toBe(false); + }); + + it('resolves aliased remote calls across files', async () => { + fs.mkdirSync(path.join(dir, 'lib'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'lib', 'repo.ex'), + `defmodule MyApp.Repo do + def get(schema, id), do: {schema, id} +end +` + ); + fs.writeFileSync( + path.join(dir, 'lib', 'accounts.ex'), + `defmodule MyApp.Accounts do + alias MyApp.Repo + + def get_user(id), do: Repo.get(User, id) +end +` + ); + const edges = await callEdges(dir); + expect(edges).toContainEqual({ sq: 'MyApp.Accounts::get_user/1', tq: 'MyApp.Repo::get/2' }); + }); +}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index ad0ba2374..d81ec2c78 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -150,6 +150,17 @@ describe('Language Detection', () => { expect(isSourceFile('default.nix')).toBe(true); }); + it('should detect Elixir files', () => { + expect(detectLanguage('lib/my_app/accounts.ex')).toBe('elixir'); + expect(detectLanguage('mix.exs')).toBe('elixir'); + expect(detectLanguage('test/my_app_test.exs')).toBe('elixir'); + expect(detectLanguage('config/config.exs')).toBe('elixir'); + expect(isSourceFile('lib/my_app.ex')).toBe(true); + expect(isSourceFile('mix.exs')).toBe(true); + expect(isLanguageSupported('elixir')).toBe(true); + expect(getSupportedLanguages()).toContain('elixir'); + }); + it('should detect a .h whose only C++ signal is an export-macro class as cpp', () => { // Lean Unreal-Engine style header: the class is annotated with an export // macro and carries no explicit `public:`/`virtual`/`namespace`/`template`, @@ -252,6 +263,8 @@ describe('Language Support', () => { expect(languages).toContain('dart'); expect(languages).toContain('solidity'); expect(languages).toContain('nix'); + expect(languages).toContain('elixir'); + expect(languages).toContain('erlang'); }); }); @@ -10939,6 +10952,430 @@ init(_) -> {ok, #{}}. }); }); +describe('Elixir Extraction', () => { + describe('Language detection', () => { + it('should report Elixir as supported', () => { + expect(isLanguageSupported('elixir')).toBe(true); + expect(getSupportedLanguages()).toContain('elixir'); + expect(isSourceFile('lib/foo.ex')).toBe(true); + expect(isSourceFile('mix.exs')).toBe(true); + }); + }); + + describe('Module and function extraction', () => { + it('should extract defmodule, public and private functions with arity-qualified names', () => { + const code = `defmodule MyApp.Accounts do + def get_user(id) do + id + end + + defp normalize(name) do + name + end +end +`; + const result = extractFromSource('lib/accounts.ex', code); + const mod = result.nodes.find((n) => n.kind === 'module'); + expect(mod?.name).toBe('MyApp.Accounts'); + const getUser = result.nodes.find((n) => n.kind === 'function' && n.name === 'get_user'); + const normalize = result.nodes.find((n) => n.kind === 'function' && n.name === 'normalize'); + expect(getUser?.qualifiedName).toBe('MyApp.Accounts::get_user/1'); + expect(getUser?.isExported).toBe(true); + expect(normalize?.qualifiedName).toBe('MyApp.Accounts::normalize/1'); + expect(normalize?.isExported).toBe(false); + expect(normalize?.visibility).toBe('private'); + expect(getUser?.language).toBe('elixir'); + }); + + it('should nest an inner defmodule under its parent', () => { + const code = `defmodule Foo do + defmodule Bar do + def baz, do: :ok + end +end +`; + const result = extractFromSource('lib/foo.ex', code); + const mods = result.nodes.filter((n) => n.kind === 'module').map((n) => n.name).sort(); + expect(mods).toEqual(['Foo', 'Foo.Bar']); + const baz = result.nodes.find((n) => n.name === 'baz'); + expect(baz?.qualifiedName).toBe('Foo.Bar::baz/0'); + }); + + it('should merge multi-clause functions of the same arity into one node', () => { + const code = `defmodule M do + def classify(:a), do: :atom + def classify(:b), do: :atom + def classify(_), do: :other +end +`; + const result = extractFromSource('lib/m.ex', code); + const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'classify'); + expect(fns).toHaveLength(1); + expect(fns[0]!.startLine).toBe(2); + expect(fns[0]!.endLine).toBe(4); + expect(fns[0]!.qualifiedName).toBe('M::classify/1'); + }); + + it('should give same-name different-arity functions separate nodes', () => { + const code = `defmodule Gap do + def f(x), do: x + 1 + def f(x, y), do: x + y +end +`; + const result = extractFromSource('lib/gap.ex', code); + const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'f'); + expect(fns).toHaveLength(2); + expect(fns.map((n) => n.qualifiedName).sort()).toEqual(['Gap::f/1', 'Gap::f/2']); + }); + + it('should attach @spec and @doc to the following function', () => { + const code = `defmodule M do + @doc "Fetches a value by key." + @spec fetch(binary()) :: {:ok, term()} | :not_found + def fetch(key) do + lookup(key) + end + + defp lookup(_k), do: :not_found +end +`; + const result = extractFromSource('lib/m.ex', code); + const fetch = result.nodes.find((n) => n.name === 'fetch'); + expect(fetch?.signature).toContain('@spec fetch(binary())'); + expect(fetch?.docstring).toBe('Fetches a value by key.'); + }); + + it('should fall back to the clause header as the signature', () => { + const code = `defmodule M do + def resize(w, h) when w > 0 and h > 0 do + {w, h} + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const resize = result.nodes.find((n) => n.name === 'resize'); + expect(resize?.signature).toContain('def resize(w, h) when w > 0 and h > 0'); + }); + }); + + describe('Types, structs, protocols', () => { + it('should extract defstruct fields onto the enclosing module', () => { + const code = `defmodule User do + defstruct [:name, age: 0] +end +`; + const result = extractFromSource('lib/user.ex', code); + const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name).sort(); + expect(fields).toEqual(['age', 'name']); + }); + + it('should extract @type / @typep / @opaque as type aliases', () => { + const code = `defmodule M do + @type t :: atom() | binary() + @typep internal :: reference() + @opaque handle() :: reference() +end +`; + const result = extractFromSource('lib/m.ex', code); + const aliases = result.nodes.filter((n) => n.kind === 'type_alias').map((n) => n.name).sort(); + expect(aliases).toEqual(['handle', 'internal', 't']); + }); + + it('should extract defprotocol as an interface and defimpl as implements', () => { + const code = `defprotocol Drawable do + def draw(data) +end + +defimpl Drawable, for: User do + def draw(user), do: user.name +end +`; + const result = extractFromSource('lib/drawable.ex', code); + const proto = result.nodes.find((n) => n.kind === 'interface'); + expect(proto?.name).toBe('Drawable'); + const implMod = result.nodes.find((n) => n.kind === 'module' && n.name === 'Drawable.User'); + expect(implMod?.qualifiedName).toBe('Drawable.User'); + const draw = result.nodes.find((n) => n.kind === 'function' && n.name === 'draw' && n.qualifiedName !== 'Drawable::draw/1'); + expect(draw?.qualifiedName).toBe('Drawable.User::draw/1'); + const impl = result.unresolvedReferences.find((r) => r.referenceKind === 'implements'); + expect(impl?.referenceName).toBe('Drawable'); + expect(impl?.fromNodeId).toBe(implMod?.id); + }); + + it('should give each top-level defimpl its own scope so callbacks do not share qnames', () => { + const code = `defprotocol Drawable do + def draw(data) +end + +defimpl Drawable, for: User do + def draw(user), do: user.name +end + +defimpl Drawable, for: Post do + def draw(post), do: post.title +end +`; + const result = extractFromSource('lib/drawable.ex', code); + const draws = result.nodes + .filter((n) => n.kind === 'function' && n.name === 'draw' && n.qualifiedName.startsWith('Drawable.')) + .map((n) => n.qualifiedName) + .sort(); + expect(draws).toEqual(['Drawable.Post::draw/1', 'Drawable.User::draw/1']); + const impls = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements'); + expect(impls).toHaveLength(2); + const userMod = result.nodes.find((n) => n.qualifiedName === 'Drawable.User'); + const postMod = result.nodes.find((n) => n.qualifiedName === 'Drawable.Post'); + expect(impls.map((r) => r.fromNodeId).sort()).toEqual([postMod?.id, userMod?.id].sort()); + }); + }); + + describe('Import extraction', () => { + it('should extract alias, import, require, and use', () => { + const code = `defmodule M do + use GenServer + alias MyApp.Repo + alias MyApp.Accounts.User + import Ecto.Query + require Logger +end +`; + const result = extractFromSource('lib/m.ex', code); + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(imports).toContain('GenServer'); + expect(imports).toContain('MyApp.Repo'); + expect(imports).toContain('MyApp.Accounts.User'); + expect(imports).toContain('Ecto.Query'); + expect(imports).toContain('Logger'); + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports').map((r) => r.referenceName); + expect(refs).toContain('MyApp.Repo'); + }); + }); + + describe('Call extraction', () => { + it('should record local calls with arity and remote calls module-qualified', () => { + const code = `defmodule M do + def run(x) do + y = prepare(x) + Other.process(y) + end + + defp prepare(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('prepare/1'); + expect(calls).toContain('Other::process/1'); + }); + + it('should expand aliases on remote calls', () => { + const code = `defmodule M do + alias MyApp.Repo + alias Foo.Bar, as: Baz + + def go(id) do + Repo.get(User, id) + Baz.qux() + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('MyApp.Repo::get/2'); + expect(calls).toContain('Foo.Bar::qux/0'); + }); + + it('should apply aliases in source order, not the final binding for the whole module', () => { + const code = `defmodule M do + def early(id), do: Repo.get(id) + + alias A.Repo + + def mid(id), do: Repo.get(id) + + alias B.Repo + + def late(id), do: Repo.get(id) +end +`; + const result = extractFromSource('lib/m.ex', code); + const byFn = (name: string) => { + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === name); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls' && r.fromNodeId === fn?.id) + .map((r) => r.referenceName); + }; + expect(byFn('early')).toContain('Repo::get/1'); + expect(byFn('early').some((c) => c.startsWith('A.Repo') || c.startsWith('B.Repo'))).toBe(false); + expect(byFn('mid')).toContain('A.Repo::get/1'); + expect(byFn('late')).toContain('B.Repo::get/1'); + }); + + it('should expand aliases and imports declared inside a function without leaking them', () => { + const code = `defmodule M do + def one(id) do + alias MyApp.Repo + Repo.get(id) + end + + def two(id) do + Repo.get(id) + end + + def three(x) do + import Foo, only: [work: 1] + work(x) + end + + def four(x), do: work(x) +end +`; + const result = extractFromSource('lib/m.ex', code); + const byFn = (name: string) => { + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === name); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls' && r.fromNodeId === fn?.id) + .map((r) => r.referenceName); + }; + expect(byFn('one')).toContain('MyApp.Repo::get/1'); + expect(byFn('two')).toContain('Repo::get/1'); + expect(byFn('two').some((c) => c.startsWith('MyApp.Repo'))).toBe(false); + expect(byFn('three')).toEqual(['Foo::work/1']); + expect(byFn('four')).toEqual(['work/1']); + }); + + it('should not leak function-local aliases across multi-clause heads', () => { + const code = `defmodule M do + def foo(:a) do + alias A.Repo + Repo.get(1) + end + def foo(:b) do + Repo.get(2) + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const foo = result.nodes.find((n) => n.kind === 'function' && n.name === 'foo'); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls' && r.fromNodeId === foo?.id) + .map((r) => r.referenceName); + expect(calls).toContain('A.Repo::get/1'); + expect(calls).toContain('Repo::get/1'); + }); + + it('should emit only the qualified target for an explicit import only: list', () => { + const code = `defmodule M do + import Foo, only: [work: 1] + + def go(x) do + work(x) + x |> work() + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls.filter((c) => c === 'Foo::work/1' || c === 'work/1').sort()).toEqual([ + 'Foo::work/1', + 'Foo::work/1', + ]); + expect(calls).not.toContain('work/1'); + }); + + it('should count piped calls with +1 arity and capture &fun/arity', () => { + const code = `defmodule M do + def piped(list) do + list + |> Enum.map(&String.upcase/1) + |> join() + end + + defp join(s), do: s +end +`; + const result = extractFromSource('lib/m.ex', code); + const refs = result.unresolvedReferences; + const calls = refs.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + const captures = refs.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName); + expect(calls).toContain('Enum::map/2'); + expect(calls).toContain('join/1'); + expect(captures).toContain('String::upcase/1'); + }); + + it('should treat __MODULE__.fun as a local call and link GenServer.dispatch', () => { + const code = `defmodule Kv do + def get(key) do + GenServer.call(__MODULE__, {:get, key}) + end + + def handle_call({:get, k}, _from, state), do: {:reply, k, state} + + def kick, do: __MODULE__.get(:x) +end +`; + const result = extractFromSource('lib/kv.ex', code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('handle_call/3'); + expect(calls).toContain('get/1'); + }); + + it('should lift static apply/spawn MFA arguments into call refs', () => { + const code = `defmodule M do + def boot(req) do + apply(Other, :handle, [req]) + spawn(M, :work, [req, :ok]) + end + + def work(_a, _b), do: :ok +end +`; + const result = extractFromSource('lib/m.ex', code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('Other::handle/1'); + expect(calls).toContain('work/2'); + }); + + it('should stay silent on dynamic remote calls', () => { + const code = `defmodule M do + def run(mod) do + mod.handle(x) + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls.some((c) => c.includes('handle'))).toBe(false); + }); + + it('should emit a calls ref for defdelegate targets', () => { + const code = `defmodule M do + defdelegate count(), to: Repo +end +`; + const result = extractFromSource('lib/m.ex', code); + const count = result.nodes.find((n) => n.name === 'count'); + expect(count?.qualifiedName).toBe('M::count/0'); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('Repo::count/0'); + }); + }); + + describe('Behaviours', () => { + it('should emit an implements reference for @behaviour', () => { + const code = `defmodule M do + @behaviour MyApp.Worker + + def init(_), do: {:ok, %{}} +end +`; + const result = extractFromSource('lib/m.ex', code); + const impl = result.unresolvedReferences.find((r) => r.referenceKind === 'implements'); + expect(impl?.referenceName).toBe('MyApp.Worker'); + }); + }); +}); + describe('Terraform Extraction', () => { describe('Language detection', () => { it('should detect Terraform files', () => { diff --git a/assets/languages/elixir.svg b/assets/languages/elixir.svg new file mode 100644 index 000000000..6b4d377a2 --- /dev/null +++ b/assets/languages/elixir.svg @@ -0,0 +1,7 @@ + + Elixir + + + + ELIXIR + diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index abf6a2346..4367780b2 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -97,7 +97,19 @@ gets no Screens picture at all today: The Vue name-index (`VueAppRoutes.byName`) is the closest existing precedent for all of these. -### 3. Native UI — no route nodes at all +### 3. Phoenix — Elixir now indexes, the router does not + +Elixir extraction is in (`defmodule` / `def` / `alias` / `Mod.fun` / `|>` / +`GenServer.call`). **Missing:** Phoenix and Phoenix LiveView **route nodes**. +`scope "/", AppWeb do get "/", PageController, :index end`, `live "/x", FooLive`, +and `push_navigate(to: "/x")` / `<.link navigate={...}>` are unread, so a Phoenix +app has no Entry points and no Screens tab. Same shape as Rails `routes.rb`: a +router DSL whose destinations are controller-or-LiveView modules, not file paths. + +**Validate on:** `phoenixframework/phoenix` (the framework itself) and any +`phx.new` app, or BlockScout / Plausible. + +### 4. Native UI — no route nodes at all | Platform | Routes would come from | Navigation would come from | |---|---|---| @@ -110,7 +122,7 @@ All three are named in `scripts/try-repo.sh`'s presets as not modelled both name routes with string literals, which is the same shape every router above reads. -### 4. ArkTS / HarmonyOS — closest to done of anything here +### 5. ArkTS / HarmonyOS — closest to done of anything here **Has:** the hard half already. `arkuiRouterEdges` in `callback-synthesizer.ts` resolves `router.pushUrl('/pages/Detail')` to the @@ -136,7 +148,7 @@ Objective-C. the first two inherit the C rules and the third has none.) **No rules** — boxes draw, arrows carry no condition, and no arguments or -trigger labels are read: PHP, Ruby, Rust, Scala, Dart, Erlang, Lua, Luau, R, +trigger labels are read: PHP, Ruby, Rust, Scala, Dart, Erlang, Elixir, Lua, Luau, R, Solidity, COBOL, CFML, VB.NET, Nix, Terraform, Pascal/Delphi, Liquid, Razor, Twig, ArkTS, and the `.svelte` / `.vue` / `.astro` template languages. diff --git a/docs/design/rust-kernel-migration-plan.md b/docs/design/rust-kernel-migration-plan.md index 14ed2673e..8b265a0cb 100644 --- a/docs/design/rust-kernel-migration-plan.md +++ b/docs/design/rust-kernel-migration-plan.md @@ -644,6 +644,7 @@ parity before porting the language. | vbnet | `languages/vbnet.ts` | T2 | **vendored, patched + external scanner** | Our wasm is a patched grammar WITH a C external scanner — the kernel must build that scanner; ts-cli 0.24 dropped `\p{...}` classes during the original build (#1164). Highest grammar-build risk of any language. | ☐ | | cobol | `languages/cobol.ts` | T2 | **vendored fork** | Paragraph-extent reconstruction + copybook resolution are extraction logic (#1161, CardDemo 43/44). Port carefully or keep TS post-pass. | ☐ | | erlang | `languages/erlang.ts` | T2 | **vendored (WhatsApp/ELP)** | npm `tree-sitter-erlang` is HIJACKED — never source from it (#1165). gen_server dispatch is synthesis-side (fine). | ☐ | +| elixir | `languages/elixir.ts` | T2 | **vendored (elixir-lang 0.3.5)** | Everything is a `call` node — defs/directives live in visitNode, calls in extractCall; alias maps are per-module. GenServer/MFA is extraction-side. | ☐ | | nix | `languages/nix.ts` | T2 | **vendored (ABI-15 rebuild)** | Option-path synthesizer is synthesis-side; the `===`-always-false → `.equals()` lesson (#1190) is wasm-binding-specific and disappears natively — still gate on nixpkgs (44k files). | ☐ | | solidity | `languages/solidity.ts` | T2 | **vendored** | `modifier_invocation` outside body walk (#1170) is extraction-side; port it. | ☐ | | terraform | `languages/terraform.ts` | T2 | **vendored** | `:`-scoped refs for module-boundary bridging (#1173); metadata does NOT persist — re-read source (#1174). | ☐ | diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index c7710f200..6c80f5089 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -46,6 +46,7 @@ const WASM_GRAMMAR_FILES: Record = { 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', @@ -162,6 +163,11 @@ export const EXTENSION_MAP: Record = { // (`.app`/`.app.src` resource files route via isErlangAppFile below: their // last-dot extension is too generic for this map.) '.escript': 'erlang', + // Elixir: modules (.ex) and scripts/tests/config (.exs). Mix + Phoenix. + // Vendored elixir-lang/tree-sitter-elixir 0.3.5 (Apache-2.0, ABI 14) — + // tree-sitter-wasms does not ship it; the npm package includes the wasm. + '.ex': 'elixir', + '.exs': 'elixir', // Spring config: `application.properties` / `application-*.properties`. Same // shape as the `.yml` variants — the YAML/properties extractor emits one node // per leaf key, and the Spring resolver links `@Value("${k}")` references. @@ -267,7 +273,10 @@ export async function initGrammars(): Promise { * MIT) — byte-identical to the npm tarball's artifact. It extends the * tree-sitter-javascript grammar the same way tree-sitter-typescript does, * adding `struct_declaration` and the `arkui_component_expression` build() - * DSL. Nix: tree-sitter-wasms doesn't ship it; we vendor a wasm built from + * DSL. Elixir: tree-sitter-wasms doesn't ship it; we vendor the prebuilt + * tree-sitter-elixir.wasm from elixir-lang/tree-sitter-elixir 0.3.5 + * (Apache-2.0, ABI 14) — byte-identical to the npm tarball's artifact. + * Nix: tree-sitter-wasms doesn't ship it; we vendor a wasm built from * nix-community/tree-sitter-nix @ 3d0173d (MIT) with tree-sitter-cli 0.25.10 * (`generate` + `build --wasm`, ABI 15 — upstream's checked-in parser.c is * still ABI 13; all 54 upstream corpus tests pass on the regenerated parser). @@ -290,7 +299,7 @@ export async function initGrammars(): Promise { */ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ 'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery', - 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', + 'cobol', 'vbnet', 'erlang', 'elixir', 'terraform', 'arkts', 'nix', 'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', // R7a (C/C++ kernel port prep): tree-sitter-c v0.24.2 (b780e47) + // tree-sitter-cpp v0.23.4 (f41e1a0), parser.c/scanner.c sha-matched against @@ -705,6 +714,7 @@ export function getLanguageDisplayName(language: Language): string { cobol: 'COBOL', vbnet: 'Visual Basic .NET', erlang: 'Erlang', + elixir: 'Elixir', terraform: 'Terraform', arkts: 'ArkTS', unknown: 'Unknown', diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 93be48352..248185542 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -194,6 +194,10 @@ const DEFAULT_IGNORE_DIRS: ReadonlySet = new Set([ 'lua_modules', '.luarocks', // Delphi / RAD Studio IDE backups (duplicate .pas source — would double-count) '__history', '__recovery', + // Elixir / Mix — compiled beam output and language-server cache. Mix + // dependencies live in `deps/`, which is deliberately NOT listed (the name + // is first-party-prone); Mix projects gitignore it. + '_build', '.elixir_ls', // Generic cache '.cache', ]); diff --git a/src/extraction/languages/elixir.ts b/src/extraction/languages/elixir.ts new file mode 100644 index 000000000..f0dbc8604 --- /dev/null +++ b/src/extraction/languages/elixir.ts @@ -0,0 +1,890 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText, getChildByField, getPrecedingDocstring } from '../tree-sitter-helpers'; +import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types'; +import type { UnresolvedReference } from '../../types'; + +// Node names follow the vendored elixir-lang/tree-sitter-elixir grammar (0.3.5, +// ABI 14). Elixir has no dedicated def/module AST nodes — `defmodule`, `def`, +// `alias`, `if`, and ordinary calls are all `call` nodes whose `target` is an +// identifier (or a `dot` for `Mod.fun`). Three of those shapes don't fit the +// generic extractor, so definitions, directives, and attributes are dispatched +// through the visitNode hook; real calls (and `|> ident`, `&fun/arity`) go +// through extractCall via callTypes, reading the per-file alias/import maps +// populated below. + +const DEF_FUN = new Set([ + 'def', 'defp', 'defmacro', 'defmacrop', 'defguard', 'defguardp', + 'defdelegate', 'defn', 'defnp', +]); +const PRIVATE_FUN = new Set(['defp', 'defmacrop', 'defguardp', 'defnp']); +const DEF_MOD = new Set(['defmodule', 'defprotocol']); +const IMPORT_DIRECTIVES = new Set(['alias', 'import', 'require', 'use']); + +/** Kernel special forms / definition macros — never call edges (tags.scm). */ +const SPECIAL_FORMS = new Set([ + 'def', 'defp', 'defdelegate', 'defguard', 'defguardp', 'defmacro', 'defmacrop', + 'defn', 'defnp', 'defmodule', 'defprotocol', 'defimpl', 'defstruct', + 'defexception', 'defoverridable', 'alias', 'case', 'cond', 'else', 'for', + 'if', 'import', 'quote', 'receive', 'require', 'super', 'try', 'unless', + 'unquote', 'unquote_splicing', 'use', 'with', +]); + +const MFA_CALLEES = new Set([ + 'apply', 'spawn', 'spawn_link', 'spawn_monitor', +]); + +function collapseWs(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function operatorOf(node: SyntaxNode, source: string): string { + const op = getChildByField(node, 'operator'); + return op ? getNodeText(op, source) : ''; +} + +function atomText(node: SyntaxNode, source: string): string { + const raw = getNodeText(node, source); + if (raw.startsWith(':')) { + const rest = raw.slice(1); + return rest.replace(/^["']([\s\S]*)["']$/, '$1'); + } + return raw.replace(/^['"]([\s\S]*)['"]$/, '$1'); +} + +function keywordName(node: SyntaxNode, source: string): string { + return getNodeText(node, source).replace(/:\s*$/, '').trim(); +} + +function stringContent(node: SyntaxNode, source: string): string { + const parts = node.namedChildren + .filter((c) => c.type === 'quoted_content') + .map((c) => getNodeText(c, source)); + if (parts.length > 0) return parts.join(''); + return getNodeText(node, source).replace(/^["']+|["']+$/g, ''); +} + +function argumentsNode(node: SyntaxNode): SyntaxNode | null { + return node.namedChildren.find((c) => c.type === 'arguments') ?? null; +} + +function doBlock(node: SyntaxNode): SyntaxNode | null { + return node.namedChildren.find((c) => c.type === 'do_block') ?? null; +} + +function keywordsNode(node: SyntaxNode): SyntaxNode | null { + return node.namedChildren.find((c) => c.type === 'keywords') + ?? argumentsNode(node)?.namedChildren.find((c) => c.type === 'keywords') + ?? null; +} + +function pairKey(pair: SyntaxNode): SyntaxNode | null { + return getChildByField(pair, 'key') ?? pair.namedChild(0); +} + +function pairValue(pair: SyntaxNode): SyntaxNode | null { + return getChildByField(pair, 'value') ?? pair.namedChild(1); +} + +function keywordValue(keywords: SyntaxNode | null, key: string, source: string): SyntaxNode | null { + if (!keywords) return null; + for (const pair of keywords.namedChildren) { + if (pair.type !== 'pair') continue; + const k = pairKey(pair); + const v = pairValue(pair); + if (k && v && keywordName(k, source) === key) return v; + } + return null; +} + +function targetNode(call: SyntaxNode): SyntaxNode | null { + return getChildByField(call, 'target'); +} + +function targetIdent(call: SyntaxNode, source: string): string | null { + const t = targetNode(call); + if (!t) return null; + if (t.type === 'identifier') return getNodeText(t, source); + return null; +} + +function isAttr(node: SyntaxNode, source: string): boolean { + return node.type === 'unary_operator' && operatorOf(node, source) === '@'; +} + +function attrName(node: SyntaxNode, source: string): string | null { + if (!isAttr(node, source)) return null; + const operand = getChildByField(node, 'operand'); + if (!operand) return null; + if (operand.type === 'identifier') return getNodeText(operand, source); + if (operand.type === 'call') return targetIdent(operand, source); + return null; +} + +function attrCall(node: SyntaxNode): SyntaxNode | null { + const operand = getChildByField(node, 'operand'); + return operand?.type === 'call' ? operand : null; +} + +// --- Per-file memos. Extraction is file-sequential within a worker. --- + +let stateFile = ''; +let aliasStack: Map[] = [new Map()]; +let importOnlyStack: Map[] = [new Map()]; // "map/2" → "Enum" +let moduleStack: string[] = []; +let lastFnName = ''; +let lastFnArity = -1; +let lastFnId = ''; + +function resetState(filePath: string): void { + if (filePath === stateFile) return; + stateFile = filePath; + aliasStack = [new Map()]; + importOnlyStack = [new Map()]; + moduleStack = []; + lastFnName = ''; + lastFnArity = -1; + lastFnId = ''; +} + +function aliases(): Map { + return aliasStack[aliasStack.length - 1]!; +} + +function importOnly(): Map { + return importOnlyStack[importOnlyStack.length - 1]!; +} + +/** Copy of the current maps — function-local alias/import must not leak. */ +function pushCopiedDirectives(): void { + aliasStack.push(new Map(aliases())); + importOnlyStack.push(new Map(importOnly())); +} + +function popDirectives(): void { + aliasStack.pop(); + importOnlyStack.pop(); +} + +function currentModule(): string | null { + return moduleStack[moduleStack.length - 1] ?? null; +} + +function expandAlias(mod: string): string { + const dot = mod.indexOf('.'); + const first = dot < 0 ? mod : mod.slice(0, dot); + const rest = dot < 0 ? '' : mod.slice(dot); + const mapped = aliases().get(first); + return mapped ? mapped + rest : mod; +} + +function nestModule(name: string): string { + const parent = currentModule(); + return parent ? `${parent}.${name}` : name; +} + +function qualifyCall(mod: string | null, fun: string, arity: number): string { + if (!mod || mod === currentModule()) return `${fun}/${arity}`; + return `${mod}::${fun}/${arity}`; +} + +/** Call-site arity, plus 1 per enclosing `|>` that this node is the right of. */ +function callArity(node: SyntaxNode, source: string): number { + const args = argumentsNode(node); + let arity = args ? args.namedChildCount : 0; + let cur: SyntaxNode = node; + for (;;) { + const parent = cur.parent; + if (!parent) break; + if (parent.type === 'binary_operator' && operatorOf(parent, source) === '|>') { + const right = getChildByField(parent, 'right'); + if (right && right.startIndex === cur.startIndex && right.endIndex === cur.endIndex) { + arity += 1; + cur = parent; + continue; + } + } + break; + } + return arity; +} + +interface FunHead { + name: string; + arity: number; + header: SyntaxNode; +} + +function unwrapWhen(node: SyntaxNode, source: string): SyntaxNode { + let cur = node; + while (cur.type === 'binary_operator' && operatorOf(cur, source) === 'when') { + const left = getChildByField(cur, 'left'); + if (!left) break; + cur = left; + } + return cur; +} + +function parseFunHead(defCall: SyntaxNode, source: string): FunHead | null { + const args = argumentsNode(defCall); + if (!args) return null; + const positional = args.namedChildren.filter((c) => c.type !== 'keywords'); + const raw = positional[0]; + if (!raw) return null; + const head = unwrapWhen(raw, source); + if (head.type === 'identifier') { + const name = getNodeText(head, source); + if (!name || name === 'unquote') return null; + return { name, arity: 0, header: head }; + } + if (head.type === 'call') { + const name = targetIdent(head, source); + if (!name || name === 'unquote') return null; + const headArgs = argumentsNode(head); + return { name, arity: headArgs ? headArgs.namedChildCount : 0, header: head }; + } + return null; +} + +function precedingAttrs(node: SyntaxNode, source: string): { doc?: string; spec?: SyntaxNode } { + const out: { doc?: string; spec?: SyntaxNode } = {}; + let prev = node.previousNamedSibling; + while (prev) { + if (prev.type === 'comment') { + prev = prev.previousNamedSibling; + continue; + } + if (isAttr(prev, source)) { + const name = attrName(prev, source); + if (name === 'doc' && out.doc === undefined) { + const call = attrCall(prev); + const args = call ? argumentsNode(call) : null; + const first = args?.namedChild(0); + if (first && (first.type === 'string' || first.type === 'charlist')) { + const text = stringContent(first, source).trim(); + if (text && text !== 'false') out.doc = text; + } else if (first && getNodeText(first, source) === 'false') { + out.doc = ''; + } + } else if (name === 'spec' && !out.spec) { + out.spec = prev; + } + prev = prev.previousNamedSibling; + continue; + } + break; + } + return out; +} + +function defSignature(defCall: SyntaxNode, spec: SyntaxNode | undefined, source: string): string | undefined { + if (spec) return collapseWs(getNodeText(spec, source)).slice(0, 300); + const body = doBlock(defCall) ?? keywordsNode(defCall); + const end = body ? body.startIndex : defCall.endIndex; + return collapseWs(source.substring(defCall.startIndex, end)) || undefined; +} + +function isContinuation(node: SyntaxNode, name: string, arity: number, source: string): boolean { + let prev = node.previousNamedSibling; + while (prev && (prev.type === 'comment' || isAttr(prev, source))) { + prev = prev.previousNamedSibling; + } + if (!prev || prev.type !== 'call') return false; + const ident = targetIdent(prev, source); + if (!ident || !DEF_FUN.has(ident)) return false; + const head = parseFunHead(prev, source); + return !!head && head.name === name && head.arity === arity; +} + +function recordAlias(asName: string, fullName: string): void { + if (asName && fullName) aliases().set(asName, fullName); +} + +function collectAlias(call: SyntaxNode, source: string): void { + const args = argumentsNode(call); + if (!args) return; + const as = keywordValue(keywordsNode(call), 'as', source); + const positional = args.namedChildren.filter((c) => c.type !== 'keywords'); + const target = positional[0]; + if (!target) return; + if (as && as.type === 'alias') { + const full = getNodeText(target, source); + recordAlias(getNodeText(as, source), full); + return; + } + if (target.type === 'alias') { + const full = getNodeText(target, source); + const last = full.includes('.') ? full.slice(full.lastIndexOf('.') + 1) : full; + recordAlias(last, full); + return; + } + // alias Foo.{One, Two} + if (target.type === 'dot') { + const left = getChildByField(target, 'left'); + const right = getChildByField(target, 'right'); + if (left?.type === 'alias' && right?.type === 'tuple') { + const prefix = getNodeText(left, source); + for (const child of right.namedChildren) { + if (child.type === 'alias') { + const name = getNodeText(child, source); + recordAlias(name, `${prefix}.${name}`); + } + } + } + } +} + +function collectImportOnly(call: SyntaxNode, source: string): void { + const args = argumentsNode(call); + if (!args) return; + const modNode = args.namedChildren.find((c) => c.type === 'alias'); + if (!modNode) return; + const mod = getNodeText(modNode, source); + const only = keywordValue(keywordsNode(call), 'only', source); + if (!only || only.type !== 'list') return; + const kws = only.namedChildren.find((c) => c.type === 'keywords') ?? only; + for (const pair of kws.namedChildren) { + if (pair.type !== 'pair') continue; + const key = getChildByField(pair, 'key'); + const value = getChildByField(pair, 'value'); + if (!key || value?.type !== 'integer') continue; + const fun = keywordName(key, source); + const arity = getNodeText(value, source); + importOnly().set(`${fun}/${arity}`, mod); + } +} + +function visitNamedChildren(body: SyntaxNode, ctx: ExtractorContext): void { + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child) ctx.visitNode(child); + } +} + +function visitBody(node: SyntaxNode, ctx: ExtractorContext): void { + const block = doBlock(node); + if (block) ctx.visitFunctionBody(block, ctx.nodeStack[ctx.nodeStack.length - 1] ?? ''); + const doVal = keywordValue(keywordsNode(node), 'do', ctx.source); + if (doVal) ctx.visitFunctionBody(doVal, ctx.nodeStack[ctx.nodeStack.length - 1] ?? ''); +} + +function handleDefmodule(node: SyntaxNode, ctx: ExtractorContext, kind: 'module' | 'interface'): boolean { + const args = argumentsNode(node); + const nameNode = args?.namedChildren.find((c) => c.type === 'alias'); + if (!nameNode) return true; + const name = nestModule(getNodeText(nameNode, ctx.source)); + const attrs = precedingAttrs(node, ctx.source); + const mod = ctx.createNode(kind, name, node, { + docstring: attrs.doc || getPrecedingDocstring(node, ctx.source), + signature: defSignature(node, undefined, ctx.source), + isExported: true, + }); + if (!mod) return true; + // Elixir modules are dotted (`Foo.Bar`), not `::`-nested. Override the + // generic `Parent::Foo.Bar` qualifiedName so functions inside become + // `Foo.Bar::baz/1` rather than `Foo::Foo.Bar::baz/1`. + mod.qualifiedName = name; + const body = doBlock(node); + aliasStack.push(new Map()); + importOnlyStack.push(new Map()); + moduleStack.push(name); + ctx.pushScope(mod.id); + // Visit in source order so alias/import bindings apply only after they + // appear (Elixir aliases are lexical). Pre-collecting every directive + // left the last binding active for the whole module. + if (body) visitNamedChildren(body, ctx); + ctx.popScope(); + moduleStack.pop(); + aliasStack.pop(); + importOnlyStack.pop(); + return true; +} + +function handleDefun(node: SyntaxNode, ctx: ExtractorContext): boolean { + const ident = targetIdent(node, ctx.source); + if (!ident) return true; + const head = parseFunHead(node, ctx.source); + if (!head) return true; + const { name, arity } = head; + + if (isContinuation(node, name, arity, ctx.source) && lastFnId && lastFnName === name && lastFnArity === arity) { + for (let i = ctx.nodes.length - 1; i >= 0; i--) { + const n = ctx.nodes[i]; + if (n && n.id === lastFnId) { + if (node.endPosition.row + 1 > n.endLine) n.endLine = node.endPosition.row + 1; + break; + } + } + ctx.pushScope(lastFnId); + pushCopiedDirectives(); + visitBody(node, ctx); + popDirectives(); + ctx.popScope(); + return true; + } + + const attrs = precedingAttrs(node, ctx.source); + const isPrivate = PRIVATE_FUN.has(ident); + const fn = ctx.createNode('function', name, node, { + docstring: attrs.doc || getPrecedingDocstring(node, ctx.source), + signature: defSignature(node, attrs.spec, ctx.source), + isExported: !isPrivate, + visibility: isPrivate ? 'private' : 'public', + }); + if (!fn) return true; + const modName = currentModule(); + fn.qualifiedName = modName ? `${modName}::${name}/${arity}` : `${name}/${arity}`; + ctx.pushScope(fn.id); + pushCopiedDirectives(); + visitBody(node, ctx); + if (ident === 'defdelegate') { + const to = keywordValue(keywordsNode(node), 'to', ctx.source); + const as = keywordValue(keywordsNode(node), 'as', ctx.source); + if (to && (to.type === 'alias' || to.type === 'identifier')) { + const destMod = to.type === 'alias' ? expandAlias(getNodeText(to, ctx.source)) : getNodeText(to, ctx.source); + const destFun = as ? atomText(as, ctx.source) : name; + ctx.addUnresolvedReference({ + fromNodeId: fn.id, + referenceName: qualifyCall(destMod, destFun, arity), + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + popDirectives(); + ctx.popScope(); + lastFnName = name; + lastFnArity = arity; + lastFnId = fn.id; + return true; +} + +function handleDefstruct(node: SyntaxNode, ctx: ExtractorContext): boolean { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!parentId) return true; + const args = argumentsNode(node); + if (!args) return true; + const addField = (name: string, fieldNode: SyntaxNode): void => { + if (name) ctx.createNode('field', name, fieldNode); + }; + const walk = (n: SyntaxNode): void => { + if (n.type === 'atom') addField(atomText(n, ctx.source), n); + else if (n.type === 'keywords') { + for (const pair of n.namedChildren) { + if (pair.type !== 'pair') continue; + const key = pairKey(pair); + if (key) addField(keywordName(key, ctx.source), pair); + } + } else if (n.type === 'list') { + for (const c of n.namedChildren) walk(c); + } + }; + for (const c of args.namedChildren) walk(c); + return true; +} + +function implTargetTypes(forType: SyntaxNode | null, source: string): string[] { + const names: string[] = []; + if (forType?.type === 'alias') names.push(expandAlias(getNodeText(forType, source))); + else if (forType?.type === 'list') { + for (const c of forType.namedChildren) { + if (c.type === 'alias') names.push(expandAlias(getNodeText(c, source))); + } + } + if (names.length === 0 && currentModule()) names.push(currentModule()!); + return names; +} + +function implModuleName(protocol: string, types: string[]): string { + if (types.length === 0) return protocol; + if (types.length === 1) return `${protocol}.${types[0]}`; + return `${protocol}.{${types.join(', ')}}`; +} + +function handleDefimpl(node: SyntaxNode, ctx: ExtractorContext): boolean { + const args = argumentsNode(node); + const protocolNode = args?.namedChildren.find((c) => c.type === 'alias'); + const protocol = protocolNode ? expandAlias(getNodeText(protocolNode, ctx.source)) : null; + const types = implTargetTypes(keywordValue(keywordsNode(node), 'for', ctx.source), ctx.source); + const implName = protocol ? implModuleName(protocol, types) : null; + const impl = implName + ? ctx.createNode('module', implName, node, { + signature: defSignature(node, undefined, ctx.source), + isExported: true, + }) + : null; + if (impl && implName) impl.qualifiedName = implName; + + const fromId = impl?.id ?? ctx.nodeStack[ctx.nodeStack.length - 1]; + if (protocol && fromId) { + ctx.addUnresolvedReference({ + fromNodeId: fromId, + referenceName: protocol, + referenceKind: 'implements', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + + if (impl && implName) { + aliasStack.push(new Map()); + importOnlyStack.push(new Map()); + moduleStack.push(implName); + ctx.pushScope(impl.id); + } + const body = doBlock(node); + if (body) visitNamedChildren(body, ctx); + if (impl) { + ctx.popScope(); + moduleStack.pop(); + aliasStack.pop(); + importOnlyStack.pop(); + } + return true; +} + +function handleImport(node: SyntaxNode, ctx: ExtractorContext): boolean { + const ident = targetIdent(node, ctx.source); + const args = argumentsNode(node); + if (!ident || !args) return true; + const mods: string[] = []; + const positional = args.namedChildren.filter((c) => c.type !== 'keywords'); + const first = positional[0]; + if (first?.type === 'alias') mods.push(getNodeText(first, ctx.source)); + else if (first?.type === 'dot') { + const left = getChildByField(first, 'left'); + const right = getChildByField(first, 'right'); + if (left?.type === 'alias' && right?.type === 'tuple') { + const prefix = getNodeText(left, ctx.source); + for (const c of right.namedChildren) { + if (c.type === 'alias') mods.push(`${prefix}.${getNodeText(c, ctx.source)}`); + } + } + } + const as = keywordValue(keywordsNode(node), 'as', ctx.source); + for (const mod of mods) { + const display = as && as.type === 'alias' && mods.length === 1 ? getNodeText(as, ctx.source) : mod; + ctx.createNode('import', display, node, { + signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200), + }); + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (parentId) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: mod, + referenceKind: 'imports', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + if (ident === 'alias') collectAlias(node, ctx.source); + if (ident === 'import') collectImportOnly(node, ctx.source); + return true; +} + +function handleAttribute(node: SyntaxNode, ctx: ExtractorContext): boolean { + const name = attrName(node, ctx.source); + const call = attrCall(node); + if (name === 'moduledoc') { + const args = call ? argumentsNode(call) : null; + const first = args?.namedChild(0); + const text = first && (first.type === 'string' || first.type === 'charlist') + ? stringContent(first, ctx.source).trim() + : ''; + if (text && text !== 'false') { + const modId = ctx.nodeStack[ctx.nodeStack.length - 1]; + const mod = modId ? ctx.nodes.find((n) => n.id === modId) : undefined; + if (mod && (mod.kind === 'module' || mod.kind === 'interface') && !mod.docstring) { + mod.docstring = text; + } + } + return true; + } + if (name === 'behaviour' || name === 'behavior') { + const args = call ? argumentsNode(call) : null; + const alias = args?.namedChildren.find((c) => c.type === 'alias'); + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (alias && parentId) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: getNodeText(alias, ctx.source), + referenceKind: 'implements', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + return true; + } + if (name === 'type' || name === 'typep' || name === 'opaque') { + const args = call ? argumentsNode(call) : null; + const first = args?.namedChild(0); + if (first) { + let typeNameNode: SyntaxNode | null = null; + if (first.type === 'binary_operator' && operatorOf(first, ctx.source) === '::') { + const left = getChildByField(first, 'left'); + if (left?.type === 'identifier') typeNameNode = left; + else if (left?.type === 'call') { + const t = targetNode(left); + if (t?.type === 'identifier') typeNameNode = t; + } + } else if (first.type === 'identifier') { + typeNameNode = first; + } + if (typeNameNode) { + ctx.createNode('type_alias', getNodeText(typeNameNode, ctx.source), node, { + signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200), + }); + } + } + return true; + } + // @doc / @spec attach to the next def via precedingAttrs; other attributes + // are consumed so their operand `call` nodes don't become bogus call refs. + return true; +} + +type CallRef = Pick; + +/** True when an explicit `import Mod, only: [fun: N]` covers this call. */ +function emitImported(fun: string, arity: number, node: SyntaxNode, out: CallRef[]): boolean { + const mod = importOnly().get(`${fun}/${arity}`); + if (!mod) return false; + out.push({ + referenceName: `${expandAlias(mod)}::${fun}/${arity}`, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + return true; +} + +/** + * Call / capture / pipe-identifier refs for the elixir extractCall branch. + * Returns null when the node is a special form or definition (no call edge); + * otherwise the refs to emit (possibly empty). + */ +export function elixirCallRefs( + node: SyntaxNode, + source: string, + filePath: string, +): Array> | null { + resetState(filePath); + const line = node.startPosition.row + 1; + const column = node.startPosition.column; + + if (node.type === 'unary_operator') { + if (operatorOf(node, source) !== '&') return null; + const operand = getChildByField(node, 'operand'); + if (!operand || operand.type !== 'binary_operator' || operatorOf(operand, source) !== '/') return null; + const left = getChildByField(operand, 'left'); + const right = getChildByField(operand, 'right'); + if (!left || right?.type !== 'integer') return null; + const arity = getNodeText(right, source); + if (left.type === 'identifier') { + return [{ referenceName: `${getNodeText(left, source)}/${arity}`, referenceKind: 'references', line, column }]; + } + if (left.type === 'call') { + const t = targetNode(left); + if (t?.type === 'dot') { + const q = qualifyDot(t, source); + if (q) { + return [{ + referenceName: qualifyCall(q.mod, q.fun, Number(arity) || 0), + referenceKind: 'references', + line, + column, + }]; + } + } + const ident = targetIdent(left, source); + if (ident) { + return [{ referenceName: `${ident}/${arity}`, referenceKind: 'references', line, column }]; + } + } + if (left.type === 'dot') { + const q = qualifyDot(left, source); + if (q) { + return [{ + referenceName: qualifyCall(q.mod, q.fun, Number(arity) || 0), + referenceKind: 'references', + line, + column, + }]; + } + } + return null; + } + + if (node.type === 'binary_operator') { + if (operatorOf(node, source) !== '|>') return []; + const right = getChildByField(node, 'right'); + if (right?.type === 'identifier') { + const fun = getNodeText(right, source); + const refs: Array> = []; + if (!emitImported(fun, 1, right, refs)) { + refs.push({ + referenceName: `${fun}/1`, + referenceKind: 'calls', + line: right.startPosition.row + 1, + column: right.startPosition.column, + }); + } + return refs; + } + return []; + } + + if (node.type !== 'call') return []; + + const ident = targetIdent(node, source); + if (ident && (SPECIAL_FORMS.has(ident) || DEF_FUN.has(ident) || DEF_MOD.has(ident) || ident === 'defimpl' || ident === 'defstruct' || ident === 'defexception')) { + // Function bodies are walked via extractCall, not visitNode, so alias/ + // import inside a def never hit handleImport. Record them here against + // the function-scoped maps pushed around visitBody. + if (ident === 'alias') collectAlias(node, source); + else if (ident === 'import') collectImportOnly(node, source); + return null; + } + + const target = targetNode(node); + const arity = callArity(node, source); + const refs: Array> = []; + + if (target?.type === 'identifier' && ident) { + if (!emitImported(ident, arity, node, refs)) { + refs.push({ referenceName: `${ident}/${arity}`, referenceKind: 'calls', line, column }); + } + maybeMfa(node, source, ident, null, refs); + } else if (target?.type === 'dot') { + const q = qualifyDot(target, source); + if (!q) return []; + refs.push({ + referenceName: qualifyCall(q.mod, q.fun, arity), + referenceKind: 'calls', + line, + column, + }); + if (q.mod) { + refs.push({ + referenceName: q.mod, + referenceKind: 'references', + line: target.startPosition.row + 1, + column: target.startPosition.column, + }); + } + maybeGenServer(q.mod, q.fun, node, source, refs); + maybeMfa(node, source, q.fun, q.mod, refs); + } + + return refs; +} + +function qualifyDot(dot: SyntaxNode, source: string): { mod: string | null; fun: string } | null { + const left = getChildByField(dot, 'left'); + const right = getChildByField(dot, 'right'); + if (!right || (right.type !== 'identifier' && right.type !== 'atom')) return null; + const fun = right.type === 'atom' ? atomText(right, source) : getNodeText(right, source); + if (!left) return { mod: null, fun }; + if (left.type === 'alias') return { mod: expandAlias(getNodeText(left, source)), fun }; + if (left.type === 'identifier' && getNodeText(left, source) === '__MODULE__') { + return { mod: currentModule(), fun }; + } + return null; // variable receiver — dynamic +} + +function maybeGenServer( + mod: string | null, + fun: string, + node: SyntaxNode, + source: string, + refs: Array>, +): void { + if (mod !== 'GenServer' && mod !== 'Elixir.GenServer') return; + if (fun !== 'call' && fun !== 'cast') return; + const args = argumentsNode(node); + const target = args?.namedChild(0); + if (!target) return; + let targetMod: string | null = null; + if (target.type === 'identifier' && getNodeText(target, source) === '__MODULE__') { + targetMod = currentModule(); + } else if (target.type === 'alias') { + targetMod = expandAlias(getNodeText(target, source)); + } + if (!targetMod) return; + const handler = fun === 'cast' ? 'handle_cast' : 'handle_call'; + const arity = fun === 'cast' ? 2 : 3; + refs.push({ + referenceName: qualifyCall(targetMod, handler, arity), + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); +} + +function maybeMfa( + node: SyntaxNode, + source: string, + fun: string, + mod: string | null, + refs: Array>, +): void { + if (!MFA_CALLEES.has(fun)) return; + if (mod && mod !== 'Kernel' && mod !== 'Process' && mod !== ':erlang' && mod !== 'erlang') return; + const args = argumentsNode(node); + if (!args || args.namedChildCount < 3) return; + const m = args.namedChild(0); + const f = args.namedChild(1); + const list = args.namedChild(2); + if (!m || !f || list?.type !== 'list') return; + let destMod: string | null = null; + if (m.type === 'alias') destMod = expandAlias(getNodeText(m, source)); + else if (m.type === 'identifier' && getNodeText(m, source) === '__MODULE__') destMod = currentModule(); + else if (m.type === 'atom') destMod = atomText(m, source); + else return; + if (f.type !== 'atom') return; + const destFun = atomText(f, source); + refs.push({ + referenceName: qualifyCall(destMod, destFun, list.namedChildCount), + referenceKind: 'calls', + line: f.startPosition.row + 1, + column: f.startPosition.column, + }); +} + +export const elixirExtractor: LanguageExtractor = { + // Definitions are `call` nodes dispatched via visitNode; real calls still + // use callTypes so visitFunctionBody (which does not invoke visitNode) sees + // them. unary_operator covers `&fun/arity`; binary_operator covers `|> ident`. + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], + callTypes: ['call', 'unary_operator', 'binary_operator'], + variableTypes: [], + nameField: 'target', + bodyField: 'do_block', + paramsField: 'arguments', + + visitNode: (node, ctx) => { + resetState(ctx.filePath); + if (node.type === 'unary_operator' && operatorOf(node, ctx.source) === '@') { + return handleAttribute(node, ctx); + } + if (node.type !== 'call') return false; + const ident = targetIdent(node, ctx.source); + if (!ident) return false; + if (ident === 'defmodule') return handleDefmodule(node, ctx, 'module'); + if (ident === 'defprotocol') return handleDefmodule(node, ctx, 'interface'); + if (ident === 'defimpl') return handleDefimpl(node, ctx); + if (DEF_FUN.has(ident)) return handleDefun(node, ctx); + if (ident === 'defstruct' || ident === 'defexception') return handleDefstruct(node, ctx); + if (IMPORT_DIRECTIVES.has(ident)) return handleImport(node, ctx); + if (ident === 'quote' || ident === 'unquote' || ident === 'unquote_splicing') return true; + return false; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..19df78dc2 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -32,6 +32,7 @@ import { cfqueryExtractor } from './cfquery'; import { cobolExtractor } from './cobol'; import { vbnetExtractor } from './vbnet'; import { erlangExtractor } from './erlang'; +import { elixirExtractor } from './elixir'; import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; @@ -65,6 +66,7 @@ export const EXTRACTORS: Partial> = { cobol: cobolExtractor, vbnet: vbnetExtractor, erlang: erlangExtractor, + elixir: elixirExtractor, solidity: solidityExtractor, terraform: terraformExtractor, arkts: arktsExtractor, diff --git a/src/extraction/tree-sitter-helpers.ts b/src/extraction/tree-sitter-helpers.ts index a6438e1b4..68dd9daf9 100644 --- a/src/extraction/tree-sitter-helpers.ts +++ b/src/extraction/tree-sitter-helpers.ts @@ -83,7 +83,7 @@ function cleanCommentMarkers(comment: string): string { return c .replace(/^\/\/[/!]?\s?/gm, '') // // , and Rust/Swift doc lines /// //! .replace(/^--\s?/gm, '') // Lua/Luau line comments - .replace(/^#\s?/gm, '') // Python/Ruby/shell line comments + .replace(/^#\s?/gm, '') // Python/Ruby/Elixir/shell line comments .replace(/^%+\s?/gm, '') // Erlang line comments (% / %% / %%%) .replace(/^\s*\*\s?/gm, '') // block-comment continuation (* foo) .trim(); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 7ef90c273..308e323aa 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -23,6 +23,7 @@ import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; import { EXTRACTORS } from './languages'; import { stripCppTemplateArgs } from './languages/c-cpp'; import { rustImplTypeName } from './languages/rust'; +import { elixirCallRefs } from './languages/elixir'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; import { SvelteExtractor } from './svelte-extractor'; @@ -3787,6 +3788,31 @@ export class TreeSitterExtractor { return; } + // Elixir: `defmodule`/`def`/`alias`/`if` and ordinary calls are the same + // `call` node (target: identifier or dot). Definitions are consumed by + // visitNode; this branch sees the rest — local `foo/2`, remote + // `Foo.Bar::baz/1` (aliases expanded, `__MODULE__` → this module), + // `|> ident` (binary_operator), `&fun/arity` (unary_operator), plus + // GenServer.call/cast → handle_call/handle_cast and static apply/spawn MFA + // when the target is an alias or `__MODULE__`. Arity is part of identity, + // matching the qualifiedName suffix the extractor stamps on every def. + if (this.language === 'elixir') { + const refs = elixirCallRefs(node, this.source, this.filePath); + if (!refs) return; + const line = node.startPosition.row + 1; + const column = node.startPosition.column; + for (const r of refs) { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: r.referenceName, + referenceKind: r.referenceKind, + line: r.line ?? line, + column: r.column ?? column, + }); + } + return; + } + // Erlang: a local call is `call(expr: atom, args)`; a remote call nests it // under `remote(module: remote_module, fun: call)` — the module qualifier // lives on the PARENT. Arity is part of a function's identity (#1610), so diff --git a/src/extraction/wasm/tree-sitter-elixir.wasm b/src/extraction/wasm/tree-sitter-elixir.wasm new file mode 100644 index 000000000..128c34191 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-elixir.wasm differ diff --git a/src/graph/named-symbol-flow.ts b/src/graph/named-symbol-flow.ts index 3fc095c79..2fedf57fe 100644 --- a/src/graph/named-symbol-flow.ts +++ b/src/graph/named-symbol-flow.ts @@ -108,6 +108,13 @@ export function matchesSymbol(node: Node, symbol: string): boolean { // `Session::request` both become `Session::request` here. const colonSuffix = parts.join('::'); if (node.qualifiedName.includes(colonSuffix)) return true; + // Elixir (and similar) modules keep dots in the module name + // (`MyApp.Accounts::get_user/2`); a query `MyApp.Accounts.get_user` splits + // into three parts whose `::` join would be `MyApp::Accounts::get_user`. + if (parts.length >= 3) { + const dottedMod = `${parts.slice(0, -1).join('.')}::${lastPart}`; + if (node.qualifiedName.includes(dottedMod)) return true; + } // Stage 2: file-path containment. Rust modules and Python packages // are not in `qualifiedName` — they're encoded in the file path. So diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 7988c1bcf..c9bd7e29d 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -892,7 +892,7 @@ export class ReferenceResolver { : ref.referenceName; // Erlang refs carry the call-site arity (`f/1`, `mod::f/2` — #1610); the // name index stores bare names, so existence is checked arity-less. - if (ref.language === 'erlang') existenceName = existenceName.replace(/\/\d{1,3}$/, ''); + if (ref.language === 'erlang' || ref.language === 'elixir') existenceName = existenceName.replace(/\/\d{1,3}$/, ''); const tPre = this.profileStages ? process.hrtime.bigint() : 0n; const preFilterPass = isNixPathImportRef(ref) || diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..968b07a14 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -503,15 +503,47 @@ export function matchByQualifiedName( } } - // Erlang qualified refs (#1610): every erlang function's qualifiedName - // carries its arity (`mod::f/2`), and refs carry the call-site arity when it - // is statically known. - if (ref.language === 'erlang' && ref.referenceName.includes('::')) { + // Erlang / Elixir qualified refs: every function's qualifiedName carries + // its arity (`mod::f/2`, `Foo.Bar::baz/1`), and refs carry the call-site + // arity when it is statically known. + if ( + (ref.language === 'erlang' || ref.language === 'elixir') && + ref.referenceName.includes('::') + ) { // A ref WITH arity that missed the exact lookup names an arity that isn't // defined (or a module out of repo). Never fall through to the partial // match — its "last segment" would be the arity digits — and never settle - // for a sibling arity: silent beats wrong. - if (/\/\d{1,3}$/.test(ref.referenceName)) return null; + // for a sibling arity: silent beats wrong. Elixir default args + // (`def foo(a, b \\ 1)`) index as the MAX arity; a shorter call is the + // same def when this module defines exactly one function of that name. + if (/\/\d{1,3}$/.test(ref.referenceName)) { + if (ref.language === 'elixir') { + const q = /^(.*)::(.+)\/(\d{1,3})$/.exec(ref.referenceName); + if (q) { + const prefix = `${q[1]}::${q[2]}/`; + const callArity = Number(q[3]); + const sameName = keepForRef(context.getNodesByName(q[2]!)).filter( + (n) => + n.language === 'elixir' && + n.kind === 'function' && + n.qualifiedName.startsWith(prefix) && + /^\d{1,3}$/.test(n.qualifiedName.slice(prefix.length)), + ); + if (sameName.length === 1) { + const defArity = Number(sameName[0]!.qualifiedName.slice(prefix.length)); + if (defArity >= callArity) { + return { + original: ref, + targetNodeId: sameName[0]!.id, + confidence: 0.8, + resolvedBy: 'qualified-name', + }; + } + } + } + } + return null; + } // An arity-LESS qualified ref (dynamic MFA whose args list wasn't a // static literal): resolve only when the module defines exactly ONE arity // of that function; several arities with no signal is a guess. @@ -2480,6 +2512,42 @@ export function dumpNameMatcherProfile(label: string): void { } } +/** `Mod::fun/N` → `Mod`; a file-level def with no module prefix yields null. */ +function elixirEnclosingModule(qn: string | undefined): string | null { + if (!qn) return null; + const sep = qn.indexOf('::'); + return sep > 0 ? qn.slice(0, sep) : null; +} + +function elixirCallerModule(ref: UnresolvedRef, context: ResolutionContext): string | null { + return elixirEnclosingModule(context.getNodeById?.(ref.fromNodeId)?.qualifiedName); +} + +/** + * Same-file Elixir defs of `name`, restricted to the caller's module when + * we know it. A later module in the same `.ex` file is not a local call. + */ +function elixirSameFileFns(context: ResolutionContext, name: string, ref: UnresolvedRef): Node[] { + const callerMod = elixirCallerModule(ref, context); + return context.getNodesByName(name).filter((n) => { + if (n.language !== 'elixir' || n.kind !== 'function' || n.filePath !== ref.filePath) return false; + if (!callerMod) return true; + return elixirEnclosingModule(n.qualifiedName) === callerMod; + }); +} + +function pickElixirEnclosingModuleDef( + candidates: Node[], + ref: UnresolvedRef, + context: ResolutionContext, +): Node | undefined { + const sameFile = candidates.filter((n) => n.filePath === ref.filePath); + if (sameFile.length === 0) return undefined; + const callerMod = elixirCallerModule(ref, context); + if (!callerMod) return sameFile[0]; + return sameFile.find((n) => elixirEnclosingModule(n.qualifiedName) === callerMod); +} + export function matchReference( ref: UnresolvedRef, context: ResolutionContext @@ -2548,16 +2616,19 @@ export function matchReference( }; } - // Erlang call/fun refs carry the call-site arity (`f/1` — #1610) because - // arity is part of the function's identity and every erlang function's + // Erlang / Elixir call/fun refs carry the call-site arity (`f/1`) because + // arity is part of the function's identity and every function's // qualifiedName carries it (`mod::f/1`). Resolve ONLY to a definition of // that exact arity: the call site's own file first (a local call targets its - // own module by language semantics; `-import`ed functions ride the - // cross-file branch), and when no definition of that arity exists anywhere, - // resolve to NOTHING rather than a sibling arity — the real target may be - // macro-generated or out of repo, and a wrong-arity edge is worse than none. + // own module by language semantics), and when no definition of that arity + // exists anywhere, resolve to NOTHING rather than a sibling arity — except + // Elixir default-args (`def foo(a, b \\ 1)` indexes as foo/2): a shorter + // local call matches when the file defines exactly one function of that name. + // Elixir files may hold several modules; a local call is scoped to the + // caller's enclosing module (`Mod::fun/N`), not the first same-name def + // in the file. if ( - ref.language === 'erlang' && + (ref.language === 'erlang' || ref.language === 'elixir') && !ref.referenceName.includes('::') && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') ) { @@ -2565,16 +2636,24 @@ export function matchReference( if (am) { // endsWith is length-anchored, so `/1` cannot match `…/11`. const arityTail = `/${am[2]}`; + const lang = ref.language; const candidates = context .getNodesByName(am[1]!) .filter( (n) => - n.language === 'erlang' && n.kind === 'function' && n.qualifiedName.endsWith(arityTail), + n.language === lang && n.kind === 'function' && n.qualifiedName.endsWith(arityTail), ); if (candidates.length > 0) { - const sameFile = candidates.find((n) => n.filePath === ref.filePath); - if (sameFile) { - return { original: ref, targetNodeId: sameFile.id, confidence: 0.95, resolvedBy: 'exact-match' }; + if (lang === 'elixir') { + const local = pickElixirEnclosingModuleDef(candidates, ref, context); + if (local) { + return { original: ref, targetNodeId: local.id, confidence: 0.95, resolvedBy: 'exact-match' }; + } + } else { + const sameFile = candidates.find((n) => n.filePath === ref.filePath); + if (sameFile) { + return { original: ref, targetNodeId: sameFile.id, confidence: 0.95, resolvedBy: 'exact-match' }; + } } if (candidates.length === 1) { return { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.8, resolvedBy: 'exact-match' }; @@ -2590,6 +2669,16 @@ export function matchReference( }; } } + if (lang === 'elixir') { + const callArity = Number(am[2]); + const sameFile = elixirSameFileFns(context, am[1]!, ref); + if (sameFile.length === 1) { + const defArity = Number(/\/(\d{1,3})$/.exec(sameFile[0]!.qualifiedName)?.[1]); + if (Number.isFinite(defArity) && defArity >= callArity) { + return { original: ref, targetNodeId: sameFile[0]!.id, confidence: 0.8, resolvedBy: 'exact-match' }; + } + } + } return null; } } diff --git a/src/types.ts b/src/types.ts index 44ffaf4e4..77558e9c7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -117,6 +117,7 @@ export const LANGUAGES = [ 'cobol', 'vbnet', 'erlang', + 'elixir', 'terraform', 'unknown', ] as const;