diff --git a/CHANGELOG.md b/CHANGELOG.md index b31d644b0..b67fd7356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. - 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 = 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`. diff --git a/__tests__/dart-extension-receiver.test.ts b/__tests__/dart-extension-receiver.test.ts new file mode 100644 index 000000000..ef0d19940 --- /dev/null +++ b/__tests__/dart-extension-receiver.test.ts @@ -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` 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 = { + 'pubspec.yaml': 'name: app\n', + 'lib/rx_string.dart': `class Rx { + Rx(this.value); + T value; +} + +extension RxStringExt on Rx { + bool endsWith(String other) => value.endsWith(other); +} + +extension RxnStringExt on Rx { + 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'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 8cb250329..9dc397769 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2065,10 +2065,39 @@ function stdMethodNames(language: string): ReadonlySet | 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 = 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`. + */ +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>>(); /** @@ -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('::');