diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ba15487..30a15782a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph install` and `codegraph upgrade` now offer CodeGraph Pro beta access after finishing — answer yes, type your email, and you join the same waitlist as the getcodegraph.com homepage form. Strictly opt-in and asked at most once per machine total: nothing is sent unless you say yes and enter an email, either answer is remembered so no later install or upgrade ever re-asks, and non-interactive runs (`--yes`, scripts, CI) never see the question. - Every release is now cryptographically verifiable: npm packages publish with npm provenance (the "Provenance" badge on npmjs.com, proving each version was built by this repository's release workflow from a specific commit), and the GitHub Release bundles carry signed build attestations you can check with `gh attestation verify -R colbymchenry/codegraph`. +- Elixir is now a supported language (`.ex`/`.exs`). CodeGraph indexes modules (including nested ones) as namespaces, public and private functions and macros (a function's multiple clauses become one symbol), structs, protocols and their implementations, and picks up specs and doc attributes as signatures and docstrings. Cross-module calls resolve through the file's alias declarations (including grouped and renamed aliases), behaviours count as implementations, and function captures and struct literals become references. Dynamic dispatch with no static target (a module held in a variable, `apply/3`, process messaging) is deliberately left unlinked rather than guessed. Phoenix/Ecto DSLs, user-macro expansion, and `.heex` templates are out of scope for this first version. ### Fixes diff --git a/README.md b/README.md index dbaa2ad69..4127bb2dc 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ Every language below gets the same treatment — full structural extraction and COBOL Visual Basic .NET Erlang + Elixir Solidity Terraform / OpenTofu Nix @@ -317,7 +318,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 | @@ -836,6 +837,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 as namespaces incl. nested modules, `def`/`defp`/`defmacro`/`defguard` with multi-clause grouping and visibility, `@spec` signatures, `@doc`/`@moduledoc` docstrings, `defstruct` fields, `%Struct{}` references, local and `Module.fun` remote call edges with `alias`/`alias …, as:`/`alias Foo.{A,B}` expansion, `__MODULE__.fun` same-module links, `&Mod.fun/arity` captures, `defprotocol`/`defimpl` namespaces, `@behaviour`/`use` implements links, `import`/`require`/`alias` import edges; dynamic dispatch — variable receivers, `apply/3`, `GenServer.call(pid, …)` — left unlinked) | | 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) | @@ -854,6 +856,7 @@ Impact and blast-radius queries are only as good as the dependency graph behind | C# | jbogard/MediatR | 85.2% | | PHP | guzzle/guzzle | 100% | | Ruby | sidekiq/sidekiq | 100% | +| Elixir | elixir-ecto/ecto | 92.9% | | C | redis/redis | 92.2% | | C++ | google/leveldb | 94.8% | | Objective-C | SDWebImage | 91.6% | diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index d7947073a..b023f8e8b 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -10329,6 +10329,520 @@ init(_) -> {ok, #{}}. }); }); +// ============================================================================= +// Elixir (prebuilt elixir-lang/tree-sitter-elixir grammar from tree-sitter-wasms) +// Every construct is a `call`/`@`-attribute macro, driven from the visitNode +// hook; remote calls emit `Module::fun` to ride the qualified-name matcher. +// ============================================================================= + +describe('Elixir Extraction', () => { + const calls = (r: ReturnType): string[] => + r.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName); + const refsOf = (r: ReturnType, kind: string): string[] => + r.unresolvedReferences.filter((u) => u.referenceKind === kind).map((u) => u.referenceName); + + describe('Language detection', () => { + it('should detect Elixir files', () => { + expect(detectLanguage('lib/foo.ex')).toBe('elixir'); + expect(detectLanguage('test/foo_test.exs')).toBe('elixir'); + expect(detectLanguage('mix.exs')).toBe('elixir'); + expect(isLanguageSupported('elixir')).toBe(true); + expect(getSupportedLanguages()).toContain('elixir'); + expect(isSourceFile('lib/app/worker.ex')).toBe(true); + }); + }); + + describe('Module and function extraction', () => { + it('should extract a module as a namespace and qualify its functions', () => { + const code = `defmodule Foo.Bar do + def greet(name), do: "hi " <> name + defp helper(x), do: x + 1 +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + const ns = result.nodes.find((n) => n.kind === 'namespace'); + expect(ns?.name).toBe('Foo.Bar'); + const greet = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet'); + expect(greet?.qualifiedName).toBe('Foo.Bar::greet'); + expect(greet?.language).toBe('elixir'); + expect(greet?.visibility).toBe('public'); + expect(greet?.isExported).toBe(true); + const helper = result.nodes.find((n) => n.kind === 'function' && n.name === 'helper'); + expect(helper?.visibility).toBe('private'); + expect(helper?.isExported).toBe(false); + }); + + it('should merge multi-clause functions (incl. default args) into one node', () => { + const code = `defmodule M do + def greet(nil), do: "hello" + def greet(name) when is_binary(name) do + "hello " <> name + end + def greet(name, greeting \\\\ "hi") do + greeting <> " " <> name + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const greets = result.nodes.filter((n) => n.kind === 'function' && n.name === 'greet'); + expect(greets).toHaveLength(1); + expect(greets[0]!.startLine).toBe(2); + expect(greets[0]!.endLine).toBe(8); + }); + + it('should not merge same-named functions across different modules', () => { + const code = `defmodule A do + def run(x), do: x +end +defmodule B do + def run(x), do: x +end +`; + const result = extractFromSource('lib/ab.ex', code); + const runs = result.nodes.filter((n) => n.kind === 'function' && n.name === 'run'); + expect(runs).toHaveLength(2); + expect(runs.map((n) => n.qualifiedName).sort()).toEqual(['A::run', 'B::run']); + }); + + it('should use @spec as signature and @doc as docstring', () => { + const code = `defmodule M do + @doc "Greets a person." + @spec greet(String.t()) :: String.t() + def greet(name), do: name +end +`; + const result = extractFromSource('lib/m.ex', code); + const greet = result.nodes.find((n) => n.name === 'greet'); + expect(greet?.signature).toBe('@spec greet(String.t()) :: String.t()'); + expect(greet?.docstring).toBe('Greets a person.'); + // @spec type expressions parse as calls — they must not leak call refs. + expect(calls(result)).not.toContain('String.t'); + expect(calls(result)).not.toContain('greet'); + }); + + it('should treat defmacro/defguard as functions and capture @moduledoc', () => { + const code = `defmodule M do + @moduledoc "A module." + defmacro mac(x), do: x + defguard is_even(n) when rem(n, 2) == 0 +end +`; + const result = extractFromSource('lib/m.ex', code); + const ns = result.nodes.find((n) => n.kind === 'namespace'); + expect(ns?.docstring).toBe('A module.'); + expect(result.nodes.find((n) => n.name === 'mac')?.kind).toBe('function'); + expect(result.nodes.find((n) => n.name === 'is_even')?.kind).toBe('function'); + }); + + it('should qualify nested modules with the full dotted name', () => { + const code = `defmodule Foo.Bar do + defmodule Nested do + def inner(y), do: y * 2 + end +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + const nested = result.nodes.find((n) => n.kind === 'namespace' && n.name === 'Foo.Bar.Nested'); + expect(nested).toBeDefined(); + const inner = result.nodes.find((n) => n.name === 'inner'); + expect(inner?.qualifiedName).toBe('Foo.Bar.Nested::inner'); + }); + + it('should still index defs inside a dynamically-named defmodule (no fake namespace)', () => { + const code = `defmodule unquote(name) do + def handle(e), do: Helper.work(e) +end +`; + const result = extractFromSource('lib/dyn.ex', code); + // No static module name is inventable, so the def is indexed like a + // top-level def (bare qualifiedName) — NOT dropped, NOT under a fabricated + // namespace (F6). + const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle'); + expect(handle).toBeDefined(); + expect(handle?.qualifiedName).toBe('handle'); + // The body call inside the def is still emitted, attributed to the def. + expect(calls(result)).toContain('Helper::work'); + // No bogus namespace node minted for the un-recoverable dynamic name. + expect(result.nodes.find((n) => n.kind === 'namespace')).toBeUndefined(); + }); + + it('should statically recover Module.concat(A, B) as a normal A.B module', () => { + const code = `defmodule Module.concat(Foo, Bar) do + def handle(e), do: Helper.work(e) +end +`; + const result = extractFromSource('lib/concat.ex', code); + const ns = result.nodes.find((n) => n.kind === 'namespace'); + expect(ns?.name).toBe('Foo.Bar'); + const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle'); + expect(handle?.qualifiedName).toBe('Foo.Bar::handle'); + expect(calls(result)).toContain('Helper::work'); + }); + + it('should statically recover Module.concat([A, B]) (list form) as A.B', () => { + const code = `defmodule Module.concat([Foo, Bar]) do + def handle(e), do: Helper.work(e) +end +`; + const result = extractFromSource('lib/concat_list.ex', code); + const ns = result.nodes.find((n) => n.kind === 'namespace'); + expect(ns?.name).toBe('Foo.Bar'); + const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle'); + expect(handle?.qualifiedName).toBe('Foo.Bar::handle'); + }); + }); + + describe('Call extraction', () => { + it('should emit remote calls as Module::fun, resolving aliases', () => { + const code = `defmodule M do + alias Foo.Baz + alias Foo.Qux, as: Q + alias Foo.{Alpha, Beta} + + def run(x) do + Foo.Sub.Mod.compute(x) + Baz.process(x) + Q.handle(x) + Alpha.a(x) + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + expect(c).toContain('Foo.Sub.Mod::compute'); + expect(c).toContain('Foo.Baz::process'); + expect(c).toContain('Foo.Qux::handle'); + expect(c).toContain('Foo.Alpha::a'); + }); + + it('should emit bare local calls and treat __MODULE__.fun as same-module', () => { + const code = `defmodule M do + def run(x) do + helper(x) + __MODULE__.greet("self") + end + def helper(x), do: x + def greet(s), do: s +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + expect(c).toContain('helper'); + expect(c).toContain('greet'); // __MODULE__.greet → bare name + expect(c).not.toContain('__MODULE__::greet'); + }); + + it('should link a piped remote call and record captures as references', () => { + const code = `defmodule M do + def run do + [1, 2, 3] + |> Enum.map(&private_helper/1) + |> MyMod.transform() + end + defp private_helper(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + expect(c).toContain('Enum::map'); + expect(c).toContain('MyMod::transform'); + expect(refsOf(result, 'references')).toContain('private_helper'); + }); + + it('should record a remote capture once, as a reference (no duplicate call edge)', () => { + const code = `defmodule M do + alias Foo.Baz + def run do + &Baz.process/2 + end +end +`; + const result = extractFromSource('lib/m.ex', code); + expect(refsOf(result, 'references')).toContain('Foo.Baz::process'); + expect(calls(result)).not.toContain('Foo.Baz::process'); + }); + + it('should emit calls in custom module-attribute values, attributed to the module', () => { + const code = `defmodule M do + @product_id Product.get_product_name("calltracker", :calltracker_api) + @nested Foo.bar(Baz.qux(1)) + def run, do: @product_id +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // Compile-time remote call in a custom attribute value → normal calls edge. + expect(c).toContain('Product::get_product_name'); + // Nested calls inside the value are walked too. + expect(c).toContain('Foo::bar'); + expect(c).toContain('Baz::qux'); + // The attribute NAME itself is never a call target. + expect(c).not.toContain('product_id'); + expect(c).not.toContain('nested'); + // The call originates from the module namespace (attributes live at + // module level), not from any function. + const ns = result.nodes.find((n) => n.kind === 'namespace' && n.name === 'M'); + const call = result.unresolvedReferences.find( + (u) => u.referenceKind === 'calls' && u.referenceName === 'Product::get_product_name' + ); + expect(call?.fromNodeId).toBe(ns?.id); + }); + + it('should NOT emit calls from @spec/@type type positions or @enforce_keys', () => { + const code = `defmodule M do + @enforce_keys [:a, :b] + defstruct [:a, :b] + @type t :: String.t() + @spec build(String.t()) :: t() + def build(s), do: s +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // Type-position expressions in @spec/@type must not leak call refs (D3). + expect(c).not.toContain('String.t'); + expect(c).not.toContain('String::t'); + expect(c).not.toContain('t'); + expect(c).not.toContain('build'); + // @enforce_keys value (an atom list) produces no call and no noise. + expect(c).not.toContain('enforce_keys'); + expect(c).toHaveLength(0); + }); + + it('should stay silent on dynamic dispatch (variable receiver, apply, gen_server pid)', () => { + const code = `defmodule M do + alias Foo.Baz + def run(x) do + GenServer.call(x, :msg) + apply(Baz, :process, [x]) + mod = Baz + mod.process(x) + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // A variable receiver has no static target — no edge to process at all. + expect(c).not.toContain('Foo.Baz::process'); + expect(c).not.toContain('process'); + // GenServer.call routes to a pid, not a local module handler. + expect(c.filter((n) => n.endsWith('::process'))).toHaveLength(0); + }); + }); + + describe('Structural edges', () => { + it('should model @behaviour and use as implements, and alias/import/require as imports', () => { + const code = `defmodule M do + @behaviour GenServer + use GenServer + import Enum, only: [map: 2] + alias Foo.Baz + require Logger +end +`; + const result = extractFromSource('lib/m.ex', code); + const impls = refsOf(result, 'implements'); + expect(impls.filter((n) => n === 'GenServer')).toHaveLength(2); // @behaviour + use + const imports = refsOf(result, 'imports'); + expect(imports).toContain('Enum'); + expect(imports).toContain('Foo.Baz'); + expect(imports).toContain('Logger'); + }); + + it('should extract defstruct fields on the module and %Struct{} as a reference', () => { + const code = `defmodule Foo.Bar do + defstruct name: nil, count: 0 + def make, do: %Foo.Bar{name: "n", count: 1} +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name); + expect(fields).toContain('name'); + expect(fields).toContain('count'); + expect(refsOf(result, 'references')).toContain('Foo.Bar'); + }); + }); + + describe('Protocols', () => { + it('should extract defprotocol and defimpl as namespaces with implements', () => { + const code = `defprotocol Sizeable do + @doc "Returns size." + def size(data) +end + +defimpl Sizeable, for: List do + def size(data), do: length(data) +end +`; + const result = extractFromSource('lib/sizeable.ex', code); + const proto = result.nodes.find((n) => n.kind === 'namespace' && n.name === 'Sizeable'); + expect(proto).toBeDefined(); + const impl = result.nodes.find((n) => n.kind === 'namespace' && n.name === 'Sizeable.List'); + expect(impl).toBeDefined(); + expect(refsOf(result, 'implements')).toContain('Sizeable'); + const implSize = result.nodes.find((n) => n.qualifiedName === 'Sizeable.List::size'); + expect(implSize).toBeDefined(); + // one-liner do: body still contributes calls + expect(calls(result)).toContain('length'); + }); + }); + + describe('Scripts', () => { + it('should index .exs script modules', () => { + const code = `defmodule Mix.Tasks.Hello do + def run(_), do: IO.puts("hi") +end +`; + const result = extractFromSource('mix.exs', code); + expect(result.nodes.find((n) => n.kind === 'namespace')?.name).toBe('Mix.Tasks.Hello'); + expect(calls(result)).toContain('IO::puts'); + }); + }); + + // Gaps found in code review (GLM + Cursor), fixed together. + describe('Review-found extraction gaps', () => { + it('should emit calls inside guard expressions (defguard body and def ... when)', () => { + const code = `defmodule M do + defguard ok(x) when local_guard(x) + def run(x) when allowed?(x), do: work(x) + def check(x) when MyValidator.valid?(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // defguard body is a \`when\` binary_operator in arguments, not a do_block. + expect(c).toContain('local_guard'); + // \`def ... when guard, do: body\` — both the guard and the body have calls. + expect(c).toContain('allowed?'); + expect(c).toContain('work'); + // Remote call in a guard resolves as Module::fun. + expect(c).toContain('MyValidator::valid?'); + // The def head names must never leak as self-calls. + expect(c).not.toContain('ok'); + expect(c).not.toContain('run'); + expect(c).not.toContain('check'); + }); + + it('should emit %__MODULE__{} as a reference to the enclosing module', () => { + const code = `defmodule Foo.Bar do + def new(attrs), do: %__MODULE__{a: attrs} +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + // Struct analog of __MODULE__.fun(): reference the current module by name. + expect(refsOf(result, 'references')).toContain('Foo.Bar'); + }); + + it('should resolve %__MODULE__{} inside a nested module to the nested name', () => { + const code = `defmodule Foo.Bar do + defmodule Nested do + def new, do: %__MODULE__{a: 1} + end +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + expect(refsOf(result, 'references')).toContain('Foo.Bar.Nested'); + }); + + it('should emit &__MODULE__.fun/arity captures as a same-module reference', () => { + const code = `defmodule M do + def cap, do: &__MODULE__.normalize/1 + def normalize(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + // __MODULE__.normalize → bare name, same-file preference (like the call form). + expect(refsOf(result, 'references')).toContain('normalize'); + expect(refsOf(result, 'references')).not.toContain('__MODULE__::normalize'); + }); + + it('should not leak defoverridable as a call ref', () => { + const code = `defmodule M do + def run(x), do: x + defoverridable run: 1 +end +`; + const result = extractFromSource('lib/m.ex', code); + expect(calls(result)).not.toContain('defoverridable'); + }); + + it('should not leak Kernel control-flow / process macros as call refs', () => { + const code = `defmodule M do + def run(x) do + if x, do: raise("no") + unless x, do: throw(:stop) + case x do + _ -> exit(:normal) + end + cond do + true -> send(self(), :ok) + end + with :ok <- x, do: spawn(fn -> real_helper(x) end) + for _ <- [], do: :ok + try do + real_helper(x) + rescue + _ -> :err + end + end + def real_helper(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + for (const m of ['if', 'unless', 'case', 'cond', 'with', 'for', 'raise', 'try', + 'send', 'spawn', 'self', 'throw', 'exit']) { + expect(c).not.toContain(m); + } + // A genuine local call in the same body is still emitted. + expect(c).toContain('real_helper'); + }); + + it('should emit calls in defstruct default values without minting field-name edges', () => { + const code = `defmodule M do + defstruct ts: DateTime.utc_now(), id: generate_id(), items: [] +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // Default-value calls are real compile-time code. + expect(c).toContain('DateTime::utc_now'); + expect(c).toContain('generate_id'); + // Field names must never become call edges. + expect(c).not.toContain('ts'); + expect(c).not.toContain('id'); + // Fields are still recorded on the module. + const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name); + expect(fields).toContain('ts'); + expect(fields).toContain('id'); + expect(fields).toContain('items'); + }); + + it('should index a one-liner module body (defmodule M, do: ...)', () => { + const code = `defmodule M, do: (def run, do: Helper.work()) +`; + const result = extractFromSource('lib/m.ex', code); + const run = result.nodes.find((n) => n.kind === 'function' && n.name === 'run'); + expect(run?.qualifiedName).toBe('M::run'); + expect(calls(result)).toContain('Helper::work'); + }); + + it('should resolve an alias declared inside a function body (module-wide)', () => { + const code = `defmodule M do + def run do + alias Foo.Bar + Bar.work() + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + expect(c).toContain('Foo.Bar::work'); + expect(c).not.toContain('Bar::work'); + }); + }); +}); + describe('Terraform Extraction', () => { describe('Language detection', () => { it('should detect Terraform files', () => { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index aa1bd72a7..f651c1d1a 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -173,6 +173,209 @@ describe('Resolution Module', () => { expect(matchReference(appRef('my_behaviour'), context)?.targetNodeId).toBe(behaviourModule.id); }); + it('should resolve Elixir bare calls same-file only, and implements refs to namespaces only', () => { + const mkNode = (partial: Partial & Pick): Node => ({ + language: 'elixir', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + ...partial, + }); + // A cross-file `config/1` function that Mix's `config :app, …` DSL calls + // must NOT link to, and a same-file `helper/1` that a bare call must. + const crossFileConfig = mkNode({ + id: 'function:lib/consumer.ex:config:5', + kind: 'function', + name: 'config', + qualifiedName: 'MyApp.Consumer::config', + filePath: 'lib/consumer.ex', + }); + const sameFileHelper = mkNode({ + id: 'function:lib/worker.ex:helper:20', + kind: 'function', + name: 'helper', + qualifiedName: 'MyApp.Worker::helper', + filePath: 'lib/worker.ex', + }); + const behaviourModule = mkNode({ + id: 'namespace:lib/my_behaviour.ex:MyBehaviour:1', + kind: 'namespace', + name: 'MyBehaviour', + qualifiedName: 'MyBehaviour', + filePath: 'lib/my_behaviour.ex', + }); + // The caller — `MyApp.Worker.run/1` — so bare calls resolve within its + // own module (`MyApp.Worker`, same module as sameFileHelper). + const runCaller = mkNode({ + id: 'function:lib/worker.ex:run:3', + kind: 'function', + name: 'run', + qualifiedName: 'MyApp.Worker::run', + filePath: 'lib/worker.ex', + startLine: 3, + }); + const nodes = [crossFileConfig, sameFileHelper, behaviourModule, runCaller]; + const byId = new Map(nodes.map((n) => [n.id, n])); + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: (name) => nodes.filter((n) => n.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + getNodeById: (id) => byId.get(id) ?? null, + }; + const mkRef = (name: string, kind: 'calls' | 'references' | 'implements') => ({ + fromNodeId: 'function:lib/worker.ex:run:3', + referenceName: name, + referenceKind: kind, + line: 4, + column: 0, + filePath: 'lib/worker.ex', + language: 'elixir' as const, + }); + + // Bare call to a function that only exists in ANOTHER file: silence. + expect(matchReference(mkRef('config', 'calls'), context)).toBeNull(); + // Bare call to a same-file function resolves. + expect(matchReference(mkRef('helper', 'calls'), context)?.targetNodeId).toBe(sameFileHelper.id); + // Bare capture reference follows the same rule. + expect(matchReference(mkRef('helper', 'references'), context)?.targetNodeId).toBe(sameFileHelper.id); + expect(matchReference(mkRef('config', 'references'), context)).toBeNull(); + // `@behaviour`/`use` implements refs resolve only to module namespaces; + // an out-of-repo behaviour stays unresolved. + expect(matchReference(mkRef('MyBehaviour', 'implements'), context)?.targetNodeId).toBe(behaviourModule.id); + expect(matchReference(mkRef('GenServer', 'implements'), context)).toBeNull(); + }); + + describe('Elixir bare-call resolution qualifies by enclosing module', () => { + // One file, two modules that BOTH define `helper/0`. A bare `helper()` + // call must resolve to the CALLER's module's function, never the other + // module's — a cross-module edge is a wrong edge, worse than none. + const mkNode = ( + partial: Partial & Pick + ): Node => ({ + language: 'elixir', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + ...partial, + }); + const FILE = 'lib/two_mods.ex'; + // Module A defined FIRST so getNodesByName returns A's helper first — + // pre-fix, a bare call from B would (wrongly) pick A[0]. + const aHelper = mkNode({ + id: 'function:lib/two_mods.ex:helper:2', + kind: 'function', + name: 'helper', + qualifiedName: 'Sample.A::helper', + filePath: FILE, + startLine: 2, + }); + const aModule = mkNode({ + id: 'namespace:lib/two_mods.ex:Sample.A:1', + kind: 'namespace', + name: 'Sample.A', + qualifiedName: 'Sample.A', + filePath: FILE, + startLine: 1, + }); + const bHelper = mkNode({ + id: 'function:lib/two_mods.ex:helper:12', + kind: 'function', + name: 'helper', + qualifiedName: 'Sample.B::helper', + filePath: FILE, + startLine: 12, + }); + const bRun = mkNode({ + id: 'function:lib/two_mods.ex:run:14', + kind: 'function', + name: 'run', + qualifiedName: 'Sample.B::run', + filePath: FILE, + startLine: 14, + }); + const bModule = mkNode({ + id: 'namespace:lib/two_mods.ex:Sample.B:11', + kind: 'namespace', + name: 'Sample.B', + qualifiedName: 'Sample.B', + filePath: FILE, + startLine: 11, + }); + + const mkContext = (nodes: Node[]): ResolutionContext => { + const byId = new Map(nodes.map((n) => [n.id, n])); + return { + getNodesInFile: () => [], + // A's helper first, so the OLD `sameFile[0]` picks the wrong module. + getNodesByName: (name) => nodes.filter((n) => n.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + getNodeById: (id) => byId.get(id) ?? null, + }; + }; + + const mkRef = (fromNodeId: string) => ({ + fromNodeId, + referenceName: 'helper', + referenceKind: 'calls' as const, + line: 15, + column: 4, + filePath: FILE, + language: 'elixir' as const, + }); + + it('(a) resolves a bare call to the CALLER module, not the first same-named function', () => { + const context = mkContext([aModule, aHelper, bModule, bHelper, bRun]); + const result = matchReference(mkRef(bRun.id), context); + expect(result).not.toBeNull(); + // The wrong-target guard: must be B's helper, never A's. + const target = [aModule, aHelper, bModule, bHelper, bRun].find( + (n) => n.id === result!.targetNodeId + ); + expect(target?.qualifiedName).toBe('Sample.B::helper'); + expect(result!.targetNodeId).toBe(bHelper.id); + }); + + it('(b) stays unresolved when only ANOTHER module defines the name', () => { + // Only module A defines helper; the call lives in module B → no edge + // (pre-fix this wrongly created B.run → A::helper). + const context = mkContext([aModule, aHelper, bModule, bRun]); + expect(matchReference(mkRef(bRun.id), context)).toBeNull(); + }); + + it('(c) control: single module, bare call to own function resolves', () => { + const context = mkContext([bModule, bHelper, bRun]); + const result = matchReference(mkRef(bRun.id), context); + expect(result?.targetNodeId).toBe(bHelper.id); + }); + + it('(d) module-attribute caller (namespace node) resolves to its own module', () => { + // c4c0eec: a call inside a custom module-attribute value carries the + // MODULE namespace node as fromNode. It must still resolve to that + // module's helper. + const context = mkContext([aModule, aHelper, bModule, bHelper, bRun]); + const result = matchReference(mkRef(bModule.id), context); + expect(result?.targetNodeId).toBe(bHelper.id); + }); + }); + it('should prefer same-module candidates over cross-module matches', () => { // Simulates a Python monorepo where multiple apps define navigate() const candidateA: Node = { diff --git a/assets/languages/elixir.svg b/assets/languages/elixir.svg new file mode 100644 index 000000000..20d4f51ae --- /dev/null +++ b/assets/languages/elixir.svg @@ -0,0 +1,6 @@ + + Elixir + + + + diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index a26d232ae..0fc4ee9bd 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,10 @@ 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 (.exs — mix.exs, tests, seeds). Prebuilt + // elixir-lang/tree-sitter-elixir grammar from tree-sitter-wasms (ABI 14). + '.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. @@ -590,6 +595,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/languages/elixir.ts b/src/extraction/languages/elixir.ts new file mode 100644 index 000000000..7383f1a0b --- /dev/null +++ b/src/extraction/languages/elixir.ts @@ -0,0 +1,655 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types'; + +// Node names follow the elixir-lang/tree-sitter-elixir grammar (ABI 14, the +// grammar shipped in tree-sitter-wasms). Elixir has no keywords — `defmodule`, +// `def`, `defp`, `use`, `import`, `@spec`, … are all MACROS, so every one of +// them parses as a `call` node (or a `@`-`unary_operator` for module +// attributes). Nothing fits the generic functionTypes/classTypes dispatch, so +// the whole language is driven from the visitNode hook below, keyed off the +// target identifier of each `call`. +// +// Verified empirically with scripts/add-lang/dump-ast.mjs (2026-07-11): +// - `defmodule Foo.Bar do … end` → call(target=identifier "defmodule", +// arguments→alias "Foo.Bar", do_block CHILD of the call) +// - `def name(args) do … end` → call(target=identifier "def", +// arguments→call(head: target=name, arguments=params), do_block child) +// - guard clause `def f(x) when g` → arguments→binary_operator("when") +// {left: head call, right: guard} +// - one-liner `def f(x), do: e` → no do_block; body is arguments→keywords +// →pair(key keyword "do:") +// - `@spec`/`@doc`/`@moduledoc`/`@behaviour` → unary_operator("@") +// {operand: call(target=identifier "spec"/"doc"/… )} +// - remote call `Mod.Sub.fun(x)` → call(target=dot{left: alias "Mod.Sub", +// right: identifier "fun"}) +// +// Remote calls are emitted (in the elixir branch of extractCall) as +// `Full.Module::fun`, byte-identical to the qualifiedName a module's functions +// carry, so cross-module resolution rides the standard qualified-name matcher — +// the same design the erlang extractor uses. + +const PUBLIC_DEFS = new Set(['def', 'defmacro', 'defguard', 'defdelegate']); +const PRIVATE_DEFS = new Set(['defp', 'defmacrop', 'defguardp']); + +/** + * Reserved/built-in module attributes whose VALUES are type positions, docs, or + * compiler metadata — NOT runtime code. Their subtree is consumed (not walked) + * because a type expression like `String.t()` in `@spec` parses as a `call` and + * would otherwise mint a bogus `calls` edge (D3). List sourced from the official + * Module docs (https://hexdocs.pm/elixir/Module.html, verified 2026-07-12) plus + * the American spelling `behavior` and the Mix `@shortdoc` convention. + * Every attribute OUTSIDE this set is a custom attribute: its value is real + * compile-time code, so we descend into it to emit normal `calls` edges. + */ +const META_ATTRIBUTES = new Set([ + 'spec', + 'doc', + 'moduledoc', + 'typedoc', + 'shortdoc', + 'behaviour', + 'behavior', + 'type', + 'typep', + 'opaque', + 'callback', + 'macrocallback', + 'optional_callbacks', + 'impl', + 'derive', + 'enforce_keys', + 'deprecated', + 'dialyzer', + 'external_resource', + 'compile', + 'before_compile', + 'after_compile', + 'after_verify', + 'on_definition', + 'on_load', + 'nifs', + 'vsn', + 'file', +]); +/** Every macro that introduces a named function. */ +const DEF_MACROS = new Set([...PUBLIC_DEFS, ...PRIVATE_DEFS]); + +/** + * Per-file clause-merge state. Elixir emits one `def` call PER CLAUSE (pattern + * matching / default args / guards), so consecutive same-name definitions in + * the same module are merged into a single function node — the endLine is + * extended and the extra clause's body attributed to the existing node. Keyed + * by (file, module node id, name): a same-named function in a different module + * must NOT merge. Extraction is file-sequential, so a single-entry memo is safe. + */ +let lastFnFile = ''; +let lastFnModuleId = ''; +let lastFnName = ''; +let lastFnId = ''; + +/** Nesting stack of enclosing module full dotted names (for nested defmodule). */ +let moduleNameStack: string[] = []; +let moduleStackFile = ''; + +function resetModuleStack(filePath: string): void { + if (moduleStackFile !== filePath) { + moduleStackFile = filePath; + moduleNameStack = []; + } +} + +function currentModulePrefix(): string { + return moduleNameStack.length ? moduleNameStack[moduleNameStack.length - 1]! : ''; +} + +function collapseWs(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +/** Text of a `keyword` key with surrounding space and trailing colon stripped + * (`do:` → `do`). The grammar can include trailing whitespace on the token. */ +function keywordKey(node: SyntaxNode, source: string): string { + return getNodeText(node, source).trim().replace(/:$/, ''); +} + +/** + * The `arguments` node of a call. In tree-sitter-elixir `arguments` is a child + * NODE TYPE, not a field, so it must be located by type (not childForFieldName). + */ +function argsOf(node: SyntaxNode): SyntaxNode | null { + for (const child of node.namedChildren) { + if (child.type === 'arguments') return child; + } + return null; +} + +/** First `alias` under a node's direct named children. */ +function firstAlias(node: SyntaxNode | null): SyntaxNode | null { + if (!node) return null; + for (const child of node.namedChildren) { + if (child.type === 'alias') return child; + } + return null; +} + +/** + * Statically recover a module name from `Module.concat(A, B)` / + * `Module.concat([A, B])` when every argument is a literal `alias`. Returns the + * dotted join (`A.B`) or null for any other shape (variable args, non-literal + * segments, nesting) — those stay genuinely dynamic. The `defmodule` argument is + * the head `call` whose target is a `dot` (left alias "Module", right identifier + * "concat"); its own `arguments` hold the aliases, either directly or wrapped in + * a single `list`. + */ +function recoverModuleConcat(head: SyntaxNode, source: string): string | null { + if (head.type !== 'call') return null; + const target = getChildByField(head, 'target'); + if (!target || target.type !== 'dot') return null; + const left = getChildByField(target, 'left'); + const right = getChildByField(target, 'right'); + if ( + !left || + left.type !== 'alias' || + getNodeText(left, source) !== 'Module' || + !right || + right.type !== 'identifier' || + getNodeText(right, source) !== 'concat' + ) { + return null; + } + const args = argsOf(head); + if (!args) return null; + let segNodes = args.namedChildren; + // List form: a single `list` child holding the aliases. + if (segNodes.length === 1 && segNodes[0]!.type === 'list') { + segNodes = segNodes[0]!.namedChildren; + } + if (segNodes.length === 0) return null; + const parts: string[] = []; + for (const seg of segNodes) { + if (seg.type !== 'alias') return null; // any non-literal segment → dynamic + parts.push(getNodeText(seg, source)); + } + return parts.join('.'); +} + +/** Look up a keyword value (`for:` → its value node) inside an arguments node. */ +function keywordValue(argsNode: SyntaxNode | null, key: string, source: string): SyntaxNode | null { + if (!argsNode) return null; + for (const child of argsNode.namedChildren) { + if (child.type !== 'keywords') continue; + for (const pair of child.namedChildren) { + if (pair.type !== 'pair') continue; + const k = getChildByField(pair, 'key'); + if (k && keywordKey(k, source) === key) return getChildByField(pair, 'value'); + } + } + return null; +} + +/** The `do_block` child of a def/defmodule call, if the block form is used. */ +function doBlockOf(node: SyntaxNode): SyntaxNode | null { + for (const child of node.namedChildren) { + if (child.type === 'do_block') return child; + } + return null; +} + +/** + * Unwrap the head `call` of a `def`: arguments[0] is either the head call + * directly, or a `binary_operator` ("when") whose `left` is the head call. + * Returns the head call (target=identifier function name, arguments=params). + */ +function defHead(node: SyntaxNode): SyntaxNode | null { + const args = argsOf(node); + const first = args?.namedChildren[0] ?? null; + if (!first) return null; + if (first.type === 'binary_operator') { + const left = getChildByField(first, 'left'); + return left && left.type === 'call' ? left : null; + } + if (first.type === 'call') return first; + // A zero-arg macro def head can be a bare identifier (`def hello, do: …`). + return null; +} + +/** Function name + head node for a def call (handles the bare-identifier head). */ +function defName(node: SyntaxNode, source: string): { name: string; head: SyntaxNode | null } | null { + const head = defHead(node); + if (head) { + const target = getChildByField(head, 'target'); + if (target?.type === 'identifier') return { name: getNodeText(target, source), head }; + return null; + } + // Bare head: `def hello do … end` / `def hello, do: …` — arguments[0] is an + // identifier (the name), or when there is a `when` guard on a zero-arg def. + const args = argsOf(node); + const first = args?.namedChildren[0] ?? null; + if (first?.type === 'identifier') return { name: getNodeText(first, source), head: null }; + if (first?.type === 'binary_operator') { + const left = getChildByField(first, 'left'); + if (left?.type === 'identifier') return { name: getNodeText(left, source), head: null }; + } + return null; +} + +/** + * Collect the `@spec` (signature) and `@doc` (docstring) directly preceding a + * def, skipping comments and other clauses. Mirrors erlang's precedingSpec. + */ +function precedingAttrs( + node: SyntaxNode, + source: string +): { signature?: string; docstring?: string } { + const out: { signature?: string; docstring?: string } = {}; + let prev = node.previousNamedSibling; + while (prev) { + if (prev.type === 'comment') { + prev = prev.previousNamedSibling; + continue; + } + if (prev.type === 'unary_operator') { + const attr = attributeCall(prev); + if (attr) { + const attrName = attributeName(attr, source); + if (attrName === 'spec' && out.signature === undefined) { + out.signature = collapseWs(getNodeText(prev, source)).slice(0, 300); + prev = prev.previousNamedSibling; + continue; + } + if (attrName === 'doc' && out.docstring === undefined) { + out.docstring = docContent(attr, source); + prev = prev.previousNamedSibling; + continue; + } + } + } + break; + } + return out; +} + +/** For a `@`-unary_operator, the inner `call` (`@spec name(...) :: ...`). */ +function attributeCall(unary: SyntaxNode): SyntaxNode | null { + const operand = getChildByField(unary, 'operand'); + return operand?.type === 'call' ? operand : null; +} + +/** The attribute name of an inner attribute call (`spec`/`doc`/`behaviour`). */ +function attributeName(call: SyntaxNode, source: string): string { + const target = getChildByField(call, 'target'); + return target?.type === 'identifier' ? getNodeText(target, source) : ''; +} + +/** The string literal content of a `@doc "..."` / `@moduledoc "..."`. */ +function docContent(call: SyntaxNode, source: string): string | undefined { + const args = argsOf(call); + const strNode = args?.namedChildren.find((c) => c.type === 'string'); + if (!strNode) return undefined; + const content = strNode.namedChildren.find((c) => c.type === 'quoted_content'); + const text = content ? getNodeText(content, source) : getNodeText(strNode, source); + return collapseWs(text) || undefined; +} + +/** `@moduledoc` docstring inside a module's do_block, if present. */ +function moduledocOf(doBlock: SyntaxNode | null, source: string): string | undefined { + if (!doBlock) return undefined; + for (const child of doBlock.namedChildren) { + if (child.type !== 'unary_operator') continue; + const call = attributeCall(child); + if (call && attributeName(call, source) === 'moduledoc') return docContent(call, source); + } + return undefined; +} + +/** + * Visit a module's body regardless of form: the `do_block` child (block form) or + * the `do:` keyword value (one-liner `defmodule M, do: def ...`). The one-liner + * value is a `block` wrapping the statements; visit its children individually so + * each inner `def` reaches the visitNode hook. + */ +function visitModuleBody(node: SyntaxNode, ctx: ExtractorContext): void { + const doBlock = doBlockOf(node); + if (doBlock) { + for (const child of doBlock.namedChildren) ctx.visitNode(child); + return; + } + const doValue = keywordValue(argsOf(node), 'do', ctx.source); + if (!doValue) return; + if (doValue.type === 'block') { + for (const child of doValue.namedChildren) ctx.visitNode(child); + } else { + ctx.visitNode(doValue); + } +} + +function handleModule(node: SyntaxNode, ctx: ExtractorContext, localName: string): boolean { + const prefix = currentModulePrefix(); + const fullName = prefix ? `${prefix}.${localName}` : localName; + const doBlock = doBlockOf(node); + const ns = ctx.createNode('namespace', fullName, node, { + qualifiedName: fullName, + docstring: moduledocOf(doBlock, ctx.source), + }); + if (!ns) return true; + ctx.pushScope(ns.id); + moduleNameStack.push(fullName); + visitModuleBody(node, ctx); + moduleNameStack.pop(); + ctx.popScope(); + return true; +} + +function handleDefImpl(node: SyntaxNode, ctx: ExtractorContext): boolean { + const args = argsOf(node); + const protoAlias = firstAlias(args); + const forValue = keywordValue(args, 'for', ctx.source); + const proto = protoAlias ? getNodeText(protoAlias, ctx.source) : ''; + const forType = + forValue?.type === 'alias' + ? getNodeText(forValue, ctx.source) + : forValue + ? collapseWs(getNodeText(forValue, ctx.source)) + : ''; + const localName = forType ? `${proto}.${forType}` : proto; + // `defimpl Proto, for: Type` implements Proto's contract — emit the + // implements ref, then treat the block like a normal module named Proto.Type. + const prefix = currentModulePrefix(); + const fullName = prefix ? `${prefix}.${localName}` : localName; + const ns = ctx.createNode('namespace', fullName, node, { qualifiedName: fullName }); + if (!ns) return true; + if (proto) { + ctx.addUnresolvedReference({ + fromNodeId: ns.id, + referenceName: proto, + referenceKind: 'implements', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + ctx.pushScope(ns.id); + moduleNameStack.push(fullName); + visitModuleBody(node, ctx); + moduleNameStack.pop(); + ctx.popScope(); + return true; +} + +function handleDef(node: SyntaxNode, ctx: ExtractorContext, macro: string): boolean { + const named = defName(node, ctx.source); + if (!named) return true; + const { name, head } = named; + const moduleId = ctx.nodeStack[ctx.nodeStack.length - 1] ?? ''; + const fullMod = currentModulePrefix(); + + // Continuation clause of the same function in the same module — merge. + if ( + ctx.filePath === lastFnFile && + moduleId === lastFnModuleId && + name === lastFnName && + lastFnId + ) { + 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); + visitDefBodies(node, head, lastFnId, ctx); + ctx.popScope(); + return true; + } + + const attrs = precedingAttrs(node, ctx.source); + const signature = + attrs.signature ?? (head ? collapseWs(getNodeText(head, ctx.source)).slice(0, 300) : name); + const fn = ctx.createNode('function', name, node, { + qualifiedName: fullMod ? `${fullMod}::${name}` : name, + signature, + docstring: attrs.docstring, + isExported: PUBLIC_DEFS.has(macro), + visibility: PRIVATE_DEFS.has(macro) ? 'private' : 'public', + }); + if (!fn) return true; + ctx.pushScope(fn.id); + visitDefBodies(node, head, fn.id, ctx); + ctx.popScope(); + lastFnFile = ctx.filePath; + lastFnModuleId = moduleId; + lastFnName = name; + lastFnId = fn.id; + return true; +} + +/** + * Walk a def's body/bodies for calls WITHOUT walking the head (which would emit + * a spurious self-call to the function's own name): the guard expression (the + * RIGHT side of a `when` binary_operator in arguments[0] — the LEFT is the head), + * the do_block child and, for the one-liner form, the `do:` keyword value. + * + * The guard walk covers two shapes both invisible before: `defguard`/`defguardp` + * (whose ONLY body is the guard — no do_block, no `do:`) and a `def f(x) when + * guard, do: body` whose guard calls were previously dropped. + */ +function visitDefBodies( + node: SyntaxNode, + _head: SyntaxNode | null, + fnId: string, + ctx: ExtractorContext +): void { + const first = argsOf(node)?.namedChildren[0] ?? null; + if (first?.type === 'binary_operator') { + // Guard clause: arguments[0] is `head when guard`; walk the guard (right). + const right = getChildByField(first, 'right'); + if (right) ctx.visitFunctionBody(right, fnId); + } + const doBlock = doBlockOf(node); + if (doBlock) ctx.visitFunctionBody(doBlock, fnId); + const doValue = keywordValue(argsOf(node), 'do', ctx.source); + if (doValue) ctx.visitFunctionBody(doValue, fnId); +} + +function handleDefstruct(node: SyntaxNode, ctx: ExtractorContext): boolean { + // Fields belong to the enclosing module (the struct IS the module). Two + // shapes: keyword form `defstruct name: nil, count: 0` and atom-list form + // `defstruct [:name, :count]`. + const args = argsOf(node); + if (!args) return true; + const addField = (fieldName: string, pos: SyntaxNode): void => { + if (!fieldName) return; + const fullMod = currentModulePrefix(); + ctx.createNode('field', fieldName, pos, { + qualifiedName: fullMod ? `${fullMod}::${fieldName}` : fieldName, + }); + }; + // Field default VALUES are real compile-time code (`ts: DateTime.utc_now()`), + // so their calls must be emitted — attributed to the enclosing module (the + // current scope). The field NAME (the keyword key) must never become an edge. + const walkValue = (pair: SyntaxNode): void => { + const value = getChildByField(pair, 'value'); + if (value) ctx.visitNode(value); + }; + for (const child of args.namedChildren) { + if (child.type === 'keywords') { + for (const pair of child.namedChildren) { + if (pair.type !== 'pair') continue; + const key = getChildByField(pair, 'key'); + if (key) addField(keywordKey(key, ctx.source), pair); + walkValue(pair); + } + } else if (child.type === 'list') { + for (const item of child.namedChildren) { + if (item.type === 'atom') { + addField(getNodeText(item, ctx.source).replace(/^:/, ''), item); + } else if (item.type === 'keywords') { + for (const pair of item.namedChildren) { + if (pair.type !== 'pair') continue; + const key = getChildByField(pair, 'key'); + if (key) addField(keywordKey(key, ctx.source), pair); + walkValue(pair); + } + } + } + } + } + return true; +} + +/** `use`/`import`/`require`/`alias` — structural edges (D7). */ +function handleModuleDirective(node: SyntaxNode, ctx: ExtractorContext, macro: string): boolean { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!parentId) return true; + const args = argsOf(node); + const line = node.startPosition.row + 1; + const column = node.startPosition.column; + + // `use Mod` injects a contract — model as `implements` (closest existing + // semantics; resolves to the module namespace via the module-only rule). + if (macro === 'use') { + const aliasNode = firstAlias(args); + if (aliasNode) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: getNodeText(aliasNode, ctx.source), + referenceKind: 'implements', + line, + column, + }); + } + return true; + } + + // import / require / alias → file-level import edge to the module. Handles + // grouped `alias Foo.{Alpha, Beta}` (dot{left: alias prefix, right: tuple}). + const emit = (moduleName: string): void => { + if (!moduleName) return; + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: moduleName, + referenceKind: 'imports', + line, + column, + }); + }; + if (args) { + for (const child of args.namedChildren) { + if (child.type === 'alias') { + emit(getNodeText(child, ctx.source)); + } else if (child.type === 'dot') { + // grouped alias: Foo.{Alpha, Beta} + const left = getChildByField(child, 'left'); + const right = getChildByField(child, 'right'); + const prefix = left?.type === 'alias' ? getNodeText(left, ctx.source) : ''; + if (right?.type === 'tuple' && prefix) { + for (const member of right.namedChildren) { + if (member.type === 'alias') emit(`${prefix}.${getNodeText(member, ctx.source)}`); + } + } + } + } + } + return true; +} + +/** + * `@behaviour Mod` → implements ref; other META attributes (@spec/@doc/…) are + * consumed silently; CUSTOM attributes have their VALUE walked so a compile-time + * call in the value (`@id Product.get_product_name(:x)`) emits a normal `calls` + * edge attributed to the enclosing module (the current scope). + */ +function handleAttribute(node: SyntaxNode, ctx: ExtractorContext): boolean { + const call = attributeCall(node); + if (!call) return true; // no inner call (e.g. bare `@x`) — nothing to walk + const name = attributeName(call, ctx.source); + if (name === 'behaviour' || name === 'behavior') { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + const aliasNode = firstAlias(argsOf(call)); + if (parentId && aliasNode) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: getNodeText(aliasNode, ctx.source), + referenceKind: 'implements', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + return true; + } + if (META_ATTRIBUTES.has(name)) { + // @doc/@spec are consumed here (picked up by precedingAttrs on the next def); + // @moduledoc by moduledocOf. Consuming prevents type-position exprs in @spec + // (`String.t()` parses as a call) from minting bogus call refs (D3). + return true; + } + // Custom attribute: its value is real compile-time code. Walk the value + // subtree (the inner call's arguments) — NOT the inner call's target, which is + // just the attribute NAME (`x` in `@x value`) and must never become a `calls` + // edge. Descending lets `Mod.fun(...)` in the value resolve normally; the + // enclosing module namespace is the current scope, so the edge originates + // there (attributes live at module level, not inside any function). + const args = argsOf(call); + if (args) { + for (const child of args.namedChildren) ctx.visitNode(child); + } + return true; +} + +export const elixirExtractor: LanguageExtractor = { + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], + // Real call sites (remote/local calls, `&` captures, `%Struct{}` literals) + // are dispatched to the elixir branch of extractCall; declaration macros are + // intercepted by visitNode below (returning true) before they reach it. + callTypes: ['call', 'unary_operator', 'map'], + variableTypes: [], + nameField: 'target', + bodyField: 'do_block', + paramsField: 'arguments', + + visitNode: (node, ctx) => { + resetModuleStack(ctx.filePath); + + if (node.type === 'unary_operator') { + // `@attr …` module attribute (operator text starts with '@'). + if (getNodeText(node, ctx.source).startsWith('@')) { + return handleAttribute(node, ctx); + } + return false; // `&capture/1`, negation, etc. → extractCall / generic walk + } + + if (node.type !== 'call') return false; + const target = getChildByField(node, 'target'); + if (target?.type !== 'identifier') return false; // remote call (dot) → extractCall + + const macro = getNodeText(target, ctx.source); + if (macro === 'defmodule' || macro === 'defprotocol') { + const aliasNode = firstAlias(argsOf(node)); + if (aliasNode) return handleModule(node, ctx, getNodeText(aliasNode, ctx.source)); + // Dynamic module name (no literal alias). Try to recover a static name from + // `Module.concat(A, B)` / `Module.concat([A, B])`; otherwise (genuinely + // dynamic) DON'T silently swallow the body — visit it so inner defs are + // indexed like top-level defs, with no fabricated namespace (F6). + const head = argsOf(node)?.namedChildren[0] ?? null; + const recovered = head ? recoverModuleConcat(head, ctx.source) : null; + if (recovered) return handleModule(node, ctx, recovered); + visitModuleBody(node, ctx); + return true; + } + if (macro === 'defimpl') return handleDefImpl(node, ctx); + if (DEF_MACROS.has(macro)) return handleDef(node, ctx, macro); + if (macro === 'defstruct' || macro === 'defexception') return handleDefstruct(node, ctx); + if (macro === 'use' || macro === 'import' || macro === 'require' || macro === 'alias') { + return handleModuleDirective(node, ctx, macro); + } + return false; // ordinary local call → extractCall + }, +}; 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.ts b/src/extraction/tree-sitter.ts index fe3664ae8..a4433bebd 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -83,6 +83,38 @@ const ERLANG_MFA_CALLS = new Set([ 'erpc:call', 'erpc:cast', ]); +/** + * Elixir declaration macros — intercepted by the elixir visitNode hook, so they + * should never reach extractCall as a bare local call. Used only as a defensive + * guard against a shape the hook might not have consumed. + */ +const ELIXIR_DECL_MACROS = new Set([ + 'defmodule', 'defprotocol', 'defimpl', 'def', 'defp', 'defmacro', 'defmacrop', + 'defguard', 'defguardp', 'defdelegate', 'defstruct', 'defexception', + 'defoverridable', + 'use', 'import', 'require', 'alias', +]); + +/** + * Elixir Kernel special forms / macros / process primitives that parse as a bare + * `call` but are never a user-defined function — emitting a `calls` ref for them + * is pure noise (they resolve to nothing; the same-file-only rule keeps them from + * ever colliding with a real function, so this only suppresses dead edges). + * + * Includes the everyday control-flow macros (`if`/`case`/`with`/…) — in Elixir + * these are macros, not keywords, so unlike erlang they parse as calls and leak. + * `send`/`spawn`/`self`/`throw`/`exit` are Kernel process/flow primitives that in + * practice never target repo code; suppressing them is a conscious trade-off + * (a hypothetical local `def send` called bare would no longer link). `apply` is + * deliberately NOT here: dynamic dispatch is handled elsewhere and its silence is + * already covered by the variable-receiver path. + */ +const ELIXIR_SPECIAL_FORMS = new Set([ + 'quote', 'unquote', 'unquote_splicing', 'super', 'receive', + 'if', 'unless', 'case', 'cond', 'with', 'for', 'raise', 'try', + 'send', 'spawn', 'self', 'throw', 'exit', +]); + /** * Extract the name from a node based on language */ @@ -3577,6 +3609,145 @@ export class TreeSitterExtractor { private erlangSelfMacros = new Set(); private erlangAtomMacros = new Map(); + // Elixir per-module alias table (`alias Foo.Bar` → Bar ⇒ Foo.Bar), memoized + // per enclosing module node so `Bar.fun(x)` expands to `Foo.Bar::fun`. Keyed by + // `(file, module.startIndex)` — a Map, not a single entry, so interleaved + // nested-module resolution (outer→inner→outer) does not thrash the cache by + // rebuilding the outer module's table on every return from a nested one. + private elixirAliasMemo = new Map>(); + + /** + * Expand an Elixir module reference (`Bar`, `Bar.Deep`, `Foo.Sub.Mod`) to its + * full dotted name using the enclosing module's `alias` declarations. Only the + * FIRST segment is an alias key; the rest is appended verbatim. A reference + * whose head isn't aliased is already fully qualified and returned as-is. + */ + private resolveElixirModule(callNode: SyntaxNode, modText: string): string { + // Find the enclosing module (defmodule/defprotocol/defimpl) call. + let module: SyntaxNode | null = callNode.parent; + while (module) { + if (module.type === 'call') { + const t = getChildByField(module, 'target'); + if (t?.type === 'identifier') { + const name = getNodeText(t, this.source); + if (name === 'defmodule' || name === 'defprotocol' || name === 'defimpl') break; + } + } + module = module.parent; + } + const key = module ? `${this.filePath}:${module.startIndex}` : this.filePath; + let aliasMap = this.elixirAliasMemo.get(key); + if (!aliasMap) { + aliasMap = new Map(); + this.elixirAliasMemo.set(key, aliasMap); + const addAlias = (stmt: SyntaxNode): void => { + // `arguments` is a child node TYPE in tree-sitter-elixir, not a field. + const args = stmt.namedChildren.find((c) => c.type === 'arguments'); + if (!args) return; + const aliasNode = args.namedChildren.find((c) => c.type === 'alias'); + const asValue = this.elixirKeywordValue(args, 'as'); + if (aliasNode) { + const full = getNodeText(aliasNode, this.source); + if (asValue?.type === 'alias') { + aliasMap!.set(getNodeText(asValue, this.source), full); + } else { + const last = full.split('.').pop()!; + aliasMap!.set(last, full); + } + } + // grouped: alias Foo.{Alpha, Beta} + const dot = args.namedChildren.find((c) => c.type === 'dot'); + if (dot) { + const left = getChildByField(dot, 'left'); + const right = getChildByField(dot, 'right'); + const prefix = left?.type === 'alias' ? getNodeText(left, this.source) : ''; + if (right?.type === 'tuple' && prefix) { + for (const member of right.namedChildren) { + if (member.type === 'alias') { + const seg = getNodeText(member, this.source); + aliasMap!.set(seg, `${prefix}.${seg}`); + } + } + } + } + }; + // Collect `alias` directives from the ENTIRE module subtree, not just the + // module body's direct children — an alias declared inside a function body + // (`def run do alias Foo.Bar; Bar.work() end`) must resolve too. Recursion + // stops at nested module boundaries so an inner module's aliases don't bleed + // outward. Scoping an alias to the one function it sits in is a deliberate + // non-goal (module-wide is the pragmatic, near-always-correct choice). + const doBlock = module + ? module.namedChildren.find((c) => c.type === 'do_block') + : null; + const collect = (n: SyntaxNode): void => { + for (const child of n.namedChildren) { + if (child.type === 'call') { + const t = getChildByField(child, 'target'); + const tn = t?.type === 'identifier' ? getNodeText(t, this.source) : ''; + if (tn === 'alias') { + addAlias(child); + continue; + } + if (tn === 'defmodule' || tn === 'defprotocol' || tn === 'defimpl') { + continue; // nested module — its aliases belong to a different scope + } + } + collect(child); + } + }; + if (doBlock) collect(doBlock); + } + const segments = modText.split('.'); + const head = segments[0]!; + const mapped = aliasMap.get(head); + if (mapped) { + return segments.length > 1 ? `${mapped}.${segments.slice(1).join('.')}` : mapped; + } + return modText; + } + + /** + * Full dotted name of the module enclosing `node`, matching the qualifiedName + * its namespace node carries (outer→inner alias names joined with `.`). Used to + * resolve `__MODULE__` self-references (`%__MODULE__{}`) to the current module. + * Empty string when there is no enclosing `defmodule`/`defprotocol`. + */ + private currentElixirModuleName(node: SyntaxNode): string { + const parts: string[] = []; + let n: SyntaxNode | null = node.parent; + while (n) { + if (n.type === 'call') { + const t = getChildByField(n, 'target'); + if (t?.type === 'identifier') { + const name = getNodeText(t, this.source); + if (name === 'defmodule' || name === 'defprotocol') { + const args = n.namedChildren.find((c) => c.type === 'arguments'); + const alias = args?.namedChildren.find((c) => c.type === 'alias'); + if (alias) parts.unshift(getNodeText(alias, this.source)); + } + } + } + n = n.parent; + } + return parts.join('.'); + } + + /** Look up a keyword value inside an Elixir `arguments` node (`as:` → value). */ + private elixirKeywordValue(argsNode: SyntaxNode, key: string): SyntaxNode | null { + for (const child of argsNode.namedChildren) { + if (child.type !== 'keywords') continue; + for (const pair of child.namedChildren) { + if (pair.type !== 'pair') continue; + const k = getChildByField(pair, 'key'); + if (k && getNodeText(k, this.source).trim().replace(/:$/, '') === key) { + return getChildByField(pair, 'value'); + } + } + } + return null; + } + private resolveErlangGenServerTarget(target: SyntaxNode): string | null { const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, ''); if (target.type === 'atom') { @@ -3840,6 +4011,163 @@ export class TreeSitterExtractor { return; } + // Elixir: every construct is a `call`, but the DECLARATION macros + // (defmodule/def/use/…) are intercepted by the elixir visitNode hook and + // never reach here — so a `call` that arrives is a real invocation. Shapes: + // - remote `Mod.Sub.fun(x)` → call(target: dot{left: alias, right: id}). + // Emitted as `Full.Module::fun` (alias-expanded), byte-identical to the + // qualifiedName the module's functions carry, so it resolves via + // matchByQualifiedName — the erlang design. A dot whose `left` is an + // identifier is `__MODULE__.fun` (same-file bare name) or a variable + // receiver (`mod.fun` / `apply/3`) which has no static target → silence. + // - local `fun(x)` → call(target: identifier) → bare name (same-file pref). + // - `&Mod.fun/2` / `&local/1` capture → unary_operator → `references`. + // - `%Foo.Bar{…}` struct literal → map > struct > alias → `references`. + if (this.language === 'elixir') { + const line = node.startPosition.row + 1; + const column = node.startPosition.column; + if (node.type === 'call') { + // Skip a call that is the function part of an `&Mod.fun/arity` capture + // (`unary_operator & → binary_operator / → left: call`): the capture is a + // `references` edge emitted by the unary_operator branch, not a `calls`. + const cap = node.parent; + if ( + cap?.type === 'binary_operator' && + cap.parent?.type === 'unary_operator' && + getNodeText(cap.parent, this.source).startsWith('&') + ) { + return; + } + const target = getChildByField(node, 'target'); + if (!target) return; + if (target.type === 'dot') { + const left = getChildByField(target, 'left'); + const right = getChildByField(target, 'right'); + if (right?.type !== 'identifier') return; + const fn = getNodeText(right, this.source); + if (left?.type === 'alias') { + const fullMod = this.resolveElixirModule(node, getNodeText(left, this.source)); + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: `${fullMod}::${fn}`, + referenceKind: 'calls', + line, + column, + }); + } else if (left?.type === 'identifier' && getNodeText(left, this.source) === '__MODULE__') { + // `__MODULE__.fun(...)` targets THIS module — bare name, same-file + // preference resolves it (like `?MODULE:fn` in erlang). + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: fn, + referenceKind: 'calls', + line, + column, + }); + } + // else: variable receiver (`mod.fun`, `apply/3`) → no static target. + return; + } + if (target.type === 'identifier') { + // Bare local call `fun(x)`. Declaration macros are consumed by the + // hook, but guard defensively in case one slips through. + const name = getNodeText(target, this.source); + if (ELIXIR_DECL_MACROS.has(name) || ELIXIR_SPECIAL_FORMS.has(name)) return; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: name, + referenceKind: 'calls', + line, + column, + }); + } + return; + } + if (node.type === 'unary_operator') { + // `&Mod.fun/arity` / `&local/arity` capture. operand is `/` + // binary_operator; `&1`-style arg captures (operand: integer) are NOT. + const operand = getChildByField(node, 'operand'); + if (operand?.type !== 'binary_operator') return; + const capLeft = getChildByField(operand, 'left'); + if (capLeft?.type === 'call') { + const t = getChildByField(capLeft, 'target'); + if (t?.type === 'dot') { + const l = getChildByField(t, 'left'); + const r = getChildByField(t, 'right'); + if (l?.type === 'alias' && r?.type === 'identifier') { + const fullMod = this.resolveElixirModule(node, getNodeText(l, this.source)); + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: `${fullMod}::${getNodeText(r, this.source)}`, + referenceKind: 'references', + line, + column, + }); + } else if ( + l?.type === 'identifier' && + getNodeText(l, this.source) === '__MODULE__' && + r?.type === 'identifier' + ) { + // `&__MODULE__.fun/arity` → bare name, same-file preference resolves + // it to the local function (mirrors the `__MODULE__.fun()` call form). + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: getNodeText(r, this.source), + referenceKind: 'references', + line, + column, + }); + } + } + } else if (capLeft?.type === 'identifier') { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: getNodeText(capLeft, this.source), + referenceKind: 'references', + line, + column, + }); + } + return; + } + if (node.type === 'map') { + // `%Foo.Bar{…}` struct literal: map > struct > alias. A `%__MODULE__{…}` + // literal parses as map > struct > identifier "__MODULE__" instead — the + // struct analog of the `__MODULE__.fun()` call: resolve it to the + // enclosing module by name so the self-build reference is not lost. + const structChild = node.namedChildren.find((c) => c.type === 'struct'); + const aliasNode = structChild?.namedChildren.find((c) => c.type === 'alias'); + if (aliasNode) { + const fullMod = this.resolveElixirModule(node, getNodeText(aliasNode, this.source)); + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: fullMod, + referenceKind: 'references', + line, + column, + }); + } else { + const idNode = structChild?.namedChildren.find( + (c) => c.type === 'identifier' && getNodeText(c, this.source) === '__MODULE__' + ); + if (idNode) { + const selfMod = this.currentElixirModuleName(node); + if (selfMod) { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: selfMod, + referenceKind: 'references', + line, + column, + }); + } + } + } + return; + } + return; + } + // Ruby `call` nodes use `receiver` + `method` fields (tree-sitter-ruby), not // the `object`/`name`/`function` fields the branches below expect — so // without this they fell through to the generic path, which took the diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9e77d630f..51b7e8647 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1908,6 +1908,22 @@ export function matchFuzzy( /** ArkUI attribute-helper decorators a `.attr(...)` chain may resolve to. */ const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExtend', 'Builder']); +/** + * The Elixir module a node belongs to, or `null` when it can't be determined. + * Function/method nodes carry a `Full.Module::fun` qualifiedName, so the module + * is everything before the last `::`. A `namespace` node IS a module, so its + * whole qualifiedName is the module name (calls in module-attribute values + * report the namespace node as their from-node — c4c0eec). A top-level function + * with no enclosing module (no `::`) yields `null`, so its bare calls stay + * unresolved rather than guess a module. + */ +function elixirModuleOf(node: Node | null | undefined): string | null { + if (!node) return null; + if (node.kind === 'namespace') return node.qualifiedName || null; + const sep = node.qualifiedName.lastIndexOf('::'); + return sep > 0 ? node.qualifiedName.slice(0, sep) : null; +} + export function matchReference( ref: UnresolvedRef, context: ResolutionContext @@ -1959,13 +1975,65 @@ export function matchReference( // ref an `.app`/`.app.src` resource file emits — its `{mod, …}` callback and // `{applications, …}` dependency names can only mean modules, and on emqx // the `ssl` OTP app otherwise resolved to a test helper FUNCTION named ssl. + // Elixir bare (unqualified, lowercase-initial) call/reference names — local + // calls, `__MODULE__.fun`, `&local/arity` captures. In Elixir a bare call can + // only legitimately target the same module — which lives in the same file — + // or an `import`ed function (deliberately unexpanded in v1: a wrong edge is + // worse than none). Letting these fall through to bare-name matching grabbed + // arbitrary same-named functions across the repo: on a Phoenix app, every + // Mix `config :app, …` DSL call resolved to some module's `config/0`, and + // every ConnTest `get(conn, path)` to an unrelated HTTP client's `get`. + // Resolve same-file only; no same-file match → stay unresolved. Function + // names in Elixir always start with a lowercase letter or underscore, so + // module references (`Foo`, `Foo.Bar` — capitalized) are unaffected. + if ( + ref.language === 'elixir' && + (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && + !ref.referenceName.includes('::') && + /^[a-z_]/.test(ref.referenceName) + ) { + // Same-file is necessary but NOT sufficient: one file can hold two+ modules, + // and a bare call may only legitimately target a function of the CALLER's own + // module. Without qualifying by module, a bare `helper()` in module B would + // grab module A's same-named `helper` (whichever getNodesByName returns + // first) — a wrong cross-module edge, worse than none. Derive the caller's + // module from its node (`Mod.Sub::fun` → `Mod.Sub`; a namespace caller, for + // calls in module-attribute values (c4c0eec), IS the module) and keep only + // candidates in that module. Caller module indeterminable, or no same-module + // candidate → stay unresolved. + const callerModule = elixirModuleOf(context.getNodeById?.(ref.fromNodeId)); + if (!callerModule) return null; + const sameModule = context + .getNodesByName(ref.referenceName) + .filter( + (n) => + n.language === 'elixir' && + n.kind === 'function' && + n.filePath === ref.filePath && + elixirModuleOf(n) === callerModule + ); + const chosen = sameModule[0]; + if (!chosen) return null; + return { + original: ref, + targetNodeId: chosen.id, + confidence: 0.9, + resolvedBy: 'exact-match', + }; + } + + // Elixir `@behaviour Mod` / `use Mod` implements refs also target a MODULE + // (namespace). Same rationale as erlang: bare-name matching would grab any + // same-named symbol, and an out-of-repo behaviour (GenServer, …) must stay + // unresolved rather than guessed. if ( - ref.language === 'erlang' && - (ref.referenceKind === 'implements' || /\.app(?:\.src)?$/i.test(ref.filePath)) + (ref.language === 'erlang' && + (ref.referenceKind === 'implements' || /\.app(?:\.src)?$/i.test(ref.filePath))) || + (ref.language === 'elixir' && ref.referenceKind === 'implements') ) { const modules = context .getNodesByName(ref.referenceName) - .filter((n) => n.language === 'erlang' && n.kind === 'namespace'); + .filter((n) => n.language === ref.language && n.kind === 'namespace'); const chosen = preferCallSiteFile(modules, ref.filePath)[0]; if (!chosen) return null; return { diff --git a/src/types.ts b/src/types.ts index 6f3de63db..83476eec1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,6 +104,7 @@ export const LANGUAGES = [ 'cobol', 'vbnet', 'erlang', + 'elixir', 'terraform', 'unknown', ] as const;