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..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,6 +34,14 @@ export interface IReadonlyLookupByPath extends Iterable<[strin groupByChild(infoByPath: Map, delimiter?: string): Map>; has(query: string, delimiter?: string): boolean; get size(): number; + // (undocumented) + get tree(): IReadonlyPathTrieNode; +} + +// @beta +export interface IReadonlyPathTrieNode { + readonly children: ReadonlyMap> | undefined; + readonly value: TItem | undefined; } // @beta @@ -42,6 +62,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..760e00413d2 --- /dev/null +++ b/libraries/lookup-by-path/src/getFirstDifferenceInCommonNodes.ts @@ -0,0 +1,118 @@ +// 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'; + +/** + * 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 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( + 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 this value was present in both tries with different values, return the prefix for this node. + return prefix; + } + + const { children: firstChildren } = first; + const { children: secondChildren } = second; + + if (firstChildren && secondChildren) { + for (const [key, firstChild] of firstChildren) { + const secondChild: IReadonlyPathTrieNode | undefined = secondChildren.get(key); + if (!secondChild) { + continue; + } + const result: string | undefined = getFirstDifferenceInCommonNodesInternal({ + first: firstChild, + second: secondChild, + prefix: key, + delimiter, + equals + }); + + if (result !== undefined) { + return prefix ? `${prefix}${delimiter}${result}` : result; + } + } + } + + 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 new file mode 100644 index 00000000000..2a85dec9f4f --- /dev/null +++ b/libraries/lookup-by-path/src/test/getFirstDifferenceInCommonNodes.test.ts @@ -0,0 +1,236 @@ +// 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({ + first: last, + second: current + }) + ).toBe(''); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last + }) + ).toBe(''); + + const prefix: string = 'some/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', () => { + 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({ + 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', () => { + 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({ + first: last, + second: current, + equals: customEquals + }) + ).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last, + equals: 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({ + first: last, + second: current + }) + ).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: 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({ + first: last, + second: current + }) + ).toBeUndefined(); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: 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({ + first: last, + second: current, + prefix, + delimiter: '@' + }) + ).toBe('some/prefix@child'); + expect( + getFirstDifferenceInCommonNodes({ + first: current, + second: last, + prefix, + delimiter: '@' + }) + ).toBe('some/prefix@child'); + }); +});