diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e01daceb..07ab7ec64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, a call on what a generic lookup returns, like flutter_bloc's `BlocProvider.of(context, listen: false).increment()` or Provider's `Provider.of(context, listen: false).add(item)`, now links to that method of the type in the angle brackets, whether the lookup is declared in your project or comes from a package. Before, such a call often linked nothing — for example whenever the lookup passed `listen: false` — so `callers` and impact missed those methods. A lookup that names no type, like `BlocProvider.of(context)`, still links nothing. Re-index Dart projects after upgrading. (#750) - In JavaScript and TypeScript, a store whose name starts or ends with a `$`, like `const items$ = create(…)`, now has its actions indexed when an `export { … }` list on a later line exports it, as in `export { items$ };` or `export { $items as default };`. Before, those actions were missing from the graph, so their callers and impact came back empty. A store whose name only lacks the `$`, like `items`, is no longer treated as exported by that list. Re-index JavaScript and TypeScript projects after upgrading. - In Dart, a comment inside a chain of calls no longer loses part of it: a call with a comment before its arguments, like `box.grow /* by */ (3)`, now links to the method it calls, and a getter read or a static access written after a comment, like `Config //` followed by `.instance` on the next line (the empty `//` keeps `dart format` from joining the lines), now counts as a use. Re-index Dart projects after upgrading. +- In Dart, a call, a constructor call or a type written without a receiver now links only to a declaration the file can see: one in its own library (the file and its `part` files) or one exported by a library it imports, with `export` chains, `show`, `hide` and `as` prefixes followed the way Dart follows them. So a generated `.g.dart` file reaches its own library's function or type instead of a same-named one in a neighbouring file, a call to a package function such as `fakeAsync`, `runApp` or `unawaited` no longer links to an unrelated project function of that name, and a bare call is never taken for another type's enum value or static constant. Re-index Dart projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/dart-library-visibility.test.ts b/__tests__/dart-library-visibility.test.ts new file mode 100644 index 000000000..f171430ce --- /dev/null +++ b/__tests__/dart-library-visibility.test.ts @@ -0,0 +1,344 @@ +/** + * A Dart name written without a receiver — a call, a type — means a + * declaration its library can see: its own (the file and its `part`s), or one + * a library it imports without a prefix exports, through `export` chains and + * `show` / `hide`. riverpod's generated `async.g.dart` (`part of + * 'async.dart'`) called the `family(…)` async.dart declares, and the call went + * to the `family` of annotated.dart beside it; a test's `fakeAsync(…)` from + * package:fake_async went to a vendored copy nobody imports. + */ +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'; +import { readDartDirectives } from '../src/resolution/dart-libraries'; + +function writeTree(root: string, files: Record): void { + 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); + } +} + +/** ` -> :` for every edge out of `file`. */ +function edgesFrom(cg: CodeGraph, file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids) + .filter((e) => e.kind !== 'contains') + .map((e) => { + const t = cg.getNode(e.target)!; + return `${e.kind} ${e.line} ${String(e.metadata?.refName ?? '')} -> ${t.filePath}:${t.qualifiedName}`; + }); +} + +describe('Dart library visibility', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-libs-')); + writeTree(root, { + 'pubspec.yaml': 'name: app\n', + // Generated parts call and name their own library's declarations; a + // library beside them declares the same names. + 'test/integration/annotated.dart': `part 'annotated.g.dart'; + +String family(Object ref, int id) => 'family $id'; +`, + 'test/integration/annotated.g.dart': `part of 'annotated.dart'; + +String annotatedGenerated(Object ref) => family(ref, 1); +`, + 'test/integration/async.dart': `import 'dart:async'; + +part 'async.g.dart'; + +Future family(Object ref, int first) async => '$first'; + +class GenericClass {} +`, + 'test/integration/async.g.dart': `part of 'async.dart'; + +Future asyncGenerated(Object ref) { + return family(ref, 2); +} + +final GenericClass generic = GenericClass(); +`, + 'test/integration/annotated_types.dart': `class GenericClass {} +`, + // package:fake_async's function, and a vendored copy no file imports. + 'test/third_party/fake_async.dart': `T fakeAsync(T Function(Object async) callback) => callback(Object()); +`, + 'test/src/core/devtool_test.dart': `import 'package:fake_async/fake_async.dart'; + +void main() { + fakeAsync((async) {}); +} +`, + // A test imports one utils library; an old copy declares the same function. + 'test/old/utils.dart': `List errorsOf(void Function() cb) => []; +`, + 'test/src/utils.dart': `List errorsOf(void Function() cb) => []; +`, + 'test/feature/uni_directional_test.dart': `import '../src/utils.dart'; + +void main() { + errorsOf(() {}); +} +`, + // A package whose barrel exports with combinators; a prefixed import; a hidden name. + 'packages/kit/pubspec.yaml': 'name: kit\n', + 'packages/kit/lib/kit.dart': `export 'src/impl.dart' + show + // the public API + shown, + Widget; +export 'src/cond_stub.dart' if (dart.library.io) 'src/cond_io.dart'; +`, + 'packages/kit/lib/src/impl.dart': `int shown() => 1; +int notShown() => 2; +class Widget {} +`, + 'packages/kit/lib/src/cond_stub.dart': `int platformName() => 0; +`, + 'packages/kit/lib/src/cond_io.dart': `int platformName() => 1; +`, + 'packages/kit/lib/src/prefixed.dart': `int viaPrefix() => 3; +class Report {} +`, + 'packages/kit/lib/src/hidden.dart': `int hiddenOne() => 4; +int keptOne() => 5; +`, + 'lib/main.dart': `import 'package:kit/kit.dart'; +import 'package:kit/src/prefixed.dart' as p; +import 'package:kit/src/hidden.dart' hide hiddenOne; + +void run(p.Report report) { + shown(); + notShown(); + platformName(); + viaPrefix(); + hiddenOne(); + keptOne(); + Widget(); +} +`, + // Another library's Report, which main.dart does not import. + 'lib/a_report.dart': `class Report {} +`, + // A part declared by library name, and a namesake in another library. + 'lib/named.dart': `library app.named; + +part 'named_part.dart'; + +int namedHelper() => 6; +`, + 'lib/named_part.dart': `part of app.named; + +int usesNamed() => namedHelper(); +`, + 'lib/other_named.dart': `int namedHelper() => 7; +`, + // A library's own declaration shadows the one it imports. + 'lib/a_util.dart': `int helper() => 1; +`, + 'lib/feature.dart': `import 'a_util.dart'; + +part 'feature_part.dart'; + +int runFeature() => helper(); +`, + 'lib/feature_part.dart': `part of 'feature.dart'; + +int helper() => 2; +`, + // A bare call never reaches another type's enum constant or static constant. + 'lib/kinds.dart': `enum Level { info, warning } + +class TypeChecker { + static const allOf = 'any'; +} +`, + 'lib/check.dart': `import 'package:mason_logger/mason_logger.dart'; +import 'package:matcher/matcher.dart'; + +void check(Object value) { + info('checking'); + expect(value, allOf([isNotNull])); +} +`, + // An unnamed extension on Color is no Color. + 'lib/colors.dart': `import 'package:flutter/material.dart'; + +extension on Color { + Color brighten() => this; +} +`, + // Nor is a generic one, beside the file that names the type. + 'lib/models/box.dart': `class Box { + T? value; +} +`, + 'lib/widgets/box_ext.dart': `import '../models/box.dart'; + +extension on Box { + bool get isEmpty => value == null; +} +`, + 'lib/widgets/panel.dart': `import '../models/box.dart'; +import 'box_ext.dart'; + +class Panel { + final Box box; + Panel(this.box); +} +`, + // An unnamed extension applies in every file of its library. + 'lib/shapes/shapes.dart': `part 'area_ext.dart'; +part 'use.dart'; + +class Shape {} +`, + 'lib/shapes/area_ext.dart': `part of 'shapes.dart'; + +extension on Shape { + int area() => 1; +} +`, + 'lib/shapes/use.dart': `part of 'shapes.dart'; + +int total(Shape s) => s.area(); +`, + }); + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("links a generated part's call to its own library's function", () => { + const edges = edgesFrom(cg, 'test/integration/async.g.dart'); + expect(edges).toContain('calls 4 family -> test/integration/async.dart:family'); + expect(edges.filter((e) => e.includes('annotated'))).toEqual([]); + }); + + it("links a generated part's type to its own library's type", () => { + const edges = edgesFrom(cg, 'test/integration/async.g.dart').filter((e) => e.includes('GenericClass')); + expect(edges.length).toBeGreaterThan(0); + expect(edges.every((e) => e.endsWith('test/integration/async.dart:GenericClass'))).toBe(true); + }); + + it('links nothing for a function no imported library declares', () => { + expect(edgesFrom(cg, 'test/src/core/devtool_test.dart').filter((e) => e.includes('fakeAsync'))).toEqual([]); + }); + + it('links the imported library over a namesake no import names', () => { + expect(edgesFrom(cg, 'test/feature/uni_directional_test.dart')).toContain('calls 4 errorsOf -> test/src/utils.dart:errorsOf'); + }); + + it('follows export chains, show, hide and import prefixes', () => { + const edges = edgesFrom(cg, 'lib/main.dart'); + expect(edges).toContain('calls 6 shown -> packages/kit/lib/src/impl.dart:shown'); + expect(edges).toContain('calls 11 keptOne -> packages/kit/lib/src/hidden.dart:keptOne'); + expect(edges.some((e) => e.startsWith('instantiates 12 Widget -> packages/kit/lib/src/impl.dart:Widget'))).toBe(true); + expect(edges.some((e) => / platformName -> packages\/kit\/lib\/src\/cond_(?:stub|io)\.dart:platformName$/.test(e))).toBe(true); + // Not shown, only reachable through the prefix, hidden. + expect(edges.filter((e) => / (?:notShown|viaPrefix|hiddenOne) /.test(e))).toEqual([]); + // `p.Report` is the prefixed library's. + expect(edges.filter((e) => e.includes(' Report '))).toEqual(['references 5 Report -> packages/kit/lib/src/prefixed.dart:Report']); + }); + + it('finds the library a part names by library name', () => { + expect(edgesFrom(cg, 'lib/named_part.dart')).toContain('calls 3 namedHelper -> lib/named.dart:namedHelper'); + }); + + it("prefers the library's own declaration to an imported one", () => { + expect(edgesFrom(cg, 'lib/feature.dart').filter((e) => e.includes('helper'))).toEqual(['calls 5 helper -> lib/feature_part.dart:helper']); + }); + + it("never reaches another type's enum constant or static constant by a bare call", () => { + expect(edgesFrom(cg, 'lib/check.dart').filter((e) => !e.startsWith('imports '))).toEqual([]); + }); + + it('never takes an unnamed extension for the type it extends', () => { + expect(edgesFrom(cg, 'lib/colors.dart').filter((e) => e.includes('Color'))).toEqual([]); + expect(edgesFrom(cg, 'lib/widgets/panel.dart').filter((e) => e.includes(' Box '))).toEqual(['references 5 Box -> lib/models/box.dart:Box']); + }); + + it("applies an unnamed extension in every file of its library", () => { + expect(edgesFrom(cg, 'lib/shapes/use.dart').some((e) => e.endsWith('-> lib/shapes/area_ext.dart:Shape::area'))).toBe(true); + }); +}); + +describe('Dart files outside any package', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-nopkg-')); + // A mason brick's template: no pubspec.yaml says what its imports mean. + writeTree(root, { + 'bricks/feature/__brick__/helper.dart': `int helper() => 1; +`, + 'bricks/feature/__brick__/page.dart': `import 'package:{{name}}/helper.dart'; + +int page() => helper(); +`, + }); + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('are resolved as before, by name', () => { + expect(edgesFrom(cg, 'bricks/feature/__brick__/page.dart')).toContain('calls 3 helper -> bricks/feature/__brick__/helper.dart:helper'); + }); +}); + +describe('readDartDirectives', () => { + it('reads every directive up to the first declaration', () => { + const d = readDartDirectives(`// Copyright +@TestOn('vm') +library app.main; + +import 'dart:async'; +import 'package:kit/kit.dart' show A, B hide B; +import 'stub.dart' if (dart.library.io) 'io.dart' if (dart.library.js_interop == 'true') 'web.dart'; +import 'lazy.dart' deferred as lazy; +export 'src/a.dart' + show + // one + One, + Two; +part 'main.g.dart'; + +void main() {} +import 'late.dart'; +`); + expect(d.libraryName).toBe('app.main'); + expect(d.partOf).toBeNull(); + expect(d.parts).toEqual(['main.g.dart']); + expect(d.imports.map((i) => i.uris)).toEqual([['dart:async'], ['package:kit/kit.dart'], ['stub.dart', 'io.dart', 'web.dart'], ['lazy.dart']]); + expect(d.imports.map((i) => i.prefix)).toEqual([null, null, null, 'lazy']); + expect([...d.imports[1]!.filter.show!]).toEqual(['A', 'B']); + expect([...d.imports[1]!.filter.hide!]).toEqual(['B']); + expect(d.exports.map((e) => [e.uris, [...(e.filter.show ?? [])]])).toEqual([[['src/a.dart'], ['One', 'Two']]]); + }); + + it('reads `part of` by URI and by library name, and an unnamed library', () => { + expect(readDartDirectives(`part of 'async.dart';\n`).partOf).toEqual({ uri: 'async.dart' }); + expect(readDartDirectives(`part of app.named;\n`).partOf).toEqual({ name: 'app.named' }); + expect(readDartDirectives(`/// Docs\nlibrary;\n\nimport 'a.dart';\n`)).toMatchObject({ libraryName: '', imports: [{ uris: ['a.dart'] }] }); + }); + + it('stops at a declaration that looks like a directive', () => { + expect(readDartDirectives(`import 'a.dart';\npart() => 1;\nimport 'b.dart';\n`).imports.map((i) => i.uris[0])).toEqual(['a.dart']); + }); +}); diff --git a/src/resolution/dart-libraries.ts b/src/resolution/dart-libraries.ts new file mode 100644 index 000000000..18c2d7930 --- /dev/null +++ b/src/resolution/dart-libraries.ts @@ -0,0 +1,693 @@ +/** + * Which files' top-level declarations a Dart file can name without a prefix. + * + * A Dart library is a file and its `part`s. Its top-level scope holds its own + * declarations, private `_` names included, and every name exported by a + * library it imports without a prefix: that library's own public declarations + * and, through its `export`s, other libraries'. Every import and export is + * narrowed by its `show` / `hide` combinators. A conditional import (`import + * 'stub.dart' if (dart.library.io) 'io.dart'`) brings in every alternative. + * `dart:` libraries and packages outside the repository (flutter, test, + * mockito, …) export nothing of the project. + * + * `package:/` is `/lib/` of the package whose + * pubspec.yaml says `name: `: the nearest pubspec.yaml above an indexed + * .dart file, never above the project root. When packages share a name + * (example apps), the importing file's own package wins, then the one + * enclosing it, else all of them count. + * + * Directives are read from the head of each file at resolution time; + * extraction, and the kernel's, are unchanged. Where the library cannot be + * established — a `part of` naming no library this can find, a file it cannot + * read, a file in no package — every file counts as visible, so the rule only + * narrows where the library is known. + */ +import * as path from 'path'; +import type { ResolutionContext } from './types'; + +/** The names an import's or export's `show` / `hide` combinators let through; `show` null lets every name through. */ +export interface NameFilter { + readonly show: ReadonlySet | null; + readonly hide: ReadonlySet | null; + /** Identifies equal filters, so a namespace keeps each once. */ + readonly key: string; +} + +const EVERY_NAME: NameFilter = { show: null, hide: null, key: '*|' }; + +function makeFilter(show: ReadonlySet | null, hide: ReadonlySet | null): NameFilter { + const hidden = hide && hide.size > 0 ? hide : null; + if (show === null && hidden === null) return EVERY_NAME; + return { show, hide: hidden, key: `${show ? [...show].sort().join(',') : '*'}|${hidden ? [...hidden].sort().join(',') : ''}` }; +} + +/** `a`, then `b`: a name gets through when both let it. */ +function composeFilters(a: NameFilter, b: NameFilter): NameFilter { + if (a === EVERY_NAME) return b; + if (b === EVERY_NAME) return a; + const show = a.show && b.show ? new Set([...a.show].filter((n) => b.show!.has(n))) : (a.show ?? b.show); + const hide = a.hide && b.hide ? new Set([...a.hide, ...b.hide]) : (a.hide ?? b.hide); + return makeFilter(show, hide); +} + +function lets(filter: NameFilter, name: string): boolean { + return (filter.show === null || filter.show.has(name)) && (filter.hide === null || !filter.hide.has(name)); +} + +/** An `import` or `export` directive. */ +export interface DartNamespaceDirective { + /** The URI, then each conditional alternative (`if (dart.library.io) 'io.dart'`). */ + uris: string[]; + /** The prefix of `import '…' as p`, or null. */ + prefix: string | null; + filter: NameFilter; +} + +/** The directives at the head of a Dart file. */ +export interface DartDirectives { + /** `library a.b;` gives `a.b`, `library;` gives '', no directive null. */ + libraryName: string | null; + partOf: { uri: string } | { name: string } | null; + parts: string[]; + imports: DartNamespaceDirective[]; + exports: DartNamespaceDirective[]; +} + +const DIRECTIVE_KEYWORDS: ReadonlySet = new Set(['library', 'import', 'export', 'part']); +const IDENTIFIER = /[A-Za-z_$][\w$]*/y; + +/** The index past the whitespace and comments at `i` (Dart block comments nest). */ +function skipTrivia(src: string, i: number): number { + const n = src.length; + while (i < n) { + const c = src.charCodeAt(i); + if (c === 32 || (c >= 9 && c <= 13) || c === 0xfeff) { + i++; + } else if (c === 47 && src.charCodeAt(i + 1) === 47) { + const newline = src.indexOf('\n', i + 2); + i = newline < 0 ? n : newline + 1; + } else if (c === 47 && src.charCodeAt(i + 1) === 42) { + let depth = 1; + i += 2; + while (i < n && depth > 0) { + if (src.charCodeAt(i) === 47 && src.charCodeAt(i + 1) === 42) { + depth++; + i += 2; + } else if (src.charCodeAt(i) === 42 && src.charCodeAt(i + 1) === 47) { + depth--; + i += 2; + } else { + i++; + } + } + } else { + break; + } + } + return i; +} + +/** The string literal at `i` — `'…'`, `"…"`, triple-quoted or raw — as its value and the index past it, or null. */ +function readString(src: string, i: number): { value: string; end: number } | null { + let raw = false; + if (src[i] === 'r' && (src[i + 1] === "'" || src[i + 1] === '"')) { + raw = true; + i++; + } + const quote = src[i]; + if (quote !== "'" && quote !== '"') return null; + const close = src.startsWith(quote.repeat(3), i) ? quote.repeat(3) : quote; + let j = i + close.length; + let value = ''; + while (j < src.length) { + if (src.startsWith(close, j)) return { value, end: j + close.length }; + const c = src[j]!; + if (c === '\n' && close.length === 1) return null; + if (c === '\\' && !raw) { + value += src[j + 1] ?? ''; + j += 2; + } else if (c === '$' && !raw && src[j + 1] === '{') { + let depth = 1; + j += 2; + while (j < src.length && depth > 0) { + const inner = readString(src, j); + if (inner) { + j = inner.end; + continue; + } + if (src[j] === '{') depth++; + else if (src[j] === '}') depth--; + j++; + } + } else { + value += c; + j++; + } + } + return null; +} + +/** The index past the balanced `open` … `close` group at `i`, strings and comments skipped; -1 if it never closes. */ +function skipGroup(src: string, i: number, open: string, close: string): number { + let depth = 0; + while (i < src.length) { + i = skipTrivia(src, i); + const literal = readString(src, i); + if (literal) { + i = literal.end; + continue; + } + const c = src[i]; + if (c === open) depth++; + else if (c === close && --depth === 0) return i + 1; + i++; + } + return -1; +} + +/** The index past the metadata annotation at `i` (`@TestOn('vm')`, `@Tags(['slow'])`, `@JS()`). */ +function skipAnnotation(src: string, i: number): number { + i++; + for (;;) { + i = skipTrivia(src, i); + IDENTIFIER.lastIndex = i; + const name = IDENTIFIER.exec(src); + if (!name) return i; + i += name[0].length; + const next = skipTrivia(src, i); + if (src[next] !== '.') break; + i = next + 1; + } + i = skipTrivia(src, i); + if (src[i] === '<') { + const end = skipGroup(src, i, '<', '>'); + if (end < 0) return src.length; + i = skipTrivia(src, end); + } + if (src[i] === '(') { + const end = skipGroup(src, i, '(', ')'); + return end < 0 ? src.length : end; + } + return i; +} + +type DirectiveToken = + | { kind: 'string'; value: string } + | { kind: 'word'; value: string } + | { kind: 'condition' } + | { kind: 'punctuation'; value: string }; + +/** + * The tokens of a directive, from just past its keyword to its `;`, and the + * index past that `;` — or null when a body (`{`, `=>`) comes first, as in a + * function named `part`, or the file ends. + */ +function readDirectiveTokens(src: string, i: number): { tokens: DirectiveToken[]; end: number } | null { + const tokens: DirectiveToken[] = []; + for (;;) { + i = skipTrivia(src, i); + if (i >= src.length || tokens.length > 2000) return null; + const c = src[i]!; + if (c === ';') return { tokens, end: i + 1 }; + if (c === '{' || c === '}' || (c === '=' && src[i + 1] === '>')) return null; + const literal = readString(src, i); + if (literal) { + tokens.push({ kind: 'string', value: literal.value }); + i = literal.end; + continue; + } + if (c === '(') { + // A configuration condition: `(dart.library.io)`, `(dart.library.js_interop == 'true')`. + i = skipGroup(src, i, '(', ')'); + if (i < 0) return null; + tokens.push({ kind: 'condition' }); + continue; + } + IDENTIFIER.lastIndex = i; + const word = IDENTIFIER.exec(src); + if (word) { + tokens.push({ kind: 'word', value: word[0] }); + i += word[0].length; + continue; + } + tokens.push({ kind: 'punctuation', value: c }); + i++; + } +} + +/** A dotted library name (`app.named`), as the words and dots it is written with. */ +function dottedName(tokens: readonly DirectiveToken[]): string | null { + let name = ''; + for (const t of tokens) { + if (t.kind === 'word' || (t.kind === 'punctuation' && t.value === '.')) name += t.value; + else return null; + } + return name; +} + +/** Record the directive `keyword` with `tokens` in `out`; false when the tokens are no such directive. */ +function addDirective(out: DartDirectives, keyword: string, tokens: DirectiveToken[]): boolean { + const first = tokens[0]; + if (keyword === 'library') { + const name = dottedName(tokens); + if (name === null) return false; + out.libraryName = name; + return true; + } + if (keyword === 'part') { + if (first?.kind === 'word' && first.value === 'of') { + const target = tokens[1]; + const name = target?.kind === 'string' ? null : dottedName(tokens.slice(1)); + if (target?.kind === 'string') out.partOf = { uri: target.value }; + else if (name) out.partOf = { name }; + else return false; + return true; + } + if (first?.kind !== 'string') return false; + out.parts.push(first.value); + return true; + } + if (first?.kind !== 'string') return false; + const uris = [first.value]; + let prefix: string | null = null; + let filter = EVERY_NAME; + let k = 1; + // Adjacent literals are one string: `'package:a/' 'b.dart'`. + for (let t = tokens[k]; t?.kind === 'string'; t = tokens[++k]) uris[0] += t.value; + while (k < tokens.length) { + const t = tokens[k]!; + const next = tokens[k + 1]; + const uri = tokens[k + 2]; + if (t.kind === 'word' && t.value === 'if' && next?.kind === 'condition' && uri?.kind === 'string') { + uris.push(uri.value); + k += 3; + } else if (t.kind === 'word' && t.value === 'as' && next?.kind === 'word') { + prefix = next.value; + k += 2; + } else if (t.kind === 'word' && (t.value === 'show' || t.value === 'hide')) { + const names = new Set(); + k++; + while (tokens[k]?.kind === 'word') { + names.add((tokens[k] as { value: string }).value); + const comma = tokens[k + 1]; + if (comma?.kind !== 'punctuation' || comma.value !== ',') { + k++; + break; + } + k += 2; + } + filter = composeFilters(filter, t.value === 'show' ? makeFilter(names, null) : makeFilter(null, names)); + } else { + // `deferred`, and anything this does not model. + k++; + } + } + (keyword === 'import' ? out.imports : out.exports).push({ uris, prefix, filter }); + return true; +} + +/** + * The `library`, `part`, `part of`, `import` and `export` directives at the + * head of a Dart source file — past its script tag, comments and metadata, + * up to its first declaration. + */ +export function readDartDirectives(source: string): DartDirectives { + const out: DartDirectives = { libraryName: null, partOf: null, parts: [], imports: [], exports: [] }; + let i = source.charCodeAt(0) === 0xfeff ? 1 : 0; + if (source.startsWith('#!', i)) { + const newline = source.indexOf('\n', i); + i = newline < 0 ? source.length : newline + 1; + } + for (;;) { + i = skipTrivia(source, i); + if (i >= source.length) break; + if (source[i] === '@') { + i = skipAnnotation(source, i); + continue; + } + IDENTIFIER.lastIndex = i; + const keyword = IDENTIFIER.exec(source)?.[0]; + if (keyword === undefined || !DIRECTIVE_KEYWORDS.has(keyword)) break; + const directive = readDirectiveTokens(source, i + keyword.length); + if (!directive || !addDirective(out, keyword, directive.tokens)) break; + i = directive.end; + } + return out; +} + +/** A pub package of the project: its pubspec.yaml's `name`, and the directory holding it ('' for the project root). */ +interface PubPackage { + name: string; + root: string; +} + +/** What a file's imports and its library's own files make visible. */ +interface FileScope { + /** The library's files: the library, its parts, and the file itself. */ + own: ReadonlySet; + /** The files whose imports apply here: the file, and for a part each file up to its library. */ + importers: readonly string[]; + /** By import prefix ('' for none), the libraries those imports name, each with the import's filter (read on first use). */ + imported: Map> | null; + /** By prefix and declaring file, the filters through which the imports bring in its names (read on first use). */ + visible: Map; +} + +interface Memo { + directives: Map; + packageOfDir: Map; + packages: Map | null; + libraryNames: Map | null; + libraryOf: Map; + /** A part's parent: the file its `part of` names. */ + parentOf: Map; + members: Map>; + exports: Map>; + scopes: Map; +} + +const memos = new WeakMap(); + +function memoFor(context: ResolutionContext): Memo { + let memo = memos.get(context); + if (!memo) { + memo = { + directives: new Map(), + packageOfDir: new Map(), + packages: null, + libraryNames: null, + libraryOf: new Map(), + parentOf: new Map(), + members: new Map(), + exports: new Map(), + scopes: new Map(), + }; + memos.set(context, memo); + } + return memo; +} + +/** Drop the memos (see ReferenceResolver.clearCaches). */ +export function clearDartLibraryMemos(context: ResolutionContext): void { + memos.delete(context); +} + +function parentDir(file: string): string { + const cut = file.lastIndexOf('/'); + return cut < 0 ? '' : file.slice(0, cut); +} + +function directivesOf(file: string, context: ResolutionContext, memo: Memo): DartDirectives | null { + const hit = memo.directives.get(file); + if (hit !== undefined) return hit; + const source = context.readFile(file); + const directives = source === null ? null : readDartDirectives(source); + memo.directives.set(file, directives); + return directives; +} + +const PUBSPEC_NAME = /^name[ \t]*:[ \t]*['"]?([A-Za-z_]\w*)/m; + +/** The package of the nearest pubspec.yaml at or above project-relative `dir`, never above the project root. */ +function packageOfDir(dir: string, context: ResolutionContext, memo: Memo): PubPackage | null { + const walked: string[] = []; + let found: PubPackage | null | undefined; + for (let d = dir; ; d = parentDir(d)) { + found = memo.packageOfDir.get(d); + if (found !== undefined) break; + walked.push(d); + const pubspec = d ? `${d}/pubspec.yaml` : 'pubspec.yaml'; + const name = context.fileExists(pubspec) ? PUBSPEC_NAME.exec(context.readFile(pubspec) ?? '')?.[1] : undefined; + if (name) { + found = { name, root: d }; + break; + } + if (d === '') { + found = null; + break; + } + } + for (const d of walked) memo.packageOfDir.set(d, found); + return found; +} + +/** The project's packages named `name`: the packages that hold an indexed .dart file. */ +function packagesNamed(name: string, context: ResolutionContext, memo: Memo): readonly PubPackage[] { + if (!memo.packages) { + const byName = new Map(); + const seen = new Set(); + for (const file of context.getAllFiles()) { + if (!file.endsWith('.dart')) continue; + const pkg = packageOfDir(parentDir(file), context, memo); + if (!pkg || seen.has(pkg)) continue; + seen.add(pkg); + const list = byName.get(pkg.name); + if (list) list.push(pkg); + else byName.set(pkg.name, [pkg]); + } + memo.packages = byName; + } + return memo.packages.get(name) ?? []; +} + +/** The project files a URI written in `from` names: none for `dart:` and other schemes, or a package outside the project. */ +function resolveDartUri(from: string, uri: string, context: ResolutionContext, memo: Memo): string[] { + if (uri.startsWith('package:')) { + const slash = uri.indexOf('/'); + if (slash < 0) return []; + const name = uri.slice('package:'.length, slash); + const rest = uri.slice(slash + 1); + let chosen = packagesNamed(name, context, memo); + if (chosen.length > 1) { + const own = packageOfDir(parentDir(from), context, memo); + const enclosing = chosen.filter((p) => p.root === '' || from.startsWith(`${p.root}/`)); + if (own && own.name === name) chosen = [own]; + else if (enclosing.length > 0) chosen = [enclosing.reduce((a, b) => (b.root.length > a.root.length ? b : a))]; + } + return chosen.map((p) => path.posix.normalize(p.root ? `${p.root}/lib/${rest}` : `lib/${rest}`)); + } + if (/^[A-Za-z][\w+.-]*:/.test(uri)) return []; + const joined = path.posix.normalize(path.posix.join(parentDir(from) || '.', uri)); + return joined === '..' || joined.startsWith('../') || joined.startsWith('/') ? [] : [joined]; +} + +/** The files declaring `library ;`. */ +function libraryFilesNamed(name: string, context: ResolutionContext, memo: Memo): readonly string[] { + if (!memo.libraryNames) { + const byName = new Map(); + for (const file of context.getAllFiles()) { + if (!file.endsWith('.dart') || context.fileContains?.(file, 'library') === false) continue; + const declared = directivesOf(file, context, memo)?.libraryName; + if (!declared) continue; + const list = byName.get(declared); + if (list) list.push(file); + else byName.set(declared, [file]); + } + memo.libraryNames = byName; + } + return memo.libraryNames.get(name) ?? []; +} + +/** The file a part's `part of` names, or null when it names none this can find. */ +function partParent(file: string, partOf: NonNullable, context: ResolutionContext, memo: Memo): string | null { + const candidates = 'uri' in partOf + ? resolveDartUri(file, partOf.uri, context, memo).filter((f) => directivesOf(f, context, memo) !== null) + : libraryFilesNamed(partOf.name, context, memo); + if (candidates.length <= 1) return candidates[0] ?? null; + const listing = candidates.filter((c) => + (directivesOf(c, context, memo)?.parts ?? []).some((uri) => resolveDartUri(c, uri, context, memo).includes(file))); + return listing.length === 1 ? listing[0]! : null; +} + +/** The library file `file` belongs to: itself, or for a part the library its `part of` chain ends at; null when unknown. */ +function libraryOf(file: string, context: ResolutionContext, memo: Memo): string | null { + const hit = memo.libraryOf.get(file); + if (hit !== undefined) return hit; + // A part cycle ends at null. + memo.libraryOf.set(file, null); + const directives = directivesOf(file, context, memo); + let library: string | null = null; + if (directives && !directives.partOf) { + library = file; + } else if (directives?.partOf) { + const parent = partParent(file, directives.partOf, context, memo); + if (parent !== null) { + memo.parentOf.set(file, parent); + library = libraryOf(parent, context, memo); + } + } + memo.libraryOf.set(file, library); + return library; +} + +/** A library's files: the library and its parts, theirs included. */ +function membersOf(library: string, context: ResolutionContext, memo: Memo): ReadonlySet { + const hit = memo.members.get(library); + if (hit) return hit; + const files = new Set([library]); + const queue = [library]; + while (queue.length > 0 && files.size < 1000) { + const file = queue.shift()!; + for (const uri of directivesOf(file, context, memo)?.parts ?? []) { + for (const part of resolveDartUri(file, uri, context, memo)) { + if (files.has(part)) continue; + files.add(part); + queue.push(part); + } + } + } + memo.members.set(library, files); + return files; +} + +function addFilter(namespace: Map, file: string, filter: NameFilter): void { + const filters = namespace.get(file); + if (!filters) namespace.set(file, [filter]); + else if (filters[0] === EVERY_NAME) return; + else if (filter === EVERY_NAME) filters.splice(0, filters.length, EVERY_NAME); + else if (!filters.some((f) => f.key === filter.key)) filters.push(filter); +} + +/** + * The files whose public names `library` exports, each with the filters on + * the way: its own files, and what its `export`s reach. `low` is the + * shallowest library still on `stack` that the walk came back to; a result + * that leans on one is incomplete until that library finishes, so it is kept + * only when the walk came back to nothing shallower than this library. + */ +function exportsOf( + library: string, + context: ResolutionContext, + memo: Memo, + stack: Map, +): { namespace: ReadonlyMap; low: number } { + const hit = memo.exports.get(library); + if (hit) return { namespace: hit, low: Infinity }; + const depth = stack.size; + stack.set(library, depth); + const namespace = new Map(); + let low = Infinity; + const members = membersOf(library, context, memo); + for (const file of members) addFilter(namespace, file, EVERY_NAME); + for (const file of members) { + for (const directive of directivesOf(file, context, memo)?.exports ?? []) { + for (const uri of directive.uris) { + for (const target of resolveDartUri(file, uri, context, memo)) { + const exported = libraryOf(target, context, memo); + if (exported === null) continue; + const onStack = stack.get(exported); + if (onStack !== undefined) { + low = Math.min(low, onStack); + continue; + } + const inner = exportsOf(exported, context, memo, stack); + low = Math.min(low, inner.low); + for (const [f, filters] of inner.namespace) { + for (const filter of filters) addFilter(namespace, f, composeFilters(directive.filter, filter)); + } + } + } + } + } + stack.delete(library); + if (low >= depth) memo.exports.set(library, namespace); + return { namespace, low }; +} + +/** + * What `file` can see, or null when that is unknown: its library cannot be + * found, or no pubspec.yaml above it says which package its `package:` + * imports start from (a mason brick's template, a project indexed below its + * package root). + */ +function scopeOf(file: string, context: ResolutionContext, memo: Memo): FileScope | null { + const hit = memo.scopes.get(file); + if (hit !== undefined) return hit; + const library = packageOfDir(parentDir(file), context, memo) === null ? null : libraryOf(file, context, memo); + let scope: FileScope | null = null; + if (library !== null) { + const own = new Set(membersOf(library, context, memo)); + own.add(file); + const importers: string[] = []; + for (let f: string | undefined = file; f !== undefined && importers.length < 32; f = memo.parentOf.get(f)) { + importers.push(f); + if (f === library) break; + } + scope = { own, importers, imported: null, visible: new Map() }; + } + memo.scopes.set(file, scope); + return scope; +} + +/** + * The filters through which the imports written with `prefix` ('' for none) + * bring `declFile`'s names in — empty when they bring in none of them — or + * null when no import has that prefix. + */ +function importedFilters(scope: FileScope, prefix: string, declFile: string, context: ResolutionContext, memo: Memo): readonly NameFilter[] | null { + if (!scope.imported) { + scope.imported = new Map(); + for (const importer of scope.importers) { + for (const directive of directivesOf(importer, context, memo)?.imports ?? []) { + const key = directive.prefix ?? ''; + const libraries = scope.imported.get(key) ?? []; + scope.imported.set(key, libraries); + for (const uri of directive.uris) { + for (const target of resolveDartUri(importer, uri, context, memo)) { + const library = libraryOf(target, context, memo); + if (library !== null) libraries.push({ filter: directive.filter, library }); + } + } + } + } + } + const libraries = scope.imported.get(prefix); + if (!libraries) return prefix === '' ? [] : null; + const key = `${prefix}\0${declFile}`; + const hit = scope.visible.get(key); + if (hit) return hit; + const through = new Map(); + for (const { filter, library } of libraries) { + for (const exported of exportsOf(library, context, memo, new Map()).namespace.get(declFile) ?? []) { + addFilter(through, declFile, composeFilters(filter, exported)); + } + } + const filters = through.get(declFile) ?? []; + scope.visible.set(key, filters); + return filters; +} + +/** + * Whether the library of `fromFile` can name `name`, declared at the top level + * of `declFile`, without a prefix: `declFile` is one of the library's own + * files, or a library it imports unprefixed exports the name. True when the + * library of `fromFile` cannot be established. + */ +export function dartLibrarySees(fromFile: string, declFile: string, name: string, context: ResolutionContext): boolean { + if (fromFile === declFile) return true; + const memo = memoFor(context); + const scope = scopeOf(fromFile, context, memo); + if (!scope || scope.own.has(declFile)) return true; + if (name.startsWith('_')) return false; + return (importedFilters(scope, '', declFile, context, memo) ?? []).some((f) => lets(f, name)); +} + +/** + * Whether `prefix.name` in `fromFile` can mean `name` declared at the top + * level of `declFile`: a library imported `as prefix` exports it. True when + * no import of the file's library has that prefix, or the library is unknown. + */ +export function dartPrefixSees(fromFile: string, prefix: string, declFile: string, name: string, context: ResolutionContext): boolean { + const memo = memoFor(context); + const scope = scopeOf(fromFile, context, memo); + if (!scope) return true; + const filters = importedFilters(scope, prefix, declFile, context, memo); + return filters === null || (!name.startsWith('_') && filters.some((f) => lets(f, name))); +} + +/** 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); +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 8964590a7..5fdbf2d9b 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -25,6 +25,7 @@ import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, ma import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; +import { clearDartLibraryMemos } from './dart-libraries'; import { clearVbnetReceiverMemos, isVbMemberRead, isVbPathCall, matchVbMemberRead, matchVbPathCall } from './vbnet-receivers'; import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters'; import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope'; @@ -470,6 +471,7 @@ export class ReferenceResolver { clearNameMatcherMemos(this.context); clearCppMacroVisibility(this.context); clearSwiftTypeVisibility(this.context); + clearDartLibraryMemos(this.context); clearVbnetReceiverMemos(this.context); clearTypeParameterMemos(this.context); clearDartLocalScopeMemos(this.context); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 640d99251..4b382c0a3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -11,6 +11,7 @@ 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 } from './dart-libraries'; import { breakVbTie, isVbMemberInScope, isVbNestedTypeInScope, isVbTypeQualifiedBy, matchVbTypedCall, preferVbProject, sameVbProject } from './vbnet-receivers'; import { isTestPath } from '../search/query-utils'; import { isMinifiedContent } from '../extraction/generated-detection'; @@ -2225,7 +2226,7 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex if (dartExtensionDecl(candidate, context)?.named === false) return false; // And it applies only in its own library: flutter_test's `find.text(…)` is // no other file's `extension on TaskStatus { String get text }`. - if (candidate.filePath !== ref.filePath && isDartUnnamedExtensionMember(candidate, context)) return false; + if (isDartUnnamedExtensionMember(candidate, context) && !inSameDartLibrary(ref.filePath, candidate.filePath, context)) return false; // A Scala package object's member is in scope in its package and those under // it, or through an import: cats.laws' `Eq` is the `cats` package object's // alias, not the `algebra` one's (752 refs went there). @@ -2968,6 +2969,72 @@ function isDartMember(n: Node): boolean { return n.kind === 'method' || (n.kind === 'function' && n.qualifiedName.includes('::')); } +/** + * A value a Dart type holds — an enum constant, a `static const` / `final` — + * which a bare name reaches only from inside the type, like its methods: + * bloc's brick hook calls `info(…)` on mason's `Logger`, and the call went to + * bloc_lint's enum constant `LinterRuleState.info`. + */ +function isDartTypeValue(n: Node): boolean { + return n.language === 'dart' && (n.kind === 'enum_member' || VALUE_KINDS.has(n.kind)) && n.qualifiedName.includes('::'); +} + +/** What a Dart library declares at its top level: a function, a type, a typedef, a top-level `final` / `const`. */ +const DART_LIBRARY_DECL_KINDS: ReadonlySet = new Set(['function', 'class', 'enum', 'type_alias', 'constant', 'variable', 'interface', 'struct', 'trait', 'mixin', 'extension']); + +function isDartLibraryDecl(n: Node): boolean { + return n.language === 'dart' && DART_LIBRARY_DECL_KINDS.has(n.kind) && !n.qualifiedName.includes('::'); +} + +/** + * Whether a Dart name written without a receiver — a call, a type — can mean + * `candidate` when it is declared at the top level of a library: the caller's + * own library declares it, or a library the caller imports without a prefix + * exports it, `show` / `hide` and `export` chains followed (see + * ./dart-libraries). A name written through an import prefix (`p.Report`) + * means only what the imports with that prefix export. riverpod's generated + * `async.g.dart` (`part of 'async.dart'`) calls the `family(…)` async.dart + * declares, and the call went to annotated.dart's `family` beside it; a test's + * `fakeAsync(…)` from package:fake_async went to a vendored copy no file imports. + */ +function isDartTopLevelVisible(candidate: Node, ref: UnresolvedRef, context: ResolutionContext, prefix: string | null = null): boolean { + if (!isDartLibraryDecl(candidate)) return true; + return prefix === null + ? dartLibrarySees(ref.filePath, candidate.filePath, candidate.name, context) + : dartPrefixSees(ref.filePath, prefix, candidate.filePath, candidate.name, context); +} + +/** + * The import prefix a Dart type name is written through — the `p` of + * `p.Report`, `p.Color.red` — or null. A type reference's column is the + * name's start; on a line with non-ASCII text before it the nearest whole-word + * occurrence stands in. + */ +function dartImportPrefixOf(ref: UnresolvedRef, context: ResolutionContext): string | 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 name = ref.referenceName; + const isWord = (at: number) => line.startsWith(name, at) && !/[\w$]/.test(line[at - 1] ?? '') && !/[\w$]/.test(line[at + name.length] ?? ''); + let start = isWord(ref.column) ? ref.column : -1; + for (let at = line.indexOf(name); start < 0 && at >= 0; at = line.indexOf(name, at + 1)) { + if (isWord(at)) start = at; + } + if (start < 0) return null; + return /([A-Za-z_$][\w$]*)\s*\.\s*$/.exec(line.slice(0, start))?.[1] ?? null; +} + +/** + * A library's own top-level declaration shadows every import of its name, so + * when the caller's library declares one, the imported namesakes are no + * candidates. (A function nested in another body is not the library's.) + */ +function preferOwnDartLibrary(candidates: Node[], ref: UnresolvedRef, context: ResolutionContext): Node[] { + if (candidates.filter(isDartLibraryDecl).length < 2) return candidates; + const own = candidates.filter((n) => + isDartLibraryDecl(n) && inSameDartLibrary(ref.filePath, n.filePath, context) && isLexicallyReachable(n, ref, context)); + return own.length === 0 ? candidates : candidates.filter((n) => !isDartLibraryDecl(n) || own.includes(n)); +} + /** * 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 — @@ -3911,7 +3978,7 @@ function dartMemberOf( const extension = dartExtensionOwner(m, context); let rank: number; if (extension) { - if (!extension.named && m.filePath !== ref.filePath) continue; + if (!extension.named && !inSameDartLibrary(ref.filePath, m.filePath, context)) continue; // `extension ObjectX on T` reaches every type, after every extension // that names one of its lineage — when the type is the project's, so // what it declares is known: a List's own `cast` is not ObjectX's. @@ -4256,7 +4323,10 @@ function dartExtensionDecl(n: Node, context: ResolutionContext): { named: boolea const head = lines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 5)).join('\n') .replace(/^\s*(?:(?:@[\w$.]+(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\/[^\n]*)\s*)*/, ''); if (!/^extension\b(?!\s+type\b)/.test(head)) return null; - return { named: !/^extension\s+on\b/.test(head) }; + // A generic one has no name either: riverpod's `extension on Map<…>` took the + // `Map` type references of riverpod's own library. + return { named: !/^extension\s*(?:<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)?\s*on\b/.test(head) }; } /** Whether a Dart method belongs to an unnamed `extension on X`, visible only in its own library. */ @@ -6692,6 +6762,9 @@ export function matchByExactName( const pythonShape = pythonCallShape(ref, context); const javaBare = ref.language === 'java' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); const dartBare = ref.language === 'dart' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) && isReceiverLessDartCall(ref, context); + // A Dart type name — a declared type, a supertype, the `Color` of `Color.red` — is in scope as a call is, or through its import prefix. + const dartTypeRef = ref.language === 'dart' && (ref.referenceKind === 'references' || isInheritanceRef(ref)) && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); + const dartPrefix = dartTypeRef ? dartImportPrefixOf(ref, context) : null; const kotlinCall = ref.language === 'kotlin' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); const kotlinBare = kotlinCall && isReceiverLessKotlinCall(ref, context); const rubyBare = ref.language === 'ruby' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*[?!]?$/.test(ref.referenceName); @@ -6737,6 +6810,8 @@ export function matchByExactName( // last link of a chain, not an annotation (riverpod's 210 `@internal` // annotations went to its providers' `internal` constructors). !(ref.language === 'dart' && isDartConstructor(n, context)) && + !(dartBare && isDartTypeValue(n) && !isDartMethodInScope(n, ref, context)) && + !((dartBare || dartTypeRef) && !isDartTopLevelVisible(n, ref, context, dartPrefix)) && !(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context))) && !(pythonShape && !fitsPythonCallShape(n, pythonShape, ref, context)) && !(rustBare && !isRustNameInScope(n, ref, context)) && @@ -6805,7 +6880,8 @@ export function matchByExactName( // file's symbol of that name, so a bare call has no cross-file candidate. !(bareJs && n.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)) ); - const candidates = dartBare ? nearestDartMembers(filtered, ref, context) + const candidates = dartBare ? nearestDartMembers(preferOwnDartLibrary(filtered, ref, context), ref, context) + : dartTypeRef && dartPrefix === null ? preferOwnDartLibrary(filtered, ref, context) : swiftShape && swiftShape.shape !== 'chained' ? nearestSwiftMembers(filtered, ref, context) : kotlinBare ? lexicalKotlinMembers(filtered, ref, context) : filtered; @@ -9969,7 +10045,7 @@ export function matchMethodCall( // `tester.pumpApp(…)` is the imported `PumpApp`, not flutter_counter's // `extension on WidgetTester`. { - const kept = targetMethods.filter((m) => m.filePath === ref.filePath || !isDartUnnamedExtensionMember(m, context)); + const kept = targetMethods.filter((m) => !isDartUnnamedExtensionMember(m, context) || inSameDartLibrary(ref.filePath, m.filePath, context)); narrowed ||= kept.length !== targetMethods.length; targetMethods = kept; } @@ -11502,6 +11578,9 @@ export function matchFuzzy( const pythonShape = pythonCallShape(ref, context); const javaBare = ref.language === 'java' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); const dartBare = ref.language === 'dart' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) && isReceiverLessDartCall(ref, context); + // A Dart type name — a declared type, a supertype, the `Color` of `Color.red` — is in scope as a call is, or through its import prefix. + const dartTypeRef = ref.language === 'dart' && (ref.referenceKind === 'references' || isInheritanceRef(ref)) && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); + const dartPrefix = dartTypeRef ? dartImportPrefixOf(ref, context) : null; const kotlinCall = ref.language === 'kotlin' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); const kotlinBare = kotlinCall && isReceiverLessKotlinCall(ref, context); const rubyBare = ref.language === 'ruby' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*[?!]?$/.test(ref.referenceName); @@ -11540,6 +11619,10 @@ export function matchFuzzy( !(javaBare && n.kind === 'method' && !isJavaMethodInScope(n, ref, context)) && !(dartBare && isDartMember(n) && !isDartMethodInScope(n, ref, context)) && !(ref.language === 'dart' && isDartConstructor(n, context)) && + !((dartBare || dartTypeRef) && !isDartTopLevelVisible(n, ref, context, dartPrefix)) && + // A Dart `extension on Color` is no `Color`, here as in exact matching: + // flutter_weather's `Color brighten(…)` in that extension names Flutter's. + dartExtensionDecl(n, context)?.named !== false && !(kotlinCall && !isKotlinTopLevelVisible(n, ref, context)) && !(kotlinBare && !isKotlinMemberReachable(n, ref, context)) && !isKotlinNumberBitwise(n, ref) && @@ -11563,7 +11646,8 @@ export function matchFuzzy( // Prefer same-language matches const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language); - const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates; + const languageCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates; + const finalCandidates = dartBare || (dartTypeRef && dartPrefix === null) ? preferOwnDartLibrary(languageCandidates, ref, context) : languageCandidates; // Both post-pipeline visibility guards (#1745 language-local + #1719 sealed // module). The sealed-module test rejects the survivor and never filters the