Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Python, a call on a module imported from outside your project, like `subprocess.run(…)` after `import subprocess`, no longer links to a project method that happens to share its name. Thanks @Egrn for the report. (#2599)
- `codegraph_explore`, the CLI `codegraph explore` and the prompt hook no longer stall for a minute or more on a large project after a full index; the step that finds the project's busiest file no longer slows to a crawl when a few files are huge, like a generated Android `R.java` (#2571). Thanks @yangfengfan2011.
- In JavaScript and TypeScript, a call written as the body of an `if`, `while`, `for` or `do` without braces, like `if (e.key === "Escape") setOpen(false);`, no longer links to a same-named property or method in another file when the name is the file's own, such as a `setOpen` from `useState` (#2585).
- In TypeScript, a method called on an element of a class field, like `this.handlers[type].handle()`, now links to the method of the type the field is declared to hold (`Item[]`, `Record<string, Handler>`) instead of to any project method that shares its name, often another class's, and is left unlinked when that type isn't written down.
- `codegraph sync` no longer fails with "FOREIGN KEY constraint failed" in a project whose code lazy-loads a workspace package, like Supabase Studio's `dynamic(() => import('icons').then((mod) => mod.EdgeFunctions))` or vben's `() => import('@vben/layouts').then((m) => m.IFrameView)`. Before, every sync after an edit in such a project rolled back, so the index stopped following your changes until a full re-index. Thanks @bompus for the report and @inth3shadows. (#2565)
- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty.
- In VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused.
Expand Down
16 changes: 15 additions & 1 deletion __tests__/expression-receiver-calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ const files: Record<string, string> = {
'export class GraphAdapter { async map(req?: string) { return req ?? \'\'; } }\n' +
'export class Runner { run() { return 1; } }\n' +
'export class Base { hello() { return 1; } }\n',
// A project `push` an element of a field must not reach (`this.listeners[type].push`).
'src/stack.ts': 'export interface UndoStack { push: (entry: string) => void; }\n',
'src/bus.ts':
'export class Bus {\n' +
' private listeners: Record<string, string[]> = {};\n' +
' on(type: string, handler: string) { this.listeners[type].push(handler); }\n' +
'}\n',
'src/use.ts':
"import { GraphAdapter, Runner, Base } from './adapter';\n" +
"import { helper } from './helper.js';\n" +
Expand Down Expand Up @@ -84,6 +91,10 @@ describe('expression receivers', () => {
expect(cg.getCallers(node('map', 'adapter.ts').id).map(({ node: n }) => n.name)).toEqual(['typed']);
});

it('TS: a method on an element of a field does not bind by its bare name', () => {
expect(calleesOf('on', 'bus.ts')).toEqual([]);
});

it('Rust: `v.iter().map()` does not bind to a TypeScript class method', () => {
expect(calleesOf('lens', 'lib.rs')).toEqual([]);
expect(calleesOf('opt', 'lib.rs')).toEqual([]);
Expand Down Expand Up @@ -116,6 +127,9 @@ describe('expression receivers', () => {
' f().list.map(g);\n' +
' (() => 1).call(null);\n' +
' this.a.b.run();\n' +
' this.listeners[type].push(h);\n' +
' this[k].stop();\n' +
" this['svc'].start();\n" +
' new Runner().go();\n' +
' window.Api.start();\n' +
'}\n';
Expand All @@ -124,7 +138,7 @@ describe('expression receivers', () => {
.map((r) => r.referenceName);
expect(refs).toEqual([
'list().map', 'list', 'x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget',
'f', 'run', 'go', 'start',
'f', 'run', 'this.listeners[].push', 'this.svc.start', 'go', 'start',
]);
});
});
8 changes: 7 additions & 1 deletion __tests__/kernel-tsjs-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ async function exprReceivers(x, y) {
f().list.map(g);
(() => 1).call(null);
this.a.b.run();
this.listeners[type].push(h);
this[k].stop();
this['svc'].start();
${typed ? 'this.items![0].save(); (this.items[1] as X).save();' : ''}
super.stop();
new Runner().go();
window.Api.start();
Expand All @@ -304,7 +308,9 @@ async function exprReceivers(x, y) {
.map((r) => r.referenceName)).toEqual([
'list().map', 'list', 'x.run',
...(typed ? ['x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget', 'has'] : []),
'f', 'run', 'stop', 'go', 'start',
'f', 'run', 'this.listeners[].push', 'this.svc.start',
...(typed ? ['this.items[].save', 'this.items[].save'] : []),
'stop', 'go', 'start',
]);
});

Expand Down
141 changes: 141 additions & 0 deletions __tests__/ts-this-field-element.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* A TS/JS method called on an element of a field of the enclosing class —
* `this.handlers[type].handle()`, `this.items[0].save()` — reaches the
* element's type as the field's declaration writes it (`Item[]`,
* `Array<Item>`, `Record<string, Handler>`), or nothing. It used to keep the
* bare method name, which exact-matched any project method of that name:
* `this.listeners[type].push(h)` reached a stack class's `push` in another
* file. `this['svc'].start()` is the field `svc` itself.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';

let dir: string;
let cg: CodeGraph;

const files: Record<string, string> = {
'src/model.ts':
'export class Item { save() { return 1; } }\n' +
'export class Handler { handle(t: string) { return t; } start() { return 0; } }\n',
// Same-named methods a bare-name match would reach.
'src/decoy.ts':
'export class UndoStack { push(entry: string) { return entry; } }\n' +
'export class Other { save() { return 2; } handle(t: string) { return t; } }\n',
'src/holder.ts':
"import { Item, Handler } from './model';\n" +
'export class Holder {\n' +
' private items: Item[] = [];\n' +
' private list: Array<Item> = [];\n' +
' private handlers: Record<string, Handler> = {};\n' +
' private listeners: Record<string, string[]> = {};\n' +
' private names: string[] = [];\n' +
' private svc: Handler = new Handler();\n' +
' #store: Item[] = [];\n' +
' first() { return this.items[0].save(); }\n' +
' fromList(i: number) { return this.list[i].save(); }\n' +
' fire(t: string) { return this.handlers[t].handle(t); }\n' +
' keyed() { return this.handlers["main"].handle("x"); }\n' +
' cast() { return (this.items[1] as Item).save(); }\n' +
' asserted() { return this.items![0]!.save(); }\n' +
' privately() { return this.#store[0].save(); }\n' +
" byKey() { return this['svc'].start(); }\n" +
' on(type: string, h: string) { this.listeners[type].push(h); }\n' +
' builtin(i: number) { return this.names[i].trim(); }\n' +
'}\n',
// `Item` here is the class's type parameter, not the project class.
'src/store.ts':
'export class Store<Item> {\n' +
' private items: Item[] = [];\n' +
' private byKey: Record<string, Item> = {};\n' +
' first() { return this.items[0].save(); }\n' +
' keyed(k: string) { return this.byKey[k].save(); }\n' +
'}\n' +
// A header over several lines, and a constraint holding braces.
'export class Wide<\n' +
' A,\n' +
' B,\n' +
' Item\n' +
'> {\n' +
' private items: Item[] = [];\n' +
' first() { return this.items[0].save(); }\n' +
'}\n' +
'export class Shaped<Item extends { id: string }> {\n' +
' private items: Item[] = [];\n' +
' first() { return this.items[0].save(); }\n' +
'}\n',
// Either array's element: no one type to resolve on.
'src/mixed.ts':
"import { Item } from './model';\n" +
"import { Other } from './decoy';\n" +
'export class Mixed {\n' +
' private entries: Item[] | Other[] = [];\n' +
' private maybe: Item[] | null = null;\n' +
' either() { return this.entries[0].save(); }\n' +
' nullable() { return this.maybe![0].save(); }\n' +
' private below: Array<Item>\n' +
' | Other = [];\n' +
' private joined: Item[] & Other[] = [];\n' +
' continued() { return this.below[0].save(); }\n' +
' both() { return this.joined[0].save(); }\n' +
'}\n',
};

beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-this-element-'));
for (const [rel, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), text);
}
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});

afterAll(() => {
cg.destroy();
fs.rmSync(dir, { recursive: true, force: true });
});

const calleesOf = (name: string, file = 'holder.ts') => {
const caller = cg.getNodesByName(name).find((n) => n.filePath.endsWith(file) && n.kind === 'method')!;
return cg.getCallees(caller.id)
.filter(({ edge }) => edge.kind === 'calls')
.map(({ node: n }) => `${path.basename(n.filePath)}:${n.qualifiedName}`)
.sort();
};

describe('a call on an element of a this-field', () => {
it.each([
['first', 'model.ts:Item::save'],
['fromList', 'model.ts:Item::save'],
['fire', 'model.ts:Handler::handle'],
['keyed', 'model.ts:Handler::handle'],
['cast', 'model.ts:Item::save'],
['asserted', 'model.ts:Item::save'],
['privately', 'model.ts:Item::save'],
['byKey', 'model.ts:Handler::start'],
])('%s reaches the element type', (caller, target) => {
expect(calleesOf(caller)).toEqual([target]);
});

it('an element of a builtin type reaches no project method', () => {
expect(calleesOf('on')).toEqual([]);
expect(calleesOf('builtin')).toEqual([]);
});

it("a class's type parameter is not the project class of that name", () => {
expect(calleesOf('first', 'store.ts')).toEqual([]);
expect(calleesOf('keyed', 'store.ts')).toEqual([]);
const caller = (cls: string) => cg.getNodesByName('first').find((n) => n.qualifiedName === `${cls}::first`)!;
for (const cls of ['Wide', 'Shaped']) expect(cg.getCallees(caller(cls).id).filter(({ edge }) => edge.kind === 'calls')).toEqual([]);
});

it('a union of element types reaches none of them; `| null` keeps the one', () => {
expect(calleesOf('either', 'mixed.ts')).toEqual([]);
expect(calleesOf('nullable', 'mixed.ts')).toEqual(['model.ts:Item::save']);
expect(calleesOf('continued', 'mixed.ts')).toEqual([]);
expect(calleesOf('both', 'mixed.ts')).toEqual([]);
});
});
43 changes: 41 additions & 2 deletions codegraph-kernel/src/tsjs/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,12 @@ impl<'t> Walker<'t> {
// resolver can read its declared type (#1496). Mirrors
// TreeSitterExtractor.extractCall.
callee_name = format!("this.{field}.{method_name}");
} else if let Some(element) = receiver.and_then(|r| self.this_subscript_of(r)) {
// `this.<field>[k].<method>()` — an element of the
// field, typed by the resolver from the field's
// declaration; `this['svc']` is the field itself.
// Mirrors TreeSitterExtractor.extractCall.
callee_name = format!("{element}.{method_name}");
} else if let Some(r) = receiver.filter(|r| r.kind() == "call_expression") {
// Call receiver — `make().run()` (#1683): keep the inner
// callee as `<inner>().<method>`, or emit nothing when it
Expand Down Expand Up @@ -1663,6 +1669,36 @@ impl<'t> Walker<'t> {
Some(self.text(property).to_string())
}

/// A receiver subscripting the enclosing class's object: `this.<field>[k]`
/// → `this.<field>[]`, `this['svc']` → `this.svc`; None for any other
/// subscript. Mirrors tsThisSubscriptReceiver.
fn this_subscript_of(&self, receiver: Node<'t>) -> Option<String> {
if receiver.kind() != "subscript_expression" {
return None;
}
let object = peel_receiver(receiver.child_by_field_name("object")?);
if object.kind() == "this" {
let index = receiver.child_by_field_name("index")?;
if index.kind() != "string" {
return None;
}
let text = self.text(index);
let quoted = text.len() >= 2
&& (text.starts_with('\'') || text.starts_with('"'))
&& text.ends_with(&text[..1]);
let key = if quoted { &text[1..text.len() - 1] } else { return None };
let mut chars = key.chars();
let first = chars.next()?;
if !(first.is_ascii_alphabetic() || first == '_' || first == '$')
|| !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
{
return None;
}
return Some(format!("this.{key}"));
}
self.this_field_of(object).map(|field| format!("this.{field}[]"))
}

/// `await f<T>(x)` parses with `await f` as the callee (#2586), and a
/// prefix operator over a generic call the same way
/// (`!settings.get<boolean>('X')`, `void apiRequest<T>()`); the call is to
Expand Down Expand Up @@ -1999,11 +2035,14 @@ fn peel_receiver(node: Node<'_>) -> Node<'_> {
}

/// `this` / `super`, a member chain rooted at either or at `window`, or
/// `new C()` still collapse to the bare method name (mirrors
/// keepsBareTsJsReceiver).
/// `new C()` still collapse to the bare method name; a subscript anywhere in
/// the chain does not (mirrors keepsBareTsJsReceiver).
fn keeps_bare_receiver(node: Node<'_>, src: &str) -> bool {
let mut cur = node;
while matches!(cur.kind(), "member_expression" | "subscript_expression") {
if cur.kind() == "subscript_expression" {
return false;
}
match cur.child_by_field_name("object") {
Some(object) => cur = peel_receiver(object),
None => return false,
Expand Down
44 changes: 42 additions & 2 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,16 +623,42 @@ function tsJsCalleeName(func: SyntaxNode, source: string): { name: string; at: S
return /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(chain) ? { name: chain, at: cur } : null;
}

/**
* A TS/JS receiver that subscripts the enclosing class's object: an element
* of a field, `this.<field>[k]` → `this.<field>[]`, or a field named by a
* string key, `this['svc']` → `this.svc`. Null for any other subscript.
* Mirrored in the kernel's this_subscript_of.
*/
function tsThisSubscriptReceiver(node: SyntaxNode, source: string): string | null {
if (node.type !== 'subscript_expression') return null;
const objectNode = getChildByField(node, 'object');
if (!objectNode) return null;
const object = peelTsJsReceiver(objectNode);
if (object.type === 'this') {
const index = getChildByField(node, 'index');
const key = index?.type === 'string' ? /^(['"])([A-Za-z_$][\w$]*)\1$/.exec(getNodeText(index, source))?.[2] : undefined;
return key ? `this.${key}` : null;
}
if (object.type !== 'member_expression' || getChildByField(object, 'object')?.type !== 'this') return null;
const property = getChildByField(object, 'property');
if (!property || !THIS_FIELD_PROPERTY_TYPES.has(property.type)) return null;
return `this.${getNodeText(property, source)}[]`;
}

/**
* Whether a TS/JS receiver still collapses to the bare method name: `this` /
* `super` (the resolver reads the owner off the enclosing class), a member
* chain rooted at either or at `window` (the project-global escape of
* {@link isUnresolvedTsJsChain}), and `new C()` (its class is written at the
* call).
* call). A subscript anywhere in the chain does not: the resolver cannot
* read the receiver back from it, so the bare name would exact-match any
* project method of that name. (An element of a this-field is typed instead,
* see {@link tsThisSubscriptReceiver}.)
*/
function keepsBareTsJsReceiver(node: SyntaxNode, source: string): boolean {
let cur: SyntaxNode | null = node;
while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) {
if (cur.type === 'subscript_expression') return false;
const object = getChildByField(cur, 'object');
cur = object ? peelTsJsReceiver(object) : null;
}
Expand Down Expand Up @@ -5760,6 +5786,8 @@ export class TreeSitterExtractor {
: rawReceiver && this.language === 'swift'
? peelSwiftCallWrappers(rawReceiver, this.source)
: rawReceiver;
// `this.<field>[]` / `this.<name>` of a subscript receiver, set by its branch below.
let thisSubscript: string | null = null;
// A LITERAL receiver — `", ".join(...)`, `"x".toUpperCase()`,
// `5.times`, `[].concat(...)` — calls a builtin of the literal's
// type, never a project symbol. The bare-name fallback below let
Expand Down Expand Up @@ -5934,6 +5962,18 @@ export class TreeSitterExtractor {
// its name keeps the `#` (#1987).
const fieldName = getNodeText(getChildByField(receiver, 'property')!, this.source);
calleeName = `this.${fieldName}.${methodName}`;
} else if (
TS_JS_CHAIN_LANGUAGES.has(this.language) &&
receiver &&
(thisSubscript = tsThisSubscriptReceiver(receiver, this.source)) !== null
) {
// An element of a field of the enclosing class —
// `this.listeners[type].push()`, `this.items[0].save()` — kept
// as `this.<field>[].<method>`: the resolver reads the element
// type off the field's declaration (`Item[]`, `Record<K, V>`)
// or leaves it unresolved. `this['svc'].start()` is the field
// itself. Mirrored in the kernel's extract_call.
calleeName = `${thisSubscript}.${methodName}`;
} else if (
(this.language === 'typescript' ||
this.language === 'javascript' ||
Expand Down Expand Up @@ -5995,7 +6035,7 @@ export class TreeSitterExtractor {
) {
// Any other TS/JS receiver is an expression with no static
// type here — `(a ?? b).map()`, `f().list.map()`,
// `arr[0].run()`, `(() => {}).call()`. The bare method name this
// `arr[0].run()`, `this.a.b[k].run()`, `(() => {}).call()`. The bare method name this
// used to emit exact-matched whichever project method shared the
// name (`(await list()).map()` onto an adapter class's `map`).
// Emit nothing: a silent miss, never a wrong edge. Mirrored in
Expand Down
Loading