From ce9657d5e2ca952144842532033dbb3148e9ab17 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 11 Mar 2022 16:17:54 -0800 Subject: [PATCH] Introduce set with custom equals and getHashCode (#48169) * Implement set with custom equals and getHashCode * Adopt custom set in session * Add doc comment * Initially store buckets as non-arrays --- src/compiler/core.ts | 153 +++++++++++++++++++++ src/server/session.ts | 12 +- src/testRunner/unittests/compilerCore.ts | 162 +++++++++++++++++++++++ 3 files changed, 325 insertions(+), 2 deletions(-) diff --git a/src/compiler/core.ts b/src/compiler/core.ts index b9adfb94fc8..d30eb0569a6 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -1488,6 +1488,159 @@ namespace ts { return createMultiMap() as UnderscoreEscapedMultiMap; } + /** + * Creates a Set with custom equality and hash code functionality. This is useful when you + * want to use something looser than object identity - e.g. "has the same span". + * + * If `equals(a, b)`, it must be the case that `getHashCode(a) === getHashCode(b)`. + * The converse is not required. + * + * To facilitate a perf optimization (lazy allocation of bucket arrays), `TElement` is + * assumed not to be an array type. + */ + export function createSet(getHashCode: (element: TElement) => THash, equals: EqualityComparer): Set { + const multiMap = new Map(); + let size = 0; + + function getElementIterator(): Iterator { + const valueIt = multiMap.values(); + let arrayIt: Iterator | undefined; + return { + next: () => { + while (true) { + if (arrayIt) { + const n = arrayIt.next(); + if (!n.done) { + return { value: n.value }; + } + arrayIt = undefined; + } + else { + const n = valueIt.next(); + if (n.done) { + return { value: undefined, done: true }; + } + if (!isArray(n.value)) { + return { value: n.value }; + } + arrayIt = arrayIterator(n.value); + } + } + } + }; + } + + const set: Set = { + has(element: TElement): boolean { + const hash = getHashCode(element); + if (!multiMap.has(hash)) return false; + const candidates = multiMap.get(hash)!; + if (!isArray(candidates)) return equals(candidates, element); + + for (const candidate of candidates) { + if (equals(candidate, element)) { + return true; + } + } + return false; + }, + add(element: TElement): Set { + const hash = getHashCode(element); + if (multiMap.has(hash)) { + const values = multiMap.get(hash)!; + if (isArray(values)) { + if (!contains(values, element, equals)) { + values.push(element); + size++; + } + } + else { + const value = values; + if (!equals(value, element)) { + multiMap.set(hash, [ value, element ]); + size++; + } + } + } + else { + multiMap.set(hash, element); + size++; + } + + return this; + }, + delete(element: TElement): boolean { + const hash = getHashCode(element); + if (!multiMap.has(hash)) return false; + const candidates = multiMap.get(hash)!; + if (isArray(candidates)) { + for (let i = 0; i < candidates.length; i++) { + if (equals(candidates[i], element)) { + if (candidates.length === 1) { + multiMap.delete(hash); + } + else if (candidates.length === 2) { + multiMap.set(hash, candidates[1 - i]); + } + else { + unorderedRemoveItemAt(candidates, i); + } + size--; + return true; + } + } + } + else { + const candidate = candidates; + if (equals(candidate, element)) { + multiMap.delete(hash); + size--; + return true; + } + } + + return false; + }, + clear(): void { + multiMap.clear(); + size = 0; + }, + get size() { + return size; + }, + forEach(action: (value: TElement, key: TElement) => void): void { + for (const elements of arrayFrom(multiMap.values())) { + if (isArray(elements)) { + for (const element of elements) { + action(element, element); + } + } + else { + const element = elements; + action(element, element); + } + } + }, + keys(): Iterator { + return getElementIterator(); + }, + values(): Iterator { + return getElementIterator(); + }, + entries(): Iterator<[TElement, TElement]> { + const it = getElementIterator(); + return { + next: () => { + const n = it.next(); + return n.done ? n : { value: [ n.value, n.value ] }; + } + }; + }, + }; + + return set; + } + /** * Tests whether a value is an array. */ diff --git a/src/server/session.ts b/src/server/session.ts index c2f86700886..b1807555462 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -299,6 +299,10 @@ namespace ts.server { navigateToItems: readonly NavigateToItem[]; }; + function createDocumentSpanSet(): Set { + return createSet(({textSpan}) => textSpan.start + 100003 * textSpan.length, documentSpansEqual); + } + function combineProjectOutputForRenameLocations( projects: Projects, defaultProject: Project, @@ -308,6 +312,7 @@ namespace ts.server { { providePrefixAndSuffixTextForRename }: UserPreferences ): readonly RenameLocation[] { const outputs: RenameLocation[] = []; + const seen = createDocumentSpanSet(); combineProjectOutputWorker( projects, defaultProject, @@ -316,7 +321,8 @@ namespace ts.server { const projectOutputs = project.getLanguageService().findRenameLocations(location.fileName, location.pos, findInStrings, findInComments, providePrefixAndSuffixTextForRename); if (projectOutputs) { for (const output of projectOutputs) { - if (!contains(outputs, output, documentSpansEqual) && !tryAddToTodo(project, documentSpanLocation(output))) { + if (!seen.has(output) && !tryAddToTodo(project, documentSpanLocation(output))) { + seen.add(output); outputs.push(output); } } @@ -1558,6 +1564,7 @@ namespace ts.server { const fileName = args.file; const references: ReferenceEntry[] = []; + const seen = createDocumentSpanSet(); forEachProjectInProjects(projects, /*path*/ undefined, project => { if (project.getCancellationToken().isCancellationRequested()) return; @@ -1565,8 +1572,9 @@ namespace ts.server { const projectOutputs = project.getLanguageService().getFileReferences(fileName); if (projectOutputs) { for (const referenceEntry of projectOutputs) { - if (!contains(references, referenceEntry, documentSpansEqual)) { + if (!seen.has(referenceEntry)) { references.push(referenceEntry); + seen.add(referenceEntry); } } } diff --git a/src/testRunner/unittests/compilerCore.ts b/src/testRunner/unittests/compilerCore.ts index 4c3918c3149..49c9601a392 100644 --- a/src/testRunner/unittests/compilerCore.ts +++ b/src/testRunner/unittests/compilerCore.ts @@ -29,5 +29,167 @@ namespace ts { assert.isTrue(equalOwnProperties({ a: 1 }, { a: 2 }, () => true), "valid equality"); }); }); + describe("customSet", () => { + it("mutation", () => { + const set = createSet(x => x % 2, (x, y) => (x % 4) === (y % 4)); + assert.equal(set.size, 0); + + const newSet = set.add(0); + assert.strictEqual(newSet, set); + assert.equal(set.size, 1); + + set.add(1); + assert.equal(set.size, 2); + + set.add(2); // Collision with 0 + assert.equal(set.size, 3); + + set.add(3); // Collision with 1 + assert.equal(set.size, 4); + + set.add(4); // Already present as 0 + assert.equal(set.size, 4); + + set.add(5); // Already present as 1 + assert.equal(set.size, 4); + + assert.isTrue(set.has(6)); + assert.isTrue(set.has(7)); + + assert.isTrue(set.delete(8)); + assert.equal(set.size, 3); + assert.isFalse(set.has(8)); + assert.isFalse(set.delete(8)); + + assert.isTrue(set.delete(9)); + assert.equal(set.size, 2); + + assert.isTrue(set.delete(10)); + assert.equal(set.size, 1); + + assert.isTrue(set.delete(11)); + assert.equal(set.size, 0); + }); + it("resizing", () => { + const set = createSet(x => x % 2, (x, y) => x === y); + const elementCount = 100; + + for (let i = 0; i < elementCount; i++) { + assert.isFalse(set.has(i)); + set.add(i); + assert.isTrue(set.has(i)); + assert.equal(set.size, i + 1); + } + + for (let i = 0; i < elementCount; i++) { + assert.isTrue(set.has(i)); + set.delete(i); + assert.isFalse(set.has(i)); + assert.equal(set.size, elementCount - (i + 1)); + } + }); + it("clear", () => { + const set = createSet(x => x % 2, (x, y) => (x % 4) === (y % 4)); + for (let j = 0; j < 2; j++) { + for (let i = 0; i < 100; i++) { + set.add(i); + } + assert.equal(set.size, 4); + + set.clear(); + assert.equal(set.size, 0); + assert.isFalse(set.has(0)); + } + }); + it("forEach", () => { + const set = createSet(x => x % 2, (x, y) => (x % 4) === (y % 4)); + for (let i = 0; i < 100; i++) { + set.add(i); + } + + const values: number[] = []; + const keys: number[] = []; + set.forEach((value, key) => { + values.push(value); + keys.push(key); + }); + + assert.equal(values.length, 4); + + values.sort(); + keys.sort(); + + // NB: first equal value wins (i.e. not [96, 97, 98, 99]) + const expected = [0, 1, 2, 3]; + assert.deepEqual(values, expected); + assert.deepEqual(keys, expected); + }); + it("iteration", () => { + const set = createSet(x => x % 2, (x, y) => (x % 4) === (y % 4)); + for (let i = 0; i < 4; i++) { + set.add(i); + } + + const expected = [0, 1, 2, 3]; + let actual: number[]; + + actual = arrayFrom(set.keys()); + actual.sort(); + assert.deepEqual(actual, expected); + + actual = arrayFrom(set.values()); + actual.sort(); + assert.deepEqual(actual, expected); + + const actualTuple = arrayFrom(set.entries()); + assert.isFalse(actualTuple.some(([v, k]) => v !== k)); + actual = actualTuple.map(([v, _]) => v); + actual.sort(); + assert.deepEqual(actual, expected); + }); + it("string hash code", () => { + interface Thing { + x: number; + y: string; + } + + const set = createSet(t => t.y, (t, u) => t.x === u.x && t.y === u.y); + + const thing1: Thing = { + x: 1, + y: "a", + }; + + const thing2: Thing = { + x: 2, + y: "b", + }; + + const thing3: Thing = { + x: 3, + y: "a", // Collides with thing1 + }; + + set.add(thing1); + set.add(thing2); + set.add(thing3); + + assert.equal(set.size, 3); + + assert.isTrue(set.has(thing1)); + assert.isTrue(set.has(thing2)); + assert.isTrue(set.has(thing3)); + + assert.isFalse(set.has({ + x: 4, + y: "a", // Collides with thing1 + })); + + assert.isFalse(set.has({ + x: 5, + y: "c", // No collision + })); + }); + }); }); }