diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfc0e9e8..061623bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- CodeGraph now indexes **Odin** (`.odin`) — procedures with their calling conventions and `@(...)` attributes, procedure groups linked to their overloads, structs, enums, unions, bit fields, `distinct` and proc-type aliases, constants, imports, `foreign` blocks, `when` blocks, and call edges that keep their package qualifier so `fmt.println` never links to a procedure of your own. An Odin package is a directory, so imports are recorded as symbols but deliberately left unlinked rather than guessed at a file. The vendored `tree-sitter-odin` v1.3.0 grammar has known gaps that leave an ERROR node in some files — most often a trailing-backslash line continuation in a CRLF working tree, which usually costs nothing but occasionally costs a file's declarations; `docs/grammars/tree-sitter-odin.md` records the measured parse health on four Odin repositories. Thanks @RainerXE, who first proposed Odin support. (#648, #1000) - Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list. - `codegraph_explore` no longer re-sends source it already returned earlier in the same conversation. A file it has already shown you comes back as a short pointer — the path, the symbols and the exact line range, with confirmation that the file hasn't changed since — and the space that frees is spent on code you haven't seen yet, so a follow-up call covers new ground instead of repeating the last one. If a file was edited in between, its source is always shown again in full. Set `CODEGRAPH_EXPLORE_DEDUP=0` to turn this off. diff --git a/README.md b/README.md index 903ae0c3f..ae2ef70c7 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,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, CFML, COBOL, Solidity, Terraform/OpenTofu, Odin, 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 | @@ -799,6 +799,7 @@ is written): | 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) | +| Odin | `.odin` | Full support (procedures incl. calling conventions, `@(...)` attributes as modifiers and `@(private)` visibility, procedure groups linked to their overloads, structs with fields, enums, unions, bit fields, `distinct`/proc-type aliases, `::` constants, `import` with and without an alias, `foreign` blocks, `when` blocks, package-qualified call edges, with an unqualified call scoped to its own package directory. An Odin package is a DIRECTORY rather than a file, so `import` statements are recorded as symbols but are **not** resolved to file edges at all — `import "core:fmt"` and `import "../shared"` alike stay external and unresolved. Note the vendored `tree-sitter-odin` v1.3.0 grammar has gaps that leave an ERROR node in some files — most commonly a trailing-backslash line continuation in a **CRLF** working tree, which is what `odinfmt` emits when it wraps a long line. Recovery is usually local and costs nothing, but not always: measured over 427 files in four Odin repositories, a CRLF checkout extracts 1.7% fewer declarations than the same bytes with LF endings. `docs/grammars/tree-sitter-odin.md` has the per-repository numbers) | ## Measured cross-file coverage diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 784023952..3f5f4826f 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -150,6 +150,11 @@ describe('Language Detection', () => { expect(isSourceFile('default.nix')).toBe(true); }); + it('should detect Odin files', () => { + expect(detectLanguage('src/artifact/sidecar.odin')).toBe('odin'); + expect(isSourceFile('src/artifact/sidecar.odin')).toBe(true); + }); + 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`, @@ -205,6 +210,7 @@ describe('Language Support', () => { expect(languages).toContain('dart'); expect(languages).toContain('solidity'); expect(languages).toContain('nix'); + expect(languages).toContain('odin'); }); }); @@ -11463,3 +11469,559 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => { expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'size')).toBe(true); }); }); + +describe('Odin Extraction', () => { + // Real-shaped Odin: everything is declared with `::`, attributes sit ABOVE + // the name, procedures are all top-level, and a package is the directory. + const code = `#+vet +package artifact + +import "core:fmt" +import "core:os" +import st "core:strings" +import "../shared" +foreign import kernel32 "system:kernel32.lib" + +MAX_DIGITS :: 12 +DEFAULT_NAME := "sidecar" + +Handle :: distinct u32 +Callback :: proc(x: int) -> bool + +Sidecar :: struct { + path: string, + source_modified: i64, + digest: Handle, +} + +Fault :: enum u8 { + None, + Unreadable, + Path_Not_Ascii, +} + +Value :: union { + string, + i64, +} + +Flags :: bit_field u8 { + visible: bool | 1, + level: u8 | 3, +} + +@(require_results) +sidecar_read :: proc(path: string) -> (Sidecar, bool) { + s: Sidecar + fmt.eprintln("reading", path) + s.path = st.clone(path) + shared.record(s.path) + if !sidecar_valid(&s) { + return s, false + } + return s, true +} + +@(private) +@(require_results) +sidecar_write :: proc(s: ^Sidecar) -> Fault { + return .None +} + +sidecar_valid :: proc "contextless" (s: ^Sidecar) -> bool { + return s.path != "" +} + +sidecar_of :: proc { + sidecar_of_path, + sidecar_of_handle, +} + +sidecar_of_path :: proc(path: string) -> Sidecar {return Sidecar{path = path}} +sidecar_of_handle :: proc(h: Handle) -> Sidecar {return Sidecar{digest = h}} + +@(test) +reads_a_sidecar :: proc(t: ^testing.T) { + _, ok := sidecar_read("a.json") + testing.expect(t, ok) +} + +foreign kernel32 { + GetLastError :: proc() -> u32 --- +} + +when ODIN_OS == .Windows { + platform_name :: proc() -> string { + return "windows" + } + + PLATFORM :: "windows" +} +`; + + describe('Language detection', () => { + it('should detect Odin files', () => { + expect(detectLanguage('src/artifact/sidecar.odin')).toBe('odin'); + expect(isSourceFile('src/artifact/sidecar.odin')).toBe(true); + }); + + it('should report Odin as supported', () => { + expect(isLanguageSupported('odin')).toBe(true); + expect(getSupportedLanguages()).toContain('odin'); + }); + }); + + describe('Procedure extraction', () => { + it('should name a procedure by its identifier, not by its attributes', () => { + // `@(require_results)` / `@(private)` / `@(test)` are the FIRST named child + // of a decorated declaration, so a firstNamedChild name walk names most of + // an idiomatic Odin file "@(require_results)". + const result = extractFromSource('sidecar.odin', code); + const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(names).toContain('sidecar_read'); + expect(names).toContain('sidecar_write'); + expect(names).toContain('reads_a_sidecar'); + expect(names.some((n) => n.startsWith('@'))).toBe(false); + }); + + it('should capture signatures including the calling convention', () => { + const result = extractFromSource('sidecar.odin', code); + const read = result.nodes.find((n) => n.kind === 'function' && n.name === 'sidecar_read'); + expect(read?.language).toBe('odin'); + expect(read?.signature).toBe('proc(path: string) -> (Sidecar, bool)'); + const valid = result.nodes.find((n) => n.kind === 'function' && n.name === 'sidecar_valid'); + expect(valid?.signature).toContain('"contextless"'); + }); + + it('should read visibility from @(private) and modifiers from every attribute', () => { + const result = extractFromSource('sidecar.odin', code); + const write = result.nodes.find((n) => n.kind === 'function' && n.name === 'sidecar_write'); + expect(write?.visibility).toBe('private'); + expect(write?.isExported).toBe(false); + expect(write?.decorators).toEqual(expect.arrayContaining(['private', 'require_results'])); + const read = result.nodes.find((n) => n.kind === 'function' && n.name === 'sidecar_read'); + expect(read?.visibility).toBe('public'); + const test = result.nodes.find((n) => n.kind === 'function' && n.name === 'reads_a_sidecar'); + expect(test?.decorators).toContain('test'); + }); + + it('should extract a procedure GROUP and link it to its overloads', () => { + const result = extractFromSource('sidecar.odin', code); + const group = result.nodes.find((n) => n.kind === 'function' && n.name === 'sidecar_of'); + expect(group).toBeDefined(); + expect(group?.signature).toContain('sidecar_of_path'); + const refs = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'references' && r.fromNodeId === group?.id + ); + expect(refs.map((r) => r.referenceName)).toEqual(['sidecar_of_path', 'sidecar_of_handle']); + }); + + it('should extract a foreign-block FFI procedure and a when-block procedure', () => { + const result = extractFromSource('sidecar.odin', code); + // Bodiless, inside `foreign kernel32 { … }` — still a real callable. + expect(result.nodes.find((n) => n.kind === 'function' && n.name === 'GetLastError')).toBeDefined(); + // Declarations inside a top-level `when` are ordinary declarations. + expect(result.nodes.find((n) => n.kind === 'function' && n.name === 'platform_name')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 'PLATFORM')).toBeDefined(); + }); + }); + + describe('Type declaration extraction', () => { + it('should extract struct fields with their declared types', () => { + const result = extractFromSource('sidecar.odin', code); + const struct = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Sidecar'); + expect(struct).toBeDefined(); + const fields = result.nodes.filter((n) => n.kind === 'field'); + expect(fields.map((f) => f.name)).toEqual( + expect.arrayContaining(['path', 'source_modified', 'digest', 'visible', 'level']) + ); + expect(fields.find((f) => f.name === 'digest')?.signature).toBe('digest: Handle'); + // The field's declared type is a dependency of the record. + expect( + result.unresolvedReferences.some( + (r) => + r.referenceKind === 'references' && + r.referenceName === 'Handle' && + r.fromNodeId === struct?.id + ) + ).toBe(true); + }); + + it('should extract enum members WITHOUT minting one named after the enum', () => { + // The enum's own name is an `identifier` child exactly like its members + // are, and the backing type (`u8`) sits between them. + const result = extractFromSource('sidecar.odin', code); + expect(result.nodes.find((n) => n.kind === 'enum' && n.name === 'Fault')).toBeDefined(); + const members = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name); + expect(members).toEqual(['None', 'Unreadable', 'Path_Not_Ascii']); + }); + + it('should extract a union as a struct referencing its variants', () => { + const result = extractFromSource('sidecar.odin', code); + const union = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Value'); + expect(union).toBeDefined(); + const variants = result.unresolvedReferences + .filter((r) => r.referenceKind === 'references' && r.fromNodeId === union?.id) + .map((r) => r.referenceName); + expect(variants).toEqual(['string', 'i64']); + }); + + it('should split type aliases out of `::` constants', () => { + const result = extractFromSource('sidecar.odin', code); + // `Handle :: distinct u32` and `Callback :: proc(...) -> bool` are types… + const aliases = result.nodes.filter((n) => n.kind === 'type_alias'); + expect(aliases.map((n) => n.name)).toEqual(['Handle', 'Callback']); + expect(aliases.find((n) => n.name === 'Handle')?.signature).toBe('distinct u32'); + // …while `MAX_DIGITS :: 12` stays a constant and `:=` stays a variable. + expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 'MAX_DIGITS')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'DEFAULT_NAME')).toBeDefined(); + }); + }); + + describe('Import extraction', () => { + it('should extract collection, relative, aliased and foreign imports', () => { + const result = extractFromSource('sidecar.odin', code); + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(imports).toEqual([ + 'core:fmt', + 'core:os', + 'core:strings', + '../shared', + 'system:kernel32.lib', + ]); + // The alias is not the module — `import st "core:strings"` imports strings. + const aliased = result.nodes.find((n) => n.kind === 'import' && n.name === 'core:strings'); + expect(aliased?.signature).toBe('import st "core:strings"'); + expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'imports')).toHaveLength(5); + }); + }); + + describe('Call extraction', () => { + it('should keep the package qualifier on a qualified call', () => { + // `fmt.eprintln(x)` parses as member_expression(identifier, call_expression) + // — the qualifier is a SIBLING of the call, so the generic callee path + // would emit a bare `eprintln` that links to any same-named local. + const result = extractFromSource('sidecar.odin', code); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toContain('fmt::eprintln'); + expect(calls).toContain('st::clone'); + expect(calls).toContain('shared::record'); + expect(calls).not.toContain('eprintln'); + }); + + it('should emit a bare name for a same-package call and attribute it to the caller', () => { + const result = extractFromSource('sidecar.odin', code); + const caller = result.nodes.find((n) => n.kind === 'function' && n.name === 'sidecar_read'); + const call = result.unresolvedReferences.find( + (r) => r.referenceKind === 'calls' && r.referenceName === 'sidecar_valid' + ); + expect(call).toBeDefined(); + expect(call?.fromNodeId).toBe(caller?.id); + }); + + it('should NOT emit a call ref for an unqualified builtin', () => { + // `len`/`append`/`make`/`max` are `base:builtin` — spelled exactly like a + // call of your own, so emitting them name-matches any same-named symbol + // anywhere, INCLUDING a struct field (`calls … -> Ring::len [field]`) and + // a package that is never imported. + const src = `package app + +use_it :: proc(xs: []int) -> int { + ys := make([]int, 4) + append(&ys, 1) + clear(&ys) + return len(xs) + cap(ys) + max(1, 2) + size_of(int) +} +`; + const result = extractFromSource('a.odin', src); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toEqual([]); + }); + + it('should still emit a QUALIFIED call whose member shares a builtin name', () => { + // Only the bare form is a builtin — `slice.max(xs)` is somebody's `max`. + const src = `package app + +f :: proc(xs: []int) -> int { + return slice.max(xs) +} +`; + const result = extractFromSource('a.odin', src); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toEqual(['slice::max']); + }); + + it('should NOT read a `->` receiver as a package qualifier', () => { + // `h->run()` is selector_call_expression(function: `h`, call_expression), + // the same shape a qualified call has — but `h` is a receiver VARIABLE. + // Emitted as `h::run` it is byte-identical to a cross-package call, so a + // receiver named after a repo package mints exactly the wrong edge the + // qualifier exists to prevent. + const src = `package app + +Sink :: struct { + run: proc(n: int), +} + +use_it :: proc(h: ^Sink) { + h->run(1) + sink.run(2) +} +`; + const result = extractFromSource('a.odin', src); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toContain('run'); + expect(calls).not.toContain('h::run'); + // The genuine package qualifier is untouched. + expect(calls).toContain('sink::run'); + }); + + it('should read the callee THROUGH a compiler directive, and emit none for a bare one', () => { + // A directive is a `function`-field child of the call and it comes FIRST: + // `#force_inline f()` has two (tag, identifier) and `#assert(x)` has only + // the tag. Reading the first named `#force_inline` as the callee, lost the + // real one, and filed `#assert` as a call to a symbol nothing can declare. + const src = `package app + +MAX_DIGITS :: 12 +#assert(MAX_DIGITS < 19) +BLOB :: #load("data.bin") + +target :: proc() {} + +f :: proc() { + #force_inline target() + #no_bounds_check target() +} +`; + const result = extractFromSource('a.odin', src); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toEqual(['target', 'target']); + expect(calls.some((n) => n.startsWith('#'))).toBe(false); + }); + }); + + describe('Composite literals', () => { + it('should link an enumerated-array table to the enum it keys on', () => { + // `[Fault]string{…}` is the compiler's exhaustiveness guard: add a member + // to `Fault` and the build fails until the table grows a row. So the table + // is the declaration that MUST change with the enum, and it was the one + // declaration that did not name it — outdeg 0 on every table in a repo + // built around them. + const src = `package app + +Fault :: enum u8 { + None, + Broken, +} + +Facts :: struct { + label: string, +} + +FAULT := [Fault]string { + .None = "", + .Broken = "broken", +} + +TABLE :: [Fault]Facts { + .None = {label = ""}, + .Broken = {label = "broken"}, +} + +None :: proc() -> int {return 0} +`; + const result = extractFromSource('a.odin', src); + const refsOf = (name: string) => { + const owner = result.nodes.find((n) => n.name === name); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'references' && r.fromNodeId === owner?.id) + .map((r) => r.referenceName); + }; + expect(refsOf('FAULT')).toEqual(['Fault', 'string']); + expect(refsOf('TABLE')).toEqual(['Fault', 'Facts']); + // `.None` is an enum MEMBER in the literal's data, not a type — taking it + // would link the table to the same-named procedure below. + expect(refsOf('FAULT')).not.toContain('None'); + expect(refsOf('TABLE')).not.toContain('None'); + }); + + it('should link a record, map and slice literal to their element types', () => { + const src = `package app + +A := Sidecar{path = "x"} +B := [dynamic]Sidecar{} +C := map[string]Sidecar{} +D := []Sidecar{} +`; + const result = extractFromSource('a.odin', src); + const refsOf = (name: string) => { + const owner = result.nodes.find((n) => n.name === name); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'references' && r.fromNodeId === owner?.id) + .map((r) => r.referenceName); + }; + expect(refsOf('A')).toEqual(['Sidecar']); + expect(refsOf('B')).toEqual(['Sidecar']); + expect(refsOf('C')).toEqual(['string', 'Sidecar']); + expect(refsOf('D')).toEqual(['Sidecar']); + }); + }); + + describe('Multi-name declarations', () => { + it('should index EVERY name in a comma-separated declaration, with its own value', () => { + // `A, B :: 1, 2` and `x, y: int = 3, 4` are ONE declaration node each, + // with the names as a leading run — taking only the first dropped B and + // y entirely, and `at(-1)` gave A the value of B. + const src = `package sample + +A, B :: 1, 2 +x, y: int = 3, 4 +g: Registry +`; + const result = extractFromSource('s.odin', src); + const named = (name: string) => result.nodes.find((n) => n.name === name); + expect(named('A')?.kind).toBe('constant'); + expect(named('A')?.signature).toBe('= 1'); + expect(named('B')?.kind).toBe('constant'); + expect(named('B')?.signature).toBe('= 2'); + expect(named('x')?.signature).toBe(': int = 3'); + expect(named('y')?.signature).toBe(': int = 4'); + // A trailing `type` child is the declared TYPE, not an initializer. + expect(named('g')?.kind).toBe('variable'); + expect(named('g')?.signature).toBe(': Registry'); + }); + + it('should not mistake `Alias :: Other` for a two-name declaration', () => { + // The comma is what bounds the name run: `Other` follows a `::`. + const src = `package sample + +Alias :: Other +Handle :: SOME_CONST +`; + const result = extractFromSource('s.odin', src); + expect( + result.nodes.filter((n) => n.kind === 'constant' || n.kind === 'variable').map((n) => n.name) + ).toEqual(['Alias', 'Handle']); + }); + + it('should attribute an initializer call to the name it initializes', () => { + const src = `package sample + +Table, Index := build_table(), build_index() +`; + const result = extractFromSource('s.odin', src); + const table = result.nodes.find((n) => n.name === 'Table'); + const index = result.nodes.find((n) => n.name === 'Index'); + const callOf = (name: string) => + result.unresolvedReferences.find( + (r) => r.referenceKind === 'calls' && r.referenceName === name + ); + expect(callOf('build_table')?.fromNodeId).toBe(table?.id); + expect(callOf('build_index')?.fromNodeId).toBe(index?.id); + }); + }); + + describe('Type references', () => { + it('should not leak a nested anonymous struct or inline enum binding as a type ref', () => { + // The nested members are BINDINGS, never type names — emitted, they made + // the record depend on same-named procedures it has no relation to. + const src = `package pkg + +Config :: struct { + inner: struct { + parse: int, + render: bool, + }, + mode: enum { + Fast, + Slow, + }, + flags: bit_field u8 { + lo: bool | 1, + }, + next: ^Node, +} + +parse :: proc() -> int {return 1} +render :: proc() -> bool {return true} +`; + const result = extractFromSource('c.odin', src); + const config = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Config'); + const refs = result.unresolvedReferences + .filter((r) => r.referenceKind === 'references' && r.fromNodeId === config?.id) + .map((r) => r.referenceName); + expect(refs).not.toContain('parse'); + expect(refs).not.toContain('render'); + expect(refs).not.toContain('Fast'); + expect(refs).not.toContain('Slow'); + expect(refs).not.toContain('lo'); + // …while the declared TYPES inside the same nesting still emit. + expect(refs).toContain('Node'); + // The nested members are still indexed as fields of the record. + expect(result.nodes.filter((n) => n.kind === 'field').map((n) => n.name)).toEqual( + expect.arrayContaining(['inner', 'mode', 'flags', 'next']) + ); + }); + + it('should not leak a parameter or named-return binding, including a comma run', () => { + const src = `package pkg + +Cb :: proc(p, q: int, r: Thing) -> (ok: bool, err: Fault) +`; + const result = extractFromSource('c.odin', src); + const alias = result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'Cb'); + const refs = result.unresolvedReferences + .filter((r) => r.referenceKind === 'references' && r.fromNodeId === alias?.id) + .map((r) => r.referenceName); + expect(refs).toEqual(['int', 'Thing', 'bool', 'Fault']); + }); + }); + + describe('Return types', () => { + it('should read a NAMED return tuple and a QUALIFIED return type', () => { + // A named tuple holds `named_type` children rather than `type` ones, and + // a qualified type is a `field_type` — 150 of transcibr's 342 returning + // procedures are one shape or the other. + const src = `package pkg + +read_sidecar :: proc(text: string) -> (s: Sidecar, ok: bool) {return} +not_a_sidecar :: proc(s: Sidecar) -> (Sidecar, bool) {return} +note_node :: proc(x: int) -> ^ast.Visitor {return nil} +plain :: proc() -> ^Sidecar {return nil} +qual :: proc() -> transcript.Render_Context {return {}} +bare :: proc() -> bool {return true} +`; + const result = extractFromSource('r.odin', src); + const ret = (name: string) => + result.nodes.find((n) => n.kind === 'function' && n.name === name)?.returnType; + expect(ret('read_sidecar')).toBe('Sidecar'); + expect(ret('not_a_sidecar')).toBe('Sidecar'); + expect(ret('note_node')).toBe('Visitor'); + expect(ret('plain')).toBe('Sidecar'); + expect(ret('qual')).toBe('Render_Context'); + expect(ret('bare')).toBe('bool'); + }); + }); + + describe('Package scoping', () => { + it('should qualify every top-level symbol with its package', () => { + const result = extractFromSource('sidecar.odin', code); + expect(result.nodes.find((n) => n.kind === 'namespace' && n.name === 'artifact')).toBeDefined(); + const read = result.nodes.find((n) => n.kind === 'function' && n.name === 'sidecar_read'); + // Matches the `pkg::callee` shape qualified calls are emitted in, so a + // cross-package call resolves by qualified name. + expect(read?.qualifiedName).toBe('artifact::sidecar_read'); + }); + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index eca1778ff..686f2b35e 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -4978,4 +4978,100 @@ in expect(importedFilePaths('main.nix')).toEqual([]); }); }); + + describe('Odin package scoping', () => { + it('never links an Odin builtin or a bare cross-package call', async () => { + // `len`/`cap`/`append`/`max` are `base:builtin` — unqualified by + // language, so without the emit-time filter they name-match ANY + // same-named symbol, including a struct FIELD (`calls -> Ring::len + // [field]`) in a package `app` never imports. And a bare `helper()` + // cannot bind outside its own package, which in Odin is the DIRECTORY. + fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'util'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'app', 'a.odin'), + `package app + +use_it :: proc(xs: []int) -> int { + ys := make([]int, 4) + append(&ys, 1) + return len(xs) + cap(ys) + max(1, 2) + helper() +} +` + ); + fs.writeFileSync( + path.join(tempDir, 'util', 'u.odin'), + `package util + +Ring :: struct { + len: int, + cap: int, +} + +max :: proc(a: int, b: int) -> int {return a} +append :: proc(r: ^Ring, v: int) {} +helper :: proc() -> int {return 0} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const caller = cg + .getNodesByKind('function') + .find((n) => n.language === 'odin' && n.name === 'use_it'); + expect(caller).toBeDefined(); + const outgoing = cg + .getOutgoingEdges(caller!.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)) + .map((n) => `${n?.filePath}:${n?.name}:${n?.kind}`); + expect(outgoing).toEqual([]); + }); + + it('still links a same-package call and a qualified cross-package call', async () => { + // The gate must not cost the two edges Odin genuinely has: a bare name + // inside one package directory, and a `pkg.fn()` call across two. + fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'shared'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'app', 'a.odin'), + `package app + +import "../shared" + +run :: proc() -> int { + shared.record(1) + return local_helper() +} +` + ); + fs.writeFileSync( + path.join(tempDir, 'app', 'b.odin'), + `package app + +local_helper :: proc() -> int {return 7} +` + ); + fs.writeFileSync( + path.join(tempDir, 'shared', 's.odin'), + `package shared + +record :: proc(v: int) {} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const run = cg.getNodesByKind('function').find((n) => n.language === 'odin' && n.name === 'run'); + expect(run).toBeDefined(); + const callees = cg + .getOutgoingEdges(run!.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)?.name) + .sort(); + expect(callees).toEqual(['local_helper', 'record']); + }); + }); }); diff --git a/docs/grammars/tree-sitter-odin.md b/docs/grammars/tree-sitter-odin.md new file mode 100644 index 000000000..78aba2e85 --- /dev/null +++ b/docs/grammars/tree-sitter-odin.md @@ -0,0 +1,202 @@ +# tree-sitter-odin.wasm — provenance & rebuild + +`src/extraction/wasm/tree-sitter-odin.wasm` is built from +[tree-sitter-grammars/tree-sitter-odin](https://github.com/tree-sitter-grammars/tree-sitter-odin) +(MIT, Copyright (c) 2023 Amaan Qureshi) at tag **`v1.3.0`**, commit +`e8adc739b78409a99f8c31313f0bb54cc538cf73`, from the tag's **checked-in** +`src/parser.c` — no `tree-sitter generate`, no patch. + +| | | +| --- | --- | +| wasm sha256 | `99fd3217b82b1ba4f438120591ec2ed24108f782ab693d69b330d753188632fb` | +| wasm size | 2,218,713 bytes | +| ABI | 14 | +| `src/parser.c` sha256 | `8f672d8d022d70eeae822796924f2e45e692d6b2acd2ddc79bfeb5f696fa24f0` | +| `src/scanner.c` sha256 | `e342d07d3e35c3865a6bda0587f3a3b46a7b2411fd93cbee01dc40ab7631c93b` | +| builder | tree-sitter-cli 0.26.11 (`build --wasm`) | + +ABI 14 rather than 15 because the tag's checked-in `parser.c` predates the +ABI-15 generator; regenerating would diverge the tables from the tag, which is +the whole point of pinning one. Odin is **wasm-only** — the native extraction +kernel has no Odin counterpart, so there is no crate revision to keep in step +and `codegraph-kernel` is untouched. + +## Pin the tag, not master + +Master carries exactly one unreleased commit past `v1.3.0` +(`d2ca8ef`, *"allow multiple identifiers before `:` in `named_type`"*), and it +**regresses multi-value return types** — the single most common Odin signature +shape: + +```odin +package p +f :: proc() -> (string, bool) { + return "x", true +} +``` + +parses clean at `v1.3.0` and yields `ERROR ", bool"` at `d2ca8ef`. Both are +ABI 14 and both load, so the regression does not announce itself as a load +failure — only a corpus diff exposes it, and the diff is large: + +| corpus | files with an ERROR tree at `v1.3.0` | at `d2ca8ef` | +| --- | --- | --- | +| odin-http | 3 | 9 | +| ols | 15 | 51 | +| SpaceLib | 50 | 51 | + +## Measured parse health + +**Every corpus below is a pinned public checkout, so these numbers are +reproducible.** They were measured on Windows with git's default +`core.autocrlf=true`, which is what puts CRs in the working tree — that turns +out to matter more than anything else here, so the CRLF and LF columns are both +given. The fourth row is my own Odin project, an 84-file snapshot, included only +because it is the one this extractor was developed against; nothing in the PR +rests on it. + +| corpus | commit | `.odin` files | ERROR as checked out (CRLF) | ERROR after `\r\n`→`\n` | +| --- | --- | --- | --- | --- | +| [laytan/odin-http](https://github.com/laytan/odin-http) | `65f57ca` | 39 | 3 | 1 | +| [DanielGavin/ols](https://github.com/DanielGavin/ols) | `e62a371` | 138 | 15 | 10 | +| [greenya/SpaceLib](https://github.com/greenya/SpaceLib) | `95bf115` | 166 | 50 | 26 | +| my own project (snapshot) | — | 84 | 5 | 1 | +| **total** | | **427** | **73** | **38** | + +**35 of the 73 failures are caused by the line ending alone** — the same bytes +with the CRs removed parse clean. Gap 1 below is that class. The remaining 38 +are other `v1.3.0` gaps that are NOT characterized here: the recurring shapes +are ternary/`?:` expressions, `matrix` types, `[dynamic; N]` array types and +shebang lines, and `ols` deliberately keeps pathological formatter fixtures +under `tools/odinfmt/tests/`. Do not read this table as "two constructs account +for everything" — that is true of my own project and of nothing else. + +Some shapes appear in the fixture in `__tests__/extraction.test.ts` rather than +in these corpora, so the table evidences nothing about them: calling conventions +(`proc "contextless"`), `when` blocks, `union`s, `bit_field`s and procedure +groups (`f :: proc{a, b}`). The fixture is where their extraction is held. + +Recovery is usually — **not always** — local. Counting top-level declaration +nodes (`procedure_declaration`, `struct_declaration`, `enum_declaration`, +`union_declaration`, `bit_field_declaration`, `import_declaration`, +`const_declaration`, `var_declaration`) and `call_expression`s over all 427 +files, CRLF against LF: + +``` +declarations 8,937 (CRLF) 9,088 (LF) −151 (−1.7%) +call sites 26,999 (CRLF) 27,196 (LF) −197 (−0.7%) +``` + +Of the 35 files that fail only because of their line ending, **27 lose nothing +at all** — the ERROR is a leaf under a `parenthesized_expression` and every +declaration around it still extracts with its correct span — and **8 lose +declarations wholesale**, because the ERROR lands at the top of the file instead +(`ols/src/server/analysis.odin` loses 105 procedures; `odin-http/response.odin` +loses 8). On my own project the loss is zero, which is exactly why a +single-project measurement was not good enough to publish. + +### Gap 1 — trailing-backslash line continuation, in a **CRLF** working tree + +What `odinfmt` emits when it wraps a long line, which is why it is the gap a +real Odin project meets first: + +```odin +return( + "a long line" \ +) +``` + +The trigger is the **line ending**. Written with LF this parses clean; written +with CRLF it yields an ERROR under the `parenthesized_expression`. To reproduce, +note that the continuation is a backslash followed *immediately* by the newline +— a shell heredoc that collapses `\\\n` into a literal `\n` produces a different +(and unrelated) parse failure: + +```js +const src = 'package p\n\nf :: proc() -> string {\n\treturn(\n\t\t"a long line" ' + + String.fromCharCode(92) + '\n\t)\n}\n'; +// LF → hasError false +// CRLF → hasError true, ERROR @5:16 under parenthesized_expression +``` + +The corpus-wide version of the same experiment is the table above: 35 files +across four repositories flip from ERROR to clean on the CRs alone. So a +repository checked out CRLF — every Windows checkout without +`core.autocrlf=input` — hits this on a construct its own formatter generates, +and the same repository on a POSIX checkout does not. Do not attempt a +workaround in `odin.ts`: the extractor is handed a tree, and the tree is where +the bytes went. + +### Gap 2 — an anonymous `proc` literal inside a composite literal + +```odin +CASES := []Case{ + {"a", proc(f: ^Found) {helper(f)}}, +} +``` + +Line-ending independent, and the pointer parameter is NOT the trigger: +`{"b", proc(f: Found) {}}` errors too, while the same literal bound to a name +(`bound := proc(f: ^Found) {helper(f)}`) parses clean. It is the composite-literal +context. Table-driven tests with `proc` mutators are an idiomatic Odin pattern, +so this is the second one worth upstreaming. + +**Measured cost on this sample: zero.** Every enclosing declaration extracts +(`Found`, `Case`, `CASES`, their fields), and the `call_expression` inside the +broken literal survives the ERROR — `helper` is called at line 15 from inside the +literal and at line 18 from the clean one, and BOTH produce a `calls` edge. + +### Gap 3 — a top-level `using ` + +This one is not localized at all. Measured on a four-line file: + +```odin +package p +using fmt +f :: proc() { helper() } +g :: proc() { other() } +``` + +yields **zero** `procedure_declaration` nodes — the ERROR swallows every +declaration in the file, including ones above the `using`. The same file without +the `using` line yields 2. It does not occur in any of the four corpora above, +but it is the gap worth knowing about, because its failure mode is silence +rather than a localized hole. + +The project's own gate passes on a clean sample: + +```bash +node scripts/add-lang/check-grammar.mjs src/extraction/wasm/tree-sitter-odin.wasm sample.odin 30 +# ABI version: 14 +# parses: 30 clean / 0 with errors (of 30) +# RESULT: PASS +``` + +## Rebuild + +```bash +git clone https://github.com/tree-sitter-grammars/tree-sitter-odin +cd tree-sitter-odin +git checkout v1.3.0 +npm install tree-sitter-cli@0.26.11 # what this wasm was built with; it pulled + # a wasi-sdk down on its own, no Docker +npx tree-sitter build --wasm -o tree-sitter-odin.wasm . # compiles the CHECKED-IN parser.c +cp tree-sitter-odin.wasm /src/extraction/wasm/tree-sitter-odin.wasm +``` + +The npm package `tree-sitter-odin@1.3.0` also ships a prebuilt +`tree-sitter-odin.wasm` (sha256 `4a3c9f50ac2356d2284d26e92322c554b276ad6bf6bce87a8594cff95d2ed6f1`). +It is byte-different from the build above (different toolchain) but +**behaviourally identical**: parsing all 427 corpus files with both and diffing +s-expressions gives 427/427 identical ASTs. Building from the tag is preferred +anyway — it is the house rule for every other vendored grammar here, and it +does not require trusting the publisher's build machine (the package carries a +registry signature but no build attestation). + +## Upstreaming + +Nothing sent. All three gaps above are worth an upstream issue — the CRLF one +especially, since `odinfmt` generates the construct, a Windows checkout is where +it lands, and it is the single largest cause of parse failure measured here. +None is patched here, so `tree-sitter build --wasm` on tag `v1.3.0` reproduces +the vendored grammar exactly. diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index d4127631d..76bc85660 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -50,6 +50,7 @@ const WASM_GRAMMAR_FILES: Record = { terraform: 'tree-sitter-terraform.wasm', arkts: 'tree-sitter-arkts.wasm', nix: 'tree-sitter-nix.wasm', + odin: 'tree-sitter-odin.wasm', }; /** @@ -170,6 +171,8 @@ export const EXTENSION_MAP: Record = { '.tf': 'terraform', '.tfvars': 'terraform', '.tofu': 'terraform', + // Odin: one extension, and a package is the DIRECTORY holding the files. + '.odin': 'odin', }; /** @@ -271,6 +274,16 @@ export async function initGrammars(): Promise { * 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). + * Odin: tree-sitter-wasms doesn't ship it either; we vendor a wasm built from + * tree-sitter-grammars/tree-sitter-odin @ v1.3.0 (e8adc73, MIT, Copyright (c) + * 2023 Amaan Qureshi) from the tag's CHECKED-IN parser.c (no `generate`) with + * tree-sitter-cli 0.26.11 `build --wasm`, ABI 14 — the tag's parser.c predates + * the ABI-15 generator, and regenerating would diverge the tables from the tag. + * Pin the TAG, not master: master's one unreleased commit (d2ca8ef) regresses + * multi-value return types (`f :: proc() -> (string, bool)` becomes an ERROR), + * which is pervasive in real Odin. Odin is wasm-only — the native kernel has no + * Odin counterpart, so there is no crate revision to match. See + * docs/grammars/tree-sitter-odin.md for the sha256 and the measured parse health. * * TypeScript/TSX/JavaScript (+jsx, which shares the javascript grammar): the * tree-sitter-wasms builds are 2023-era (^0.20.x); we vendor wasm built from @@ -290,7 +303,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', 'terraform', 'arkts', 'nix', 'odin', '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 @@ -643,6 +656,7 @@ export function getLanguageDisplayName(language: Language): string { objc: 'Objective-C', solidity: 'Solidity', nix: 'Nix', + odin: 'Odin', yaml: 'YAML', twig: 'Twig', xml: 'XML', diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..0274ec4b6 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -36,6 +36,7 @@ import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; import { nixExtractor } from './nix'; +import { odinExtractor } from './odin'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -69,4 +70,5 @@ export const EXTRACTORS: Partial> = { terraform: terraformExtractor, arkts: arktsExtractor, nix: nixExtractor, + odin: odinExtractor, }; diff --git a/src/extraction/languages/odin.ts b/src/extraction/languages/odin.ts new file mode 100644 index 000000000..8530b374b --- /dev/null +++ b/src/extraction/languages/odin.ts @@ -0,0 +1,581 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText, getChildByField, getPrecedingDocstring } from '../tree-sitter-helpers'; +import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; + +/** + * Odin extractor — tree-sitter-odin v1.3.0 (ABI 14, vendored). + * + * Odin declares EVERYTHING with `Name :: `, and the grammar splits that + * one syntax into a node type per thing: + * - procedure_declaration → function (`f :: proc(...) -> T {}`) + * - overloaded_procedure_declaration → function (`f :: proc{f_a, f_b}`) + * - struct_declaration → struct + * - union_declaration → struct (no `union` NodeKind; the + * variants become `references`) + * - bit_field_declaration → struct (its members are fields) + * - enum_declaration → enum + enum_member + * - const_declaration → constant, or type_alias when the value + * is a type expression (`distinct u32`) + * - variable_declaration/var_declaration → variable + * - import_declaration → import + * - package_declaration → namespace (via packageTypes) + * + * Three grammar shapes drive the design: + * + * 1. NO `name:`/`body:`/`parameters:` FIELDS EXIST. The grammar's only fields + * are `alias`, `function`, `argument`, and expression operands — every + * declaration is positional: an optional `attributes` node, then the name + * identifier, then the thing. So `resolveName` reads the first `identifier` + * child (skipping `attributes`, which would otherwise name every decorated + * procedure "@(require_results)"), and a procedure's body is reached + * through its `procedure` child. + * + * 2. RECORD MEMBERS ARE DIRECT CHILDREN — there is no body node to hand the + * core's extractStruct/extractEnum, and the record's OWN name is one of the + * same-typed children its members are. Both are handled in `visitNode`, the + * way tree-sitter-solidity's identically-shaped declarations are. + * + * 3. A PACKAGE QUALIFIER IS A SIBLING OF THE CALL, not part of it: + * `fmt.println(x)` is member_expression(identifier, call_expression). The + * odin branch of extractCall re-attaches it — see tree-sitter.ts. + */ + +/** Node types that make a `Name :: ` declaration a TYPE alias. */ +const TYPE_EXPRESSION_NODES: ReadonlySet = new Set([ + 'distinct_type', + 'array_type', + 'pointer_type', + 'multi_pointer_type', + 'map_type', + 'bit_set_type', + 'matrix_type', + 'variadic_type', + 'tuple_type', + 'struct_type', + 'union_type', + 'enum_type', + 'bit_field_type', + 'procedure_type', + 'polymorphic_type', + 'specialized_type', + 'conditional_type', + 'constant_type', +]); + +/** + * Odin's builtin procedures — `base:builtin`, in scope in every package with no + * import and no qualifier. A call to one is spelled exactly like a call to a + * procedure of your own, so without this set `len(xs)` enters the resolver as a + * user symbol and name-matches anything called `len` anywhere in the repo, + * including a struct FIELD and a package that is never imported. Suppressed at + * emit time, the way SCALA_BUILTIN_TYPES and terraform's BUILTIN_HEADS are. + * + * The cost is a package that SHADOWS a builtin with its own procedure: its + * calls go unrecorded. That direction loses an edge; the other invents one. + */ +export const ODIN_BUILTINS: ReadonlySet = new Set([ + 'len', 'cap', 'size_of', 'align_of', 'offset_of', 'offset_of_selector', + 'offset_of_member', 'offset_of_by_string', 'type_of', 'type_info_of', 'typeid_of', + 'swizzle', 'complex', 'quaternion', 'real', 'imag', 'jmag', 'kmag', 'conj', + 'expand_values', 'min', 'max', 'abs', 'clamp', 'soa_zip', 'soa_unzip', + 'make', 'make_slice', 'make_dynamic_array', 'make_dynamic_array_len', + 'make_dynamic_array_len_cap', 'make_map', 'make_multi_pointer', + 'new', 'new_clone', 'free', 'free_all', + 'delete', 'delete_string', 'delete_map', 'delete_slice', 'delete_dynamic_array', + 'delete_key', 'delete_soa', + 'append', 'append_elem', 'append_elems', 'append_string', 'append_elem_string', + 'append_soa', 'append_nothing', 'inject_at', 'assign_at', + 'clear', 'clear_map', 'clear_dynamic_array', 'clear_soa', + 'reserve', 'reserve_soa', 'resize', 'resize_soa', 'shrink', + 'copy', 'copy_slice', 'copy_from_string', + 'pop', 'pop_safe', 'pop_front', 'pop_front_safe', + 'ordered_remove', 'unordered_remove', 'remove_range', + 'card', 'raw_data', 'container_of', + 'assert', 'assert_contextless', 'ensure', 'ensure_contextless', + 'panic', 'panic_contextless', 'unimplemented', 'unimplemented_contextless', +]); + +/** Nodes whose LEADING identifier run is binding names rather than type names. */ +const BINDING_PARENTS: ReadonlySet = new Set([ + 'parameter', + 'default_parameter', + 'named_type', + // A nested anonymous `struct { … }` in a field's type position — its members + // are `struct_member` (a `field` is the top-level form, handled by + // extractRecord, and listed here so a nested one can never leak either). + 'struct_member', + 'field', +]); + +/** + * Nodes whose DIRECT identifier children are member names, scattered rather + * than leading: an inline `enum { Fast, Slow }` and an inline + * `bit_field u8 { lo: bool | 1 }`. Each member's declared TYPE sits under a + * `type` child, so it still emits. + */ +const MEMBER_LIST_PARENTS: ReadonlySet = new Set(['enum_type', 'bit_field_type']); + +/** + * Whether an identifier is one of its parent's leading binding names — the run + * before the `:`, which Odin lets carry commas (`a, b: int`, `p, q: int`). + * Read off the anonymous siblings, because the `,` and `:` are tokens. + */ +function isLeadingBindingName(id: SyntaxNode): boolean { + if (!BINDING_PARENTS.has(id.parent?.type ?? '')) return false; + for (let prev = id.previousSibling; prev; prev = prev.previousSibling) { + if (prev.type !== 'identifier' && prev.text !== ',') return false; + } + return true; +} + +/** Named children of an Odin declaration, minus its leading `attributes`. */ +function declarationChildren(node: SyntaxNode): SyntaxNode[] { + return node.namedChildren.filter((c: SyntaxNode) => c.type !== 'attributes'); +} + +/** The declared name — the first identifier once `attributes` is skipped. */ +function declaredNameNode(node: SyntaxNode): SyntaxNode | null { + return declarationChildren(node).find((c: SyntaxNode) => c.type === 'identifier') ?? null; +} + +/** The `procedure` child holding a procedure declaration's signature + block. */ +function procedureOf(node: SyntaxNode): SyntaxNode | null { + return node.namedChildren.find((c: SyntaxNode) => c.type === 'procedure') ?? null; +} + +/** + * Names of the `@(...)` attributes on a declaration (`private`, `test`, …). + * Read off the nodes' own text rather than the file source, because + * `extractModifiers` is handed no source. + */ +function attributeNames(node: SyntaxNode): string[] { + const names: string[] = []; + for (const attrs of node.namedChildren) { + if (attrs.type !== 'attributes') continue; + for (const attr of attrs.namedChildren) { + for (const id of attr.namedChildren) { + if (id.type === 'identifier') names.push(id.text); + } + } + } + return names; +} + +/** + * A member identifier of an enum/bit_field body: everything after the declared + * name that isn't the backing type and isn't an explicit VALUE (`Low = 1`, + * `Alias = Other`). The `=` is an anonymous token, so a value identifier is + * recognised by its preceding sibling rather than by its type. + */ +function memberIdentifiers(node: SyntaxNode, nameNode: SyntaxNode): SyntaxNode[] { + const members: SyntaxNode[] = []; + for (const child of declarationChildren(node)) { + if (child.type !== 'identifier') continue; + if (child.equals(nameNode)) continue; + if (child.previousSibling?.text === '=') continue; + members.push(child); + } + return members; +} + +/** + * The names, optional type annotation and initializer values of a positional + * Odin declaration. Odin allows a COMMA-SEPARATED name list on both the `::` + * and the `:` form — `A, B :: 1, 2` and `x, y: int = 3, 4` are ONE declaration + * node each, with the names as a leading run. + * + * The comma is what bounds that run, and it has to: `Alias :: Other` has the + * same two-identifier shape, and taking both would mint a bogus symbol named + * after the right-hand side. `B` follows a `,`; `Other` follows a `::`. + */ +export function odinDeclarationParts(node: SyntaxNode): { + names: SyntaxNode[]; + typeNode: SyntaxNode | null; + values: SyntaxNode[]; +} { + const children = declarationChildren(node); + const names: SyntaxNode[] = []; + let index = 0; + for (; index < children.length; index++) { + const child = children[index]; + if (!child || child.type !== 'identifier') break; + if (names.length > 0 && child.previousSibling?.text !== ',') break; + names.push(child); + } + const annotation = children[index]; + const typeNode = annotation?.type === 'type' ? annotation : null; + if (typeNode) index++; + return { names, typeNode, values: children.slice(index) }; +} + +/** Every declared name in a struct `field` — Odin allows `a, b: int`. */ +function fieldNameNodes(field: SyntaxNode): SyntaxNode[] { + const names: SyntaxNode[] = []; + for (const child of field.namedChildren) { + if (child.type === 'type') break; // the type ends the name list + if (child.type === 'identifier') names.push(child); + } + return names; +} + +/** `Name :: struct/union/bit_field/enum { … }` — one node plus its members. */ +function extractRecord( + node: SyntaxNode, + ctx: ExtractorContext, + kind: 'struct' | 'enum', + memberKind: 'field' | 'enum_member', +): boolean { + const nameNode = declaredNameNode(node); + if (!nameNode) return true; + + const created = ctx.createNode(kind, getNodeText(nameNode, ctx.source), node, { + docstring: getPrecedingDocstring(node, ctx.source), + visibility: odinVisibility(node), + isExported: odinIsExported(node), + }); + if (!created) return true; + + ctx.pushScope(created.id); + if (node.type === 'struct_declaration') { + for (const field of declarationChildren(node)) { + if (field.type !== 'field') continue; + const typeNode = getChildByField(field, 'type') ?? field.namedChildren.find((c: SyntaxNode) => c.type === 'type'); + const typeText = typeNode ? getNodeText(typeNode, ctx.source) : undefined; + for (const name of fieldNameNodes(field)) { + const fieldName = getNodeText(name, ctx.source); + ctx.createNode('field', fieldName, name, { + signature: typeText ? `${fieldName}: ${typeText}` : fieldName, + }); + } + // The field's declared type is a dependency of the record — `next: ^Node` + // is what makes a struct graph traversable. + if (typeNode) emitTypeReferences(typeNode, created.id, ctx); + } + } else { + for (const member of memberIdentifiers(node, nameNode)) { + ctx.createNode(memberKind, getNodeText(member, ctx.source), member); + } + } + ctx.popScope(); + return true; +} + +/** + * `Name :: union { A, B }` — the variants are `type` children, not named + * members, so they become `references` from the union to each variant type. + */ +function extractUnion(node: SyntaxNode, ctx: ExtractorContext): boolean { + const nameNode = declaredNameNode(node); + if (!nameNode) return true; + + const created = ctx.createNode('struct', getNodeText(nameNode, ctx.source), node, { + docstring: getPrecedingDocstring(node, ctx.source), + visibility: odinVisibility(node), + isExported: odinIsExported(node), + }); + if (!created) return true; + + for (const variant of declarationChildren(node)) { + if (variant.type === 'type') emitTypeReferences(variant, created.id, ctx); + } + return true; +} + +/** + * `f :: proc{f_a, f_b}` — a procedure GROUP. Callers spell it `f(...)`, so it + * is a function node; the members it dispatches to become `references` so the + * group links to the overloads an agent actually has to read. + */ +function extractProcedureGroup(node: SyntaxNode, ctx: ExtractorContext): boolean { + const nameNode = declaredNameNode(node); + if (!nameNode) return true; + + const created = ctx.createNode('function', getNodeText(nameNode, ctx.source), node, { + docstring: getPrecedingDocstring(node, ctx.source), + signature: ctx.source.slice(nameNode.endIndex, node.endIndex).replace(/\s+/g, ' ').replace(/^\s*::\s*/, '').trim(), + visibility: odinVisibility(node), + isExported: odinIsExported(node), + }); + if (!created) return true; + + for (const member of memberIdentifiers(node, nameNode)) { + ctx.addUnresolvedReference({ + fromNodeId: created.id, + referenceName: getNodeText(member, ctx.source), + referenceKind: 'references', + line: member.startPosition.row + 1, + column: member.startPosition.column, + }); + } + return true; +} + +/** `Name :: distinct u32` / `Name :: ^Node` / `Name :: proc() -> bool`. */ +function extractTypeAlias(node: SyntaxNode, ctx: ExtractorContext, target: SyntaxNode): boolean { + const nameNode = declaredNameNode(node); + if (!nameNode) return false; + + const created = ctx.createNode('type_alias', getNodeText(nameNode, ctx.source), node, { + docstring: getPrecedingDocstring(node, ctx.source), + signature: getNodeText(target, ctx.source).trim().slice(0, 200), + visibility: odinVisibility(node), + isExported: odinIsExported(node), + }); + if (created) emitTypeReferences(target, created.id, ctx); + return true; +} + +/** + * The identifiers inside a type expression that NAME a type, with the two runs + * of bindings that sit in the same subtree removed: + * - a `parameter` / `named_type` / nested `struct_member` leads with its + * BINDING names, so `proc(x: int)`, `-> (ok: bool)` and + * `inner: struct { parse: int }` would otherwise reference `x`, `ok` and + * `parse` as if they were types, and name-match same-named procedures; + * - an inline `enum { Fast, Slow }` / `bit_field { lo: bool | 1 }` names its + * MEMBERS here; they are bindings too, and never leading ones. + */ +function typeIdentifiers(typeNode: SyntaxNode): SyntaxNode[] { + return typeNode + .descendantsOfType('identifier') + .filter((id: SyntaxNode) => !isLeadingBindingName(id) && !MEMBER_LIST_PARENTS.has(id.parent?.type ?? '')); +} + +/** + * Emit a `references` ref for every user-defined type name inside a type + * expression, so `^Node`, `[]Cue` and `map[string]Sidecar` all link to the + * types they name. Builtin TYPE names (`int`, `string`, `u8`) simply resolve to + * nothing, which costs a lookup and never a wrong edge — no `int` is declared + * anywhere for one to land on. That argument does NOT carry to builtin + * PROCEDURES, which share their names with ordinary declarations: see + * ODIN_BUILTINS, which the odin branch of extractCall refuses before emitting. + */ +function emitTypeReferences(typeNode: SyntaxNode, fromNodeId: string, ctx: ExtractorContext): void { + for (const id of typeIdentifiers(typeNode)) { + ctx.addUnresolvedReference({ + fromNodeId, + referenceName: getNodeText(id, ctx.source), + referenceKind: 'references', + line: id.startPosition.row + 1, + column: id.startPosition.column, + }); + } +} + +/** + * The type names a composite literal spells in its own HEAD, in source order: + * `[Fault]string{…}` names the KEY enum and the value type, `[dynamic]Sidecar{}` + * and `[4]Sidecar{}` name the element, `map[string]Sidecar{}` names both sides, + * and `Sidecar{…}` names the record itself. + * + * Everything from the first `struct_field` on is member DATA and is excluded by + * construction: `.None = ""` holds a `member_expression` whose identifier is an + * enum MEMBER, and referencing that would link the literal to whatever `None` + * happens to name in the package. + * + * The enumerated-array TABLE is why this exists. `FAULT := [Fault]string{…}` is + * a compiler-checked companion to its key enum — add a member and the build + * fails until the table grows a row — so it is precisely the declaration that + * must change when the enum does, and without the head it was the one that did + * not name it. The core walks the literal for CALLS already; the types in front + * of it are what nothing was reading. + */ +export function odinLiteralTypeNames(value: SyntaxNode): SyntaxNode[] { + if (value.type !== 'struct' && value.type !== 'map') return []; + const names: SyntaxNode[] = []; + for (const child of value.namedChildren) { + if (child.type === 'struct_field') break; + if (child.type === 'identifier') names.push(child); + if (child.type === 'type') names.push(...typeIdentifiers(child)); + } + return names; +} + +/** `@(private)` is Odin's only visibility marker; everything else is package-public. */ +function odinVisibility(node: SyntaxNode): 'public' | 'private' { + return attributeNames(node).some((a) => a === 'private') ? 'private' : 'public'; +} + +function odinIsExported(node: SyntaxNode): boolean { + return odinVisibility(node) === 'public'; +} + +/** + * Type nodes a return type is reached THROUGH: `-> (s: Sidecar, ok: bool)` is + * type > tuple_type > named_type > type > identifier, and `-> ^ast.Visitor` is + * type > pointer_type > type > field_type. Descending picks the FIRST `type` + * child at each level, which is also what skips a `named_type`'s binding name. + */ +const RETURN_TYPE_WRAPPERS: ReadonlySet = new Set([ + 'type', + 'tuple_type', + 'pointer_type', + 'multi_pointer_type', + 'named_type', +]); + +/** The bare name of the first type in a return clause, or undefined. */ +function returnTypeName(node: SyntaxNode, source: string): string | undefined { + let cursor: SyntaxNode | null = node; + for (let depth = 0; cursor && depth < RETURN_TYPE_MAX_DEPTH; depth++) { + if (cursor.type === 'identifier') return getNodeText(cursor, source).trim(); + // `ast.Visitor` / `transcript.Render_Context` — the last segment is the + // type; the ones before it are the package. + if (cursor.type === 'field_type') { + const last = cursor.namedChildren.filter((c: SyntaxNode) => c.type === 'identifier').at(-1); + return last ? getNodeText(last, source).trim() : undefined; + } + if (!RETURN_TYPE_WRAPPERS.has(cursor.type)) return undefined; + cursor = + cursor.namedChildren.find((c: SyntaxNode) => c.type === 'type') ?? + cursor.namedChildren.find( + (c: SyntaxNode) => + RETURN_TYPE_WRAPPERS.has(c.type) || c.type === 'field_type' || c.type === 'identifier' + ) ?? + null; + } + return undefined; +} + +/** Deeper than any return clause nests; a guard against a cyclic walk, not a limit. */ +const RETURN_TYPE_MAX_DEPTH = 8; + +/** Whether a bodiless `procedure_declaration` is an FFI declaration. */ +function isForeignProcedure(node: SyntaxNode): boolean { + return node.parent?.parent?.type === 'foreign_block'; +} + +export const odinExtractor: LanguageExtractor = { + functionTypes: ['procedure_declaration'], + classTypes: [], // Odin has no classes — procedures are all top-level + methodTypes: [], + interfaceTypes: [], + // struct / union / bit_field / enum members are DIRECT children of the + // declaration (no body node) and the declaration's own name is one of them, + // so all four are built in visitNode instead of by the core walkers. + structTypes: [], + enumTypes: [], + enumMemberTypes: [], + // `Name :: distinct u32` is a const_declaration whose VALUE is a type — the + // split happens in visitNode, so no node type is a type alias on its own. + typeAliasTypes: [], + importTypes: ['import_declaration'], + // `selector_call_expression` (`s->m(3)`) WRAPS a call_expression, so listing + // it here would emit the same call twice; the odin branch of extractCall + // reads the wrapper from the inner call instead. + callTypes: ['call_expression'], + variableTypes: ['const_declaration', 'const_type_declaration', 'variable_declaration', 'var_declaration'], + packageTypes: ['package_declaration'], + + // The grammar declares none of these fields (see the header note); they are + // the interface's required shape, and resolveName/resolveBody do the work. + nameField: 'name', + bodyField: 'body', + paramsField: 'parameters', + + extractPackage: (node, source) => { + const name = declaredNameNode(node); + return name ? getNodeText(name, source) : null; + }, + + // Skip the leading `attributes` node: `@(require_results)` sits where a + // name-field grammar would put the name, and taking firstNamedChild names + // three quarters of an idiomatic Odin file "@(require_results)". + resolveName: (node, source) => { + const name = declaredNameNode(node); + return name ? getNodeText(name, source) : undefined; + }, + + // A procedure's block hangs off its `procedure` child, not off the declaration. + resolveBody: (node) => { + const proc = procedureOf(node); + return proc?.namedChildren.find((c: SyntaxNode) => c.type === 'block') ?? null; + }, + + getSignature: (node, source) => { + const proc = procedureOf(node); + if (!proc) return undefined; + const block = proc.namedChildren.find((c: SyntaxNode) => c.type === 'block'); + const end = block ? block.startIndex : proc.endIndex; + return source.slice(proc.startIndex, end).trim(); + }, + + // The declared return type, reduced to the bare name a `type_of` edge can + // land on: `-> ^Sidecar`, `-> (Sidecar, bool)`, `-> (s: Sidecar, ok: bool)` + // and `-> ^ast.Visitor` give `Sidecar`, `Sidecar`, `Sidecar` and `Visitor`. + // A NAMED return tuple holds `named_type` children rather than `type` ones, + // and a QUALIFIED type is a `field_type` — the two shapes that account for + // 150 of transcibr's 342 returning procedures. + getReturnType: (node, source) => { + const proc = procedureOf(node); + const declared = proc?.namedChildren.find((c: SyntaxNode) => c.type === 'type'); + if (!declared) return undefined; + const text = returnTypeName(declared, source); + return text && /^[A-Za-z_]\w*$/.test(text) ? text : undefined; + }, + + getVisibility: odinVisibility, + isExported: odinIsExported, + isConst: (node) => node.type === 'const_declaration' || node.type === 'const_type_declaration', + + // `@(test)` / `@(init)` / `@(deferred_out)` are the closest thing Odin has to + // decorators, and `@(test)` in particular is what marks a test procedure. + extractModifiers: (node) => { + const names = attributeNames(node); + return names.length > 0 ? names : undefined; + }, + + // `import "core:fmt"` / `import st "core:strings"` / `foreign import k "…"`. + // The path lives in a `string` child's `string_content`; the optional local + // binding is the only field this node has. + extractImport: (node, source) => { + const stringNode = node.namedChildren.find((c: SyntaxNode) => c.type === 'string'); + if (!stringNode) return null; + const content = stringNode.namedChildren.find((c: SyntaxNode) => c.type === 'string_content'); + const moduleName = getNodeText(content ?? stringNode, source).replace(/^["'`]|["'`]$/g, '').trim(); + if (!moduleName) return null; + return { moduleName, signature: source.slice(node.startIndex, node.endIndex).trim() }; + }, + + visitNode: (node, ctx) => { + switch (node.type) { + case 'struct_declaration': + return extractRecord(node, ctx, 'struct', 'field'); + case 'bit_field_declaration': + return extractRecord(node, ctx, 'struct', 'field'); + case 'enum_declaration': + return extractRecord(node, ctx, 'enum', 'enum_member'); + case 'union_declaration': + return extractUnion(node, ctx); + case 'overloaded_procedure_declaration': + return extractProcedureGroup(node, ctx); + case 'const_declaration': + case 'const_type_declaration': { + // `Digest :: distinct string` is a type, `MAX :: 12` is a constant, and + // `Alias :: Other` is indistinguishable from `Alias :: SOME_CONST` at + // parse time — so only an explicit type EXPRESSION reclassifies, and + // the bare-identifier form stays a constant (a silent miss, never a + // wrong kind). + const value = declarationChildren(node).at(-1); + if (value && TYPE_EXPRESSION_NODES.has(value.type)) { + return extractTypeAlias(node, ctx, value); + } + return false; + } + case 'procedure_declaration': { + // A procedure with no block is either a proc TYPE (`Cb :: proc(x: int) + // -> bool`) or an FFI declaration inside a `foreign` block + // (`GetLastError :: proc() -> u32 ---`). The FFI one is a real callable + // and falls through to the function path; the type is an alias. + const proc = procedureOf(node); + const block = proc?.namedChildren.find((c: SyntaxNode) => c.type === 'block'); + if (proc && !block && !isForeignProcedure(node)) { + return extractTypeAlias(node, ctx, proc); + } + return false; + } + default: + return false; + } + }, +}; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9e53e62da..6a394f814 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -22,6 +22,7 @@ import { isGeneratedFile } from './generated-detection'; import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; import { EXTRACTORS } from './languages'; import { stripCppTemplateArgs } from './languages/c-cpp'; +import { ODIN_BUILTINS, odinDeclarationParts, odinLiteralTypeNames } from './languages/odin'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; import { SvelteExtractor } from './svelte-extractor'; @@ -2866,6 +2867,59 @@ export class TreeSitterExtractor { isExported, }); } + } else if (this.language === 'odin') { + // Odin declares everything positionally — `[attributes] NAME :: value`, + // `NAME := value`, `NAME: T = value` — with no `name:` field anywhere in + // the grammar, and it allows a COMMA-SEPARATED name list on both the `::` + // and the `:` form (`A, B :: 1, 2`, `x, y: int = 3, 4`). The generic + // fallback below takes EVERY identifier child, so `Alias :: Other` and + // `Handle :: SomeConst` minted a second, bogus symbol named after the + // right-hand side; odinDeclarationParts bounds the name run on the commas + // that separate it, so every declared name is indexed and no value is. + const { names, typeNode, values } = odinDeclarationParts(node); + const typeText = typeNode ? getNodeText(typeNode, this.source).trim() : ''; + // Positional pairing: `x, y: int = 3, 4` gives each name its own value, + // while `a, b := f()` gives them one call to share. + const valueFor = (i: number): SyntaxNode | null => + (values.length === names.length ? values[i] : values.length === 1 ? values[0] : null) ?? null; + const createdIds: (string | null)[] = names.map((nameNode, i) => { + const value = valueFor(i); + const initValue = value ? getNodeText(value, this.source).slice(0, 100) : ''; + // A trailing `type` child is the declared TYPE, not an initializer — + // `g: Registry` has no value at all. + const signature = [ + typeText ? `: ${typeText}` : '', + initValue ? ` = ${initValue}${initValue.length >= 100 ? '...' : ''}` : '', + ].join('').trim(); + const varNode = this.createNode(kind, getNodeText(nameNode, this.source), node, { + docstring, + signature: signature || undefined, + isExported, + }); + return varNode?.id ?? null; + }); + // The dispatcher sets skipChildren for variableTypes, so a call in a + // top-level initializer (`Table := build_table()`) is only seen here. + values.forEach((value, i) => { + const ownerId = (values.length === names.length ? createdIds[i] : createdIds[0]) ?? ''; + // A composite literal's HEAD is the declaration's dependency on the + // types it is built out of — `FAULT := [Fault]string{…}` on the enum it + // keys on, which the compiler makes it change with. visitFunctionBody + // walks the literal for calls and reads no types at all. + for (const typeName of odinLiteralTypeNames(value)) { + if (!ownerId) break; + this.unresolvedReferences.push({ + fromNodeId: ownerId, + referenceName: getNodeText(typeName, this.source), + referenceKind: 'references', + line: typeName.startPosition.row + 1, + column: typeName.startPosition.column, + }); + } + if (ownerId) this.nodeStack.push(ownerId); + this.visitFunctionBody(value, ownerId); + if (ownerId) this.nodeStack.pop(); + }); } else { // Generic fallback for other languages // Try to find identifier children @@ -3965,6 +4019,69 @@ export class TreeSitterExtractor { return; } + // Odin puts a call's package qualifier OUTSIDE the call: `fmt.println(x)` + // is member_expression(identifier `fmt`, call_expression(function: + // `println`)). The generic path below reads only the `function` field, so + // every qualified call collapsed to its bare member name — `fmt.println` + // became a `calls` ref to `println`, which then linked to whatever + // same-named procedure the repo happened to define — the same wrong-edge + // class #1079/#1107 fixed for same-named methods, one step earlier. (Ruby's + // `call` branch above is the structural sibling: a grammar whose call node + // hides part of the callee name from the generic path.) + // Re-attach the qualifier as `pkg::callee`, which is + // byte-identical to the qualifiedName the package namespace gives every + // top-level symbol (see packageTypes in languages/odin.ts), so a repo-local + // cross-package call resolves via matchByQualifiedName while a `core:` / + // `vendor:` call resolves to nothing rather than to a wrong local. + // + // A `->` call is the one shape that must NOT be qualified: `h->run()` is + // selector_call_expression(function: `h`, call_expression), where `h` is a + // RECEIVER VARIABLE and not a package. Qualifying it emits `h::run`, which + // is byte-identical in shape to a genuine cross-package call, so a receiver + // sharing a name with a repo package mints exactly the wrong edge this + // branch exists to prevent. It gets the bare member name instead, scoped to + // its own directory by the resolver like any other same-package call. + if (this.language === 'odin' && node.type === 'call_expression') { + // A compiler directive occupies a `function` field too, and it comes + // FIRST: `#force_inline f()` gives the call TWO of them (the `tag`, then + // the identifier) and `#assert(x < y)` gives it only the tag. Read the + // LAST one — `childForFieldName` returns the first, which emitted + // `#force_inline` as the callee and lost the real one. + const fnChildren = node.childrenForFieldName('function'); + const fn = fnChildren.length > 0 ? fnChildren[fnChildren.length - 1] : null; + // Nothing but the directive: `#assert(MAX < 19)`, `#load("x.bin")`. The + // callee is the compiler's, not the repo's — no symbol can carry the name, + // so emitting one is a dead reference on every file that asserts. + if (!fn || fn.type === 'tag') return; + const calleeName = getNodeText(fn, this.source).trim(); + if (!calleeName) return; + const parent = node.parent; + let qualifier = ''; + if (parent?.type === 'member_expression') { + const receiver = parent.namedChild(0); + // `deep.pkg.fn()` nests member_expressions — the LAST segment is the + // package that owns the callee. + if (receiver && !receiver.equals(node)) { + qualifier = getNodeText(receiver, this.source).split('.').pop()?.trim() ?? ''; + } + } + // An UNQUALIFIED call can be a builtin — `len(xs)`, `append(&ys, 1)`, + // `max(1, 2)` are spelled exactly like a call to a procedure of your own. + // Emitting them would name-match any same-named symbol in the repo, + // including a struct FIELD (`calls … -> Ring::len [field]`) and a package + // that is never imported. Suppress at emit, as terraform's BUILTIN_HEADS + // and SCALA_BUILTIN_TYPES do. + if (!qualifier && ODIN_BUILTINS.has(calleeName)) return; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: qualifier ? `${qualifier}::${calleeName}` : calleeName, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + return; + } + // ArkTS build()-DSL handling. Three shapes carry UI-attribute chains, and // all of their attribute names are emitted with a LEADING DOT // (`.titleStyle`, `.width`) — an impossible identifier that routes them to diff --git a/src/extraction/wasm/tree-sitter-odin.wasm b/src/extraction/wasm/tree-sitter-odin.wasm new file mode 100644 index 000000000..174824e43 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-odin.wasm differ diff --git a/src/resolution/index.ts b/src/resolution/index.ts index ef3a5fc23..710c7cf04 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1019,6 +1019,21 @@ export class ReferenceResolver { // linkable symbol) — without this, a Python script's `split()` lands // on some module's `split = ...` binding as a low-confidence match. nameResult = null; + } else if ( + ref.language === 'odin' && + ref.referenceKind === 'calls' && + !ref.referenceName.includes('::') + ) { + // An Odin package IS a directory, and an unqualified callee can only + // bind inside its own package — a cross-package call carries its + // qualifier (`fmt::println`, `shared::record`) and resolves by + // qualified name above. So a bare name matching a symbol in another + // directory is wrong by construction, the same way a cross-file nix + // match is. Odin's builtins are already suppressed at emit; this is + // what stops a plain `helper()` linking to an unimported package's. + if (!target || path.dirname(target.filePath) !== path.dirname(ref.filePath)) { + nameResult = null; + } } } if (nameResult) { diff --git a/src/types.ts b/src/types.ts index a1861bba4..e9325a8c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,7 @@ export const LANGUAGES = [ 'r', 'solidity', 'nix', + 'odin', 'yaml', 'twig', 'xml',