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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ run `orvix init` there instead — it writes a different, much shorter file.)
```bash
npm run build # copies grammars into grammars/, then bundles the CLI
npx tsc --noEmit
npm test # vitest, currently 370 tests
npm test # vitest, currently 394 tests
```

All three must pass. Never report work as done without running them.
Expand Down
31 changes: 23 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,29 @@ below into the release notes.

### Added

- Java. Classes, records, interfaces, enums, annotation types, methods, constructors and fields,
with visibility read from the `public` modifier rather than inferred from the enclosing type —
Java says `private` out loud, and the enclosing class must not speak over it.
Measured on google/gson (264 files, 5 009 symbols): 18% of call sites resolved to edges, 32%
were refused as ambiguous, 49% pointed into the JDK. `map`, `show` and `find` behave as they do
in every other language; `who-calls` and `calls` see about a third of what they see in a
TypeScript project, because `x.get(…)` cannot be attributed without the type of `x`. The README
says so next to the language list.
- Java, C#, C++ and C. `@vscode/tree-sitter-wasm` ships no C grammar; the C++ one parses C
without error and the tags a C file can produce are a subset, so both share a grammar, a query
and a resolution family — C++ calls into C without ceremony.
- Visibility is now read per member where the language states it. `private int shut()` inside a
public Java class is private; inferring it from the enclosing type, as the ECMAScript path
does, contradicted the source.
- A call written without a receiver may now reach a method, in languages where every function is
one. `helper()` inside a Java, C# or C++ class means `this.helper()`, so applying the
ECMAScript rule — a bare call reaches only a non-method — dropped every intra-class call in
those languages. It still holds for ECMAScript, Python, Go and Rust, where a method needs its
receiver spelled out.
- Constructors are no longer indexed. One carries the name of the type it builds, so a symbol for
it made `show S` ambiguous and left every call site naming `S` with two candidates and no edge.
Nothing is lost: a constructor is not separately addressable, and its source sits inside the
class's range.
- Each new language was measured against a real project rather than fixtures, and the results
differ enough to be worth publishing: C resolves 42% of call sites with 2% refused as
ambiguous (curl), while C# resolves 27% with 59% refused (Newtonsoft.Json), C++ 27% with 30%
(fmt) and Java 25% with 33% (gson). C has free functions with project-unique names, which is
what a syntactic resolver can follow; the others send most calls through a receiver on a typed
object, which it cannot. The README carries the table.
- Packed size grows from 703 kB to 1.4 MB: the C# and C++ grammars are 4.9 and 5.1 MB
unpacked, against 405 kB for Java.

### Changed

Expand Down
42 changes: 28 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ $ orvix who-calls l00y # every symbol that calls parse
## Results

Every number below comes from a command in this repository. Measured on Orvix's own source —
75 files, 395 symbols, 1 150 call edges, 0 call sites too ambiguous to link — on Windows with
75 files, 402 symbols, 1 196 call edges, 0 call sites too ambiguous to link — on Windows with
Node 24. Re-run them; they will differ on your machine and your code.

### Reading one symbol instead of one file — `npm run measure`

| Task | Reading files | With orvix | Saved |
|---|---|---|---|
| Target one function (`resolveModule`) to edit it | ~2,000 | ~235 | **88%** |
| Take in the whole project's shape | ~33,630 | ~1,998 | **94%** |
| Take in the whole project's shape | ~35,309 | ~1,994 | **94%** |

### What the hooks actually replace — `npm run benchmark`

Expand Down Expand Up @@ -88,7 +88,7 @@ repository the files a query reads are **31%** of the cache.

### Correctness — `npm test`

370 tests. The load-bearing one asserts that after a run of randomised edits, the incrementally
394 tests. The load-bearing one asserts that after a run of randomised edits, the incrementally
updated index is **byte-identical** to indexing the project from scratch. It has caught four real
bugs that unit tests missed.

Expand Down Expand Up @@ -230,19 +230,33 @@ file, gets nothing, and concludes the tool is broken instead of just reading the

## Languages

TypeScript · TSX · JavaScript · Python · Go · Rust · Java
TypeScript · TSX · JavaScript · Python · Go · Rust · Java · C# · C++ · C

**Java resolves fewer calls than the rest, and it is worth knowing why before you rely on it.**
Indexing google/gson — 264 files, 5 009 symbols — 18% of call sites became edges, 32% were
refused as ambiguous and 49% pointed into the JDK. The refusals are the rule working: Java names
methods `get`, `add`, `create`, `value` across unrelated classes, and `x.get(…)` cannot be
attributed without knowing the type of `x`. Only 5% of them were overloads of one method, so
collapsing those would not help. `map`, `show` and `find` are unaffected and behave as they do
everywhere; `who-calls` and `calls` see roughly a third of what they see in a TypeScript project,
and ranking, which reads the call graph, is correspondingly flatter.
`map`, `show` and `find` work the same in all of them. **The call graph does not.** Each of these
was measured by indexing a real project:

Adding one means three things: an entry in `src/core/languages.ts`, its grammar in
`scripts/copy-grammars.mjs`, and a tag query in `queries/<id>.scm`.
| Project | Language | Files | Calls resolved | Refused as ambiguous |
|---|---|---|---|---|
| curl/curl | C | 1 068 | **42%** | **2%** |
| JamesNK/Newtonsoft.Json | C# | 945 | 27% | **59%** |
| fmtlib/fmt | C++ | 77 | 27% | 30% |
| google/gson | Java | 264 | 25% | 33% |

The rest of each row is calls into the standard library, which resolve to nothing by design.

**The split is not about the language being hard, it is about where calls go.** C has free
functions with project-unique names, which is exactly what a syntactic resolver can follow — it
gives the densest graph of any language here. C#, C++ and Java send most calls through a receiver
on a typed object, and `x.Get(…)` cannot be attributed without knowing the type of `x`. Refusing
is the rule working, not a defect; a wrong edge would be worse. Overloads are not the cause — on
gson only 5% of ambiguous sites were overloads of one method.

So: `who-calls` and `calls` are strong in C, Go, Rust, TypeScript and Python, and partial in C#,
C++ and Java, where ranking is also flatter because it reads the call graph.

Adding one means an entry in `src/core/languages.ts`, its grammar in
`scripts/copy-grammars.mjs`, a tag query in `queries/<id>.scm`, and its scope and visibility
rules in `src/core/extract.ts`. `AGENTS.md` has the full list.

## What the call graph does and does not know

Expand Down
2 changes: 1 addition & 1 deletion benchmarks/results/synthetic.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"mode": "synthetic",
"at": "2026-08-09T11:22:04.736Z",
"at": "2026-08-09T14:11:26.498Z",
"fixtures": 2,
"succeeded": 2,
"before": 2481,
Expand Down
66 changes: 66 additions & 0 deletions queries/cpp.scm
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
; Tag query for C++, shared with C.
;
; A C file produces a subset of these tags — it has no class_specifier and no
; qualified_identifier — so one query serves both.
;
; Only definitions are tagged. A member declared inside a class body is a
; `field_declaration`, not a `function_definition`, so the declaration in the
; header and the definition in the source do not become two symbols; the
; definition alone is indexed.
;
; Includes are not captured: `#include "a.h"` names a header, and the symbol it
; declares is defined elsewhere, so following it would point at the wrong file.

; --- functions ---------------------------------------------------------------

(function_definition
declarator: (function_declarator
declarator: (identifier) @name)) @definition.function

; `int Service::run() { … }` sits outside the class, so containment cannot make
; it a method. Tagged as one directly, and under `run` rather than
; `Service::run`, or a receiver call could never reach it.
(function_definition
declarator: (function_declarator
declarator: (qualified_identifier
name: (identifier) @name))) @definition.method

; Defined inline in the class body. The grammar calls the name a
; field_identifier there rather than an identifier, and that node type occurs
; nowhere else, so the match is a method by construction.
(function_definition
declarator: (function_declarator
declarator: (field_identifier) @name)) @definition.method

; --- types -------------------------------------------------------------------

(class_specifier
name: (type_identifier) @name) @definition.class

(struct_specifier
name: (type_identifier) @name) @definition.class

(union_specifier
name: (type_identifier) @name) @definition.class

(enum_specifier
name: (type_identifier) @name) @definition.enum

(type_definition
declarator: (type_identifier) @name) @definition.type

(namespace_definition
name: (namespace_identifier) @name) @definition.type

; --- call sites --------------------------------------------------------------

(call_expression
function: (identifier) @name) @reference.call

(call_expression
function: (qualified_identifier
name: (identifier) @name)) @reference.call

(call_expression
function: (field_expression
field: (field_identifier) @name)) @reference.method
63 changes: 63 additions & 0 deletions queries/csharp.scm
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
; Tag query for C#.
;
; Access is written on each member, so extract.ts reads it from the `modifier`
; children rather than inferring it from the enclosing type. Unlike Java, the
; modifiers are repeated siblings rather than one `modifiers` node.
;
; Imports are not captured: `using App.Services` names a namespace, and a
; namespace maps to no particular file. Calls resolve through same-file scope
; and project-unique names.

; --- types -------------------------------------------------------------------

(class_declaration
name: (identifier) @name) @definition.class

(record_declaration
name: (identifier) @name) @definition.class

(struct_declaration
name: (identifier) @name) @definition.class

(interface_declaration
name: (identifier) @name) @definition.interface

(enum_declaration
name: (identifier) @name) @definition.enum

(delegate_declaration
name: (identifier) @name) @definition.type

; --- members -----------------------------------------------------------------

(method_declaration
name: (identifier) @name) @definition.method

; Constructors are deliberately not tagged, for the same reason as java.scm:
; one carries its class's name, so a symbol for it makes every `new S()`
; ambiguous with the class and no edge is drawn.

; A property is read like a field and never called, so it is a value rather
; than a method — the kind decides what a receiver call may reach.
(property_declaration
name: (identifier) @name) @definition.constant

(field_declaration
(variable_declaration
(variable_declarator
(identifier) @name))) @definition.constant

; --- call sites --------------------------------------------------------------

; Kept apart by receiver, as in typescript.scm: without it every `list.Add(…)`
; would reach any method named Add.

(invocation_expression
function: (identifier) @name) @reference.call

(invocation_expression
function: (member_access_expression
name: (identifier) @name)) @reference.method

(object_creation_expression
type: (identifier) @name) @reference.call
6 changes: 4 additions & 2 deletions queries/java.scm
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
(method_declaration
name: (identifier) @name) @definition.method

(constructor_declaration
name: (identifier) @name) @definition.method
; Constructors are deliberately not tagged. One carries its class's name, so a
; symbol for it makes every `new S()` match two candidates and draw no edge at
; all. It is not separately addressable either — `show S` must mean the class —
; and its source sits inside the class's range regardless.

; A field, anchored on the declaration so the signature keeps its modifiers and
; type. `int a = 1, b = 2` therefore gives a and b one range, the same trade-off
Expand Down
3 changes: 3 additions & 0 deletions scripts/copy-grammars.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const GRAMMARS = [
"tree-sitter-go.wasm",
"tree-sitter-rust.wasm",
"tree-sitter-java.wasm",
"tree-sitter-c-sharp.wasm",
// Serves both C++ and C; there is no C grammar in this package.
"tree-sitter-cpp.wasm",
];

const available = await readdir(SOURCE).catch(() => {
Expand Down
67 changes: 65 additions & 2 deletions src/core/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const ECMASCRIPT_SCOPES = new Set([
"method_definition",
]);

const CPP_SCOPES = new Set(["function_definition", "lambda_expression"]);

const LOCAL_SCOPES: Record<string, Set<string>> = {
typescript: ECMASCRIPT_SCOPES,
tsx: ECMASCRIPT_SCOPES,
Expand All @@ -47,6 +49,15 @@ const LOCAL_SCOPES: Record<string, Set<string>> = {
"constructor_declaration",
"lambda_expression",
]),
csharp: new Set([
"method_declaration",
"constructor_declaration",
"accessor_declaration",
"local_function_statement",
"lambda_expression",
]),
cpp: CPP_SCOPES,
c: CPP_SCOPES,
};

/**
Expand Down Expand Up @@ -162,7 +173,10 @@ function collect(
});
}

const kept = dedupe(symbols).sort((a, b) => a.startByte - b.startByte);
const kept = dropConstructors(
dedupe(symbols).sort((a, b) => a.startByte - b.startByte),
def.id,
);
assignContainers(kept);
promoteToMethods(kept);
if (!STATES_MEMBER_VISIBILITY.has(def.id)) {
Expand Down Expand Up @@ -199,12 +213,40 @@ function promoteToMethods(symbols: RawSymbol[]): void {
}
}

/**
* Languages with no constructor node, where a constructor is an ordinary
* function definition that happens to carry its class's name. Java and C# name
* theirs in the grammar, so their queries simply do not tag them.
*/
const CONSTRUCTORS_LOOK_LIKE_FUNCTIONS = new Set(["cpp", "c"]);

/**
* Drops constructors, which duplicate the name of the type they build.
*
* Left indexed, `S` names both the class and its constructor: `show S` becomes
* ambiguous, and every call site naming S matches two candidates so no edge is
* drawn at all. Nothing is lost — a constructor is not separately addressable,
* and its source sits inside the class's range.
*/
function dropConstructors(symbols: RawSymbol[], languageId: string): RawSymbol[] {
if (!CONSTRUCTORS_LOOK_LIKE_FUNCTIONS.has(languageId)) return symbols;

const types = new Set(
symbols.filter((s) => CONTAINER_KINDS.has(s.kind)).map((s) => s.name),
);
if (types.size === 0) return symbols;

return symbols.filter(
(symbol) => !types.has(symbol.name) || CONTAINER_KINDS.has(symbol.kind),
);
}

/**
* Languages where a member carries its own visibility, so the enclosing type
* must not speak for it. `private int shut()` inside a public class is
* private, and saying otherwise contradicts the source.
*/
const STATES_MEMBER_VISIBILITY = new Set(["java"]);
const STATES_MEMBER_VISIBILITY = new Set(["java", "csharp"]);

/**
* A public method of an exported class is part of the file's public surface
Expand Down Expand Up @@ -360,6 +402,18 @@ function isExported(node: Node, name: string, languageId: string): boolean {
case "java":
return /\bpublic\b/.test(modifiersOf(node));

// C# writes each modifier as its own node rather than grouping them.
case "csharp":
return hasChildWithText(node, "modifier", "public");

// C and C++ have no export list. `static` at file scope is the one thing
// that genuinely narrows a name's reach, so it is what marks a symbol
// private; access specifiers inside a class are siblings of the member
// rather than children, and are not read.
case "cpp":
case "c":
return !hasChildWithText(node, "storage_class_specifier", "static");

default:
return false;
}
Expand All @@ -379,6 +433,15 @@ function modifiersOf(node: Node): string {
return "";
}

/** Whether a direct child of this type carries exactly this text. */
function hasChildWithText(node: Node, type: string, text: string): boolean {
for (let index = 0; index < node.childCount; index += 1) {
const child = node.child(index);
if (child?.type === type && child.text === text) return true;
}
return false;
}

function hasChildOfType(node: Node, type: string): boolean {
for (let index = 0; index < node.childCount; index += 1) {
if (node.child(index)?.type === type) return true;
Expand Down
Loading
Loading