diff --git a/CHANGELOG.md b/CHANGELOG.md index 3055b1a6e..49ffc639a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, a call further down a chain, like Riverpod's `FutureProvider.autoDispose.family(…)` or `events.map(mapper).transform(…)` on a stream, now links to the method of the type the chain before it produces, worked out from what each step is declared to return, and to nothing when that type can't be worked out. Before, it linked to whichever project method shared its name, so `X.autoDispose.family(…)` reached the same provider builder whatever `X` was, and a stream's `transform` reached an unrelated `transform` method. `super.` calls now reach the superclass's method, `context.read().add(…)` reaches the bloc's own `add`, and the members of an extension marked `@internal` are found too. Re-index Dart projects after upgrading. (#750) - In VB.NET, reading or setting a field or property through a variable, parameter, field, `Me` or `MyBase`, like `x.Normal = 3`, `Me._h.Title`, `.Value` inside a `With` block or `.Switch` in `New BoolParam With {…}`, now counts as a use of that member, and so do calls inside a `With` block, functions called without parentheses and reads through a class whose name starts in lower case. Before, `codegraph callers` on an instance field or property missed most of its uses. Only a member that the value's declared type has or inherits is linked, nothing when that type comes from outside your project, and a value named after its class, like `theme As Theme`, counts as a use of the class only when it reads a `Shared` member. Re-index VB.NET projects after upgrading. - In JavaScript and TypeScript, an import whose name contains a `$`, like `import items$ from './store'`, `import * as api$ from './api'` or `import { load as load$ } from './data'`, now links the calls made through it, such as `items$.getState().inc()`, to the code it imports, and so does a `$` name in a `require(…)` destructuring or an `export { … } from` barrel. Before, such an import was skipped or bound under a cut-off name (`load` for `load$`), so those calls reached nothing and the functions they call could look unused. A local variable or parameter that reuses an imported `$` name, like `const items$ = …` inside a function, is no longer mistaken for the import. Re-index JavaScript and TypeScript projects after upgrading. +- In Dart, calling a parameter or a local variable, like `create(ref, arg)` inside `overrideWith(… create)`, a closure's parameter, a loop variable or a test's mock `listener(0, 1)`, no longer links the call to an unrelated function or constant of the same name in another file, or to a method the class inherits: a Bloc event handler's `emit(…)` is the handler's own `emit` parameter, not `Bloc.emit`. So `codegraph callers` and impact no longer list code that never calls them, while calls the parameter or variable doesn't cover, and calls to a local function, link as before. Re-index Dart projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/dart-local-call-scope.test.ts b/__tests__/dart-local-call-scope.test.ts new file mode 100644 index 000000000..7dec2813e --- /dev/null +++ b/__tests__/dart-local-call-scope.test.ts @@ -0,0 +1,248 @@ +/** + * A bare Dart call to a name a parameter or local around it binds calls that + * parameter or local — never a same-named symbol elsewhere. + * + * riverpod's `overrideWith(CreatedT Function(Ref ref, ArgT arg) create)` calls + * its parameter (`create(ref, arg)`), as do its tests' provider factories and + * an analyzer cache's `upsert(key, create)`. All of them were linked to a docs + * example's top-level `final create = Mutation<…>()` that none of them import. + * Tests' mocks (`final listener = Listener(); listener(0, 1)`) went to + * other test files' `listener` functions, and bloc's event handlers' `emit` + * parameter (`on((event, emit) => emit(…))`) to `Bloc.emit`, which a + * handler never calls. None of those bindings is a node, so such a call links + * nothing. A call the binding does not cover still links. + * + * Runs against the native kernel (when built) and the wasm extractor, which + * must agree. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const FILES: Record = { + 'pubspec.yaml': 'name: app\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n', + // The docs example nobody imports: the only top-level `create`. + 'doc/mutation.dart': `class Mutation { + Object call(String key) => key; +} + +final create = Mutation(); +final createTodo = create('create_todo'); +`, + 'lib/family.dart': `class Ref {} + +class Override { + Override(Object Function(Ref ref) build); +} + +mixin FunctionalFamilyOverride { + ArgT get argument => throw 0; + + Override overrideWith(CreatedT Function(Ref ref, ArgT arg) create) { + return Override((ref) => create(ref, argument) as Object); + } +} + +class Cache { + final _values = {}; + + T upsert(Object key, T Function() create) { + final existing = _values[key]; + if (existing != null) return existing; + final created = create(); + _values[key] = created; + return created; + } +} + +class Holder { + void create() {} + + void run(void Function() create) { + this.create(); + create(); + } +} + +enum Kind { set, list } + +R pick({required R Function() set, required R Function() list}) => set(); + +class Box {} + +Box? find() => null; + +void check() { + final Box? box = find(); + print(box); + Box(); +} +`, + 'test/matrix.dart': `typedef Factory = Object Function(Object Function(Object ref, Object? arg) create, {String? name}); + +final factories = [ + (create, {name}) => create(Object(), name), +]; + +final generic = (T Function() create) => create(); +`, + // Another test's function of the same name as the mock below. + 'test/other_test.dart': `void listener() {} + +void fn() {} +`, + 'test/listener_test.dart': `class Listener { + void call(T? previous, T next) {} +} + +void main() { + final listener = Listener(); + listener(0, 1); + + void Function() build = () {}; + build(); + + for (final fn in []) { + fn(); + } + + final (create, _) = (() => 0, 1); + create(); +} +`, + 'lib/bloc.dart': `abstract class Emitter { + void call(S state); +} + +class Bloc { + void emit(S state) {} + + void on(void Function(E event, Emitter emit) handler) {} +} + +class CounterBloc extends Bloc { + CounterBloc() { + on((event, emit) => emit(1)); + } + + void onReset(String event, Emitter emit) { + emit(0); + } + + void bump() => emit(2); +} +`, + 'lib/util.dart': `void notify(int value) {} +`, + // Calls a closure parameter or a loop variable does not cover, and a local + // function nearer than the parameter it shadows. + 'lib/wire.dart': `import 'util.dart'; + +void wire(List values) { + values.forEach((notify) => print(notify)); + notify(1); + for (final notify in values) { + print(notify); + } + notify(2); +} + +int outer(int Function() make) { + int helper() { + int make() => 2; + return make(); + } + + return helper() + make(); +} +`, +}; + +describe('Dart calls to a parameter or local', () => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-local-calls-')); + 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); + } + kernel = process.env.CODEGRAPH_KERNEL; + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + it.each(['default', 'wasm'])('link nothing, and leave the calls they do not cover alone (%s)', async (backend) => { + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const node = (file: string, qualifiedName: string): Node => { + const found = graph.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName); + expect(found, `${file} ${qualifiedName}`).toBeDefined(); + return found!; + }; + /** `qualifiedName:line` of each call (or construction) out of a node. */ + const calls = (from: Node): string[] => + graph + .getOutgoingEdgesFrom([from.id]) + .filter((e) => e.kind === 'calls' || e.kind === 'instantiates') + .map((e) => `${graph.getNode(e.target)!.qualifiedName}:${e.line}`) + .sort(); + /** The line of `file` that holds `text`. */ + const lineOf = (file: string, text: string): number => FILES[file]!.split('\n').findIndex((l) => l.includes(text)) + 1; + + // Only the docs example's own file calls into it (a call through its + // `create` constant constructs the `Mutation` the constant holds). + const docs = graph.getNodesInFile('doc/mutation.dart').map((n) => n.id); + expect([...new Set(graph.getIncomingEdgesTo(docs, ['calls', 'instantiates']).map((e) => graph.getNode(e.source)!.filePath))]) + .toEqual(['doc/mutation.dart']); + + // A parameter: a method's, a function-typed one inside a closure, closures' in initializers. + expect(calls(node('lib/family.dart', 'FunctionalFamilyOverride::overrideWith'))) + .toEqual([`Override:${lineOf('lib/family.dart', 'create(ref, argument)')}`]); + expect(calls(node('lib/family.dart', 'Cache::upsert'))).toEqual([]); + // (a parameter may be named `set`, which is no enum's member here) + expect(calls(node('lib/family.dart', 'pick'))).toEqual([]); + expect(calls(node('test/matrix.dart', 'factories'))).toEqual([]); + expect(calls(node('test/matrix.dart', 'generic'))).toEqual([]); + + // A parameter shadows a method of the class, which `this.` still reaches. + expect(calls(node('lib/family.dart', 'Holder::run'))) + .toEqual([`Holder::create:${lineOf('lib/family.dart', 'this.create();')}`]); + + // A local: a mock, a function-typed variable, a loop variable, a destructured one. + expect(calls(node('test/listener_test.dart', 'main'))) + .toEqual([`Listener:${lineOf('test/listener_test.dart', 'Listener()')}`]); + const otherTest = graph.getNodesInFile('test/other_test.dart').map((n) => n.id); + expect(graph.getIncomingEdgesTo(otherTest, ['calls'])).toEqual([]); + + // A handler's `Emitter` parameter is not `Bloc.emit`; a bare call of the inherited method is. + expect(calls(node('lib/bloc.dart', 'CounterBloc'))).toEqual([`Bloc::on:${lineOf('lib/bloc.dart', 'on')}`]); + expect(calls(node('lib/bloc.dart', 'CounterBloc::onReset'))).toEqual([]); + expect(calls(node('lib/bloc.dart', 'CounterBloc::bump'))).toEqual([`Bloc::emit:${lineOf('lib/bloc.dart', 'emit(2)')}`]); + + // A sibling closure's parameter and a finished loop's variable cover nothing after them. + expect(calls(node('lib/wire.dart', 'wire'))) + .toEqual([`notify:${lineOf('lib/wire.dart', 'notify(1)')}`, `notify:${lineOf('lib/wire.dart', 'notify(2)')}`]); + + // A local function is nearer than the parameter it shadows, inside its block only. + expect(calls(node('lib/wire.dart', 'helper'))).toEqual([`make:${lineOf('lib/wire.dart', 'return make()')}`]); + expect(calls(node('lib/wire.dart', 'outer'))).not.toContain(`make:${lineOf('lib/wire.dart', 'helper() + make()')}`); + + // A nullable typed local's type is not a binding: `Box()` still constructs `Box`. + expect(calls(node('lib/family.dart', 'check'))) + .toEqual([`Box:${lineOf('lib/family.dart', ' Box();')}`, `find:${lineOf('lib/family.dart', '= find()')}`]); + }); +}); diff --git a/src/resolution/dart-local-scope.ts b/src/resolution/dart-local-scope.ts new file mode 100644 index 000000000..8fdea685e --- /dev/null +++ b/src/resolution/dart-local-scope.ts @@ -0,0 +1,666 @@ +/** + * A name a Dart function, closure or block around a call declares — a + * parameter, a local variable, a loop or `catch` variable, a pattern + * variable — is what a bare call of that name means there. + * + * riverpod's `overrideWith(CreatedT Function(Ref ref, ArgT arg) create)` calls + * its parameter, `create(ref, arg)`, and so do its tests' provider factories + * (`(create, {name, dependencies}) => …`). The index holds no node for any of + * those, so the name matcher handed 48 such calls to the one top-level + * `create` it knew: a docs example's `final create = Mutation<…>()` that none + * of the callers import. A parameter hides a member as well: a bloc event + * handler's `emit(…)` calls its `Emitter` parameter, never `Bloc.emit`. The + * same holds for a function passed by name. Read from the source of the + * call's file: + * + * - a parameter list that a body follows (`{ … }` or `=> …`), the body being + * its scope: a function's, a method's, a closure's, a `catch` clause's, a + * constructor's (its initializer list too); + * - the variables a `for` head declares, for the loop; + * - a local a block declares (`final x = …`, `Foo? x;`, `var (a, b) = …`, a + * pattern's `final x`), from there to the block's end. + * + * Fields and top-level variables are not locals. The nearest binding around + * the call decides: a local function declared there is a node, and the call + * keeps the one it resolved to. + */ +import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types'; + +/** Where a name is bound: offsets into the file's source, inclusive. */ +interface Scope { + start: number; + end: number; + /** A local function's name, which a call can reach. */ + fn?: boolean; +} + +interface DartFileScopes { + lineStarts: number[]; + /** Name → the scopes it is bound in. */ + names: Map; +} + +const memos = new WeakMap>(); + +/** Drop the memos (see ReferenceResolver.clearCaches). */ +export function clearDartLocalScopeMemos(context: ResolutionContext): void { + memos.delete(context); +} + +/** + * The resolved reference, or null when it is a receiver-less Dart call (or a + * function passed by name) whose name a parameter or local around it binds. + */ +export function gateDartLocal(resolved: ResolvedRef | null, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (!resolved || ref.language !== 'dart') return resolved; + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'function_ref') return resolved; + const name = ref.referenceName; + if (!/^[A-Za-z_$][\w$]*$/.test(name)) return resolved; + const file = fileScopes(ref.filePath, context); + const bound = file?.names.get(name); + if (!file || !bound) return resolved; + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split('\n'); + const line = lines?.[ref.line - 1]; + if (line === undefined) return resolved; + const column = nameColumn(line, name, ref.column); + // `this.create()`, `x.create()`, `..create()` — the dot maybe ending the + // line before: a member, whatever the locals are. + const before = line.slice(0, column); + if (column < 0 || /\.\s*$/.test(before) || + (before.trim() === '' && /\.\s*$/.test((lines![ref.line - 2] ?? '').replace(/\/\/.*$/, '')))) return resolved; + const at = (file.lineStarts[ref.line - 1] ?? 0) + column; + // Scopes nest, so the binding that starts last is the nearest one. + let nearest: Scope | null = null; + for (const scope of bound) { + if (at >= scope.start && at <= scope.end && (!nearest || scope.start > nearest.start)) nearest = scope; + } + return !nearest || nearest.fn ? resolved : null; +} + +/** + * 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 + * on a line with non-ASCII text before it, so the nearest whole-word + * occurrence is taken then. + */ +function nameColumn(line: string, name: string, column: number): number { + const isWord = (at: number): boolean => + line.startsWith(name, at) && !/[\w$]/.test(line[at - 1] ?? '') && !/[\w$]/.test(line[at + name.length] ?? ''); + if (column >= 0 && isWord(column)) return column; + if (column >= name.length && isWord(column - name.length)) return column - name.length; + let best = -1; + for (let at = line.indexOf(name); at >= 0; at = line.indexOf(name, at + 1)) { + if (isWord(at) && (best < 0 || Math.abs(at - column) < Math.abs(best - column))) best = at; + } + return best; +} + +function fileScopes(filePath: string, context: ResolutionContext): DartFileScopes | null { + let memo = memos.get(context); + if (!memo) { + memo = new Map(); + memos.set(context, memo); + } + const hit = memo.get(filePath); + if (hit !== undefined) return hit; + const source = context.readFile(filePath); + const scopes = source === null ? null : readScopes(source); + memo.set(filePath, scopes); + return scopes; +} + +/** Every parameter, loop variable and local a Dart file binds, with its scope. */ +function readScopes(source: string): DartFileScopes { + const lineStarts = [0]; + for (let i = source.indexOf('\n'); i >= 0; i = source.indexOf('\n', i + 1)) lineStarts.push(i + 1); + const code = blankTypeArguments(blankDartLiterals(source)); + const brackets = matchBrackets(code); + const names = new Map(); + const bind = (name: string, start: number, end: number, fn = false): void => { + if (name === '_' || DART_RESERVED.has(name)) return; + const scope: Scope = fn ? { start, end, fn } : { start, end }; + const list = names.get(name); + if (list) list.push(scope); + else names.set(name, [scope]); + }; + const parameterLists = bindParameters(code, brackets, bind); + bindLocals(code, brackets, parameterLists, bind); + return { lineStarts, names }; +} + +type Bind = (name: string, start: number, end: number, fn?: boolean) => void; + +/** + * Each parameter list a body follows, the body being where its names are + * bound — and a `for` head's variables, for the loop. A group a control-flow + * keyword heads (`if (create) {`) holds an expression, and a call's arguments + * (`: super(x) {`, `: _f = make(x) {` in an initializer list) bind nothing. + * A function declared in a block binds its own name there, to the block's end. + * Returns the offsets of the groups read as parameter lists. + */ +function bindParameters(code: string, b: Brackets, bind: Bind): Set { + const lists = new Set(); + for (let k = 0; k < b.at.length; k++) { + const open = b.partner[k]!; + if (code[b.at[k]!] !== ')' || open < 0) continue; + const from = b.at[open]!; + const head = tokenBefore(code, from); + let i = skipSpace(code, b.at[k]! + 1); + const modifier = /^(?:async\s*\*?|sync\s*\*)/.exec(code.slice(i, i + 8)); + if (modifier) i = skipSpace(code, i + modifier[0].length); + const list = code.slice(from + 1, b.at[k]!); + const block = code[i] === '{' && b.at[k + 1] === i && b.partner[k + 1]! >= 0 ? b.at[b.partner[k + 1]!]! : -1; + if (head.word === 'for') { + const end = block >= 0 ? block : endOfStatement(code, b, i, false); + for (const name of forHeadNames(list)) bind(name, from, end); + lists.add(from); + continue; + } + if (head.word !== null && CONTROL_HEADS.has(head.word)) continue; + let scope: Scope | null = null; + if (block >= 0) scope = { start: i, end: block }; + else if (code.startsWith('=>', i)) scope = { start: i, end: endOfStatement(code, b, i + 2, true) }; + else if (code[i] === ':' && isConstructorHead(code, head)) scope = { start: i, end: endOfConstructor(code, b, i + 1) }; + if (!scope || isCallArguments(code, head)) continue; + lists.add(from); + for (const name of parameterNames(list)) bind(name, scope.start, scope.end); + // `int make() => 2;` in a block: a local function, a node of its own. + const around = head.word !== null && !DART_KEYWORDS.has(head.word) ? enclosingBlock(code, b, head.at) : -1; + if (around >= 0) bind(head.word!, head.at, blockEnd(code, b, around), true); + } + return lists; +} + +/** + * Whether a group is a call's arguments rather than a declaration's + * parameters: a call in a constructor's initializer list (`: _value = + * compute(x) {`, `: super.named(x) {`, `: assert(x) {`). + */ +function isCallArguments(code: string, head: { word: string | null; at: number }): boolean { + if (head.word === null) return false; + if (head.word === 'super' || head.word === 'this' || head.word === 'assert') return true; + const before = tokenBefore(code, head.at); + if (before.word === null && /^[=:,]$/.test(code[before.at] ?? '')) return true; + // `super.named(x)` / `this.named(x)`, never a named constructor `Foo.named(x)`. + if (before.word === null && code[before.at] === '.') { + const owner = tokenBefore(code, before.at); + return owner.word === 'super' || owner.word === 'this'; + } + return false; +} + +/** + * Whether a group followed by `:` is a constructor's parameter list — + * `Foo(this.x) :`, `const Foo.named(int x) :` at the start of a member — and + * not a call in a conditional (`c ? f(x) : y`) or a `case Foo(x):`. + */ +function isConstructorHead(code: string, head: { word: string | null; at: number }): boolean { + if (head.word === null || DART_KEYWORDS.has(head.word)) return false; + let type = head.word; + let before = tokenBefore(code, head.at); + if (before.word === null && code[before.at] === '.') { + const owner = tokenBefore(code, before.at); + if (owner.word === null) return false; + type = owner.word; + before = tokenBefore(code, owner.at); + } + if (!/^_*[A-Z$]/.test(type)) return false; + if (before.word !== null) return before.word === 'const' || before.word === 'external'; + return before.at < 0 || /^[;{}]$/.test(code[before.at] ?? ''); +} + +/** + * The end of a constructor's initializer list and body, from just past its + * `:` — the body being the first `{` that does not start a value (`: _m = {}`). + */ +function endOfConstructor(code: string, b: Brackets, from: number): number { + let k = firstBracketFrom(b, from); + let pos = from; + for (;;) { + const next = k < b.at.length ? b.at[k]! : code.length; + const semicolon = code.indexOf(';', pos); + if (semicolon >= 0 && semicolon < next) return semicolon; + if (k >= b.at.length) return code.length; + const ch = code[next]; + if (ch === ')' || ch === ']' || ch === '}') return next; + const close = b.partner[k]!; + if (close < 0) return code.length; + if (ch === '{') { + const prior = tokenBefore(code, next); + if (prior.word !== null || /[)\]'"]/.test(code[prior.at] ?? '')) return b.at[close]!; + } + pos = b.at[close]! + 1; + k = close + 1; + } +} + +/** + * The names a parameter list declares: `(Ref ref, {required this.create, + * int count = 0})`, `([String? label])`, old-style `void cb(int x)` (`cb`), + * untyped closure parameters `(create, {name})`, and a switch arm's pattern + * `Foo(:final value) =>`. + */ +function parameterNames(list: string): string[] { + const names: string[] = []; + for (const raw of splitParameters(list)) { + let item = raw.replace(/@[\w$.]+(?:\s*\([^()]*\))?/g, ' '); + const eq = item.indexOf('='); + if (eq >= 0) item = item.slice(0, eq); + item = item.trimEnd(); + // `void cb(int x)`: the name is before its own parameter list. + if (item.endsWith(')')) { + let depth = 0; + let at = item.length - 1; + for (; at >= 0; at--) { + if (item[at] === ')') depth++; + else if (item[at] === '(' && --depth === 0) break; + } + item = item.slice(0, Math.max(0, at)).trimEnd(); + } + const name = /([A-Za-z_$][\w$]*)$/.exec(item)?.[1]; + if (name) names.push(name); + } + return names; +} + +/** A parameter list's items, the optional group's (`{…}` / `[…]`, always last) included. */ +function splitParameters(list: string): string[] { + const items: string[] = []; + let depth = 0; + let item = ''; + for (let i = 0; i < list.length; i++) { + const ch = list[i]!; + if (depth === 0 && (ch === '{' || ch === '[') && item.trim() === '') { + const close = list.lastIndexOf(ch === '{' ? '}' : ']'); + items.push(...splitParameters(list.slice(i + 1, close > i ? close : list.length))); + return items; + } + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') depth--; + if (ch === ',' && depth === 0) { + if (item.trim() !== '') items.push(item); + item = ''; + } else { + item += ch; + } + } + if (item.trim() !== '') items.push(item); + return items; +} + +/** The variables a `for` head declares: `final x in xs`, `var (a, b) in pairs`, `var i = 0, j = n; …`. */ +function forHeadNames(head: string): string[] { + const loop = /^([^;]*?)\bin\b/.exec(head); + if (loop) { + const decl = loop[1]!.trim(); + const pattern = /^(?:final|var)\s*(?:[\w$.]+\s*)?([([{][\s\S]*)$/.exec(decl); + if (pattern) return patternBinders(pattern[1]!); + const declared = /^(?:(?:final|var|const)\s+(?:[\w$.]+\s*\??\s+)?|[\w$.]+\s*\??\s+)([A-Za-z_$][\w$]*)$/.exec(decl); + return declared ? [declared[1]!] : []; + } + const init = head.split(';')[0] ?? ''; + if (!/^\s*(?:(?:final|var|const|late)\b|[\w$.]+\s*\??\s+[A-Za-z_$][\w$]*\s*=)/.test(init)) return []; + return [...init.matchAll(/(?:^|,|\s)([A-Za-z_$][\w$]*)\s*=(?![=>])/g)].map((m) => m[1]!); +} + +/** The names a destructuring pattern binds: `(a, b)`, `[x, ...rest]`, `Point(:x, y: final py)`, `{'k': v}`. */ +function patternBinders(pattern: string): string[] { + const names: string[] = []; + for (const m of pattern.matchAll(/(?? x = …`, `void Function() x + * = …`, `final (a, b) = …`, and a pattern's `final x` / `var x` (`case + * Foo(:final x)`, `if (v case final x?)`). A declaration directly in a class + * body is a field, and one at the top of the file a library variable. + */ +function bindLocals(code: string, b: Brackets, parameterLists: Set, bind: Bind): void { + // `direct`: a statement's own declaration, whose block is the bracket right + // around it; a pattern's variable is in the block around its `case` or `if`. + const bindInBlock = (name: string, at: number, direct: boolean): void => { + let k = innermostBracket(code, b, at); + while (!direct && k >= 0 && code[b.at[k]!] !== '{') { + // A parameter list's `final x` is the parameter, bound where the list is read. + if (parameterLists.has(b.at[k]!)) return; + k = b.parent[k]!; + } + if (k >= 0 && code[b.at[k]!] === '{' && !isTypeBody(code, b.at[k]!)) bind(name, at, blockEnd(code, b, k)); + }; + for (const m of code.matchAll(/(?])|;))/g)) { + if (isDeclarationType(code, b, tokenBefore(code, m.index))) bindInBlock(m[1]!, m.index, true); + } + // A pattern variable: `case Foo(:final value)`, `if (x case final y?)` — + // not the type of `final Foo? x = …`. + for (const m of code.matchAll(/(?|\bwhen\b|&&|\|\|))/g)) { + bindInBlock(m[1]!, m.index, false); + } + // A destructuring declaration: `final (a, b) = …`, `var Point(:x, :y) = …`. + for (const m of code.matchAll(/(?])/.test(code.slice(close + 1, close + 4))) continue; + for (const name of patternBinders(code.slice(open + 1, close))) bindInBlock(name, m.index, true); + } +} + +/** + * Whether the token before a name followed by `=` / `;` makes it a + * declaration: `final` / `var` / `const`, a type (`Foo`, `Foo?`, `List` + * — type arguments are blanked — or a function type ending `Function(…)`). + */ +function isDeclarationType(code: string, b: Brackets, before: { word: string | null; at: number }): boolean { + if (before.word !== null) return DECLARATION_KEYWORDS.has(before.word) || !DART_KEYWORDS.has(before.word); + const ch = code[before.at]; + if (ch === '?') return /[\w$)]/.test(code[before.at - 1] ?? ''); + if (ch === ')') { + const k = bracketIndexAt(b, before.at); + const open = k >= 0 ? b.partner[k]! : -1; + return open >= 0 && tokenBefore(code, b.at[open]!).word === 'Function'; + } + return false; +} + +/** Whether the `{` at `brace` opens a class, mixin, enum or extension body. */ +function isTypeBody(code: string, brace: number): boolean { + const from = Math.max(code.lastIndexOf(';', brace - 1), code.lastIndexOf('{', brace - 1), code.lastIndexOf('}', brace - 1)); + return TYPE_BODY_HEAD.test(code.slice(from + 1, brace)); +} + +const TYPE_BODY_HEAD = /^\s*(?:@[\w$.]+(?:\s*\((?:[^()]|\([^()]*\))*\))?\s*)*(?:(?:abstract|base|final|sealed|interface|mixin|augment)\s+)*(?:class|mixin|enum|extension)\b/; + +/** The index of the `{` right around offset `at` when it holds statements (no class or extension body), or -1. */ +function enclosingBlock(code: string, b: Brackets, at: number): number { + const k = innermostBracket(code, b, at); + return k >= 0 && code[b.at[k]!] === '{' && !isTypeBody(code, b.at[k]!) ? k : -1; +} + +/** Where the bracket at index `k` closes: the end of the file when it never does. */ +function blockEnd(code: string, b: Brackets, k: number): number { + return b.partner[k]! >= 0 ? b.at[b.partner[k]!]! : code.length; +} + +/** + * Where the expression or statement starting at `from` ends: the first `;` + * (and, for an expression, `,`) outside the brackets it opens, or the close + * of the bracket around it — the end of a `=> …` body in an argument list, + * a switch arm or a declaration. + */ +function endOfStatement(code: string, b: Brackets, from: number, expression: boolean): number { + let k = firstBracketFrom(b, from); + let pos = from; + for (;;) { + const next = k < b.at.length ? b.at[k]! : code.length; + for (let i = pos; i < next; i++) { + if (code[i] === ';' || (expression && code[i] === ',')) return i; + } + if (k >= b.at.length) return code.length; + const ch = code[next]; + if (ch === ')' || ch === ']' || ch === '}') return next; + const close = b.partner[k]!; + if (close < 0) return code.length; + pos = b.at[close]! + 1; + k = close + 1; + } +} + +/** The word right before offset `at` (skipping blanks), or else the offset of the character there (-1 at the start). */ +function tokenBefore(code: string, at: number): { word: string | null; at: number } { + let i = at - 1; + while (i >= 0 && /\s/.test(code[i]!)) i--; + let start = i; + while (start >= 0 && /[\w$]/.test(code[start]!)) start--; + if (start === i) return { word: null, at: i }; + return { word: code.slice(start + 1, i + 1), at: start + 1 }; +} + +function skipSpace(code: string, at: number): number { + while (at < code.length && /\s/.test(code[at]!)) at++; + return at; +} + +/** Keywords that head a parenthesised expression, not a parameter list. */ +const CONTROL_HEADS: ReadonlySet = new Set(['if', 'while', 'switch']); + +/** Keywords a declaration's name can follow. */ +const DECLARATION_KEYWORDS: ReadonlySet = new Set(['final', 'var', 'const']); + +/** Dart's reserved words: never a name a declaration binds. */ +const DART_RESERVED: ReadonlySet = new Set([ + 'assert', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'do', 'else', 'enum', 'extends', + 'false', 'final', 'finally', 'for', 'if', 'in', 'is', 'new', 'null', 'rethrow', 'return', 'super', 'switch', + 'this', 'throw', 'true', 'try', 'var', 'void', 'while', 'with', +]); + +/** + * The reserved words and the built-in and contextual ones that never name a + * declaration's type or a function there. Several are fine parameter names — + * json_generator's `R Function(DartType item) set` — so only DART_RESERVED + * keeps a name from binding. + */ +const DART_KEYWORDS: ReadonlySet = new Set([ + ...DART_RESERVED, + 'abstract', 'as', 'async', 'await', 'base', 'covariant', 'deferred', 'export', 'extension', 'external', 'factory', + 'get', 'hide', 'implements', 'import', 'interface', 'late', 'library', 'mixin', 'of', 'on', 'operator', 'part', + 'required', 'sealed', 'set', 'show', 'static', 'sync', 'typedef', 'when', 'yield', +]); + +// ---------------------------------------------------------------- brackets + +interface Brackets { + /** The offset of every bracket character, in order. */ + at: number[]; + /** For each bracket, the index of its partner, or -1. */ + partner: number[]; + /** For each bracket, the index of the open bracket around it, or -1. */ + parent: number[]; +} + +/** Pairs `()`, `[]` and `{}`; a stray close is left unpaired, and so are the opens a close skips past. */ +function matchBrackets(code: string): Brackets { + const at: number[] = []; + const partner: number[] = []; + const parent: number[] = []; + const stack: number[] = []; + for (let i = 0; i < code.length; i++) { + const ch = code[i]; + if (ch === '(' || ch === '[' || ch === '{') { + at.push(i); + partner.push(-1); + parent.push(stack.length > 0 ? stack[stack.length - 1]! : -1); + stack.push(at.length - 1); + } else if (ch === ')' || ch === ']' || ch === '}') { + const want = ch === ')' ? '(' : ch === ']' ? '[' : '{'; + let s = stack.length - 1; + while (s >= 0 && code[at[stack[s]!]!] !== want) s--; + at.push(i); + partner.push(-1); + if (s < 0) { + parent.push(stack.length > 0 ? stack[stack.length - 1]! : -1); + continue; + } + const open = stack[s]!; + stack.length = s; + partner[open] = at.length - 1; + partner[at.length - 1] = open; + parent.push(parent[open]!); + } + } + return { at, partner, parent }; +} + +/** The index of the first bracket at or after offset `from`. */ +function firstBracketFrom(b: Brackets, from: number): number { + let lo = 0; + let hi = b.at.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (b.at[mid]! < from) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** The index of the bracket at offset `at`, or -1. */ +function bracketIndexAt(b: Brackets, at: number): number { + const k = firstBracketFrom(b, at); + return b.at[k] === at ? k : -1; +} + +/** The index of the innermost open bracket around offset `at`, or -1. */ +function innermostBracket(code: string, b: Brackets, at: number): number { + const k = firstBracketFrom(b, at) - 1; + if (k < 0) return -1; + const ch = code[b.at[k]!]; + return ch === '(' || ch === '[' || ch === '{' ? k : b.parent[k]!; +} + +// ---------------------------------------------------------------- lexing + +/** + * The source with comments and the text of string literals blanked to + * spaces (offsets and newlines kept). Code inside a `${…}` interpolation + * stays, minus the braces that delimit it; block comments nest, and raw + * strings (`r'…'`) have no escapes or interpolation. + */ +function blankDartLiterals(source: string): string { + const out = source.split(''); + const n = source.length; + const blank = (from: number, to: number): void => { + for (let k = from; k < to && k < n; k++) if (source[k] !== '\n' && source[k] !== '\r') out[k] = ' '; + }; + // The strings an interpolation was opened from, innermost last, with the brace depth inside it. + const suspended: Array<{ quote: string; braces: number }> = []; + let quote: string | null = null; + let raw = false; + let i = 0; + while (i < n) { + if (quote !== null) { + if (source.startsWith(quote, i)) { + i += quote.length; + quote = null; + continue; + } + const ch = source[i]!; + if (!raw && ch === '\\') { + blank(i, i + 2); + i += 2; + continue; + } + if (!raw && ch === '$' && source[i + 1] === '{') { + blank(i, i + 2); + suspended.push({ quote, braces: 0 }); + quote = null; + i += 2; + continue; + } + // A single-quoted string ends at the line's end however it was left. + if (quote.length === 1 && ch === '\n') { + quote = null; + i++; + continue; + } + blank(i, i + 1); + i++; + continue; + } + const ch = source[i]!; + if (ch === '/' && source[i + 1] === '/') { + const end = source.indexOf('\n', i); + blank(i, end < 0 ? n : end); + i = end < 0 ? n : end; + continue; + } + if (ch === '/' && source[i + 1] === '*') { + let depth = 1; + let j = i + 2; + while (j < n && depth > 0) { + if (source[j] === '/' && source[j + 1] === '*') { + depth++; + j += 2; + } else if (source[j] === '*' && source[j + 1] === '/') { + depth--; + j += 2; + } else { + j++; + } + } + blank(i, j); + i = j; + continue; + } + if (suspended.length > 0 && (ch === '{' || ch === '}')) { + const top = suspended[suspended.length - 1]!; + if (ch === '{') top.braces++; + else if (top.braces > 0) top.braces--; + else { + blank(i, i + 1); + suspended.pop(); + quote = top.quote; + raw = false; + } + i++; + continue; + } + if (ch === '"' || ch === "'") { + raw = source[i - 1] === 'r' && !/[\w$]/.test(source[i - 2] ?? ''); + quote = source[i + 1] === ch && source[i + 2] === ch ? ch.repeat(3) : ch; + i += quote.length; + continue; + } + i++; + } + return out.join(''); +} + +/** + * The code with type-argument lists blanked — `Map`, `(x) =>`, + * `[]` — so their commas never end an argument and their angle brackets + * never read as comparisons. A `<` opens one when it follows a name with no + * space (`List`), or when what it closes is followed by `(`, `[`, `{` or + * `.` (a generic function or a typed literal), and everything inside is type + * syntax. + */ +function blankTypeArguments(code: string): string { + if (!code.includes('<')) return code; + const out = code.split(''); + let i = code.indexOf('<'); + while (i >= 0) { + const end = typeArgumentsEnd(code, i); + if (end > i) { + const glued = /[\w$]/.test(code[i - 1] ?? ''); + const next = code[skipSpace(code, end + 1)] ?? ''; + if (glued || next === '(' || next === '[' || next === '{' || next === '.') { + for (let k = i; k <= end; k++) if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' '; + i = code.indexOf('<', end + 1); + continue; + } + } + i = code.indexOf('<', i + 1); + } + return out.join(''); +} + +/** The offset of the `>` closing a type-argument list opened at `open`, or -1 if what follows is not one. */ +function typeArgumentsEnd(code: string, open: number): number { + let angles = 0; + let parens = 0; + for (let i = open; i < code.length && i < open + 400; i++) { + const ch = code[i]!; + if (ch === '<') angles++; + else if (ch === '>') { + if (code[i - 1] === '=') return -1; + if (--angles === 0) return parens === 0 ? i : -1; + } else if (ch === '(' || ch === '{') parens++; + else if (ch === ')' || ch === '}') { + if (--parens < 0) return -1; + } else if (!/[\w$\s.,?]/.test(ch)) return -1; + } + return -1; +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 0b952a641..8964590a7 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -27,6 +27,7 @@ import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; import { clearVbnetReceiverMemos, isVbMemberRead, isVbPathCall, matchVbMemberRead, matchVbPathCall } from './vbnet-receivers'; import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters'; +import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope'; import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isJsPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; @@ -471,6 +472,7 @@ export class ReferenceResolver { clearSwiftTypeVisibility(this.context); clearVbnetReceiverMemos(this.context); clearTypeParameterMemos(this.context); + clearDartLocalScopeMemos(this.context); } } @@ -1096,9 +1098,14 @@ export class ReferenceResolver { // A Swift type reference never lands on an `extension X {}` node, nor on a // nested type it cannot name bare (see ./swift-type-visibility). // A name a declaration around the reference declares as a type parameter - // (`def f[A]`, `class Foo`) is that parameter (see ./type-parameters). - const candidate = gateTypeParameter( - gateSwiftTypeTarget(this.gateTargetKind(this.resolveOneInner(ref), ref), ref, this.context), + // (`def f[A]`, `class Foo`) is that parameter (see ./type-parameters), + // and a Dart call to a parameter or local calls that (./dart-local-scope). + const candidate = gateDartLocal( + gateTypeParameter( + gateSwiftTypeTarget(this.gateTargetKind(this.resolveOneInner(ref), ref), ref, this.context), + ref, + this.context, + ), ref, this.context, );