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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Python, a from-import of a module whose name has no dot, like `from loader import Registry` with `loader.py` at the repository root, or `from mypackage import helper` in a `src/` layout, now links the importing file to that module's file, as `import loader` and `from pkg.loader import Registry` already did. Before, the file's dependency on the module itself was left out, so `codegraph affected` and impact could miss the files that import it. When the file also imported something else under the module's name, like `from django.core import mail` beside `from mail import …`, the module was linked to that instead. Re-index Python projects after upgrading.
- In Python, a call through a module-level instance, like `_svc.delete(obj)` after `_svc = SceneService()`, now links to the method its class has or inherits, even when other classes have a method of that name. (#2582)
- In Python, a call of your own class's method named like a dictionary, list or file method, such as `get`, `update`, `items`, `pop` or `close`, now links to that method when the function says what the variable holds: `reg = SystemPackRegistry()` then `reg.get(…)`, `registry = load_registry()` with `def load_registry() -> SystemPackRegistry`, or a parameter written `reg: SystemPackRegistry`. Before, these calls were dropped as built-in methods unless the variable was named after the class. A variable the function also sets to something else, like `{}` or what `json.load` returns, a class imported from another package, like `queue.Queue`, and a union type still link nothing. Re-index Python projects after upgrading. Thanks @inth3shadows for the report. (#2583)
- In Python, Scala and Java, a standard-library method called on a value whose type CodeGraph can't see, like `options.setdefault(…)` on a dictionary, `m.getOrElse(…)` on an `Option` or `data.put(…)` on a map held in a `var`, no longer links to an unrelated project method that happens to share its name; the call still links when the variable is named after your class, like `registry.get(…)` for a `Registry`. Re-index Python, Scala and Java projects after upgrading. Thanks @danusha2345. (#1947)
- 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.
- `codegraph_explore`, the CLI `codegraph explore` and the Claude Code prompt hook now answer a long query, like a pasted log, document or source file, in seconds on a large project instead of minutes. Every word of such a query used to search the whole index several times over, so the prompt hook ran out of time on long prompts and added nothing. Those searches now reuse what they read, and a very long query is searched by its code names and its opening words rather than by every word in it. Answers to ordinary questions and lists of symbol names are unchanged. Thanks @retsamekohC for the report and the first fix. (#2620)
Expand Down
133 changes: 133 additions & 0 deletions __tests__/library-method-calls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* A standard-library method called on a receiver of unknown type — Python
* `options.setdefault(...)`, Scala `m.getOrElse(...)`, Java `m.put(...)` on a
* `var` — does not bind by name to the one project method that shares the
* name: the receiver's own words must name the method's type.
*/
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';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';

let dir: string;
let cg: CodeGraph;

const files: Record<string, string> = {
'py/globals.py':
'class Globals:\n' +
' def setdefault(self, key, value):\n' +
' return value\n' +
' def reset(self):\n' +
' return self.setdefault("a", 1)\n' +
'\n' +
'def build(options):\n' +
' options.setdefault("x", 1)\n' +
' return options.copy().setdefault("y", 2)\n',
'java/com/app/Registry.java':
'package com.app;\n' +
'public class Registry {\n' +
' public static final String KEY = "k";\n' +
' public Object get(String k) { return null; }\n' +
' public int size() { return 0; }\n' +
' public int hash() { return 0; }\n' +
' public boolean equals(Object o) { return false; }\n' +
' public String toString() { return "r"; }\n' +
' int count() { return size(); }\n' +
' Object own(String k) { return this.get(k); }\n' +
'}\n',
'sc/Registry.scala':
'package app\n' +
'class Registry {\n' +
' def getOrElse(d: Int): Int = d\n' +
'}\n',
'sc/Use.scala':
'package app\n' +
'object Use {\n' +
' def go(): Int = {\n' +
' val m = compute()\n' +
' m.getOrElse(0)\n' +
' }\n' +
' def compute() = Option(1)\n' +
' def viaName(): Int = { val registry = make(); registry.getOrElse(0) }\n' +
' def make() = new Registry\n' +
'}\n',
'java/com/app/Use.java':
'package com.app;\n' +
'import java.util.*;\n' +
'public class Use {\n' +
' private Registry registry;\n' +
' Object untyped(String a) {\n' +
' var data = load();\n' +
' data.put(a, 1);\n' +
' return data.get(a);\n' +
' }\n' +
' Map<String, Object> load() { return null; }\n' +
' Object typed(String k) { return registry.get(k); }\n' +
' int named() { var userRegistry = make(); return userRegistry.size(); }\n' +
' Registry make() { return null; }\n' +
'}\n',
'cs/App/Money.cs':
'namespace App {\n' +
' public class Money {\n' +
' public override string ToString() { return "m"; }\n' +
' }\n' +
' public class Use {\n' +
' object Make() { return null; }\n' +
' public string Look() { var cls = Make(); return cls.ToString(); }\n' +
' }\n' +
'}\n',
};

beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-libmethods-'));
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 node = (name: string, file: string) =>
cg.getNodesByName(name).find((n) => n.filePath.endsWith(file) && (n.kind === 'function' || n.kind === 'method'))!;
const calleesOf = (name: string, file: string) =>
cg.getCallees(node(name, file).id)
.filter(({ edge }) => edge.kind === 'calls')
.map(({ node: n }) => n.qualifiedName.split('::').slice(-2).join('::'))
.sort();

describe('library-method calls on untyped receivers', () => {
it('Python: `options.setdefault()` declines; `self.setdefault()` keeps its own method', () => {
expect(calleesOf('build', 'globals.py')).toEqual([]);
expect(calleesOf('reset', 'globals.py')).toEqual(['Globals::setdefault']);
});

it('Scala: `m.getOrElse()` on an untyped value declines; a receiver named after the type keeps its method', () => {
expect(calleesOf('go', 'Use.scala')).toEqual(['Use::compute']);
expect(calleesOf('viaName', 'Use.scala')).toEqual(['Registry::getOrElse', 'Use::make']);
});

it('Java: `data.put()` / `data.get()` on an untyped `var` do not bind to a lone project method', () => {
expect(calleesOf('untyped', 'Use.java')).toEqual(['Use::load']);
});

it('Java: a typed field, an own-class call and a receiver named after the type keep their method', () => {
expect(calleesOf('typed', 'Use.java')).toEqual(['Registry::get']);
expect(calleesOf('count', 'Registry.java')).toEqual(['Registry::size']);
expect(calleesOf('own', 'Registry.java')).toEqual(['Registry::get']);
expect(calleesOf('named', 'Use.java')).toEqual(['Registry::size', 'Use::make']);
});

it('C#: a receiver named `cls` is a value like any other, not the class itself', () => {
expect(calleesOf('Look', 'Money.cs')).toEqual(['Use::Make']);
});
});
64 changes: 61 additions & 3 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5902,6 +5902,60 @@ const CSHARP_STD_METHODS: ReadonlySet<string> = new Set([
/** The same .NET names as VB.NET writes them — in any case. */
const VBNET_STD_METHODS: ReadonlySet<string> = new Set([...CSHARP_STD_METHODS].map((m) => m.toLowerCase()));

/**
* Python's dict / list / set / str / file / `re` methods. On an untyped
* receiver (`options.setdefault(…)`, `d.copy()`) they are the builtin
* type's, never a same-named project method the call happens to share a
* file with.
*/
const PYTHON_STD_METHODS: ReadonlySet<string> = new Set([
'get', 'setdefault', 'pop', 'popitem', 'update', 'items', 'keys', 'values', 'copy', 'clear',
'append', 'extend', 'insert', 'remove', 'index', 'count', 'sort', 'reverse', 'add', 'discard',
'union', 'intersection', 'difference', 'split', 'rsplit', 'join', 'strip', 'lstrip', 'rstrip',
'replace', 'startswith', 'endswith', 'lower', 'upper', 'format', 'encode', 'decode', 'find',
'rfind', 'splitlines', 'read', 'readline', 'readlines', 'write', 'writelines', 'close', 'seek',
'flush', 'group', 'groups', 'match', 'search', 'sub', 'findall', 'fullmatch',
]);

/**
* Scala's collection / Option / Future methods and the `java.*` calls Scala
* code makes. On Play, ~2,500 `map` / `get` / `foreach` calls on untyped
* values landed on unrelated project methods.
*/
const SCALA_STD_METHODS: ReadonlySet<string> = new Set([
'map', 'flatMap', 'filter', 'filterNot', 'foreach', 'fold', 'foldLeft', 'foldRight', 'reduce',
'collect', 'collectFirst', 'find', 'exists', 'forall', 'count', 'get', 'getOrElse', 'orElse',
'orNull', 'isEmpty', 'nonEmpty', 'isDefined', 'contains', 'headOption', 'head', 'tail', 'last',
'lastOption', 'take', 'drop', 'groupBy', 'sortBy', 'sorted', 'mkString', 'toList', 'toSeq',
'toSet', 'toMap', 'toVector', 'toArray', 'zip', 'zipWithIndex', 'size', 'length', 'update',
'updated', 'recover', 'recoverWith', 'transform', 'andThen', 'compose', 'onComplete', 'asScala',
'asJava', 'getOrElseUpdate', 'put', 'remove', 'add', 'append', 'replace', 'split', 'trim',
'startsWith', 'endsWith', 'format', 'equals', 'hashCode', 'toString', 'set', 'build', 'execute',
'of', 'encode', 'decode', 'max', 'min',
]);

/**
* `java.lang` / `java.util` / streams and the Android framework calls Java
* code makes on values whose type the resolver cannot see — a lambda
* parameter, a `var`, a call result, an inherited field. On Play ~1,100
* test-map `put` calls landed on a test class's `RejectingMap::put`. Names
* Java projects commonly declare themselves (`post`, `of`, `apply`, `run`,
* `start`, `getId`) stay out.
*/
const JAVA_STD_METHODS: ReadonlySet<string> = new Set([
'equals', 'hashCode', 'toString', 'getClass', 'compareTo',
'get', 'put', 'add', 'remove', 'contains', 'containsKey', 'containsValue', 'size', 'isEmpty',
'clear', 'iterator', 'hasNext', 'next', 'stream', 'forEach', 'keySet', 'values', 'entrySet',
'addAll', 'removeAll', 'putAll', 'putIfAbsent', 'getOrDefault', 'computeIfAbsent', 'indexOf',
'toArray', 'subList',
'length', 'charAt', 'substring', 'trim', 'split', 'startsWith', 'endsWith', 'equalsIgnoreCase',
'toLowerCase', 'toUpperCase', 'replace', 'format', 'valueOf',
'map', 'filter', 'orElse', 'orElseGet', 'orElseThrow', 'ifPresent', 'isPresent', 'collect',
'findFirst', 'anyMatch', 'allMatch',
'postDelayed', 'removeCallbacks', 'obtainMessage', 'getResources', 'getSystemService',
'findViewById', 'setVisibility', 'startActivity',
]);

/**
* A request handler the web framework dispatches to: a Django / DRF / Flask
* view's `get` / `post` / …, a controller's `index` / `store` / `update` /
Expand Down Expand Up @@ -5948,6 +6002,9 @@ function stdMethodNames(language: string): ReadonlySet<string> | null {
case 'csharp': return CSHARP_STD_METHODS;
case 'vbnet': return VBNET_STD_METHODS;
case 'dart': return DART_STD_METHODS;
case 'python': return PYTHON_STD_METHODS;
case 'scala': return SCALA_STD_METHODS;
case 'java': return JAVA_STD_METHODS;
default: return null;
}
}
Expand Down Expand Up @@ -20596,9 +20653,10 @@ export function matchMethodCall(
// `json` of its `MockedResponse`.
!isUnnamedTestDouble(targetMethods[0]!, objectOrClass!, ref, context) &&
!((ref.language === 'lua' || ref.language === 'luau') && isLuaLibraryCall(objectOrClass!, methodName!, ref, targetMethods[0]!)) &&
// Rust / Go / Kotlin / C# / VB.NET: a standard-library method name on
// an untyped receiver (`sym.map(…)`, `w.Header().Get(…)`,
// `reader.Value.ToString()`) is the library type's.
// Rust / Go / Kotlin / C# / VB.NET / Python / Scala / Java: a
// standard-library method name on an untyped receiver (`sym.map(…)`,
// `w.Header().Get(…)`, `reader.Value.ToString()`,
// `options.setdefault(…)`) is the library type's.
!(isStdMethodName(ref.language, methodName!) &&
!/^(?:self|Self|this|base)$/.test(objectOrClass!) && !receiverNamesOwner(receiverLink(objectOrClass!), targetMethods[0]!, context)) &&
!(UNTYPED_RECEIVER_LANGUAGES.has(ref.language) && !/^(?:self|self\.class|this|super|weak_?self|strong_?self)$/i.test(objectOrClass!) &&
Expand Down