diff --git a/src/domain/javascript/javascriptSemanticEffectPathIndex.test.ts b/src/domain/javascript/javascriptSemanticEffectPathIndex.test.ts new file mode 100644 index 0000000000..ca7338ed7a --- /dev/null +++ b/src/domain/javascript/javascriptSemanticEffectPathIndex.test.ts @@ -0,0 +1,114 @@ +import { expect, it } from "vitest"; + +import type { SemanticAliasSelection } from "./javascriptSemanticAliasSelections.js"; +import { + semanticEffectPathCandidates, + semanticEffectPathMatches, + semanticSomeEffectPathCandidate, +} from "./javascriptSemanticEffectPathIndex.js"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import type { JavaScriptSemanticMemberEffect } from "./javascriptSemanticState.js"; + +const effectKeys: JavaScriptSemanticPropertyPath = [ + "a", + "b", + "c", + 0, + "0", + 1, + null, + { excludedKeys: ["a"] }, + { excludedKeys: [], startIndex: 1 }, +]; +const selections: readonly SemanticAliasSelection[] = [ + "a", + "b", + "d", + 0, + "1", + null, + { excludedKeys: ["b"] }, + { alternatives: ["a", "c"] }, + { alternatives: [0, "0", "b"] }, + { alternatives: ["a", null] }, +]; + +it("selects exactly the linearly matching effects, in record order", () => { + let seed = 7774; + const next = (bound: number): number => { + seed = (seed * 1_103_515_245 + 12_345) % 2_147_483_648; + return Math.floor((seed / 2_147_483_648) * bound); + }; + const effects: JavaScriptSemanticMemberEffect[] = []; + let compared = 0; + for (let round = 0; round < 400; round++) { + // Records are append-only; the index must extend across queries. + for (let added = next(4); added > 0; added--) + effects.push({ + path: Array.from({ length: next(5) }, () => effectKeys[next(9)]!), + node: undefined, + }); + const path = Array.from({ length: next(5) }, () => selections[next(10)]!); + const expected = effects.filter((effect) => + semanticEffectPathMatches(effect.path, path), + ); + const candidates = semanticEffectPathCandidates(effects, path); + expect( + candidates.filter((effect) => + semanticEffectPathMatches(effect.path, path), + ), + ).toEqual(expected); + expect( + candidates.every( + (effect, index) => + index === 0 || + effects.indexOf(candidates[index - 1]!) < effects.indexOf(effect), + ), + ).toBe(true); + const evenDepth = (effect: JavaScriptSemanticMemberEffect) => + semanticEffectPathMatches(effect.path, path) && + effect.path.length % 2 === 0; + expect(semanticSomeEffectPathCandidate(effects, path, evenDepth)).toBe( + effects.some(evenDepth), + ); + compared += expected.length; + } + expect(effects.length).toBeGreaterThan(400); + expect(compared).toBeGreaterThan(0); +}); + +it("skips effects whose exact keys no selected choice names", () => { + const effects: JavaScriptSemanticMemberEffect[] = [ + { path: ["a", "x"], node: undefined }, + { path: ["b"], node: undefined }, + { path: [null, "x"], node: undefined }, + { path: ["a", "x", "y"], node: undefined }, + { path: [], node: undefined }, + ]; + expect( + semanticEffectPathCandidates(effects, ["a", "x"]).map(({ path }) => path), + ).toEqual([["a", "x"], [null, "x"], []]); + expect( + semanticEffectPathCandidates(effects, [{ alternatives: ["b", "c"] }]).map( + ({ path }) => path, + ), + ).toEqual([["b"], []]); +}); + +it("stops visiting candidates at the first satisfied predicate", () => { + const effects: JavaScriptSemanticMemberEffect[] = Array.from( + { length: 100 }, + () => ({ path: ["a"], node: undefined }), + ); + let visited = 0; + expect( + semanticSomeEffectPathCandidate(effects, ["a"], () => { + visited += 1; + return true; + }), + ).toBe(true); + expect(visited).toBe(1); + expect(semanticSomeEffectPathCandidate(effects, ["b"], () => true)).toBe( + false, + ); +}); diff --git a/src/domain/javascript/javascriptSemanticEffectPathIndex.ts b/src/domain/javascript/javascriptSemanticEffectPathIndex.ts new file mode 100644 index 0000000000..73bfa27b5b --- /dev/null +++ b/src/domain/javascript/javascriptSemanticEffectPathIndex.ts @@ -0,0 +1,140 @@ +import type { JavaScriptSemanticMemberEffect } from "./javascriptSemanticState.js"; +import { + semanticAliasSelectionChoices, + type SemanticAliasSelectionPath, +} from "./javascriptSemanticAliasSelections.js"; +import { + semanticPropertyPathKeyMatches, + type JavaScriptSemanticPropertyPath, +} from "./javascriptSemanticPropertyPaths.js"; + +/** Whether an effect recorded at one member path can reach a projected read. */ +export const semanticEffectPathMatches = ( + effectPath: JavaScriptSemanticPropertyPath, + path: SemanticAliasSelectionPath, +): boolean => + path.length > 0 && + effectPath.length <= path.length && + effectPath.every((key, index) => { + const selected = path[index]; + return ( + selected !== undefined && + semanticAliasSelectionChoices(selected).some( + (choice) => + choice === null || + typeof choice === "object" || + semanticPropertyPathKeyMatches(key, String(choice)), + ) + ); + }); + +// Most nodes are leaves of one path. Materialize each collection only when it +// holds a record or child. +interface EffectPathNode { + /** Record positions whose recorded path ends at this depth. */ + ends?: number[]; + /** Children for exact keys, by their string property name. */ + exact?: Map; + /** Child for a wildcard or copied-slot key, which can match any name. */ + open?: EffectPathNode; +} + +interface EffectPathIndex { + readonly root: EffectPathNode; + indexed: number; +} + +// Binding member effects are append-only, so each index extends itself with +// records appended since its previous query and lives as long as its records. +const effectPathIndexes = new WeakMap< + readonly JavaScriptSemanticMemberEffect[], + EffectPathIndex +>(); + +const currentEffectPathIndex = ( + effects: readonly JavaScriptSemanticMemberEffect[], +): EffectPathIndex => { + let index = effectPathIndexes.get(effects); + if (index === undefined) { + index = { root: {}, indexed: 0 }; + effectPathIndexes.set(effects, index); + } + for (; index.indexed < effects.length; index.indexed++) { + let node = index.root; + for (const key of effects[index.indexed]!.path) { + if (key === null || typeof key === "object") node = node.open ??= {}; + else { + const exact = (node.exact ??= new Map()); + const name = String(key); + let child = exact.get(name); + if (child === undefined) { + child = {}; + exact.set(name, child); + } + node = child; + } + } + (node.ends ??= []).push(index.indexed); + } + return index; +}; + +// Visit record positions whose recorded path may match a projected read, in +// index order. Only records longer than the read, or with an exact key that no +// selected choice names, are skipped; wildcard and copied-slot keys remain. +// Returns true as soon as `visit` does. +const visitEffectPathCandidates = ( + effects: readonly JavaScriptSemanticMemberEffect[], + path: SemanticAliasSelectionPath, + visit: (position: number) => boolean, +): boolean => { + const pending = [{ node: currentEffectPathIndex(effects).root, depth: 0 }]; + while (pending.length > 0) { + const { node, depth } = pending.pop()!; + for (const position of node.ends ?? []) if (visit(position)) return true; + if (depth >= path.length) continue; + if (node.open !== undefined) + pending.push({ node: node.open, depth: depth + 1 }); + const exact = node.exact; + if (exact === undefined) continue; + const choices = semanticAliasSelectionChoices(path[depth]!); + if (choices.some((choice) => choice === null || typeof choice === "object")) + for (const child of exact.values()) + pending.push({ node: child, depth: depth + 1 }); + else + for (const name of new Set(choices.map((choice) => String(choice)))) { + const child = exact.get(name); + if (child !== undefined) + pending.push({ node: child, depth: depth + 1 }); + } + } + return false; +}; + +/** + * Effects whose recorded path may match a projected read, in record order. + * Candidates are a superset, so callers still apply `semanticEffectPathMatches`. + */ +export const semanticEffectPathCandidates = ( + effects: readonly JavaScriptSemanticMemberEffect[], + path: SemanticAliasSelectionPath, +): readonly JavaScriptSemanticMemberEffect[] => { + const positions: number[] = []; + visitEffectPathCandidates(effects, path, (position) => { + positions.push(position); + return false; + }); + return positions + .sort((left, right) => left - right) + .map((position) => effects[position]!); +}; + +/** Whether any candidate effect for a projected read satisfies `predicate`. */ +export const semanticSomeEffectPathCandidate = ( + effects: readonly JavaScriptSemanticMemberEffect[], + path: SemanticAliasSelectionPath, + predicate: (effect: JavaScriptSemanticMemberEffect) => boolean, +): boolean => + visitEffectPathCandidates(effects, path, (position) => + predicate(effects[position]!), + ); diff --git a/src/domain/javascript/javascriptSemanticProjectedSources.ts b/src/domain/javascript/javascriptSemanticProjectedSources.ts index 79fb463a71..152271cfb4 100644 --- a/src/domain/javascript/javascriptSemanticProjectedSources.ts +++ b/src/domain/javascript/javascriptSemanticProjectedSources.ts @@ -3,10 +3,7 @@ import type { JavaScriptSemanticAnalysisState, JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; -import { - semanticPropertyPathKeyMatches, - type JavaScriptSemanticPropertyPath, -} from "./javascriptSemanticPropertyPaths.js"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; import { semanticObjectPropertyReferences, @@ -26,6 +23,11 @@ import { semanticReferenceSourcesInactive, } from "./javascriptSemanticReferenceSources.js"; import { semanticStoredSourceSelection } from "./javascriptSemanticIterationSources.js"; +import { + semanticEffectPathCandidates, + semanticEffectPathMatches, + semanticSomeEffectPathCandidate, +} from "./javascriptSemanticEffectPathIndex.js"; import { semanticAliasSelectionChoices, type SemanticAliasSelectionPath, @@ -301,20 +303,7 @@ const projectedSourceFactsAtRead = ( const matches = ( effect: JavaScriptSemanticBindingState["memberWrites"][number], ) => - path.length > 0 && - effect.path.length <= path.length && - effect.path.every((key, index) => { - const selected = path[index]; - return ( - selected !== undefined && - semanticAliasSelectionChoices(selected).some( - (choice) => - choice === null || - typeof choice === "object" || - semanticPropertyPathKeyMatches(key, String(choice)), - ) - ); - }) && + semanticEffectPathMatches(effect.path, path) && (effect.receiverInitializers === undefined || effect.receiverInitializers.length === 0 || captured.length === 0 || @@ -329,7 +318,10 @@ const projectedSourceFactsAtRead = ( semanticEffectTiming(selectedAt(effect.path.length), effect.node, state) !== "after" && !targetsReplacedPrefix(effect); - const writes = binding.memberWrites.filter(matches); + const writes = semanticEffectPathCandidates( + binding.memberWrites, + path, + ).filter(matches); let replacement: | JavaScriptSemanticBindingState["memberWrites"][number] | undefined; @@ -374,7 +366,9 @@ const projectedSourceFactsAtRead = ( (replacement.assignedSources?.[0]?.target?.start ?? -1))); if ( includeUnknown && - binding.escapedPaths.some( + semanticSomeEffectPathCandidate( + binding.escapedPaths, + path, (effect) => matches(effect) && !superseded(effect), ) )