From 0d6e90ce936964fb8dde20fa28c999ae2f1c8eb8 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 6 Oct 2026 12:02:37 -0500 Subject: [PATCH] fix(dart): a call through an import prefix reaches only what that import exports A Dart call written through an import prefix - `http.get(uri)` after `import 'package:http/http.dart' as http;` - was resolved like a method call on a receiver named `http`, so it linked to any project member that fit the name: riverpod's docs' `http.get(...)` / `http.post(...)` calls went to a docs example's `Http::get` / `Http::post` by the receiver's capitalized name, and `fmt.describe(...)` to a class's `describe` method. Now `p.name(...)` calls a top-level declaration a library imported with that prefix exports (export chains, show / hide and parts followed through the library model), or nothing when the prefixed library is a package outside the repository. A constructor call (`p.Widget()`) instantiates the prefixed library's class. The same holds where the prefix heads a chain - `p.Box.create()`, `p.Report.empty()` - and for `p.Box.named(...)`, which arrives as `Box.named`: the type is the one the prefix brings in. A parameter, local or member named like the prefix still hides it, and `const p.Box.named()`, which the extractors record as `p.named`, links nothing. The library model's one prefix reader is `dartImportPrefixes`: Dart annotations (#2390's `matchDartAnnotation`) read prefixes through it too, in place of the `isDartImportPrefix` it had alongside. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/dart-import-prefix-calls.test.ts | 200 +++++++++++++++++++++ docs/design/dart-kernel-port-checklist.md | 2 +- src/resolution/dart-libraries.ts | 35 ++-- src/resolution/dart-local-scope.ts | 10 ++ src/resolution/name-matcher.ts | 196 ++++++++++++++++---- 6 files changed, 401 insertions(+), 43 deletions(-) create mode 100644 __tests__/dart-import-prefix-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e088fa7a7..d87506bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, an `import` or `export` now links to the file it names: `package:my_app/src/cart.dart` to that package's `lib/src/cart.dart`, and a relative path to the file it points to from the importing file. Before, only the file name at the end was matched, so `import 'package:flutter/widgets.dart'` linked to whichever project file was called `widgets.dart`, and most imports of your own files didn't link to the file at all, which threw off `codegraph affected` and the files listed as using a file. An import of a Dart SDK library or of a package from outside your project now links nothing, and when packages in one repository share a name, the one the importing app depends on by `path:` is used. Re-index Dart projects after upgrading. - In Dart, an annotation now links to the constant or constructor it names, read the way it is written: `@riverpod` reaches Riverpod's `const riverpod = Riverpod();`, `@Riverpod(keepAlive: true)` the `Riverpod` class, an annotation written through a class, like `@SomeClass.value` or `@SomeClass.named(…)`, that class's own constant, enum value or constructor, and `@meta.immutable` only what the `meta` import brings in. Before, an annotation was matched like a function decorator, so a method, getter or function that only shared its name won: every `@riverpod` provider in Riverpod's own repository was linked to an unrelated getter called `riverpod`, and `callers` and impact for the real annotation missed them. Re-index Dart projects after upgrading. - In Dart, an annotation is no longer lost when a comment sits between it and the declaration, like `@override` followed by `// ignore: must_call_super`, a `///` comment written below the annotations, or `@riverpod` followed by an `// expect_lint: …` line. Before, the comment hid the annotation, so a provider written that way was missing from the annotation's `callers` and impact. Re-index Dart projects after upgrading. +- In Dart, a call written through an import prefix, like `http.get(…)` after `import 'package:http/http.dart' as http;`, or `utils.Listener()` and `kit.Box.create()` through a prefix of your own, now links to what the library imported with that prefix exports, following its `export` chains, `show` and `hide`, and to nothing when that library is a package outside your project. Before, the prefix was taken for a variable, so `http.get(…)` could link to an unrelated `Http` class's `get` method, and calls made through a prefix into your own libraries were missing from `callers` and impact. Re-index Dart projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/dart-import-prefix-calls.test.ts b/__tests__/dart-import-prefix-calls.test.ts new file mode 100644 index 000000000..8c3b7cdb9 --- /dev/null +++ b/__tests__/dart-import-prefix-calls.test.ts @@ -0,0 +1,200 @@ +/** + * A Dart call written through an import prefix — `http.get(uri)` after + * `import 'package:http/http.dart' as http;` — calls a top-level declaration + * the libraries imported with that prefix export, or nothing in the project. + * It is no method call on a receiver named `http`: riverpod's docs' 25 + * `http.get(…)` / `http.post(…)` calls went to a docs example's `Http` class. + * + * Runs against the native kernel (when built) and the wasm extractor. + */ +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'; + +const FILES: Record = { + 'pubspec.yaml': 'name: app\n', + // Namesakes of everything the prefixes below name, in a library beside the + // callers — nearer than the libraries the prefixes import. + 'lib/legacy.dart': `class Http { + Future get(Uri url) async => ''; +} + +class Client { + Client(); + static Client fromEnv() => Client(); +} + +class Formatter { + String describe(Object value) => ''; +} + +class Loader { + int load() => 0; +} + +class Report { + Report.empty(); + static Report blank() => Report.empty(); +} + +class Box { + const Box.named(); + static Box create() => const Box.named(); +} + +String describe(Object value) => 'legacy'; +int shown() => 0; +`, + 'lib/src/format.dart': `String describe(Object value) => '$value'; + +class Report { + Report(); + Report.empty(); + static Report blank() => Report(); +} +`, + 'lib/src/tools.dart': `int secret() => 1; +int open() => 2; +`, + 'lib/lazy.dart': `int load() => 1; +`, + 'packages/kit/pubspec.yaml': 'name: kit\n', + 'packages/kit/lib/kit.dart': `export 'src/impl.dart' show shown, Widget, Box, named; +`, + 'packages/kit/lib/src/impl.dart': `int shown() => 1; +int notShown() => 2; +int named() => 3; + +class Widget { + Widget(); +} + +class Box { + const Box.named(); + static Box create() => const Box.named(); +} +`, + // package:http is not in the repository. + 'lib/net.dart': `import 'package:http/http.dart' as http; + +Future fetch() async { + await http.get(Uri.parse('https://example.com')); + http.Client(); + http.Client.fromEnv(); +} +`, + 'lib/app.dart': `import 'package:kit/kit.dart' as kit; +import 'src/format.dart' as fmt; +import 'src/tools.dart' as tools hide secret; +import 'lazy.dart' deferred as lazy; + +part 'app_part.dart'; + +String run(Object value) { + fmt.Report(); + fmt.Report.empty(); + fmt.Report.blank(); + kit.shown(); + kit.notShown(); + kit.Widget(); + tools.open(); + tools.secret(); + final box = kit.Box.named(); + kit.Box.create(); + const kit.Box.named(); + return fmt.describe(value); +} + +Future boot() async { + await lazy.loadLibrary(); + return lazy.load(); +} +`, + 'lib/app_part.dart': `part of 'app.dart'; + +String again(Object value) => fmt.describe(value); +`, + // A parameter named like the prefix is the parameter, there. + 'lib/shadow.dart': `import 'package:http/http.dart' as http; +import 'legacy.dart'; + +Future viaParameter(Http http) => http.get(Uri()); +`, +}; + +/** ` -> :` for every call out of `file`. */ +function callsFrom(cg: CodeGraph, file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls' || e.kind === 'instantiates') + .map((e) => { + const t = cg.getNode(e.target)!; + return `${e.kind} ${e.line} ${String(e.metadata?.refName ?? '')} -> ${t.filePath}:${t.qualifiedName}`; + }); +} + +describe.each(['default', 'wasm'])('Dart calls through an import prefix (%s)', (backend) => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + const calls = (file: string): string[] => callsFrom(cg!, file); + + beforeAll(async () => { + kernel = process.env.CODEGRAPH_KERNEL; + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-prefix-')); + 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 }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + it('links nothing for a call into a package outside the repository', () => { + expect(calls('lib/net.dart')).toEqual([]); + }); + + it("links a call to the prefixed library's function, not a namesake or a method", () => { + expect(calls('lib/app.dart')).toContain('calls 20 fmt.describe -> lib/src/format.dart:describe'); + expect(calls('lib/app.dart')).toContain('calls 25 lazy.load -> lib/lazy.dart:load'); + expect(calls('lib/app_part.dart')).toEqual(['calls 3 fmt.describe -> lib/src/format.dart:describe']); + }); + + it('follows the export chain, show and hide of the prefixed import', () => { + const edges = calls('lib/app.dart'); + expect(edges).toContain('calls 12 kit.shown -> packages/kit/lib/src/impl.dart:shown'); + expect(edges).toContain('instantiates 14 kit.Widget -> packages/kit/lib/src/impl.dart:Widget'); + expect(edges).toContain('calls 15 tools.open -> lib/src/tools.dart:open'); + // Not shown by kit.dart, hidden from `tools`, the deferred library's own `loadLibrary`. + expect(edges.filter((e) => / (?:kit\.notShown|tools\.secret|lazy\.loadLibrary) /.test(e))).toEqual([]); + }); + + it("constructs and calls through the prefixed library's type", () => { + const edges = calls('lib/app.dart'); + expect(edges).toContain('instantiates 9 fmt.Report -> lib/src/format.dart:Report'); + expect(edges).toContain('calls 10 empty -> lib/src/format.dart:Report::empty'); + expect(edges).toContain('calls 11 blank -> lib/src/format.dart:Report::blank'); + expect(edges).toContain('calls 17 Box.named -> packages/kit/lib/src/impl.dart:Box::named'); + expect(edges).toContain('calls 18 create -> packages/kit/lib/src/impl.dart:Box::create'); + }); + + it('never lands a prefixed call on another library', () => { + expect(calls('lib/app.dart').filter((e) => e.includes('lib/legacy.dart'))).toEqual([]); + // `const kit.Box.named()` is no call of kit's top-level `named`. + expect(calls('lib/app.dart').filter((e) => e.startsWith('calls 19 '))).toEqual([]); + }); + + it('leaves a name a parameter shadows to the parameter', () => { + expect(calls('lib/shadow.dart')).toEqual(['calls 4 http.get -> lib/legacy.dart:Http::get']); + }); +}); diff --git a/docs/design/dart-kernel-port-checklist.md b/docs/design/dart-kernel-port-checklist.md index 8943485fe..4e1b2e8f1 100644 --- a/docs/design/dart-kernel-port-checklist.md +++ b/docs/design/dart-kernel-port-checklist.md @@ -948,7 +948,7 @@ enum emits nothing). Mechanics for dart: the own library shadowing imports). `@X(…)`: a visible class `X`, not an extension. - `@p.x` / `@p.X(…)`, when `p` is an import prefix (dart-libraries.ts - `isDartImportPrefix`, the imports of the file's whole library): the same + `dartImportPrefixes`, the imports of the file's whole library): the same through that prefix. Otherwise `@T.x` → T's own `constant` / `enum_member` `x`, and `@T.named(…)` → T's named constructor (#2380's `isDartConstructor`); `@p.T.x` / `@p.T.named(…)` likewise through `p`. diff --git a/src/resolution/dart-libraries.ts b/src/resolution/dart-libraries.ts index 8d9a0034f..61b328fb6 100644 --- a/src/resolution/dart-libraries.ts +++ b/src/resolution/dart-libraries.ts @@ -368,6 +368,7 @@ interface Memo { members: Map>; exports: Map>; scopes: Map; + prefixes: Map>; } const memos = new WeakMap(); @@ -386,6 +387,7 @@ function memoFor(context: ResolutionContext): Memo { members: new Map(), exports: new Map(), scopes: new Map(), + prefixes: new Map(), }; memos.set(context, memo); } @@ -763,20 +765,31 @@ export function dartPrefixSees(fromFile: string, prefix: string, declFile: strin return filters === null || (!name.startsWith('_') && filters.some((f) => lets(f, name))); } +/** + * The import prefixes written in `fromFile` can stand for — the `p` of + * `import '…' as p` and `deferred as p` — read from the imports of its + * library, which a part (`part of …`) takes from the file it is part of. + */ +export function dartImportPrefixes(fromFile: string, context: ResolutionContext): ReadonlySet { + const memo = memoFor(context); + const hit = memo.prefixes.get(fromFile); + if (hit) return hit; + const prefixes = new Set(); + // Records each part's parent, a library in no package included. + libraryOf(fromFile, context, memo); + let file: string | undefined = fromFile; + for (let hops = 0; file !== undefined && hops < 32; file = memo.parentOf.get(file), hops++) { + for (const directive of directivesOf(file, context, memo)?.imports ?? []) { + if (directive.prefix !== null) prefixes.add(directive.prefix); + } + } + memo.prefixes.set(fromFile, prefixes); + return prefixes; +} + /** Whether `declFile` is a file of the library `fromFile` belongs to (false when that library is unknown). */ export function inSameDartLibrary(fromFile: string, declFile: string, context: ResolutionContext): boolean { if (fromFile === declFile) return true; const scope = scopeOf(fromFile, context, memoFor(context)); return scope !== null && scope.own.has(declFile); } - -/** - * Whether `name` is an import prefix where `fromFile` is written: an import - * of its library is `as name` (`p` in `p.Foo`, not the `Foo` of `Foo.bar`). - * Where the library is unknown, the file's own imports say. - */ -export function isDartImportPrefix(fromFile: string, name: string, context: ResolutionContext): boolean { - const memo = memoFor(context); - const importers = scopeOf(fromFile, context, memo)?.importers ?? [fromFile]; - return importers.some((file) => (directivesOf(file, context, memo)?.imports ?? []).some((d) => d.prefix === name)); -} diff --git a/src/resolution/dart-local-scope.ts b/src/resolution/dart-local-scope.ts index 8fdea685e..f8d6d93d2 100644 --- a/src/resolution/dart-local-scope.ts +++ b/src/resolution/dart-local-scope.ts @@ -77,6 +77,16 @@ export function gateDartLocal(resolved: ResolvedRef | null, ref: UnresolvedRef, return !nearest || nearest.fn ? resolved : null; } +/** + * Whether a parameter, local or local function `name` is bound at offset `at` + * of the file's source — where it hides a library name, an import prefix + * among them: `http.get(…)` with a parameter `http` calls the parameter's. + */ +export function isDartLocallyBound(filePath: string, name: string, at: number, context: ResolutionContext): boolean { + const bound = fileScopes(filePath, context)?.names.get(name); + return bound !== undefined && bound.some((scope) => at >= scope.start && at <= scope.end); +} + /** * Where on its line a reference's name starts. A Dart ref's column sits just * past the name (a function value's at it); a column counted in bytes misses diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 4ded3cfce..d2f07797e 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -11,7 +11,8 @@ import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, isSuperty import { blankStringContents, stripCommentsForRegex } from './strip-comments'; import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility'; -import { dartLibrarySees, dartPrefixSees, inSameDartLibrary, isDartImportPrefix } from './dart-libraries'; +import { dartImportPrefixes, dartLibrarySees, dartPrefixSees, inSameDartLibrary } from './dart-libraries'; +import { isDartLocallyBound } from './dart-local-scope'; import { breakVbTie, isVbMemberInScope, isVbNestedTypeInScope, isVbTypeQualifiedBy, matchVbTypedCall, preferVbProject, sameVbProject } from './vbnet-receivers'; import { isTestPath } from '../search/query-utils'; import { isMinifiedContent } from '../extraction/generated-detection'; @@ -3035,6 +3036,85 @@ function preferOwnDartLibrary(candidates: Node[], ref: UnresolvedRef, context: R return own.length === 0 ? candidates : candidates.filter((n) => !isDartLibraryDecl(n) || own.includes(n)); } +/** + * A Dart call through an import prefix — `http.get(…)` after `import + * 'package:http/http.dart' as http;`, `fmt.describe(…)`, `kit.Widget()` — + * calls a top-level declaration a library imported with that prefix exports, + * `export` chains and `show` / `hide` followed, or nothing of the project: + * never a member found by its name. riverpod's docs' 25 `http.get(…)` calls + * went to a docs example's `Http::get`, by their receiver's capitalized name. + * Undefined when the receiver is no import prefix there, or a parameter, local + * or member of that name hides it. + */ +function matchDartPrefixedCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + const parts = DART_MEMBER_READ.exec(ref.referenceName); + if (!parts || !dartImportPrefixes(ref.filePath, context).has(parts[1]!)) return undefined; + const prefix = parts[1]!; + const name = parts[2]!; + const at = dartPrefixedCallSite(ref, prefix, name, context); + // `const p.Box.named()` reaches here as `p.named`: no call of a top-level `named`. + if (at < 0) return null; + if (isDartNameHidden(prefix, ref, at, context)) return undefined; + const found = dartPrefixedDecls(prefix, name, ref, context); + return found.length === 1 ? { original: ref, targetNodeId: found[0]!.id, confidence: 0.9, resolvedBy: 'import' } : null; +} + +/** The top-level Dart declarations named `name` that the imports written with `prefix` bring into the reference's library. */ +function dartPrefixedDecls(prefix: string, name: string, ref: UnresolvedRef, context: ResolutionContext): Node[] { + return context.getNodesByName(name).filter((n) => isDartLibraryDecl(n) && dartPrefixSees(ref.filePath, prefix, n.filePath, name, context)); +} + +/** + * Where `prefix` starts in the code of a Dart call written `prefix.name(…)`, + * or -1 when the call is written otherwise: `const p.Box.named()` arrives as + * `p.named`. (The reference's column sits just past `name`.) + */ +function dartPrefixedCallSite(ref: UnresolvedRef, prefix: string, name: string, context: ResolutionContext): number { + const code = dartCodeOf(ref.filePath, context); + const start = code ? dartNameStart({ ...ref, referenceName: name }, code) : -1; + if (!code || start < 0) return -1; + const text = code.text; + let k = start - 1; + while (k >= 0 && /\s/.test(text[k]!)) k--; + if (text[k] !== '.' || text[k - 1] === '.' || text[k - 1] === '?') return -1; + k--; + while (k >= 0 && /\s/.test(text[k]!)) k--; + const end = k + 1; + while (k >= 0 && /[\w$]/.test(text[k]!)) k--; + if (text.slice(k + 1, end) !== prefix) return -1; + while (k >= 0 && /\s/.test(text[k]!)) k--; + return text[k] === '.' ? -1 : end - prefix.length; +} + +/** + * Whether a parameter or local around offset `at`, or a member of the type the + * reference is written in, is named `name`: it hides a library-level name + * there — an import prefix among them — as Dart's scopes nest. + */ +function isDartNameHidden(name: string, ref: UnresolvedRef, at: number, context: ResolutionContext): boolean { + if (isDartLocallyBound(ref.filePath, name, at, context)) return true; + const own = dartEnclosingType(ref, context); + return own !== null && (classMemberType(own, name, context) !== null || + context.getNodesByName(name).some((n) => n.filePath === own.filePath && n.qualifiedName === `${own.qualifiedName}::${name}`)); +} + +/** + * The import prefix a Dart call through a type is written with — the `p` of + * `p.Box.named(…)`, which arrives as `Box.named` with its column on the + * prefix — or null. + */ +function dartTypeCallPrefix(typeName: string, member: string, ref: UnresolvedRef, context: ResolutionContext): string | null { + const prefixes = dartImportPrefixes(ref.filePath, context); + if (prefixes.size === 0) return null; + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1]; + if (line === undefined) return null; + const written = new RegExp(String.raw`(?]|<(?:[^<>]|<[^<>]*>)*>)*>)?\s*\.\s*${member.replace(/\$/g, '\\$')}(?![\w$])`, 'g'); + // The one at the reference's column, else the first: a column counted in bytes misses after non-ASCII text. + const found = [...line.matchAll(written)].filter((m) => prefixes.has(m[1]!)); + return (found.find((m) => m.index === ref.column) ?? found[0])?.[1] ?? null; +} + /** * Whether a bare Dart reference really is receiver-less at its call site. The * extractor keeps one receiver level, so the later links of a chain — @@ -3096,7 +3176,7 @@ export function matchDartChainLink(ref: UnresolvedRef, context: ResolutionContex // Static members are the type's own: `p.Provider.autoDispose(…)` calls the // constant, as `Provider.autoDispose(…)` does. if (receiver.static) { - const member = dartStaticMember(receiver.type, ref.referenceName, ref, context); + const member = dartStaticMember(receiver.type, ref.referenceName, ref, context, dartOwnerOf(receiver)); return member && member.kind !== 'enum_member' ? { original: ref, targetNodeId: member.id, confidence: 0.85, resolvedBy: 'qualified-name' } : null; } @@ -3127,25 +3207,29 @@ interface DartChainLink { name: string; typeArgs: string[]; call: boolean; index /** * What a Dart chain starts from: a name (`x`, `Foo`, `foo(…)`, `Foo(…)`, - * with the keyword written before it — `const`, `new`, `as`, `await`), a - * string literal, or a parenthesized expression. + * with the keyword written before it — `const`, `new`, `as`, `await` — and + * where it starts), a string literal, or a parenthesized expression. */ type DartChainHead = - | { kind: 'name'; name: string; typeArgs: string[]; call: boolean; keyword: string } + | { kind: 'name'; name: string; typeArgs: string[]; call: boolean; keyword: string; at: number } | { kind: 'string' } | { kind: 'paren'; open: number; close: number }; interface DartChain { head: DartChainHead; links: DartChainLink[] } -/** What a Dart chain evaluates to: an instance of `type`, or (`static`) the type itself, before a static member. */ -interface DartValue { type: string; static: boolean; viaSuper?: boolean } +/** + * What a Dart chain evaluates to: an instance of `type`, or (`static`) the + * type itself, before a static member — of the type `owner` declares, when + * the chain says which file that is (`p.Box` names the one `p` brings in). + */ +interface DartValue { type: string; static: boolean; viaSuper?: boolean; owner?: string } /** * A Dart file's code with comments and string contents blanked, where each - * line starts, the import prefixes it declares, and the files of its library - * it names: its `part`s and the file it is `part of`. + * line starts, and the files of its library it names: its `part`s and the + * file it is `part of`. */ -interface DartCode { text: string; lineStarts: number[]; prefixes: Set; parts: string[]; partOf: string | null } +interface DartCode { text: string; lineStarts: number[]; parts: string[]; partOf: string | null } const DART_CODE = new WeakMap>(); @@ -3160,11 +3244,10 @@ function dartCodeOf(filePath: string, context: ResolutionContext): DartCode | nu const text = blankDartCode(source); const lineStarts = [0]; for (let i = 0; i < text.length; i++) if (text[i] === '\n') lineStarts.push(i + 1); - const prefixes = new Set([...text.matchAll(/^\s*import\s+['"][^'"]*['"]\s+(?:deferred\s+)?as\s+([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]!)); const sibling = (uri: string): string => path.posix.normalize(path.posix.join(path.posix.dirname(filePath), uri)); const parts = [...source.matchAll(/^\s*part\s+['"]([^'"]+)['"]\s*;/gm)].map((m) => sibling(m[1]!)); const partOf = /^\s*part\s+of\s+['"]([^'"]+)['"]\s*;/m.exec(source)?.[1]; - code = { text, lineStarts, prefixes, parts, partOf: partOf ? sibling(partOf) : null }; + code = { text, lineStarts, parts, partOf: partOf ? sibling(partOf) : null }; } // Refs arrive file by file, so a few files' worth is enough. if (memo.size >= 64) memo.delete(memo.keys().next().value!); @@ -3350,7 +3433,7 @@ function readDartChain(text: string, end: number): DartChain | null { continue; } const keyword = /([A-Za-z_]\w*)$/.exec(text.slice(Math.max(0, j - 10), j + 1))?.[1] ?? ''; - return { head: { kind: 'name', name, typeArgs, call, keyword }, links: links.reverse() }; + return { head: { kind: 'name', name, typeArgs, call, keyword, at: i + 1 }, links: links.reverse() }; } return null; } @@ -3364,17 +3447,18 @@ function typeDartChain(chain: DartChain, ref: UnresolvedRef, context: Resolution if (head.kind === 'string') value = { type: 'String', static: false }; else if (head.kind === 'paren') value = typeDartParen(head, ref, context, depth); else { - let name = head; // `p.Provider.autoDispose`: an import prefix names a library, so the chain - // starts at the link after it. + // starts at the link after it, which names what that library exports. const first = links[0]; - if (!name.call && name.typeArgs.length === 0 && first && !first.index && dartCodeOf(ref.filePath, context)?.prefixes.has(name.name)) { - name = { kind: 'name', name: first.name, typeArgs: first.typeArgs, call: first.call, keyword: '' }; + if (!head.call && head.typeArgs.length === 0 && first && !first.index && dartImportPrefixes(ref.filePath, context).has(head.name) && + !isDartNameHidden(head.name, ref, head.at, context)) { + value = typeDartPrefixedHead(head.name, first, ref, context, depth); links = links.slice(1); + } else { + // `const Foo.named(…)`: the named constructor is the head's own link. + if ((head.keyword === 'const' || head.keyword === 'new') && !head.call && links[0]?.call) links = links.slice(1); + value = typeDartHead(head, ref, context, depth); } - // `const Foo.named(…)`: the named constructor is the head's own link. - if ((name.keyword === 'const' || name.keyword === 'new') && !name.call && links[0]?.call) links = links.slice(1); - value = typeDartHead(name, ref, context, depth); } for (const link of links) { // Every Dart object's `toString()` returns a String, whatever it is. @@ -3397,10 +3481,15 @@ function typeDartChain(chain: DartChain, ref: UnresolvedRef, context: Resolution /** Whether the type a Dart chain has reached declares the member a link names. */ function dartLinkDeclared(value: DartValue, link: DartChainLink, ref: UnresolvedRef, context: ResolutionContext): boolean { return value.static - ? dartStaticMember(value.type, link.name, ref, context) !== null + ? dartStaticMember(value.type, link.name, ref, context, dartOwnerOf(value)) !== null : dartMemberOf(value.type, link.name, ref, context, (n) => !isDartSetter(n, context), value.viaSuper ? 1 : 0) !== null; } +/** Which of the types named like a chain's value it is: the one its `owner` file declares, when the chain said. */ +function dartOwnerOf(value: DartValue): ((type: Node) => boolean) | undefined { + return value.owner === undefined ? undefined : (type) => type.filePath === value.owner; +} + /** `(x as Foo)` is a Foo; any other parenthesized expression is what its last operand is: `(a ?? Todo(…))`. */ function typeDartParen(head: { open: number; close: number }, ref: UnresolvedRef, context: ResolutionContext, depth: number): DartValue | null { const code = dartCodeOf(ref.filePath, context); @@ -3459,11 +3548,36 @@ function typeDartHead( return type ? { type, static: false } : null; } +/** + * What the first link after an import prefix evaluates to — `p.Provider` + * before a static member, `p.Report(…)`, `p.make(…)`, `p.config` — read from + * the top-level declarations the prefixed import brings in, and nothing when + * it brings in none of that name: a package outside the repository, whatever + * the project declares under the name elsewhere. + */ +function typeDartPrefixedHead(prefix: string, link: DartChainLink, ref: UnresolvedRef, context: ResolutionContext, depth: number): DartValue | null { + const found = dartPrefixedDecls(prefix, link.name, ref, context); + const types = found.filter((n) => DART_TYPE_KINDS.has(n.kind)); + if (types.length > 0) { + return { type: link.name, static: !link.call, owner: types.length === 1 ? types[0]!.filePath : undefined }; + } + if (found.length !== 1) return null; + const decl = found[0]!; + if (decl.kind === 'function') { + const type = link.call ? dartReturnType(decl, link.typeArgs, context) : null; + return type ? { type, static: false } : null; + } + if (decl.kind !== 'constant' && decl.kind !== 'variable') return null; + const held = dartConstantType(decl, ref, context, depth); + if (!held) return null; + return link.call ? dartCallResult(held, link.typeArgs, ref, context) : { type: held, static: false }; +} + /** What one link of a Dart chain makes of the value before it. */ function typeDartLink(value: DartValue, link: DartChainLink, ref: UnresolvedRef, context: ResolutionContext, depth: number): DartValue | null { if (link.index) return null; if (value.static) { - const member = dartStaticMember(value.type, link.name, ref, context); + const member = dartStaticMember(value.type, link.name, ref, context, dartOwnerOf(value)); if (!member) { // `BlocProvider.value(…)`, `AsyncValue.data(…)`: a constructor the index holds no node for. return link.call && context.getNodesByName(value.type).some((n) => n.language === 'dart' && DART_TYPE_KINDS.has(n.kind)) @@ -3548,10 +3662,17 @@ function dartEnclosingType(ref: UnresolvedRef, context: ResolutionContext): Node * The static member `name` the Dart type `typeName` itself declares — a * static method or getter, a named constructor or factory, a static * constant, an enum value — preferring the call site's file, then the - * nearest. Static members are not inherited. + * nearest. Static members are not inherited. `isOwner` narrows the types of + * that name to the one meant (the one an import prefix brings in). */ -function dartStaticMember(typeName: string, name: string, ref: UnresolvedRef, context: ResolutionContext): Node | null { - const owners = context.getNodesByName(typeName).filter((n) => n.language === 'dart' && DART_TYPE_KINDS.has(n.kind)); +function dartStaticMember( + typeName: string, + name: string, + ref: UnresolvedRef, + context: ResolutionContext, + isOwner: (type: Node) => boolean = () => true, +): Node | null { + const owners = context.getNodesByName(typeName).filter((n) => n.language === 'dart' && DART_TYPE_KINDS.has(n.kind) && isOwner(n)); if (owners.length === 0) return null; const found = context.getNodesByName(name).filter((m) => m.language === 'dart' && (m.kind === 'method' || m.kind === 'function' || m.kind === 'constant' || m.kind === 'enum_member') && @@ -4077,14 +4198,14 @@ export function matchDartAnnotation(ref: UnresolvedRef, context: ResolutionConte const { names, call } = written; let pool: Node[]; let resolvedBy: ResolvedRef['resolvedBy'] = 'exact-match'; - if (names.length === 1 || (names.length === 2 && isDartImportPrefix(ref.filePath, names[0]!, context))) { + if (names.length === 1 || (names.length === 2 && dartImportPrefixes(ref.filePath, context).has(names[0]!))) { const prefix = names.length === 2 ? names[0]! : null; const own = prefix === null && !call ? dartEnclosingStatic(name, ref, context) : null; if (own) return { original: ref, targetNodeId: own.id, confidence: 0.9, resolvedBy }; pool = context.getNodesByName(name).filter((n) => isDartLibraryDecl(n) && (call ? isDartAnnotationClass(n, context) : n.kind === 'constant') && isDartTopLevelVisible(n, ref, context, prefix)); if (prefix === null) pool = preferOwnDartLibrary(pool, ref, context); - } else if (names.length === 2 || (names.length === 3 && isDartImportPrefix(ref.filePath, names[0]!, context))) { + } else if (names.length === 2 || (names.length === 3 && dartImportPrefixes(ref.filePath, context).has(names[0]!))) { const prefix = names.length === 3 ? names[0]! : null; let owners = context.getNodesByName(names[names.length - 2]!).filter((n) => isDartLibraryDecl(n) && DART_TYPE_KINDS.has(n.kind) && isDartTopLevelVisible(n, ref, context, prefix)); @@ -4194,8 +4315,10 @@ function dartExtensionMemberOf(typeName: string, name: string, ref: UnresolvedRe * it, and the SDK's `Uri.parse(…)`, past bloc_lint's `extension on Uri`, to a * project `parse`. A static constant's callee is the value it holds (`static * const autoDispose = AutoDisposeFutureProviderBuilder();`). The member is - * found as a chain's static link finds it (dartStaticMember). Undefined when - * the receiver names no Dart type of the project. + * found as a chain's static link finds it (dartStaticMember). Written through + * an import prefix (`p.Box.named(…)`, which arrives as `Box.named`), the + * type is the one that import brings in, if any. Undefined when the receiver + * names no Dart type of the project. */ function matchDartTypeMemberCall( typeName: string, @@ -4203,8 +4326,11 @@ function matchDartTypeMemberCall( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null | undefined { - if (!context.getNodesByName(typeName).some((n) => n.language === 'dart' && DART_TYPE_KINDS.has(n.kind))) return undefined; - const own = dartStaticMember(typeName, member, ref, context); + const types = context.getNodesByName(typeName).filter((n) => n.language === 'dart' && DART_TYPE_KINDS.has(n.kind)); + const prefix = types.length > 0 || /^[_$]*[A-Z]/.test(typeName) ? dartTypeCallPrefix(typeName, member, ref, context) : null; + const isOwner = (n: Node): boolean => prefix === null || dartPrefixSees(ref.filePath, prefix, n.filePath, typeName, context); + if (!types.some(isOwner)) return prefix === null ? undefined : null; + const own = dartStaticMember(typeName, member, ref, context, isOwner); return own && own.kind !== 'enum_member' ? { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'qualified-name' } : null; } @@ -12222,6 +12348,14 @@ function matchReferenceInner( return nmTimed('swiftTypePath', ref, () => resolveSwiftTypePathCall(ref, context)); } + // A Dart call through an import prefix (`http.get(…)`) is to what the + // prefixed import exports, or to nothing: the strategies below would take + // the prefix for a receiver and guess a method by its name. + if (ref.language === 'dart' && ref.referenceKind === 'calls') { + const prefixed = nmTimedT('dartPrefixed', ref, () => matchDartPrefixedCall(ref, context)); + if (prefixed !== undefined) return prefixed; + } + // Try strategies in order of confidence let result: ResolvedRef | null;