From dfe18b599f21b8f916ab6a42088abd93e087e1e2 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 16 Oct 2025 17:54:47 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20add=20overlap(?= =?UTF-8?q?)=20and=20normalize()=20methods=20to=20string=20diffing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/str.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/util/diff/str.ts b/src/util/diff/str.ts index de0b2499eb..07e9b2d605 100644 --- a/src/util/diff/str.ts +++ b/src/util/diff/str.ts @@ -10,6 +10,33 @@ export type PatchOperationDelete = [type: PATCH_OP_TYPE.DEL, txt: string]; export type PatchOperationEqual = [type: PATCH_OP_TYPE.EQL, txt: string]; export type PatchOperationInsert = [type: PATCH_OP_TYPE.INS, txt: string]; +export const normalize = (patch: Patch): Patch => { + const length = patch.length; + if (length < 2) return patch; + let i: number = 0; + CHECK_IS_NORMALIZED: { + if (!patch[0][1]) break CHECK_IS_NORMALIZED; + i = 1; + for (; i < length; i++) { + const prev = patch[i - 1]; + const curr = patch[i]; + if (!curr[1]) break CHECK_IS_NORMALIZED; + if (prev[0] === curr[0]) break CHECK_IS_NORMALIZED; + } + return patch; + } + const normalized: Patch = []; + for (let j = 0; j < i; j++) normalized.push(patch[j]); + for (let j = i; j < length; j++) { + const op = patch[j]; + if (!op[1]) continue; + const last = normalized.length > 0 ? normalized[normalized.length - 1] : null; + if (last && last[0] === op[0]) last[1] += op[1]; + else normalized.push(op); + } + return normalized; +}; + const startsWithPairEnd = (str: string) => { const code = str.charCodeAt(0); return code >= 0xdc00 && code <= 0xdfff; @@ -406,6 +433,40 @@ export const sfx = (txt1: string, txt2: string): number => { return mid; }; +/** + * Determine if the suffix of one string is the prefix of another. + * + * @see http://neil.fraser.name/news/2010/11/04/ + * + * @param str1 First string. + * @param str2 Second string. + * @return {number} The number of characters common to the end of the first + * string and the start of the second string. + */ +export const overlap = (str1: string, str2: string): number => { + const str1Len = str1.length; + const str2Len = str2.length; + if (str1Len === 0 || str2Len === 0) return 0; + let minLen = str1Len; + if (str1Len > str2Len) { + minLen = str2Len; + str1 = str1.substring(str1Len - str2Len); + } else if (str1Len < str2Len) str2 = str2.substring(0, str1Len); + if (str1 === str2) return minLen; + let best = 0; + let length = 1; + while (true) { + const pattern = str1.substring(minLen - length); + const found = str2.indexOf(pattern); + if (found === -1) return best; + length += found; + if (found === 0 || str1.substring(minLen - length) === str2.substring(0, length)) { + best = length; + length++; + } + } +}; + /** * Find the differences between two texts. Simplifies the problem by stripping * any common prefix or suffix off the texts before diffing. From 2b8afce7a064510ef69cdc65a91fad3bc035a763 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 16 Oct 2025 17:55:13 +0200 Subject: [PATCH 2/3] =?UTF-8?q?test(util):=20=F0=9F=92=8D=20harden=20text?= =?UTF-8?q?=20diffing=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/str.spec.ts | 525 +++++++++++++++++++++++++++- 1 file changed, 524 insertions(+), 1 deletion(-) diff --git a/src/util/diff/__tests__/str.spec.ts b/src/util/diff/__tests__/str.spec.ts index 3895055d65..417c2d493f 100644 --- a/src/util/diff/__tests__/str.spec.ts +++ b/src/util/diff/__tests__/str.spec.ts @@ -1,6 +1,117 @@ -import {PATCH_OP_TYPE, type Patch, diff, diffEdit} from '../str'; +import {PATCH_OP_TYPE, type Patch, diff, diffEdit, overlap, normalize, apply, src, dst, invert} from '../str'; import {assertPatch} from './util'; +describe('normalize()', () => { + test('joins consecutive same type operations', () => { + expect( + normalize([ + [1, 'a'], + [1, 'b'], + [0, 'c'], + [0, 'd'], + ]), + ).toEqual([ + [1, 'ab'], + [0, 'cd'], + ]); + expect( + normalize([ + [1, 'a'], + [0, 'b'], + [1, 'b'], + [0, 'c'], + [0, 'd'], + ]), + ).toEqual([ + [1, 'a'], + [0, 'b'], + [1, 'b'], + [0, 'cd'], + ]); + expect( + normalize([ + [1, 'a'], + [-1, 'b'], + [0, 'b'], + [1, 'b'], + [0, 'c'], + [0, 'd'], + ]), + ).toEqual([ + [1, 'a'], + [-1, 'b'], + [0, 'b'], + [1, 'b'], + [0, 'cd'], + ]); + expect( + normalize([ + [1, 'a'], + [-1, 'b'], + [-1, 'b'], + [0, 'b'], + [1, 'b'], + [0, 'c'], + [0, 'd'], + ]), + ).toEqual([ + [1, 'a'], + [-1, 'bb'], + [0, 'b'], + [1, 'b'], + [0, 'cd'], + ]); + expect( + normalize([ + [1, 'a'], + [-1, 'b'], + [-1, ''], + [0, 'b'], + [1, 'b'], + [0, 'c'], + [0, 'd'], + ]), + ).toEqual([ + [1, 'a'], + [-1, 'b'], + [0, 'b'], + [1, 'b'], + [0, 'cd'], + ]); + expect( + normalize([ + [1, 'a'], + [-1, 'b'], + [-1, ''], + [0, ''], + [1, 'b'], + [0, 'c'], + [0, 'd'], + ]), + ).toEqual([ + [1, 'a'], + [-1, 'b'], + [1, 'b'], + [0, 'cd'], + ]); + expect( + normalize([ + [1, 'a'], + [-1, 'x'], + [-1, ''], + [0, ''], + [-1, 'b'], + [0, 'c'], + [0, 'd'], + ]), + ).toEqual([ + [1, 'a'], + [-1, 'xb'], + [0, 'cd'], + ]); + }); +}); + describe('diff()', () => { test('returns a single equality tuple, when strings are identical', () => { const patch = diffEdit('hello', 'hello', 1); @@ -221,3 +332,415 @@ describe('diffEdit()', () => { assertDiffEdit('1', '2', '3'); }); }); + +describe('overlap()', () => { + test('can compute various overlaps', () => { + expect(overlap('abc', 'xyz')).toEqual(0); + expect(overlap('abc', 'cxyz')).toEqual(1); + expect(overlap('abc', 'xyzc')).toEqual(0); + expect(overlap('abc', 'xyza')).toEqual(0); + expect(overlap('Have some CoCo and CoCo', 'CoCo and CoCo is here.')).toEqual('CoCo and CoCo'.length); + expect(overlap('Fire at Will', 'William Riker is number one')).toEqual('Will'.length); + }); + + test('edge cases with empty strings', () => { + expect(overlap('', '')).toEqual(0); + expect(overlap('abc', '')).toEqual(0); + expect(overlap('', 'abc')).toEqual(0); + }); + + test('edge cases with identical strings', () => { + expect(overlap('abc', 'abc')).toEqual(3); + expect(overlap('a', 'a')).toEqual(1); + }); +}); + +describe('Unicode edge cases', () => { + test('handles surrogate pairs at boundaries', () => { + const emoji1 = 'πŸ™ƒ'; // surrogate pair + const emoji2 = 'πŸ‘‹'; // surrogate pair + + assertPatch(`a${emoji1}`, `a${emoji2}`); + assertPatch(emoji1, emoji2); + assertPatch(`${emoji1}b`, `${emoji2}b`); + assertPatch(`a${emoji1}b`, `a${emoji2}b`); + }); + + test('handles complex emoji sequences', () => { + const family = 'πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'; // family emoji with ZWJ sequences + const flag = 'πŸ‡ΊπŸ‡Έ'; // flag emoji (regional indicator) + + assertPatch('hello', family); + assertPatch(family, 'world'); + assertPatch(family, flag); + assertPatch(`a${family}b`, `a${flag}b`); + }); + + test('handles combining characters', () => { + const combining = 'e\u0301'; // e with acute accent (combining) + const precomposed = 'Γ©'; // precomposed acute e + + assertPatch(combining, precomposed); + assertPatch(precomposed, combining); + assertPatch(`a${combining}b`, `a${precomposed}b`); + }); + + test('handles zero-width characters', () => { + const zwj = '\u200D'; // zero-width joiner + const zwnj = '\u200C'; // zero-width non-joiner + const zwsp = '\u200B'; // zero-width space + + assertPatch(`a${zwj}b`, 'ab'); + assertPatch(`a${zwnj}b`, 'ab'); + assertPatch(`a${zwsp}b`, 'ab'); + assertPatch('abc', `a${zwj}b${zwnj}c`); + }); + + test('handles mixed Unicode normalization forms', () => { + const nfc = 'Γ©'; // NFC normalized + const nfd = 'e\u0301'; // NFD normalized + + assertPatch(nfc, nfd); + assertPatch(nfd, nfc); + assertPatch(`hello ${nfc}`, `hello ${nfd}`); + }); +}); + +describe('Algorithm edge cases', () => { + test('handles repetitive patterns', () => { + assertPatch('aaaaaaaaaa', 'aaabaaaa'); + assertPatch('abcabcabc', 'abcabcabcd'); + assertPatch('xyxyxyxy', 'xyxyxyz'); + assertPatch('121212121212', '121212131212'); + }); + + test('handles one string contained in another', () => { + assertPatch('abcdefghijk', 'def'); + assertPatch('def', 'abcdefghijk'); + assertPatch('xabcdefghijky', 'abcdefghijk'); + assertPatch('abcdefghijk', 'xabcdefghijky'); + }); + + test('handles strings with many small differences', () => { + assertPatch('abcdefghijklmnop', 'aXcXeXgXiXkXmXoX'); + assertPatch('1234567890', '1X3X5X7X9X'); + assertPatch('abababababab', 'acacacacacac'); + }); + + test('handles very different strings of same length', () => { + assertPatch('aaaaaaaaaa', 'bbbbbbbbbb'); + assertPatch('1234567890', 'abcdefghij'); + assertPatch('!@#$%^&*()', '0987654321'); + }); + + test('handles prefix-suffix edge cases', () => { + // Cases where prefix/suffix detection might be tricky + assertPatch('abcabc', 'abcdef'); + assertPatch('defdef', 'abcdef'); + assertPatch('abcdefabc', 'abcxyzabc'); + assertPatch('xyzabcxyz', 'xyzdefxyz'); + }); +}); + +describe('Patch operation edge cases', () => { + test('handles patches with empty operations mixed in', () => { + const patch: Patch = [ + [PATCH_OP_TYPE.EQL, 'hello'], + [PATCH_OP_TYPE.INS, ''], + [PATCH_OP_TYPE.EQL, ' world'], + [PATCH_OP_TYPE.DEL, ''], + ]; + const normalized = normalize(patch); + expect(normalized).toEqual([[PATCH_OP_TYPE.EQL, 'hello world']]); + }); + + test('handles patches with only insertions', () => { + const originalPatch: Patch = [ + [PATCH_OP_TYPE.INS, 'hello'], + [PATCH_OP_TYPE.INS, ' '], + [PATCH_OP_TYPE.INS, 'world'], + ]; + // Make a deep copy to avoid mutation + const patch: Patch = originalPatch.map((op) => [op[0], op[1]]); + + const normalized = normalize(patch); + expect(normalized).toEqual([[PATCH_OP_TYPE.INS, 'hello world']]); + + // Test with original patch, not normalized one + const srcText = src(originalPatch); + const dstText = dst(originalPatch); + expect(srcText).toBe(''); + expect(dstText).toBe('hello world'); + // Test this specific patch directly without assertPatch since it's a manual patch + }); + + test('handles patches with only deletions', () => { + const originalPatch: Patch = [ + [PATCH_OP_TYPE.DEL, 'hello'], + [PATCH_OP_TYPE.DEL, ' '], + [PATCH_OP_TYPE.DEL, 'world'], + ]; + // Make a deep copy to avoid mutation + const patch: Patch = originalPatch.map((op) => [op[0], op[1]]); + + const normalized = normalize(patch); + expect(normalized).toEqual([[PATCH_OP_TYPE.DEL, 'hello world']]); + + // Test with original patch, not normalized one + const srcText = src(originalPatch); + const dstText = dst(originalPatch); + expect(srcText).toBe('hello world'); + expect(dstText).toBe(''); + // Test this specific patch directly without assertPatch since it's a manual patch + }); + + test('handles empty patch', () => { + const patch: Patch = []; + assertPatch('', '', patch); + assertPatch('hello', 'hello', [[PATCH_OP_TYPE.EQL, 'hello']]); + }); + + test('validates src and dst functions work correctly', () => { + // Test with real diffs to ensure src/dst functions work + const testCases = [ + {srcStr: '', dstStr: 'hello'}, + {srcStr: 'hello', dstStr: ''}, + {srcStr: 'hello', dstStr: 'world'}, + {srcStr: 'hello world', dstStr: 'hello universe'}, + ]; + + for (const {srcStr, dstStr} of testCases) { + const patch = diff(srcStr, dstStr); + expect(src(patch)).toBe(srcStr); + expect(dst(patch)).toBe(dstStr); + } + }); +}); + +describe('Apply function edge cases', () => { + test('applies complex patches correctly', () => { + const src = 'The quick brown fox jumps over the lazy dog'; + const dst = 'A fast red fox leaps over a sleepy cat'; + const patch = diff(src, dst); + + let result = src; + apply( + patch, + result.length, + (pos, str) => { + result = result.slice(0, pos) + str + result.slice(pos); + }, + (pos, len) => { + result = result.slice(0, pos) + result.slice(pos + len); + }, + ); + + expect(result).toBe(dst); + }); +}); + +describe('Performance and stress tests', () => { + test('handles very long strings efficiently', () => { + const longStr1 = 'a'.repeat(1000) + 'different' + 'b'.repeat(1000); + const longStr2 = 'a'.repeat(1000) + 'changed' + 'b'.repeat(1000); + + const startTime = Date.now(); + const patch = diff(longStr1, longStr2); + const endTime = Date.now(); + + // Should complete in reasonable time (less than 1 second for this size) + expect(endTime - startTime).toBeLessThan(1000); + assertPatch(longStr1, longStr2, patch); + }); + + test('handles worst-case scenario strings', () => { + // Strings that could cause quadratic behavior in naive algorithms + const str1 = 'x'.repeat(100) + 'y'; + const str2 = 'y' + 'x'.repeat(100); + + const patch = diff(str1, str2); + assertPatch(str1, str2, patch); + }); + + test('handles strings with many tiny differences', () => { + let str1 = ''; + let str2 = ''; + for (let i = 0; i < 50; i++) { + str1 += 'a' + 'b'.repeat(10); + str2 += 'c' + 'b'.repeat(10); + } + + const patch = diff(str1, str2); + assertPatch(str1, str2, patch); + }); +}); + +describe('Boundary conditions', () => { + test('handles strings with only whitespace differences', () => { + assertPatch('hello world', 'hello world'); + assertPatch('hello\tworld', 'hello world'); + assertPatch('hello\nworld', 'hello world'); + assertPatch('hello\r\nworld', 'hello\nworld'); + }); + + test('handles strings with control characters', () => { + assertPatch('hello\x00world', 'hello\x01world'); + assertPatch('hello\x1fworld', 'helloworld'); + assertPatch('hello\x7fworld', 'hello world'); + }); + + test('handles very similar strings with tiny differences', () => { + const base = 'The quick brown fox jumps over the lazy dog'; + assertPatch(base, base.replace('quick', 'slow')); + assertPatch(base, base.replace('brown', 'red')); + assertPatch(base, base.replace('fox', 'cat')); + assertPatch(base, base.replace(' ', '')); + assertPatch(base, base + '.'); + }); + + test('handles palindromes and symmetric strings', () => { + assertPatch('racecar', 'racekar'); + assertPatch('abccba', 'abcdcba'); + assertPatch('12321', '123321'); + }); + + test('handles strings with repeated substrings', () => { + assertPatch('abcabcabc', 'abcdefabc'); + assertPatch('aaabaaabaaab', 'aaabbbabaaab'); + assertPatch('121212121212', '121213121212'); + }); +}); + +describe('Invert function', () => { + test('correctly inverts basic patches', () => { + const testCases = [ + {src: 'hello', dst: 'world'}, + {src: '', dst: 'hello'}, + {src: 'hello', dst: ''}, + {src: 'abc', dst: 'def'}, + {src: 'hello world', dst: 'hello universe'}, + ]; + + for (const {src: srcStr, dst: dstStr} of testCases) { + const patch = diff(srcStr, dstStr); + const inverted = invert(patch); + + // Inverted patch should transform dst back to src + expect(src(inverted)).toBe(dstStr); + expect(dst(inverted)).toBe(srcStr); + + // Double inversion should give original patch + const doubleInverted = invert(inverted); + expect(src(doubleInverted)).toBe(srcStr); + expect(dst(doubleInverted)).toBe(dstStr); + } + }); + + test('preserves equality operations in inversion', () => { + const patch: Patch = [ + [PATCH_OP_TYPE.EQL, 'hello'], + [PATCH_OP_TYPE.INS, ' world'], + ]; + const inverted = invert(patch); + expect(inverted).toEqual([ + [PATCH_OP_TYPE.EQL, 'hello'], + [PATCH_OP_TYPE.DEL, ' world'], + ]); + }); + + test('swaps insert and delete operations', () => { + const patch: Patch = [ + [PATCH_OP_TYPE.DEL, 'old'], + [PATCH_OP_TYPE.INS, 'new'], + ]; + const inverted = invert(patch); + expect(inverted).toEqual([ + [PATCH_OP_TYPE.INS, 'old'], + [PATCH_OP_TYPE.DEL, 'new'], + ]); + }); +}); + +describe('Apply function edge cases', () => { + test('handles patches applied to different positions', () => { + // Test that apply function works correctly for simple cases + // The apply function processes patches backwards, so we need to understand the correct behavior + + // Test simple insertion at beginning + let result1 = 'abc'; + const patch1: Patch = [ + [PATCH_OP_TYPE.INS, 'x'], + [PATCH_OP_TYPE.EQL, 'abc'], + ]; + apply( + patch1, + result1.length, + (pos, str) => { + result1 = result1.slice(0, pos) + str + result1.slice(pos); + }, + (pos, len, str) => { + result1 = result1.slice(0, pos) + result1.slice(pos + len); + }, + ); + expect(result1).toBe('xabc'); + + // Test simple deletion at beginning + let result2 = 'abc'; + const patch2: Patch = [ + [PATCH_OP_TYPE.DEL, 'a'], + [PATCH_OP_TYPE.EQL, 'bc'], + ]; + apply( + patch2, + result2.length, + (pos, str) => { + result2 = result2.slice(0, pos) + str + result2.slice(pos); + }, + (pos, len, str) => { + result2 = result2.slice(0, pos) + result2.slice(pos + len); + }, + ); + expect(result2).toBe('bc'); + + // Test insertion in middle + let result3 = 'abc'; + const patch3: Patch = [ + [PATCH_OP_TYPE.EQL, 'ab'], + [PATCH_OP_TYPE.INS, 'x'], + [PATCH_OP_TYPE.EQL, 'c'], + ]; + apply( + patch3, + result3.length, + (pos, str) => { + result3 = result3.slice(0, pos) + str + result3.slice(pos); + }, + (pos, len, str) => { + result3 = result3.slice(0, pos) + result3.slice(pos + len); + }, + ); + expect(result3).toBe('abxc'); + }); + + test('handles empty operations gracefully', () => { + let result = 'hello'; + const patch: Patch = [ + [PATCH_OP_TYPE.EQL, 'hello'], + [PATCH_OP_TYPE.INS, ''], + [PATCH_OP_TYPE.DEL, ''], + ]; + + apply( + patch, + result.length, + (pos, str) => { + result = result.slice(0, pos) + str + result.slice(pos); + }, + (pos, len, str) => { + result = result.slice(0, pos) + result.slice(pos + len); + }, + ); + + expect(result).toBe('hello'); // Should be unchanged + }); +}); From 6f5fe066b2c7f026e4a5a13d96d98298d6ca97b5 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 16 Oct 2025 18:07:59 +0200 Subject: [PATCH 3/3] =?UTF-8?q?style:=20=F0=9F=92=84=20run=20formatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-path/JsonPathEval.ts | 86 ++++++++++++------- src/json-path/__tests__/JsonPathEval.spec.ts | 8 +- .../JsonPathParser-descendant.spec.ts | 2 +- 3 files changed, 61 insertions(+), 35 deletions(-) diff --git a/src/json-path/JsonPathEval.ts b/src/json-path/JsonPathEval.ts index 8d39252728..11b6d503d3 100644 --- a/src/json-path/JsonPathEval.ts +++ b/src/json-path/JsonPathEval.ts @@ -5,7 +5,11 @@ import type * as types from './types'; /** * Function signature for JSONPath functions */ -type JSONPathFunction = (args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], currentNode: Value, evaluator: JsonPathEval) => any; +type JSONPathFunction = ( + args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], + currentNode: Value, + evaluator: JsonPathEval, +) => any; export class JsonPathEval { public static run = (path: string | types.JSONPath, data: unknown): Value[] => { @@ -355,23 +359,23 @@ export class JsonPathEval { private evalFunctionExpression(expression: types.FunctionExpression, currentNode: Value): boolean { const result = this.evaluateFunction(expression, currentNode); - + // Functions in test expressions should return LogicalType // If the function returns a value, convert it to boolean if (typeof result === 'boolean') { return result; } - + // For count() and length() returning numbers, convert to boolean (non-zero is true) if (typeof result === 'number') { return result !== 0; } - + // For nodelists, true if non-empty if (Array.isArray(result)) { return result.length > 0; } - + // For other values, check if they exist (not null/undefined) return result != null; } @@ -386,17 +390,17 @@ export class JsonPathEval { return expression.value; case 'path': { if (!expression.path.segments) return undefined; - + // Evaluate path segments starting from current node let currentResults = [currentNode]; - + for (const segment of expression.path.segments) { currentResults = this.evalSegment(currentResults, segment); if (currentResults.length === 0) break; // No results, early exit } - + // For function arguments, we want the actual data, not Value objects - return currentResults.map(v => v.data); + return currentResults.map((v) => v.data); } case 'function': return this.evaluateFunction(expression, currentNode); @@ -477,13 +481,13 @@ export class JsonPathEval { * Uses the function registry for extensible function support */ private evaluateFunction(expression: types.FunctionExpression, currentNode: Value): any { - const { name, args } = expression; - + const {name, args} = expression; + const func = this.funcs.get(name); if (func) { return func(args, currentNode, this); } - + // Unknown function - return false for logical context, undefined for value context return false; } @@ -493,12 +497,16 @@ export class JsonPathEval { * Parameters: ValueType * Result: ValueType (unsigned integer or Nothing) */ - private lengthFunction(args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], currentNode: Value, evaluator: JsonPathEval): number | undefined { + private lengthFunction( + args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], + currentNode: Value, + evaluator: JsonPathEval, + ): number | undefined { if (args.length !== 1) return undefined; const [arg] = args; const result = this.getValueFromArg(arg, currentNode, evaluator); - + // For length() function, we need the single value let value: any; if (Array.isArray(result)) { @@ -517,7 +525,7 @@ export class JsonPathEval { if (value && typeof value === 'object' && value !== null) { return Object.keys(value).length; } - + return undefined; // Nothing for other types } @@ -526,12 +534,16 @@ export class JsonPathEval { * Parameters: NodesType * Result: ValueType (unsigned integer) */ - private countFunction(args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], currentNode: Value, evaluator: JsonPathEval): number { + private countFunction( + args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], + currentNode: Value, + evaluator: JsonPathEval, + ): number { if (args.length !== 1) return 0; const [arg] = args; const result = this.getValueFromArg(arg, currentNode, evaluator); - + // Count logic based on RFC 9535: // For count(), we count the number of nodes selected by the expression if (Array.isArray(result)) { @@ -549,14 +561,18 @@ export class JsonPathEval { * Parameters: ValueType (string), ValueType (string conforming to RFC9485) * Result: LogicalType */ - private matchFunction(args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], currentNode: Value, evaluator: JsonPathEval): boolean { + private matchFunction( + args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], + currentNode: Value, + evaluator: JsonPathEval, + ): boolean { if (args.length !== 2) return false; const [stringArg, regexArg] = args; - + const strResult = this.getValueFromArg(stringArg, currentNode, evaluator); const regexResult = this.getValueFromArg(regexArg, currentNode, evaluator); - + // Handle array results (get single value) const str = Array.isArray(strResult) ? (strResult.length === 1 ? strResult[0] : undefined) : strResult; const regex = Array.isArray(regexResult) ? (regexResult.length === 1 ? regexResult[0] : undefined) : regexResult; @@ -579,14 +595,18 @@ export class JsonPathEval { * Parameters: ValueType (string), ValueType (string conforming to RFC9485) * Result: LogicalType */ - private searchFunction(args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], currentNode: Value, evaluator: JsonPathEval): boolean { + private searchFunction( + args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], + currentNode: Value, + evaluator: JsonPathEval, + ): boolean { if (args.length !== 2) return false; const [stringArg, regexArg] = args; - + const strResult = this.getValueFromArg(stringArg, currentNode, evaluator); const regexResult = this.getValueFromArg(regexArg, currentNode, evaluator); - + // Handle array results (get single value) const str = Array.isArray(strResult) ? (strResult.length === 1 ? strResult[0] : undefined) : strResult; const regex = Array.isArray(regexResult) ? (regexResult.length === 1 ? regexResult[0] : undefined) : regexResult; @@ -609,12 +629,16 @@ export class JsonPathEval { * Parameters: NodesType * Result: ValueType */ - private valueFunction(args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], currentNode: Value, evaluator: JsonPathEval): any { + private valueFunction( + args: (types.ValueExpression | types.FilterExpression | types.JSONPath)[], + currentNode: Value, + evaluator: JsonPathEval, + ): any { if (args.length !== 1) return undefined; const [nodeArg] = args; const result = this.getValueFromArg(nodeArg, currentNode, evaluator); - + // For value() function, return single value if exactly one result, // otherwise undefined (following RFC 9535 value() semantics) if (Array.isArray(result)) { @@ -627,7 +651,11 @@ export class JsonPathEval { /** * Helper to get value from function argument */ - private getValueFromArg(arg: types.ValueExpression | types.FilterExpression | types.JSONPath, currentNode: Value, evaluator: JsonPathEval): any { + private getValueFromArg( + arg: types.ValueExpression | types.FilterExpression | types.JSONPath, + currentNode: Value, + evaluator: JsonPathEval, + ): any { if (this.isValueExpression(arg)) { return evaluator.evalValueExpression(arg, currentNode); } else if (this.isJSONPath(arg)) { @@ -643,15 +671,13 @@ export class JsonPathEval { * Type guard for ValueExpression */ private isValueExpression(arg: any): arg is types.ValueExpression { - return arg && typeof arg === 'object' && - ['current', 'root', 'literal', 'path', 'function'].includes(arg.type); + return arg && typeof arg === 'object' && ['current', 'root', 'literal', 'path', 'function'].includes(arg.type); } /** * Type guard for JSONPath */ private isJSONPath(arg: any): arg is types.JSONPath { - return arg && typeof arg === 'object' && - Array.isArray(arg.segments); + return arg && typeof arg === 'object' && Array.isArray(arg.segments); } } diff --git a/src/json-path/__tests__/JsonPathEval.spec.ts b/src/json-path/__tests__/JsonPathEval.spec.ts index 14460e14a8..3def2d699a 100644 --- a/src/json-path/__tests__/JsonPathEval.spec.ts +++ b/src/json-path/__tests__/JsonPathEval.spec.ts @@ -368,7 +368,7 @@ describe('JsonPathEval', () => { }); test('length function with Unicode characters', () => { - const unicodeData = { text: 'Hello 🌍 World' }; + const unicodeData = {text: 'Hello 🌍 World'}; const expr = '$[?length(@.text) == 13]'; const result = JsonPathEval.run(expr, unicodeData); expect(result.length).toBe(1); @@ -541,10 +541,10 @@ describe('JsonPathEval', () => { test('match and search difference', () => { const matchExpr = '$.store.book[?match(@.title, "Lord")]'; const searchExpr = '$.store.book[?search(@.title, "Lord")]'; - + const matchResult = JsonPathEval.run(matchExpr, testData); const searchResult = JsonPathEval.run(searchExpr, testData); - + expect(matchResult.length).toBe(0); // Exact match fails expect(searchResult.length).toBe(1); // Substring search succeeds }); @@ -582,7 +582,7 @@ describe('JsonPathEval', () => { }); test('function with null values', () => { - const nullData = { items: [null, '', 0, false] }; + const nullData = {items: [null, '', 0, false]}; const expr = '$.items[?length(@) == 0]'; const result = JsonPathEval.run(expr, nullData); expect(result.length).toBe(1); // Only empty string has length 0 diff --git a/src/json-path/__tests__/JsonPathParser-descendant.spec.ts b/src/json-path/__tests__/JsonPathParser-descendant.spec.ts index ecf1fea0b2..4378fe00a6 100644 --- a/src/json-path/__tests__/JsonPathParser-descendant.spec.ts +++ b/src/json-path/__tests__/JsonPathParser-descendant.spec.ts @@ -35,4 +35,4 @@ describe('JsonPathParser - @.. descendant parsing tests', () => { const result = JsonPathParser.parse('$[?count(@..[0]) > 0]'); expect(result.success).toBe(true); }); -}); \ No newline at end of file +});