From 03a56b6025806e2932d4d58959d8fe91a4c24944 Mon Sep 17 00:00:00 2001 From: David Michon Date: Wed, 7 May 2025 22:01:45 +0000 Subject: [PATCH 1/2] [lookup-by-path] Expose tree, add comparer --- ...git-first-difference_2025-05-07-22-00.json | 10 ++ ...git-first-difference_2025-05-07-22-01.json | 10 ++ common/reviews/api/lookup-by-path.api.md | 6 + libraries/lookup-by-path/src/LookupByPath.ts | 34 +++- .../src/getFirstDifferenceInCommonNodes.ts | 57 +++++++ .../getFirstDifferenceInCommonNodes.test.ts | 158 ++++++++++++++++++ 6 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 common/changes/@rushstack/lookup-by-path/git-first-difference_2025-05-07-22-00.json create mode 100644 common/changes/@rushstack/lookup-by-path/git-first-difference_2025-05-07-22-01.json create mode 100644 libraries/lookup-by-path/src/getFirstDifferenceInCommonNodes.ts create mode 100644 libraries/lookup-by-path/src/test/getFirstDifferenceInCommonNodes.test.ts diff --git a/common/changes/@rushstack/lookup-by-path/git-first-difference_2025-05-07-22-00.json b/common/changes/@rushstack/lookup-by-path/git-first-difference_2025-05-07-22-00.json new file mode 100644 index 00000000000..1ffbe9077f6 --- /dev/null +++ b/common/changes/@rushstack/lookup-by-path/git-first-difference_2025-05-07-22-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/lookup-by-path", + "comment": "Add `getFirstDifferenceInCommonNodes` API.", + "type": "minor" + } + ], + "packageName": "@rushstack/lookup-by-path" +} \ No newline at end of file diff --git a/common/changes/@rushstack/lookup-by-path/git-first-difference_2025-05-07-22-01.json b/common/changes/@rushstack/lookup-by-path/git-first-difference_2025-05-07-22-01.json new file mode 100644 index 00000000000..d044b219aed --- /dev/null +++ b/common/changes/@rushstack/lookup-by-path/git-first-difference_2025-05-07-22-01.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/lookup-by-path", + "comment": "Expose `tree` accessor on `IReadonlyLookupByPath` for a readonly view of the raw tree.", + "type": "minor" + } + ], + "packageName": "@rushstack/lookup-by-path" +} \ No newline at end of file diff --git a/common/reviews/api/lookup-by-path.api.md b/common/reviews/api/lookup-by-path.api.md index 694afe0e223..770139fe968 100644 --- a/common/reviews/api/lookup-by-path.api.md +++ b/common/reviews/api/lookup-by-path.api.md @@ -22,6 +22,10 @@ export interface IReadonlyLookupByPath extends Iterable<[strin groupByChild(infoByPath: Map, delimiter?: string): Map>; has(query: string, delimiter?: string): boolean; get size(): number; + // Warning: (ae-forgotten-export) The symbol "IReadonlyPathTrieNode" needs to be exported by the entry point index.d.ts + // + // (undocumented) + get tree(): IReadonlyPathTrieNode; } // @beta @@ -42,6 +46,8 @@ export class LookupByPath implements IReadonlyLookupByPath, value: TItem): this; get size(): number; + // (undocumented) + get tree(): IReadonlyPathTrieNode; } ``` diff --git a/libraries/lookup-by-path/src/LookupByPath.ts b/libraries/lookup-by-path/src/LookupByPath.ts index 63868c0a677..957944ed83d 100644 --- a/libraries/lookup-by-path/src/LookupByPath.ts +++ b/libraries/lookup-by-path/src/LookupByPath.ts @@ -16,6 +16,26 @@ interface IPathTrieNode { children: Map> | undefined; } +/** + * Readonly view of a node in the path trie used in LookupByPath + * + * @remarks + * This interface is used to facilitate parallel traversals for comparing two `LookupByPath` instances. + * + * @beta + */ +export interface IReadonlyPathTrieNode { + /** + * The value that exactly matches the current relative path + */ + readonly value: TItem | undefined; + + /** + * Child nodes by subfolder + */ + readonly children: ReadonlyMap> | undefined; +} + interface IPrefixEntry { /** * The prefix that was matched @@ -123,6 +143,11 @@ export interface IReadonlyLookupByPath extends Iterable<[strin */ get size(): number; + /** + * @returns The root node of the trie, corresponding to the path '' + */ + get tree(): IReadonlyPathTrieNode; + /** * Iterates over the entries in this trie. * @@ -263,12 +288,19 @@ export class LookupByPath implements IReadonlyLookupByPath { + return this._root; + } + /** * Deletes all entries from this `LookupByPath` instance. * diff --git a/libraries/lookup-by-path/src/getFirstDifferenceInCommonNodes.ts b/libraries/lookup-by-path/src/getFirstDifferenceInCommonNodes.ts new file mode 100644 index 00000000000..6767333e717 --- /dev/null +++ b/libraries/lookup-by-path/src/getFirstDifferenceInCommonNodes.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReadonlyPathTrieNode } from './LookupByPath'; + +/** + * Recursively compares two path tries to find the first shared node with a different value. + * + * @param first - The first node to compare + * @param second - The second node to compare + * @param prefix - The path prefix to the current node + * @param delimiter - The delimiter used to join path segments + * @param equals - A function to compare the values of the nodes + * @returns The path to the first differing node, or undefined if they are identical + * + * @remarks + * Ignores any nodes that are not shared between the two tries. + */ +export function getFirstDifferenceInCommonNodes( + first: IReadonlyPathTrieNode, + second: IReadonlyPathTrieNode, + prefix: string = '', + delimiter: string = '/', + equals: (a: TItem, b: TItem) => boolean = (a, b) => a === b +): string | undefined { + const firstItem: TItem | undefined = first.value; + const secondItem: TItem | undefined = second.value; + + if (firstItem !== undefined && secondItem !== undefined && !equals(firstItem, secondItem)) { + // If the value at this node changed, we only care if we were tracking it to begin with. + return prefix; + } + + const { children: firstChildren } = first; + const { children: secondChildren } = second; + + if (firstChildren && secondChildren) { + for (const [key, lastInputChild] of firstChildren) { + const secondChild: IReadonlyPathTrieNode | undefined = secondChildren.get(key); + if (!secondChild) { + continue; + } + const result: string | undefined = getFirstDifferenceInCommonNodes( + lastInputChild, + secondChild, + key, + delimiter, + equals + ); + if (result !== undefined) { + return prefix ? `${prefix}${delimiter}${result}` : result; + } + } + } + + return; +} diff --git a/libraries/lookup-by-path/src/test/getFirstDifferenceInCommonNodes.test.ts b/libraries/lookup-by-path/src/test/getFirstDifferenceInCommonNodes.test.ts new file mode 100644 index 00000000000..405470fdc16 --- /dev/null +++ b/libraries/lookup-by-path/src/test/getFirstDifferenceInCommonNodes.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { getFirstDifferenceInCommonNodes } from '../getFirstDifferenceInCommonNodes'; +import type { IReadonlyPathTrieNode } from '../LookupByPath'; + +describe(getFirstDifferenceInCommonNodes.name, () => { + it('detects a changed file at the current node', () => { + const last: IReadonlyPathTrieNode = { + children: undefined, + value: 'old' + }; + const current: IReadonlyPathTrieNode = { + children: undefined, + value: 'new' + }; + + expect(getFirstDifferenceInCommonNodes(last, current)).toBe(''); + expect(getFirstDifferenceInCommonNodes(current, last)).toBe(''); + + const prefix: string = 'some/prefix'; + + expect(getFirstDifferenceInCommonNodes(last, current, prefix)).toBe(prefix); + expect(getFirstDifferenceInCommonNodes(current, last, prefix)).toBe(prefix); + }); + + it('detects no changes when both nodes are identical', () => { + const last: IReadonlyPathTrieNode = { + children: new Map([ + [ + 'same', + { + children: undefined, + value: 'same' + } + ] + ]), + value: undefined + }; + const current: IReadonlyPathTrieNode = { + children: new Map([ + [ + 'same', + { + children: undefined, + value: 'same' + } + ] + ]), + value: undefined + }; + + expect(getFirstDifferenceInCommonNodes(last, current)).toBeUndefined(); + expect(getFirstDifferenceInCommonNodes(current, last)).toBeUndefined(); + }); + + it('detects no changes when both nodes are identical based on a custom equals', () => { + const last: IReadonlyPathTrieNode = { + children: new Map([ + [ + 'same', + { + children: undefined, + value: 'same' + } + ] + ]), + value: undefined + }; + const current: IReadonlyPathTrieNode = { + children: new Map([ + [ + 'same', + { + children: undefined, + value: 'other' + } + ] + ]), + value: undefined + }; + + function customEquals(a: string, b: string): boolean { + return a === b || (a === 'same' && b === 'other') || (a === 'other' && b === 'same'); + } + + expect(getFirstDifferenceInCommonNodes(last, current, '', '/', customEquals)).toBeUndefined(); + expect(getFirstDifferenceInCommonNodes(current, last, '', '/', customEquals)).toBeUndefined(); + }); + + it('detects no changes for extra children', () => { + const last: IReadonlyPathTrieNode = { + children: undefined, + value: undefined + }; + const current: IReadonlyPathTrieNode = { + children: new Map([ + [ + 'same', + { + children: undefined, + value: 'same' + } + ] + ]), + value: undefined + }; + + expect(getFirstDifferenceInCommonNodes(last, current)).toBeUndefined(); + expect(getFirstDifferenceInCommonNodes(current, last)).toBeUndefined(); + }); + + it('detects no changes if the set of common nodes differs', () => { + const last: IReadonlyPathTrieNode = { + children: undefined, + value: undefined + }; + const current: IReadonlyPathTrieNode = { + children: undefined, + value: 'new' + }; + + expect(getFirstDifferenceInCommonNodes(last, current)).toBeUndefined(); + expect(getFirstDifferenceInCommonNodes(current, last)).toBeUndefined(); + }); + + it('detects a nested change', () => { + const last: IReadonlyPathTrieNode = { + children: new Map([ + [ + 'child', + { + children: undefined, + value: 'old' + } + ] + ]), + value: undefined + }; + const current: IReadonlyPathTrieNode = { + children: new Map([ + [ + 'child', + { + children: undefined, + value: 'new' + } + ] + ]), + value: undefined + }; + + const prefix: string = 'some/prefix'; + + expect(getFirstDifferenceInCommonNodes(last, current, prefix, '@')).toBe('some/prefix@child'); + expect(getFirstDifferenceInCommonNodes(current, last, prefix, '@')).toBe('some/prefix@child'); + }); +}); From 10f82c077f74b9ef23b0049a96488f1f74b94f39 Mon Sep 17 00:00:00 2001 From: David Michon Date: Wed, 7 May 2025 22:48:39 +0000 Subject: [PATCH 2/2] Use options object, expose interfaces --- common/reviews/api/lookup-by-path.api.md | 20 +++- .../src/getFirstDifferenceInCommonNodes.ts | 95 +++++++++++++--- libraries/lookup-by-path/src/index.ts | 4 +- .../getFirstDifferenceInCommonNodes.test.ts | 106 +++++++++++++++--- 4 files changed, 191 insertions(+), 34 deletions(-) diff --git a/common/reviews/api/lookup-by-path.api.md b/common/reviews/api/lookup-by-path.api.md index 770139fe968..c6646149dff 100644 --- a/common/reviews/api/lookup-by-path.api.md +++ b/common/reviews/api/lookup-by-path.api.md @@ -4,6 +4,18 @@ ```ts +// @beta +export function getFirstDifferenceInCommonNodes(options: IGetFirstDifferenceInCommonNodesOptions): string | undefined; + +// @beta +export interface IGetFirstDifferenceInCommonNodesOptions { + delimiter?: string; + equals?: (a: TItem, b: TItem) => boolean; + first: IReadonlyPathTrieNode; + prefix?: string; + second: IReadonlyPathTrieNode; +} + // @beta export interface IPrefixMatch { index: number; @@ -22,12 +34,16 @@ export interface IReadonlyLookupByPath extends Iterable<[strin groupByChild(infoByPath: Map, delimiter?: string): Map>; has(query: string, delimiter?: string): boolean; get size(): number; - // Warning: (ae-forgotten-export) The symbol "IReadonlyPathTrieNode" needs to be exported by the entry point index.d.ts - // // (undocumented) get tree(): IReadonlyPathTrieNode; } +// @beta +export interface IReadonlyPathTrieNode { + readonly children: ReadonlyMap> | undefined; + readonly value: TItem | undefined; +} + // @beta export class LookupByPath implements IReadonlyLookupByPath { [Symbol.iterator](query?: string, delimiter?: string): IterableIterator<[string, TItem]>; diff --git a/libraries/lookup-by-path/src/getFirstDifferenceInCommonNodes.ts b/libraries/lookup-by-path/src/getFirstDifferenceInCommonNodes.ts index 6767333e717..760e00413d2 100644 --- a/libraries/lookup-by-path/src/getFirstDifferenceInCommonNodes.ts +++ b/libraries/lookup-by-path/src/getFirstDifferenceInCommonNodes.ts @@ -3,31 +3,81 @@ import type { IReadonlyPathTrieNode } from './LookupByPath'; +/** + * Options for the getFirstDifferenceInCommonNodes function. + * @beta + */ +export interface IGetFirstDifferenceInCommonNodesOptions { + /** + * The first node to compare. + */ + first: IReadonlyPathTrieNode; + /** + * The second node to compare. + */ + second: IReadonlyPathTrieNode; + /** + * The path prefix to the current node. + * @defaultValue '' + */ + prefix?: string; + /** + * The delimiter used to join path segments. + * @defaultValue '/' + */ + delimiter?: string; + /** + * A function to compare the values of the nodes. + * If not provided, strict equality (===) is used. + */ + equals?: (a: TItem, b: TItem) => boolean; +} + /** * Recursively compares two path tries to find the first shared node with a different value. * - * @param first - The first node to compare - * @param second - The second node to compare - * @param prefix - The path prefix to the current node - * @param delimiter - The delimiter used to join path segments - * @param equals - A function to compare the values of the nodes + * @param options - The options for the comparison * @returns The path to the first differing node, or undefined if they are identical * * @remarks * Ignores any nodes that are not shared between the two tries. + * + * @beta */ export function getFirstDifferenceInCommonNodes( - first: IReadonlyPathTrieNode, - second: IReadonlyPathTrieNode, - prefix: string = '', - delimiter: string = '/', - equals: (a: TItem, b: TItem) => boolean = (a, b) => a === b + options: IGetFirstDifferenceInCommonNodesOptions ): string | undefined { + const { first, second, prefix = '', delimiter = '/', equals = defaultEquals } = options; + + return getFirstDifferenceInCommonNodesInternal({ + first, + second, + prefix, + delimiter, + equals + }); +} + +/** + * Recursively compares two path tries to find the first shared node with a different value. + * + * @param options - The options for the comparison + * @returns The path to the first differing node, or undefined if they are identical + * + * @remarks + * Ignores any nodes that are not shared between the two tries. + * Separated out to avoid redundant parameter defaulting in the recursive calls. + */ +function getFirstDifferenceInCommonNodesInternal( + options: Required> +): string | undefined { + const { first, second, prefix, delimiter, equals } = options; + const firstItem: TItem | undefined = first.value; const secondItem: TItem | undefined = second.value; if (firstItem !== undefined && secondItem !== undefined && !equals(firstItem, secondItem)) { - // If the value at this node changed, we only care if we were tracking it to begin with. + // If this value was present in both tries with different values, return the prefix for this node. return prefix; } @@ -35,18 +85,19 @@ export function getFirstDifferenceInCommonNodes( const { children: secondChildren } = second; if (firstChildren && secondChildren) { - for (const [key, lastInputChild] of firstChildren) { + for (const [key, firstChild] of firstChildren) { const secondChild: IReadonlyPathTrieNode | undefined = secondChildren.get(key); if (!secondChild) { continue; } - const result: string | undefined = getFirstDifferenceInCommonNodes( - lastInputChild, - secondChild, - key, + const result: string | undefined = getFirstDifferenceInCommonNodesInternal({ + first: firstChild, + second: secondChild, + prefix: key, delimiter, equals - ); + }); + if (result !== undefined) { return prefix ? `${prefix}${delimiter}${result}` : result; } @@ -55,3 +106,13 @@ export function getFirstDifferenceInCommonNodes( return; } + +/** + * Default equality function for comparing two items, using strict equality. + * @param a - The first item to compare + * @param b - The second item to compare + * @returns True if the items are reference equal, false otherwise + */ +function defaultEquals(a: TItem, b: TItem): boolean { + return a === b; +} diff --git a/libraries/lookup-by-path/src/index.ts b/libraries/lookup-by-path/src/index.ts index 1beccb0d9ec..c91d2692ef9 100644 --- a/libraries/lookup-by-path/src/index.ts +++ b/libraries/lookup-by-path/src/index.ts @@ -7,5 +7,7 @@ * @packageDocumentation */ -export type { IPrefixMatch, IReadonlyLookupByPath } from './LookupByPath'; +export type { IPrefixMatch, IReadonlyLookupByPath, IReadonlyPathTrieNode } from './LookupByPath'; export { LookupByPath } from './LookupByPath'; +export type { IGetFirstDifferenceInCommonNodesOptions } from './getFirstDifferenceInCommonNodes'; +export { getFirstDifferenceInCommonNodes } from './getFirstDifferenceInCommonNodes'; diff --git a/libraries/lookup-by-path/src/test/getFirstDifferenceInCommonNodes.test.ts b/libraries/lookup-by-path/src/test/getFirstDifferenceInCommonNodes.test.ts index 405470fdc16..2a85dec9f4f 100644 --- a/libraries/lookup-by-path/src/test/getFirstDifferenceInCommonNodes.test.ts +++ b/libraries/lookup-by-path/src/test/getFirstDifferenceInCommonNodes.test.ts @@ -15,13 +15,35 @@ describe(getFirstDifferenceInCommonNodes.name, () => { value: 'new' }; - expect(getFirstDifferenceInCommonNodes(last, current)).toBe(''); - expect(getFirstDifferenceInCommonNodes(current, last)).toBe(''); + expect( + getFirstDifferenceInCommonNodes({ + first: last, + second: current + }) + ).toBe(''); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last + }) + ).toBe(''); const prefix: string = 'some/prefix'; - expect(getFirstDifferenceInCommonNodes(last, current, prefix)).toBe(prefix); - expect(getFirstDifferenceInCommonNodes(current, last, prefix)).toBe(prefix); + expect( + getFirstDifferenceInCommonNodes({ + first: last, + second: current, + prefix + }) + ).toBe(prefix); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last, + prefix + }) + ).toBe(prefix); }); it('detects no changes when both nodes are identical', () => { @@ -50,8 +72,18 @@ describe(getFirstDifferenceInCommonNodes.name, () => { value: undefined }; - expect(getFirstDifferenceInCommonNodes(last, current)).toBeUndefined(); - expect(getFirstDifferenceInCommonNodes(current, last)).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: last, + second: current + }) + ).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last + }) + ).toBeUndefined(); }); it('detects no changes when both nodes are identical based on a custom equals', () => { @@ -84,8 +116,20 @@ describe(getFirstDifferenceInCommonNodes.name, () => { return a === b || (a === 'same' && b === 'other') || (a === 'other' && b === 'same'); } - expect(getFirstDifferenceInCommonNodes(last, current, '', '/', customEquals)).toBeUndefined(); - expect(getFirstDifferenceInCommonNodes(current, last, '', '/', customEquals)).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: last, + second: current, + equals: customEquals + }) + ).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last, + equals: customEquals + }) + ).toBeUndefined(); }); it('detects no changes for extra children', () => { @@ -106,8 +150,18 @@ describe(getFirstDifferenceInCommonNodes.name, () => { value: undefined }; - expect(getFirstDifferenceInCommonNodes(last, current)).toBeUndefined(); - expect(getFirstDifferenceInCommonNodes(current, last)).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: last, + second: current + }) + ).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last + }) + ).toBeUndefined(); }); it('detects no changes if the set of common nodes differs', () => { @@ -120,8 +174,18 @@ describe(getFirstDifferenceInCommonNodes.name, () => { value: 'new' }; - expect(getFirstDifferenceInCommonNodes(last, current)).toBeUndefined(); - expect(getFirstDifferenceInCommonNodes(current, last)).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: last, + second: current + }) + ).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last + }) + ).toBeUndefined(); }); it('detects a nested change', () => { @@ -152,7 +216,21 @@ describe(getFirstDifferenceInCommonNodes.name, () => { const prefix: string = 'some/prefix'; - expect(getFirstDifferenceInCommonNodes(last, current, prefix, '@')).toBe('some/prefix@child'); - expect(getFirstDifferenceInCommonNodes(current, last, prefix, '@')).toBe('some/prefix@child'); + expect( + getFirstDifferenceInCommonNodes({ + first: last, + second: current, + prefix, + delimiter: '@' + }) + ).toBe('some/prefix@child'); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last, + prefix, + delimiter: '@' + }) + ).toBe('some/prefix@child'); }); });