mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
fix(dart): core method names on untyped receivers are the core type's (#2207)
Dart joins Rust/Go/Kotlin/C# with a standard-method list (String, List,
Iterable, Map, Set: endsWith, contains, forEach, putIfAbsent, where,
sublist...): a guess onto a project method of such a name needs a receiver
named after its owner. And a Dart extension's owner, for that naming, is the
type it is `on` - getx's `ext.endsWith(".avi")` on a String shared the word
`Ext` with `extension RxStringExt on Rx<String>`, none with `Rx<String>`.
getx -36 (`RxStringExt::endsWith` x23, IOHttpResponse::join/toList),
riverpod -37 (`UnmodifiableMap::putIfAbsent/addAll` on plain maps), bloc -18
(`ignore.contains(...)` -> GithubCache, a Map's `forEach` -> _Emitter; bloc's
untyped `emit.forEach(...)` handler lambdas lose their lucky guess too).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- A Dart call like `ext.endsWith(".avi")` or `map.putIfAbsent(…)` on a value whose type isn't known now counts as Dart's own String, List or Map method. It no longer lands on a project method of the same name. A Dart extension is also matched by the type it extends, not by its own name, so getx's `ext.endsWith` on a String stopped reaching `extension RxStringExt on Rx<String>`.
|
||||
- Kotlin calls inside a DSL lambda now reach the lambda's own receiver. That receiver comes from the function the lambda is passed to, even through a typealias like koin's `Definition<T> = Scope.(…) -> T`. It also wins over other types the project uses as lambda receivers elsewhere. On koin, over 2,000 `get()` calls in `single { … }` now reach `Scope.get` instead of `Koin.get`; on Exposed, `varchar(…)` inside a table object reaches `Table.varchar`; on kotlinx.coroutines, `launch { }` reaches `CoroutineScope.launch`.
|
||||
- A Swift call on a property declared with a type, like Kingfisher's `var cache: ImageCache!` or `self.storage.write(…)`, now reaches that type's method or one it inherits. It no longer lands on a subclass override in a test, or on another type's method of the same name.
|
||||
- Calls in your own JavaScript no longer link to a same-named function inside a vendored minified script, like a `*.min.js` or a bundle made of a few enormous lines. Those names are mangled. In healthchecks, every jQuery `$(…)` call had gone to a one-letter helper inside `bootstrap-native.min.js`.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* A Dart core method name (`endsWith`, `contains`, `forEach`, …) on an
|
||||
* untyped receiver is the core type's, unless the receiver is named after
|
||||
* the owner — and a Dart extension's owner is the type it is `on`, not the
|
||||
* extension's own name: getx's `ext.endsWith(".avi")` on a String went to
|
||||
* `extension RxStringExt on Rx<String>` because `ext` matched `…Ext`.
|
||||
*/
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
let root = '';
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-ext-'));
|
||||
const files: Record<string, string> = {
|
||||
'pubspec.yaml': 'name: app\n',
|
||||
'lib/rx_string.dart': `class Rx<T> {
|
||||
Rx(this.value);
|
||||
T value;
|
||||
}
|
||||
|
||||
extension RxStringExt on Rx<String> {
|
||||
bool endsWith(String other) => value.endsWith(other);
|
||||
}
|
||||
|
||||
extension RxnStringExt on Rx<String?> {
|
||||
bool endsWith(String other) => false;
|
||||
}
|
||||
`,
|
||||
'lib/get_utils.dart': `class GetUtils {
|
||||
static bool isVideo(String filePath) {
|
||||
var ext = filePath.toLowerCase();
|
||||
return ext.endsWith(".avi");
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Dart core method names on untyped receivers', () => {
|
||||
it('are not an extension’s method whose name the receiver happens to share', () => {
|
||||
const ids = cg.getNodesInFile('lib/get_utils.dart').map((n) => n.id);
|
||||
const targets = cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.target)!.qualifiedName);
|
||||
expect(targets).not.toContain('RxStringExt::endsWith');
|
||||
expect(targets).not.toContain('RxnStringExt::endsWith');
|
||||
});
|
||||
});
|
||||
@@ -2065,10 +2065,39 @@ function stdMethodNames(language: string): ReadonlySet<string> | null {
|
||||
case 'rust': return RUST_STD_METHODS;
|
||||
case 'kotlin': return KOTLIN_STD_METHODS;
|
||||
case 'csharp': return CSHARP_STD_METHODS;
|
||||
case 'dart': return DART_STD_METHODS;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Methods of Dart's String, List, Iterable, Map and Set — names a project type rarely carries itself. */
|
||||
const DART_STD_METHODS: ReadonlySet<string> = new Set([
|
||||
'endsWith', 'startsWith', 'contains', 'split', 'substring', 'trim', 'trimLeft', 'trimRight', 'toLowerCase',
|
||||
'toUpperCase', 'replaceAll', 'replaceFirst', 'replaceRange', 'indexOf', 'lastIndexOf', 'padLeft', 'padRight',
|
||||
'codeUnitAt', 'allMatches', 'firstMatch', 'hasMatch', 'addAll', 'removeAt', 'removeWhere', 'removeLast',
|
||||
'retainWhere', 'insertAll', 'where', 'whereType', 'forEach', 'toList', 'toSet', 'join', 'reduce', 'fold',
|
||||
'any', 'every', 'firstWhere', 'lastWhere', 'singleWhere', 'containsKey', 'containsValue', 'putIfAbsent',
|
||||
'sublist', 'take', 'takeWhile', 'skip', 'skipWhile', 'expand', 'cast', 'compareTo', 'elementAt', 'followedBy',
|
||||
'asMap', 'getRange', 'setAll', 'fillRange', 'shuffle', 'sort', 'indexWhere', 'lastIndexWhere',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether a receiver is named after the owner of `method` — for a Dart
|
||||
* extension, after the type it is `on`: getx's `ext.endsWith(".avi")` on a
|
||||
* String shares a word with `RxStringExt`, none with its `Rx<String>`.
|
||||
*/
|
||||
function receiverNamesOwner(receiver: string, method: Node, context: ResolutionContext): boolean {
|
||||
if (method.language === 'dart') {
|
||||
const cut = method.qualifiedName.lastIndexOf('::');
|
||||
const owner = cut > 0 ? method.qualifiedName.slice(0, cut).split('::').pop()! : '';
|
||||
const decl = owner ? context.getNodesByName(owner).find((n) => n.language === 'dart' && n.filePath === method.filePath) : undefined;
|
||||
const line = decl ? (context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? [])[decl.startLine - 1] ?? '' : '';
|
||||
const on = /\bextension\s+\w*\s*(?:<[^>]*>)?\s*on\s+([\w<>, ?]+?)\s*\{/.exec(line)?.[1];
|
||||
if (on) return sharesReceiverWord(receiver, { ...method, qualifiedName: `${on.replace(/[<>, ?]+/g, '')}::${method.name}` });
|
||||
}
|
||||
return sharesReceiverWord(receiver, method);
|
||||
}
|
||||
|
||||
const CSHARP_ALIASES = new WeakMap<ResolutionContext, Map<string, Map<string, string>>>();
|
||||
|
||||
/**
|
||||
@@ -6635,7 +6664,7 @@ export function matchMethodCall(
|
||||
// untyped receiver (`sym.map(…)`, `w.Header().Get(…)`,
|
||||
// `reader.Value.ToString()`) is the library type's.
|
||||
!(stdMethodNames(ref.language)?.has(methodName!) &&
|
||||
!/^(?:self|Self|this|base)$/.test(objectOrClass!) && !sharesReceiverWord(receiverLink(objectOrClass!), targetMethods[0]!)) &&
|
||||
!/^(?: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!) &&
|
||||
!sharesReceiverWord(objectOrClass!, targetMethods[0]!) &&
|
||||
!(ref.language === 'objc' && objcReceiverReaches(objectOrClass!, targetMethods[0]!, context)) &&
|
||||
@@ -6662,7 +6691,7 @@ export function matchMethodCall(
|
||||
// first-indexed duplicate (#1079).
|
||||
const std = stdMethodNames(ref.language)?.has(methodName!) && !/^(?:self|Self|this|base)$/.test(objectOrClass!);
|
||||
for (const method of preferCallSiteFile(targetMethods, ref.filePath)) {
|
||||
if (std && !sharesReceiverWord(receiverLink(objectOrClass!), method)) continue;
|
||||
if (std && !receiverNamesOwner(receiverLink(objectOrClass!), method, context)) continue;
|
||||
// The owner type's own name — not its namespace (`eShop.ClientApp…`
|
||||
// shares `Client` with every `httpClient`) nor the method's.
|
||||
const cut = method.qualifiedName.lastIndexOf('::');
|
||||
|
||||
Reference in New Issue
Block a user