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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,56 @@

## Unreleased

### Added

- **Swift gets full-fidelity (depth-tier) extraction**, promoted from the
breadth tier the same way Kotlin was (#130) — whose swift tags query had no
call captures at all, so Swift repos indexed symbols with zero wiring.
`tree-sitter-swift` (native, `^0.7.1` — the first release whose install
compiles the shipped parser instead of regenerating it, which broke under
npm's hoisting; a root `overrides` entry pins its `tree-sitter` peer for dev
installs) parses `.swift`. One `class_declaration` node covers `class` /
`struct` / `enum` / `actor` / `extension`, told apart by their own keyword:
class and actor → `class`, struct → `struct`, enum → `enum`, `protocol` →
`interface`, `typealias` → `type`, top-level `let`/`var` → `variable`. An
`extension Point` node takes the extended type's own name, so its members
mint as Point methods and member calls on a Point receiver resolve to them.
The extension node itself is kind `module`, not a second same-named class —
otherwise every `Point()` call and `: Point` heritage target would go
ambiguous and drop. `init` becomes a method named after its type (like a
Java constructor); `func` is a method inside any type and a function
elsewhere. Calls resolve via `call_expression` with `navigation_expression`
/ `self` / `super` receivers. A bare lowercase call inside a type body is
ONE edge carrying both of its readings in Swift's own inner-scope-first
order: the member reading first (owner-qualified index + the in-repo
ancestor chain — a stdlib call like `contains` inside `extension Set`
drops instead of binding to an unrelated type's only same-named method, a
false positive dogfooding on swift-composable-architecture caught), then
the free-function reading only when no member exists on the chain — so a
name defined as both yields the member edge alone, as Swift dispatches it.
`super.method()` resolves against the declaration's own superclass (the
first `:` entry, which Swift's grammar puts before any protocol), climbing
past the current class's override. Overloads disambiguate by declared
arity vs call-site argument count (Java's exact mechanism — defaults and
variadics make the arity a minimum); an overload set arity can't split
(`save(Int)` vs `save(String)`) DROPS rather than taking the same-file
tiebreak, which would stamp whichever overload appears first `extracted`.
An initializer call (`Animal(legs: 4)` — an ordinary call node, no `new`)
falls back to class/struct/enum targets once functions find nothing,
Python's constructor-fallback shape. The `:` inheritance clause yields `extends`
edges (bare names — Swift can't say syntactically which specifier is the
superclass), `import` declarations yield module-path import edges, and
visibility maps to `exported` as only `private`/`fileprivate` hidden —
Swift's default `internal` is module-wide, which for a one-module repo is
the API surface. A Swift bindings collector types receivers from the
confident, syntax-local clues: typed parameters (`func feed(animal:
Animal)`, argument labels handled), typed properties, initializer-call
assignments (`let vet = Vet()` — UpperCamelCase callee, the same convention
trust as Go's `NewX`), and fields bound both bare and `self.`-prefixed —
so `vet.check()`, `keeper.wave()`, `self.repo.save()`, and type-member
calls (`Animal.census()`) all resolve through the owner-qualified method
index. A receiver with no local clue (a chained call's result) stays
unresolved rather than guessed.
### Fixed

- **`allowScripts` now names R by the identity npm actually matches on.** The
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,21 +193,23 @@ compiler-grade layer — all `$0` and deterministic (no model, no key):
- **Full-fidelity** — hand-written extractors with scope-aware, cross-file call
and import resolution:
**TypeScript / JavaScript** (incl. JSX & TSX), **Python**, **Go**, **Java**,
**R** (`.R`/`.r` — plain functions, S3/S4/R6 classes and methods, roxygen
`@export`, `library()`/`source()` imports).
**Kotlin**, **PHP**, **Swift** (classes, structs, enums, actors, protocols;
extension members attach to the extended type), **R** (`.R`/`.r` — plain
functions, S3/S4/R6 classes and methods, roxygen `@export`,
`library()`/`source()` imports).

- **Broad** — symbols (functions, classes, methods, types, …) plus name-resolved
call edges via a generic tree-sitter extractor, one grammar per language:
**Rust, C, C++, C#, Ruby, PHP, Kotlin, Scala, Swift, Elixir, Solidity,
OCaml, Zig, Dart, Clojure, Lua**.
**Rust, C, C++, C#, Ruby, Scala, Elixir, Solidity,
OCaml, Zig, Dart, Clojure, Nix, Lua**.

- **Compiler-grade edges (opt-in)** — `graft build --lsp` adds precise
`lsp_resolved` call edges (member calls the static pass can't type) when a
language server is on your `PATH`: **rust-analyzer** (Rust), **clangd** (C/C++),
**gopls** (Go), **pyright** (Python), **typescript-language-server** (TS/JS).
It's best-effort — with no server installed the graph is unchanged.

Twenty-two languages in total. A file whose language isn't listed is skipped, not
Twenty-three languages in total. A file whose language isn't listed is skipped, not
indexed. Adding a broad-tier language is a small contribution — see
[CREDITS.md](CREDITS.md) for the folks who added the current set.

Expand Down
56 changes: 56 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"tree-sitter-php": "^0.23.4",
"tree-sitter-python": "^0.21.0",
"tree-sitter-r": "npm:@davisvaughan/tree-sitter-r@^1.3.0",
"tree-sitter-swift": "^0.7.1",
"tree-sitter-typescript": "^0.23.2",
"tree-sitter-wasm": "^1.1.6",
"vscode-jsonrpc": "^8.2.1",
Expand All @@ -93,6 +94,12 @@
"fsevents@2.3.3": true,
"tree-sitter-kotlin@0.3.8": true,
"tree-sitter-php@0.23.12": true,
"@davisvaughan/tree-sitter-r@1.3.0": true
"@davisvaughan/tree-sitter-r@1.3.0": true,
"tree-sitter-swift@0.7.1": true
},
"overrides": {
"tree-sitter-swift": {
"tree-sitter": "$tree-sitter"
}
}
}
123 changes: 118 additions & 5 deletions src/graph/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function defName(node: Parser.SyntaxNode, lang: Language): string | null
return null;
}
if (lang === "r") return rDefName(node);
if (lang === "swift") return swiftDefName(node);
if (lang === "php") {
const phpDefTypes = new Set([
"class_declaration",
Expand Down Expand Up @@ -97,6 +98,38 @@ export function defName(node: Parser.SyntaxNode, lang: Language): string | null
return null;
}

/** The scope segment a Swift definition pushes, mirroring extract.ts's
* `describeSwift` (duplicated, not imported, per this file's
* no-value-import-of-extract rule): types and extensions push the type's name
* (an extension is named after the type it extends), functions their own name,
* and an `init` the enclosing type's name — extract.ts names initializers after
* their type, so the two scope stacks stay in lockstep inside an init body.
* Typealias and top-level properties DO mint nodes in extract.ts but are
* deliberately absent here: neither has a body a binding could be set in (a
* property initializer's closure is the one vanishing exception, mis-keying
* only bindings set inside itself). */
function swiftDefName(node: Parser.SyntaxNode): string | null {
if (node.type === "class_declaration") {
if (node.children.some((c) => c.type === "extension")) {
const ut = node.namedChildren.find((c) => c.type === "user_type");
const ids = ut?.namedChildren.filter((c) => c.type === "type_identifier") ?? [];
return ids.at(-1)?.text ?? null;
}
return node.namedChildren.find((c) => c.type === "type_identifier")?.text ?? null;
}
if (node.type === "protocol_declaration") {
return node.namedChildren.find((c) => c.type === "type_identifier")?.text ?? null;
}
if (node.type === "function_declaration" || node.type === "protocol_function_declaration") {
return node.namedChildren.find((c) => c.type === "simple_identifier")?.text ?? null;
}
if (node.type === "init_declaration") {
const owner = node.parent?.parent; // class_body / protocol_body → the declaration
return owner ? swiftDefName(owner) : null;
}
return null;
}

const R_ASSIGN_OPS = new Set(["<-", "<<-", "="]);
const R_RIGHT_ASSIGN_OPS = new Set(["->", "->>"]);

Expand Down Expand Up @@ -158,11 +191,12 @@ function goReceiverTypeOf(node: Parser.SyntaxNode): string | null {

/** Resolves a call site's receiver text (from `calleeName`) to a bound type
* name, given the enclosing walk state. `self`/`cls`/`this`/the Go receiver
* var resolve directly to the enclosing class; R6's `super` (Phase 3) resolves
* to the PARENT class instead (`ctx.rSuperClass`, not `ctx.enclosingClass` —
* a `super$method()` call must climb past the current class's own same-named
* override, not find it); anything else is a bindings-map lookup, normalizing
* `this.` to `self.` since both are stored the same way. */
* var resolve directly to the enclosing class; `super` — R6's `super$` and
* Swift's `super.` alike — resolves to the PARENT class instead
* (`ctx.rSuperClass`, not `ctx.enclosingClass` — a super call must climb past
* the current class's own same-named override, not find it); anything else is
* a bindings-map lookup, normalizing `this.` to `self.` since both are stored
* the same way. */
export function resolveRecvType(
receiver: string | undefined,
ctx: Pick<WalkCtx, "scope" | "enclosingClass" | "goReceiverVar" | "lang" | "bindings" | "rSuperClass">,
Expand All @@ -183,6 +217,10 @@ export function resolveRecvType(
return (
(ctx.lang === "go" && receiver === ctx.goReceiverVar ? ctx.enclosingClass : undefined) ??
ctx.bindings.lookup(ctx.scope, receiver) ??
// Swift type-member call `Animal.staticThing()`: an uppercase receiver with no
// local binding is the type itself (Swift naming: types are UpperCamelCase,
// values lowerCamelCase — and a shadowing binding was already tried above).
(ctx.lang === "swift" && /^[A-Z]/.test(receiver) ? receiver : undefined) ??
undefined
);
}
Expand All @@ -193,6 +231,11 @@ function isClassNode(node: Parser.SyntaxNode, lang: Language): boolean {
if (lang === "typescript" || lang === "tsx") {
return node.type === "class_declaration" || node.type === "abstract_class_declaration";
}
// Swift: class_declaration covers class/struct/enum/actor/extension — all can
// hold members whose `self.field` bindings live at the type's scope.
if (lang === "swift") {
return node.type === "class_declaration" || node.type === "protocol_declaration";
}
return false;
}

Expand Down Expand Up @@ -262,6 +305,7 @@ function visit(
// collect yet (see extract.ts's calleeName R branch) — no handleR needed.
else if (lang === "r") void 0;
else if (lang === "java") handleJava(node, scope, classScope, bindings);
else if (lang === "swift") handleSwift(node, scope, classScope, bindings);
else if (lang === "php") handlePhp(node, scope, bindings);
else handleTs(node, scope, classScope, bindings, aliases);

Expand Down Expand Up @@ -300,6 +344,75 @@ function callTypeName(node: Parser.SyntaxNode | null | undefined, aliases: Map<s
return aliases.get(fn.text) ?? fn.text;
}

/** Swift variable->type bindings from the confident, syntax-local clues:
* a typed parameter (`func feed(animal: Animal)`, `init(keeper k: Keeper)` —
* the LAST simple_identifier before the `:` is the local name, the first may be
* an external argument label), a typed property (`var d: Doctor`), and an
* initializer-call assignment (`let vet = Vet()` — an initializer call is an
* ordinary call whose callee is the type's own UpperCamelCase name; Swift's
* naming convention makes the case split reliable, the same way Go's `NewX`
* convention is trusted in handleGo). A property directly inside a type body is
* a field: bound at the type's scope, both bare (`repo.save()`) and
* `self.`-prefixed (`self.repo.save()`), like Java's fields. */
function handleSwift(
node: Parser.SyntaxNode,
scope: string[],
classScope: string | null,
bindings: FileBindings,
): void {
const scopePath = scope.join(".");
if (node.type === "parameter") {
const ids = node.namedChildren.filter((c) => c.type === "simple_identifier");
const name = ids.at(-1)?.text;
const type = swiftTypeName(
node.namedChildren.find((c) => c.type === "user_type" || c.type === "optional_type"),
);
if (name && type) bindings.set(scopePath, name, type);
return;
}
if (node.type !== "property_declaration") return;
const name = node.namedChildren
.find((c) => c.type === "pattern")
?.namedChildren.find((c) => c.type === "simple_identifier")?.text;
if (!name) return;
const annotated = node.namedChildren
.find((c) => c.type === "type_annotation")
?.namedChildren.find((c) => c.type === "user_type" || c.type === "optional_type");
const type =
swiftTypeName(annotated) ??
swiftCtorTypeName(node.namedChildren.find((c) => c.type === "call_expression"));
if (!type) return;
const isField = node.parent?.type === "class_body" || node.parent?.type === "protocol_body";
const target = isField ? (classScope ?? scopePath) : scopePath;
bindings.set(target, name, type);
if (isField) bindings.set(target, `self.${name}`, type);
}

/** A Swift type node's bare name: a `user_type`'s LAST type_identifier (so a
* module-qualified `Foundation.Date` binds as `Date`, generic arguments live in
* nested nodes and never leak in), unwrapping one level of optional (`Animal?`).
* Collections, tuples, and function types bind nothing — no single confident
* receiver type to name. */
function swiftTypeName(node: Parser.SyntaxNode | null | undefined): string | null {
if (!node) return null;
if (node.type === "optional_type") {
return swiftTypeName(node.namedChildren.find((c) => c.type === "user_type"));
}
if (node.type !== "user_type") return null;
const ids = node.namedChildren.filter((c) => c.type === "type_identifier");
return ids.at(-1)?.text ?? null;
}

/** The type constructed by an initializer call (`Vet()` → `Vet`), or null when
* the callee isn't a bare UpperCamelCase name — a lowercase callee is an
* ordinary function whose return type a single-file pass cannot know. */
function swiftCtorTypeName(call: Parser.SyntaxNode | null | undefined): string | null {
if (call?.type !== "call_expression") return null;
const fn = call.namedChildren[0];
if (fn?.type !== "simple_identifier") return null;
return /^[A-Z]/.test(fn.text) ? fn.text : null;
}

/** PHP variable->type bindings from the two confident, syntax-local clues:
* a type-hinted parameter (`function f(Foo $x)`) and a `new` assignment
* (`$x = new Foo()`). Keyed by the `$var` text (with the `$`) so a
Expand Down
Loading
Loading