From 395b4dbc3e6042732d27d5c07ea815add017e045 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 00:57:09 +0200 Subject: [PATCH 01/68] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20implement=20text=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/__tests__/diff.spec.ts | 89 ++++++ src/util/diff.ts | 490 ++++++++++++++++++++++++++++++++ 2 files changed, 579 insertions(+) create mode 100644 src/util/__tests__/diff.spec.ts create mode 100644 src/util/diff.ts diff --git a/src/util/__tests__/diff.spec.ts b/src/util/__tests__/diff.spec.ts new file mode 100644 index 0000000000..21f0437126 --- /dev/null +++ b/src/util/__tests__/diff.spec.ts @@ -0,0 +1,89 @@ +import {PATCH_OP_TYPE, diff, diffEdit} from '../diff'; + +describe('diff()', () => { + test('returns a single equality tuple, when strings are identical', () => { + const patch = diffEdit('hello', 'hello', 1); + expect(patch).toEqual([[PATCH_OP_TYPE.EQUAL, 'hello']]); + }); + + test('single character insert at the beginning', () => { + const patch1 = diff('hello', '_hello'); + const patch2 = diffEdit('hello', '_hello', 1); + const patch3 = diffEdit('hello', '_hello', 4); + expect(patch1).toEqual([ + [PATCH_OP_TYPE.INSERT, '_'], + [PATCH_OP_TYPE.EQUAL, 'hello'], + ]); + expect(patch2).toEqual([ + [PATCH_OP_TYPE.INSERT, '_'], + [PATCH_OP_TYPE.EQUAL, 'hello'], + ]); + expect(patch3).toEqual([ + [PATCH_OP_TYPE.INSERT, '_'], + [PATCH_OP_TYPE.EQUAL, 'hello'], + ]); + }); + + test('single character insert at the end', () => { + const patch1 = diff('hello', 'hello!'); + const patch2 = diffEdit('hello', 'hello!', 6); + const patch3 = diffEdit('hello', 'hello!', 2); + expect(patch1).toEqual([ + [PATCH_OP_TYPE.EQUAL, 'hello'], + [PATCH_OP_TYPE.INSERT, '!'], + ]); + expect(patch2).toEqual([ + [PATCH_OP_TYPE.EQUAL, 'hello'], + [PATCH_OP_TYPE.INSERT, '!'], + ]); + expect(patch3).toEqual([ + [PATCH_OP_TYPE.EQUAL, 'hello'], + [PATCH_OP_TYPE.INSERT, '!'], + ]); + }); + + test('single character removal at the beginning', () => { + const patch = diff('hello', 'ello'); + expect(patch).toEqual([ + [PATCH_OP_TYPE.DELETE, 'h'], + [PATCH_OP_TYPE.EQUAL, 'ello'], + ]); + }); + + test('single character removal at the end', () => { + const patch1 = diff('hello', 'hell'); + const patch2 = diffEdit('hello', 'hell', 4); + expect(patch1).toEqual([ + [PATCH_OP_TYPE.EQUAL, 'hell'], + [PATCH_OP_TYPE.DELETE, 'o'], + ]); + expect(patch2).toEqual([ + [PATCH_OP_TYPE.EQUAL, 'hell'], + [PATCH_OP_TYPE.DELETE, 'o'], + ]); + }); + + test('single character replacement at the beginning', () => { + const patch1 = diff('hello', 'Hello'); + const patch2 = diffEdit('hello', 'Hello', 1); + expect(patch1).toEqual([ + [PATCH_OP_TYPE.DELETE, 'h'], + [PATCH_OP_TYPE.INSERT, 'H'], + [PATCH_OP_TYPE.EQUAL, 'ello'], + ]); + expect(patch2).toEqual([ + [PATCH_OP_TYPE.DELETE, 'h'], + [PATCH_OP_TYPE.INSERT, 'H'], + [PATCH_OP_TYPE.EQUAL, 'ello'], + ]); + }); + + test('single character replacement at the end', () => { + const patch = diff('hello', 'hellO'); + expect(patch).toEqual([ + [PATCH_OP_TYPE.EQUAL, 'hell'], + [PATCH_OP_TYPE.DELETE, 'o'], + [PATCH_OP_TYPE.INSERT, 'O'], + ]); + }); +}); diff --git a/src/util/diff.ts b/src/util/diff.ts new file mode 100644 index 0000000000..75d35c1e7a --- /dev/null +++ b/src/util/diff.ts @@ -0,0 +1,490 @@ +/** + * This is a port of diff-patch-match to TypeScript. + */ + +export const enum PATCH_OP_TYPE { + DELETE = -1, + EQUAL = 0, + INSERT = 1, +} + +export type Patch = PatchOperation[]; +export type PatchOperation = PatchOperationDelete | PatchOperationEqual | PatchOperationInsert; +export type PatchOperationDelete = [PATCH_OP_TYPE.DELETE, string]; +export type PatchOperationEqual = [PATCH_OP_TYPE.EQUAL, string]; +export type PatchOperationInsert = [PATCH_OP_TYPE.INSERT, string]; + +const startsWithPairEnd = (str: string) => { + const code = str.charCodeAt(0); + return code >= 0xdc00 && code <= 0xdfff; +}; + +const endsWithPairStart = (str: string): boolean => { + const code = str.charCodeAt(str.length - 1); + return code >= 0xd800 && code <= 0xdbff; +}; + +/** + * Reorder and merge like edit sections. Merge equalities. + * Any edit section can move as long as it doesn't cross an equality. + * + * @param diff Array of diff tuples. + * @param fix_unicode Whether to normalize to a unicode-correct diff + */ +const cleanupMerge = (diff: Patch, fix_unicode: boolean) => { + diff.push([PATCH_OP_TYPE.EQUAL, '']); + let pointer = 0; + let count_delete = 0; + let count_insert = 0; + let text_delete = ''; + let text_insert = ''; + let commonLength: number = 0; + while (pointer < diff.length) { + if (pointer < diff.length - 1 && !diff[pointer][1]) { + diff.splice(pointer, 1); + continue; + } + switch (diff[pointer][0]) { + case PATCH_OP_TYPE.INSERT: + count_insert++; + text_insert += diff[pointer][1]; + pointer++; + break; + case PATCH_OP_TYPE.DELETE: + count_delete++; + text_delete += diff[pointer][1]; + pointer++; + break; + case PATCH_OP_TYPE.EQUAL: { + let previous_equality = pointer - count_insert - count_delete - 1; + if (fix_unicode) { + // prevent splitting of unicode surrogate pairs. when fix_unicode is true, + // we assume that the old and new text in the diff are complete and correct + // unicode-encoded JS strings, but the tuple boundaries may fall between + // surrogate pairs. we fix this by shaving off stray surrogates from the end + // of the previous equality and the beginning of this equality. this may create + // empty equalities or a common prefix or suffix. for example, if AB and AC are + // emojis, `[[0, 'A'], [-1, 'BA'], [0, 'C']]` would turn into deleting 'ABAC' and + // inserting 'AC', and then the common suffix 'AC' will be eliminated. in this + // particular case, both equalities go away, we absorb any previous inequalities, + // and we keep scanning for the next equality before rewriting the tuples. + if (previous_equality >= 0 && endsWithPairStart(diff[previous_equality][1])) { + const stray = diff[previous_equality][1].slice(-1); + diff[previous_equality][1] = diff[previous_equality][1].slice(0, -1); + text_delete = stray + text_delete; + text_insert = stray + text_insert; + if (!diff[previous_equality][1]) { + // emptied out previous equality, so delete it and include previous delete/insert + diff.splice(previous_equality, 1); + pointer--; + let k = previous_equality - 1; + if (diff[k] && diff[k][0] === PATCH_OP_TYPE.INSERT) { + count_insert++; + text_insert = diff[k][1] + text_insert; + k--; + } + if (diff[k] && diff[k][0] === PATCH_OP_TYPE.DELETE) { + count_delete++; + text_delete = diff[k][1] + text_delete; + k--; + } + previous_equality = k; + } + } + if (startsWithPairEnd(diff[pointer][1])) { + const stray = diff[pointer][1].charAt(0); + diff[pointer][1] = diff[pointer][1].slice(1); + text_delete += stray; + text_insert += stray; + } + } + if (pointer < diff.length - 1 && !diff[pointer][1]) { + // for empty equality not at end, wait for next equality + diff.splice(pointer, 1); + break; + } + if (text_delete.length > 0 || text_insert.length > 0) { + // note that diff_commonPrefix and diff_commonSuffix are unicode-aware + if (text_delete.length > 0 && text_insert.length > 0) { + // Factor out any common prefixes. + commonLength = pfx(text_insert, text_delete); + if (commonLength !== 0) { + if (previous_equality >= 0) { + diff[previous_equality][1] += text_insert.substring(0, commonLength); + } else { + diff.splice(0, 0, [PATCH_OP_TYPE.EQUAL, text_insert.substring(0, commonLength)]); + pointer++; + } + text_insert = text_insert.substring(commonLength); + text_delete = text_delete.substring(commonLength); + } + // Factor out any common suffixes. + commonLength = sfx(text_insert, text_delete); + if (commonLength !== 0) { + diff[pointer][1] = text_insert.substring(text_insert.length - commonLength) + diff[pointer][1]; + text_insert = text_insert.substring(0, text_insert.length - commonLength); + text_delete = text_delete.substring(0, text_delete.length - commonLength); + } + } + // Delete the offending records and add the merged ones. + const n = count_insert + count_delete; + if (text_delete.length === 0 && text_insert.length === 0) { + diff.splice(pointer - n, n); + pointer = pointer - n; + } else if (text_delete.length === 0) { + diff.splice(pointer - n, n, [PATCH_OP_TYPE.INSERT, text_insert]); + pointer = pointer - n + 1; + } else if (text_insert.length === 0) { + diff.splice(pointer - n, n, [PATCH_OP_TYPE.DELETE, text_delete]); + pointer = pointer - n + 1; + } else { + diff.splice(pointer - n, n, [PATCH_OP_TYPE.DELETE, text_delete], [PATCH_OP_TYPE.INSERT, text_insert]); + pointer = pointer - n + 2; + } + } + if (pointer !== 0 && diff[pointer - 1][0] === PATCH_OP_TYPE.EQUAL) { + // Merge this equality with the previous one. + diff[pointer - 1][1] += diff[pointer][1]; + diff.splice(pointer, 1); + } else { + pointer++; + } + count_insert = 0; + count_delete = 0; + text_delete = ''; + text_insert = ''; + break; + } + } + } + if (diff[diff.length - 1][1] === '') { + diff.pop(); // Remove the dummy entry at the end. + } + + // Second pass: look for single edits surrounded on both sides by equalities + // which can be shifted sideways to eliminate an equality. + // e.g: ABAC -> ABAC + let changes = false; + pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diff.length - 1) { + if (diff[pointer - 1][0] === PATCH_OP_TYPE.EQUAL && diff[pointer + 1][0] === PATCH_OP_TYPE.EQUAL) { + // This is a single edit surrounded by equalities. + if (diff[pointer][1].substring(diff[pointer][1].length - diff[pointer - 1][1].length) === diff[pointer - 1][1]) { + // Shift the edit over the previous equality. + diff[pointer][1] = + diff[pointer - 1][1] + diff[pointer][1].substring(0, diff[pointer][1].length - diff[pointer - 1][1].length); + diff[pointer + 1][1] = diff[pointer - 1][1] + diff[pointer + 1][1]; + diff.splice(pointer - 1, 1); + changes = true; + } else if (diff[pointer][1].substring(0, diff[pointer + 1][1].length) === diff[pointer + 1][1]) { + // Shift the edit over the next equality. + diff[pointer - 1][1] += diff[pointer + 1][1]; + diff[pointer][1] = diff[pointer][1].substring(diff[pointer + 1][1].length) + diff[pointer + 1][1]; + diff.splice(pointer + 1, 1); + changes = true; + } + } + pointer++; + } + // If shifts were made, the diff needs reordering and another shift sweep. + if (changes) cleanupMerge(diff, fix_unicode); +}; + +/** + * Given the location of the 'middle snake', split the diff in two parts + * and recurse. + * + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param x Index of split point in text1. + * @param y Index of split point in text2. + * @return Array of diff tuples. + */ +const bisectSplit = (text1: string, text2: string, x: number, y: number): Patch => { + const diffsA = diff_(text1.substring(0, x), text2.substring(0, y), false); + const diffsB = diff_(text1.substring(x), text2.substring(y), false); + return diffsA.concat(diffsB); +}; + +/** + * Find the 'middle snake' of a diff, split the problem in two + * and return the recursively constructed diff. + * + * @see http://www.xmailserver.org/diff2.pdf EUGENE W. MYERS 1986 paper: An + * O(ND) Difference Algorithm and Its Variations. + * + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @return A {@link Patch} - an array of patch operations. + */ +const bisect = (text1: string, text2: string): Patch => { + const text1Length = text1.length; + const text2Length = text2.length; + const maxD = Math.ceil((text1Length + text2Length) / 2); + const vOffset = maxD; + const vLength = 2 * maxD; + const v1 = new Array(vLength); + const v2 = new Array(vLength); + for (let x = 0; x < vLength; x++) { + v1[x] = -1; + v2[x] = -1; + } + v1[vOffset + 1] = 0; + v2[vOffset + 1] = 0; + const delta = text1Length - text2Length; + // If the total number of characters is odd, then the front path will collide + // with the reverse path. + const front = delta % 2 !== 0; + // Offsets for start and end of k loop. + // Prevents mapping of space beyond the grid. + let k1start = 0; + let k1end = 0; + let k2start = 0; + let k2end = 0; + for (let d = 0; d < maxD; d++) { + for (let k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { + const k1_offset = vOffset + k1; + let x1: number = 0; + if (k1 === -d || (k1 !== d && v1[k1_offset - 1] < v1[k1_offset + 1])) x1 = v1[k1_offset + 1]; + else x1 = v1[k1_offset - 1] + 1; + let y1 = x1 - k1; + while (x1 < text1Length && y1 < text2Length && text1.charAt(x1) === text2.charAt(y1)) { + x1++; + y1++; + } + v1[k1_offset] = x1; + if (x1 > text1Length) k1end += 2; + else if (y1 > text2Length) k1start += 2; + else if (front) { + const k2Offset = vOffset + delta - k1; + if (k2Offset >= 0 && k2Offset < vLength && v2[k2Offset] !== -1) { + if (x1 >= text1Length - v2[k2Offset]) return bisectSplit(text1, text2, x1, y1); + } + } + } + // Walk the reverse path one step. + for (let k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { + const k2_offset = vOffset + k2; + let x2: number = 0; + if (k2 === -d || (k2 !== d && v2[k2_offset - 1] < v2[k2_offset + 1])) x2 = v2[k2_offset + 1]; + else x2 = v2[k2_offset - 1] + 1; + let y2 = x2 - k2; + while ( + x2 < text1Length && + y2 < text2Length && + text1.charAt(text1Length - x2 - 1) === text2.charAt(text2Length - y2 - 1) + ) { + x2++; + y2++; + } + v2[k2_offset] = x2; + if (x2 > text1Length) k2end += 2; + else if (y2 > text2Length) k2start += 2; + else if (!front) { + const k1_offset = vOffset + delta - k2; + if (k1_offset >= 0 && k1_offset < vLength && v1[k1_offset] !== -1) { + const x1 = v1[k1_offset]; + const y1 = vOffset + x1 - k1_offset; + x2 = text1Length - x2; + if (x1 >= x2) return bisectSplit(text1, text2, x1, y1); + } + } + } + } + return [ + [PATCH_OP_TYPE.DELETE, text1], + [PATCH_OP_TYPE.INSERT, text2], + ]; +}; + +/** + * Find the differences between two texts. Assumes that the texts do not + * have any common prefix or suffix. + * + * @param src Old string to be diffed. + * @param dst New string to be diffed. + * @return A {@link Patch} - an array of patch operations. + */ +const diffNoCommonAffix = (src: string, dst: string): Patch => { + if (!src) return [[PATCH_OP_TYPE.INSERT, dst]]; + if (!dst) return [[PATCH_OP_TYPE.DELETE, src]]; + const text1Length = src.length; + const text2Length = dst.length; + const long = text1Length > text2Length ? src : dst; + const short = text1Length > text2Length ? dst : src; + const shortTextLength = short.length; + const indexOfContainedShort = long.indexOf(short); + if (indexOfContainedShort >= 0) { + const start = long.substring(0, indexOfContainedShort); + const end = long.substring(indexOfContainedShort + shortTextLength); + return text1Length > text2Length + ? [ + [PATCH_OP_TYPE.DELETE, start], + [PATCH_OP_TYPE.EQUAL, short], + [PATCH_OP_TYPE.DELETE, end], + ] + : [ + [PATCH_OP_TYPE.INSERT, start], + [PATCH_OP_TYPE.EQUAL, short], + [PATCH_OP_TYPE.INSERT, end], + ]; + } + if (shortTextLength === 1) + return [ + [PATCH_OP_TYPE.DELETE, src], + [PATCH_OP_TYPE.INSERT, dst], + ]; + return bisect(src, dst); +}; + +/** + * Determine the common prefix of two strings. + * + * @param txt1 First string. + * @param txt2 Second string. + * @return The number of characters common to the start of each string. + */ +export const pfx = (txt1: string, txt2: string) => { + if (!txt1 || !txt2 || txt1.charAt(0) !== txt2.charAt(0)) return 0; + let min = 0; + let max = Math.min(txt1.length, txt2.length); + let mid = max; + let start = 0; + while (min < mid) { + if (txt1.substring(start, mid) === txt2.substring(start, mid)) { + min = mid; + start = min; + } else max = mid; + mid = Math.floor((max - min) / 2 + min); + } + const code = txt1.charCodeAt(mid - 1); + const isSurrogatePairStart = code >= 0xd800 && code <= 0xdbff; + if (isSurrogatePairStart) mid--; + return mid; +}; + +/** + * Determine the common suffix of two strings. + * + * @param txt1 First string. + * @param txt2 Second string. + * @return The number of characters common to the end of each string. + */ +export const sfx = (txt1: string, txt2: string): number => { + if (!txt1 || !txt2 || txt1.slice(-1) !== txt2.slice(-1)) return 0; + let min = 0; + let max = Math.min(txt1.length, txt2.length); + let mid = max; + let end = 0; + while (min < mid) { + if ( + txt1.substring(txt1.length - mid, txt1.length - end) === + txt2.substring(txt2.length - mid, txt2.length - end) + ) { + min = mid; + end = min; + } else max = mid; + mid = Math.floor((max - min) / 2 + min); + } + const code = txt1.charCodeAt(txt1.length - mid); + const isSurrogatePairEnd = code >= 0xd800 && code <= 0xdbff; + if (isSurrogatePairEnd) mid--; + return mid; +}; + +/** + * Find the differences between two texts. Simplifies the problem by stripping + * any common prefix or suffix off the texts before diffing. + * + * @param src Old string to be diffed. + * @param dst New string to be diffed. + * @param cleanup Whether to apply semantic cleanup before returning. + * @return A {@link Patch} - an array of patch operations. + */ +const diff_ = (src: string, dst: string, fixUnicode: boolean): Patch => { + if (src === dst) return src ? [[PATCH_OP_TYPE.EQUAL, src]] : []; + + // Trim off common prefix (speedup). + const prefixLength = pfx(src, dst); + const prefix = src.substring(0, prefixLength); + src = src.substring(prefixLength); + dst = dst.substring(prefixLength); + + // Trim off common suffix (speedup). + const suffixLength = sfx(src, dst); + const suffix = src.substring(src.length - suffixLength); + src = src.substring(0, src.length - suffixLength); + dst = dst.substring(0, dst.length - suffixLength); + + // Compute the diff on the middle block. + const diff: Patch = diffNoCommonAffix(src, dst); + if (prefix) diff.unshift([PATCH_OP_TYPE.EQUAL, prefix]); + if (suffix) diff.push([PATCH_OP_TYPE.EQUAL, suffix]); + cleanupMerge(diff, fixUnicode); + return diff; +}; + +/** + * Find the differences between two texts. + * + * @param src Old string to be diffed. + * @param dst New string to be diffed. + * @return A {@link Patch} - an array of patch operations. + */ +export const diff = (src: string, dst: string): Patch => diff_(src, dst, true); + +/** + * Considers simple insertion and deletion cases around the caret position in + * the destination string. If the fast patch cannot be constructed, it falls + * back to the default full implementation. + * + * Cases considered: + * + * 1. Insertion of a single or multiple characters right before the caret. + * 2. Deletion of one or more characters right before the caret. + * + * @param src Old string to be diffed. + * @param dst New string to be diffed. + * @param caret The position of the caret in the new string. Set to -1 to + * ignore the caret position. + * @return A {@link Patch} - an array of patch operations. + */ +export const diffEdit = (src: string, dst: string, caret: number) => { + edit: { + if (caret < 0) break edit; + const srcLen = src.length; + const dstLen = dst.length; + if (srcLen === dstLen) break edit; + const dstSfx = dst.slice(caret); + const sfxLen = dstSfx.length; + if (sfxLen > srcLen) break edit; + const srcSfx = src.slice(srcLen - sfxLen); + if (srcSfx !== dstSfx) break edit; + const isInsert = dstLen > srcLen; + if (isInsert) ins: { + const pfxLen = srcLen - sfxLen; + const srcPfx = src.slice(0, pfxLen); + const dstPfx = dst.slice(0, pfxLen); + if (srcPfx !== dstPfx) break ins; + const insert = dst.slice(pfxLen, caret); + const patch: Patch = []; + if (srcPfx) patch.push([PATCH_OP_TYPE.EQUAL, srcPfx]); + if (insert) patch.push([PATCH_OP_TYPE.INSERT, insert]); + if (dstSfx) patch.push([PATCH_OP_TYPE.EQUAL, dstSfx]); + return patch; + } else del: { + const pfxLen = dstLen - sfxLen; + const dstPfx = dst.slice(0, pfxLen); + const srcPfx = src.slice(0, pfxLen); + if (srcPfx !== dstPfx) break del; + const del = src.slice(pfxLen, srcLen - sfxLen); + const patch: Patch = []; + if (srcPfx) patch.push([PATCH_OP_TYPE.EQUAL, srcPfx]); + if (del) patch.push([PATCH_OP_TYPE.DELETE, del]); + if (dstSfx) patch.push([PATCH_OP_TYPE.EQUAL, dstSfx]); + return patch; + } + } + return diff(src, dst); +}; From 0fb9c7c0dd2a0f1b9033bb8a74c8a4d5c166e7dc Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 10:00:27 +0200 Subject: [PATCH 02/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20add=20diff?= =?UTF-8?q?=20utility=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/util/diff.ts b/src/util/diff.ts index 75d35c1e7a..616c5c4dd7 100644 --- a/src/util/diff.ts +++ b/src/util/diff.ts @@ -488,3 +488,72 @@ export const diffEdit = (src: string, dst: string, caret: number) => { } return diff(src, dst); }; + +export const src = (patch: Patch): string => { + let txt = ''; + const length = patch.length; + for (let i = 0; i < length; i++) { + const op = patch[i]; + switch (op[0]) { + case PATCH_OP_TYPE.EQUAL: + case PATCH_OP_TYPE.DELETE: + txt += op[1]; + break; + } + } + return txt; +}; + +export const dst = (patch: Patch): string => { + let txt = ''; + const length = patch.length; + for (let i = 0; i < length; i++) { + const op = patch[i]; + switch (op[0]) { + case PATCH_OP_TYPE.EQUAL: + case PATCH_OP_TYPE.INSERT: + txt += op[1]; + break; + } + } + return txt; +}; + +export const invertOp = (op: PatchOperation): PatchOperation => { + switch (op[0]) { + case PATCH_OP_TYPE.EQUAL: + return op; + case PATCH_OP_TYPE.INSERT: + return [PATCH_OP_TYPE.DELETE, op[1]]; + case PATCH_OP_TYPE.DELETE: + return [PATCH_OP_TYPE.INSERT, op[1]]; + } +}; + +export const invert = (patch: Patch): Patch => { + const inverted: Patch = []; + const length = patch.length; + for (let i = 0; i < length; i++) inverted.push(invertOp(patch[i])); + return inverted; +}; + +export const apply = (patch: Patch, onInsert: (pos: number, str: string) => void, onDelete: (pos: number, len: number) => void) => { + const length = patch.length; + let pos = 0; + for (let i = 0; i < length; i++) { + const op = patch[i]; + switch (op[0]) { + case PATCH_OP_TYPE.EQUAL: + pos += op[1].length; + break; + case PATCH_OP_TYPE.INSERT: + const txt = op[1]; + onInsert(pos, txt); + pos += txt.length; + break; + case PATCH_OP_TYPE.DELETE: + onDelete(pos, op[1].length); + break; + } + } +}; From 9f35ef792a5bea0673f2e29d9bef816296f5c8a0 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 10:01:12 +0200 Subject: [PATCH 03/68] =?UTF-8?q?test(util):=20=F0=9F=92=8D=20improve=20di?= =?UTF-8?q?ff=20tests=20add=20fuzz=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/__tests__/diff-fuzz.spec.ts | 15 +++++++ src/util/__tests__/diff.spec.ts | 67 ++++++++++++++++++++++++++++ src/util/__tests__/util.ts | 27 +++++++++++ 3 files changed, 109 insertions(+) create mode 100644 src/util/__tests__/diff-fuzz.spec.ts create mode 100644 src/util/__tests__/util.ts diff --git a/src/util/__tests__/diff-fuzz.spec.ts b/src/util/__tests__/diff-fuzz.spec.ts new file mode 100644 index 0000000000..a1a0f9876a --- /dev/null +++ b/src/util/__tests__/diff-fuzz.spec.ts @@ -0,0 +1,15 @@ +import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; +import {assertPatch} from './util'; +import {diffEdit} from '../diff'; + +const str = () => Math.random() > .7 ? RandomJson.genString() : Math.random().toString(36).slice(2); + +test('fuzzing diff() and diffEdit()', () => { + for (let i = 0; i < 200; i++) { + const src = str(); + const dst = str(); + assertPatch(src, dst); + const patch = diffEdit(src, dst, Math.floor(Math.random() * src.length)); + assertPatch(src, dst, patch); + } +}); diff --git a/src/util/__tests__/diff.spec.ts b/src/util/__tests__/diff.spec.ts index 21f0437126..bb97628cad 100644 --- a/src/util/__tests__/diff.spec.ts +++ b/src/util/__tests__/diff.spec.ts @@ -1,9 +1,11 @@ import {PATCH_OP_TYPE, diff, diffEdit} from '../diff'; +import {assertPatch} from './util'; describe('diff()', () => { test('returns a single equality tuple, when strings are identical', () => { const patch = diffEdit('hello', 'hello', 1); expect(patch).toEqual([[PATCH_OP_TYPE.EQUAL, 'hello']]); + assertPatch('hello', 'hello', patch); }); test('single character insert at the beginning', () => { @@ -22,6 +24,9 @@ describe('diff()', () => { [PATCH_OP_TYPE.INSERT, '_'], [PATCH_OP_TYPE.EQUAL, 'hello'], ]); + assertPatch('hello', '_hello', patch1); + assertPatch('hello', '_hello', patch2); + assertPatch('hello', '_hello', patch3); }); test('single character insert at the end', () => { @@ -40,6 +45,9 @@ describe('diff()', () => { [PATCH_OP_TYPE.EQUAL, 'hello'], [PATCH_OP_TYPE.INSERT, '!'], ]); + assertPatch('hello', 'hello!', patch1); + assertPatch('hello', 'hello!', patch2); + assertPatch('hello', 'hello!', patch3); }); test('single character removal at the beginning', () => { @@ -48,6 +56,7 @@ describe('diff()', () => { [PATCH_OP_TYPE.DELETE, 'h'], [PATCH_OP_TYPE.EQUAL, 'ello'], ]); + assertPatch('hello', 'ello', patch); }); test('single character removal at the end', () => { @@ -61,6 +70,8 @@ describe('diff()', () => { [PATCH_OP_TYPE.EQUAL, 'hell'], [PATCH_OP_TYPE.DELETE, 'o'], ]); + assertPatch('hello', 'hell', patch1); + assertPatch('hello', 'hell', patch2); }); test('single character replacement at the beginning', () => { @@ -76,6 +87,8 @@ describe('diff()', () => { [PATCH_OP_TYPE.INSERT, 'H'], [PATCH_OP_TYPE.EQUAL, 'ello'], ]); + assertPatch('hello', 'Hello', patch1); + assertPatch('hello', 'Hello', patch2); }); test('single character replacement at the end', () => { @@ -85,5 +98,59 @@ describe('diff()', () => { [PATCH_OP_TYPE.DELETE, 'o'], [PATCH_OP_TYPE.INSERT, 'O'], ]); + assertPatch('hello', 'hellO', patch); + }); + + test('two inserts', () => { + const src = '0123456789'; + const dst = '012__3456xx789'; + const patch = diff(src, dst); + assertPatch(src, dst, patch); + }); + + test('two deletes', () => { + const src = '0123456789'; + const dst = '0134589'; + const patch = diff(src, dst); + assertPatch(src, dst, patch); + }); + + test('two inserts and two deletes', () => { + const src = '0123456789'; + const dst = '01_245-678'; + assertPatch(src, dst); + }); + + test('emoji', () => { + assertPatch('a🙃b', 'ab'); + assertPatch('a🙃b', 'a🙃'); + assertPatch('a🙃b', '🙃b'); + assertPatch('a🙃b', 'aasasdfdf👋b'); + assertPatch('a🙃b', 'a👋b'); + }); + + test('same strings', () => { + assertPatch('', ''); + assertPatch('1', '1'); + assertPatch('12', '12'); + assertPatch('asdf asdf asdf', 'asdf asdf asdf'); + assertPatch('a🙃b', 'a🙃b'); + }); + + test('delete everything', () => { + assertPatch('1', ''); + assertPatch('12', ''); + assertPatch('123', ''); + assertPatch('asdf asdf asdf asdf asdf', ''); + assertPatch('a🙃b', ''); + }); + + test('insert into empty string', () => { + assertPatch('', '1'); + assertPatch('', '12'); + assertPatch('', '123'); + assertPatch('', '1234'); + assertPatch('', 'asdf asdf asdf asdf asdf asdf asdf asdf asdf'); + assertPatch('', 'a🙃b'); }); }); diff --git a/src/util/__tests__/util.ts b/src/util/__tests__/util.ts new file mode 100644 index 0000000000..016860de60 --- /dev/null +++ b/src/util/__tests__/util.ts @@ -0,0 +1,27 @@ +import * as diff from "../diff"; + +export const assertPatch = (src: string, dst: string, patch: diff.Patch = diff.diff(src, dst)) => { + const src1 = diff.src(patch); + const dst1 = diff.dst(patch); + let dst2 = src; + diff.apply(patch, (pos, str) => { + dst2 = dst2.slice(0, pos) + str + dst2.slice(pos); + }, (pos, len) => { + dst2 = dst2.slice(0, pos) + dst2.slice(pos + len); + }); + const inverted = diff.invert(patch); + const src2 = diff.dst(inverted); + const dst3 = diff.src(inverted); + let src3 = dst; + diff.apply(inverted, (pos, str) => { + src3 = src3.slice(0, pos) + str + src3.slice(pos); + }, (pos, len) => { + src3 = src3.slice(0, pos) + src3.slice(pos + len); + }); + expect(src1).toBe(src); + expect(src2).toBe(src); + expect(src3).toBe(src); + expect(dst1).toBe(dst); + expect(dst2).toBe(dst); + expect(dst3).toBe(dst); +}; From 016b166a17998aea598c95d6af4e60ba86c84fa5 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 11:02:37 +0200 Subject: [PATCH 04/68] =?UTF-8?q?test(util):=20=F0=9F=92=8D=20add=20fuzz?= =?UTF-8?q?=20testing=20for=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/util/__tests__/diff-fuzz.spec.ts | 31 +++++++++++++++++++++++----- src/util/__tests__/diff.spec.ts | 24 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4ff25803d7..87a02220e4 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "benchmark": "^2.1.4", "config-galore": "^1.0.0", "editing-traces": "https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b", + "fast-diff": "^1.3.0", "fast-json-patch": "^3.1.1", "html-webpack-plugin": "^5.6.0", "jest": "^29.7.0", diff --git a/src/util/__tests__/diff-fuzz.spec.ts b/src/util/__tests__/diff-fuzz.spec.ts index a1a0f9876a..ab1d5cd80d 100644 --- a/src/util/__tests__/diff-fuzz.spec.ts +++ b/src/util/__tests__/diff-fuzz.spec.ts @@ -1,15 +1,36 @@ import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; import {assertPatch} from './util'; -import {diffEdit} from '../diff'; +import {diff, diffEdit} from '../diff'; +const fastDiff = require('fast-diff') as typeof diff; -const str = () => Math.random() > .7 ? RandomJson.genString() : Math.random().toString(36).slice(2); +const str = () => Math.random() > .7 + ? RandomJson.genString(Math.ceil(Math.random() * 200)) + : Math.random().toString(36).slice(2); +const iterations = 100; -test('fuzzing diff() and diffEdit()', () => { - for (let i = 0; i < 200; i++) { +test('fuzzing diff()', () => { + for (let i = 0; i < iterations; i++) { + const src = str(); + const dst = str(); + const patch = diff(src, dst); + assertPatch(src, dst, patch); + } +}); + +test('fuzzing diffEdit()', () => { + for (let i = 0; i < iterations; i++) { const src = str(); const dst = str(); - assertPatch(src, dst); const patch = diffEdit(src, dst, Math.floor(Math.random() * src.length)); assertPatch(src, dst, patch); } }); + +test('fuzzing fast-diff', () => { + for (let i = 0; i < iterations; i++) { + const src = str(); + const dst = str(); + const patch = fastDiff(src, dst); + assertPatch(src, dst, patch); + } +}); diff --git a/src/util/__tests__/diff.spec.ts b/src/util/__tests__/diff.spec.ts index bb97628cad..98c8c66e0e 100644 --- a/src/util/__tests__/diff.spec.ts +++ b/src/util/__tests__/diff.spec.ts @@ -153,4 +153,28 @@ describe('diff()', () => { assertPatch('', 'asdf asdf asdf asdf asdf asdf asdf asdf asdf'); assertPatch('', 'a🙃b'); }); + + test('common prefix', () => { + assertPatch('abc', 'xyz'); + assertPatch('1234abcdef', '1234xyz'); + assertPatch('1234', '1234xyz'); + assertPatch('1234_', '1234xyz'); + }); + + test('common suffix', () => { + assertPatch('abcdef1234', 'xyz1234'); + assertPatch('1234abcdef', 'xyz1234'); + assertPatch('1234', 'xyz1234'); + assertPatch('_1234', 'xyz1234'); + }); + + test('common overlap', () => { + assertPatch('ab', 'bc'); + assertPatch('abc', 'abcd'); + assertPatch('ab', 'abcd'); + assertPatch('xab', 'abcd'); + assertPatch('xabc', 'abcd'); + assertPatch('xyabc', 'abcd_'); + assertPatch('12345xxx', 'xxabcd'); + }); }); From 163278a9599bec0f7214445180f3f0002f7845ba Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 11:30:34 +0200 Subject: [PATCH 05/68] =?UTF-8?q?test(util):=20=F0=9F=92=8D=20add=20diffEd?= =?UTF-8?q?it()=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/__tests__/diff.spec.ts | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/util/__tests__/diff.spec.ts b/src/util/__tests__/diff.spec.ts index 98c8c66e0e..3354b8e6d7 100644 --- a/src/util/__tests__/diff.spec.ts +++ b/src/util/__tests__/diff.spec.ts @@ -1,4 +1,4 @@ -import {PATCH_OP_TYPE, diff, diffEdit} from '../diff'; +import {PATCH_OP_TYPE, Patch, diff, diffEdit} from '../diff'; import {assertPatch} from './util'; describe('diff()', () => { @@ -178,3 +178,46 @@ describe('diff()', () => { assertPatch('12345xxx', 'xxabcd'); }); }); + +describe('diffEdit()', () => { + const assertDiffEdit = (prefix: string, edit: string, suffix: string) => { + const src1 = prefix + suffix; + const dst1 = prefix + edit + suffix; + const cursor1 = prefix.length + edit.length; + const patch1 = diffEdit(src1, dst1, cursor1); + assertPatch(src1, dst1, patch1); + const patch1Expected: Patch = []; + if (prefix) patch1Expected.push([PATCH_OP_TYPE.EQUAL, prefix]); + if (edit) patch1Expected.push([PATCH_OP_TYPE.INSERT, edit]); + if (suffix) patch1Expected.push([PATCH_OP_TYPE.EQUAL, suffix]); + expect(patch1).toEqual(patch1Expected); + const src2 = prefix + edit + suffix; + const dst2 = prefix + suffix; + const cursor2 = prefix.length; + const patch2 = diffEdit(src2, dst2, cursor2); + assertPatch(src2, dst2, patch2); + const patch2Expected: Patch = []; + if (prefix) patch2Expected.push([PATCH_OP_TYPE.EQUAL, prefix]); + if (edit) patch2Expected.push([PATCH_OP_TYPE.DELETE, edit]); + if (suffix) patch2Expected.push([PATCH_OP_TYPE.EQUAL, suffix]); + expect(patch2).toEqual(patch2Expected); + }; + + test('can handle various inserts', () => { + assertDiffEdit('', 'a', ''); + assertDiffEdit('a', 'b', ''); + assertDiffEdit('ab', 'c', ''); + assertDiffEdit('abc', 'd', ''); + assertDiffEdit('abcd', 'efg', ''); + assertDiffEdit('abcd', '_', 'efg'); + assertDiffEdit('abcd', '__', 'efg'); + assertDiffEdit('abcd', '___', 'efg'); + assertDiffEdit('', '_', 'var'); + assertDiffEdit('', '_', '_var'); + assertDiffEdit('a', 'b', 'c'); + assertDiffEdit('Hello', ' world', ''); + assertDiffEdit('Hello world', '!', ''); + assertDiffEdit('aaa', 'bbb', 'ccc'); + assertDiffEdit('1', '2', '3'); + }); +}); \ No newline at end of file From f1db4d8eb2f0ebfb6a77afc16a2057dba00fd2fc Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 12:36:39 +0200 Subject: [PATCH 06/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20im?= =?UTF-8?q?plement=20StrNode=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 63 +++++++++++++++++++++++ src/json-crdt-diff/__tests__/Diff.spec.ts | 46 +++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/json-crdt-diff/Diff.ts create mode 100644 src/json-crdt-diff/__tests__/Diff.spec.ts diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts new file mode 100644 index 0000000000..a9c3674707 --- /dev/null +++ b/src/json-crdt-diff/Diff.ts @@ -0,0 +1,63 @@ +import {Patch, PatchBuilder} from '../json-crdt-patch'; +import {ArrNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; +import {diff, PATCH_OP_TYPE} from '../util/diff'; +import type {Model} from '../json-crdt/model'; + +export class DiffError extends Error { + constructor(message: string = 'DIFF') { + super(message); + } +} + +export class Diff { + protected builder: PatchBuilder; + + public constructor(protected readonly model: Model) { + this.builder = new PatchBuilder(model.clock.clone()); + } + + protected diffStr(src: StrNode, dst: string): void { + const view = src.view(); + const patch = diff(view, dst); + const builder = this.builder; + const length = patch.length; + let pos = 0; + for (let i = 0; i < length; i++) { + const op = patch[i]; + switch (op[0]) { + case PATCH_OP_TYPE.EQUAL: + pos += op[1].length; + break; + case PATCH_OP_TYPE.INSERT: { + const txt = op[1]; + const after = !pos ? src.id : src.find(pos - 1); + if (!after) throw new DiffError(); + builder.insStr(src.id, after, txt); + pos += txt.length; + break; + } + case PATCH_OP_TYPE.DELETE: { + const length = op[1].length; + const spans = src.findInterval(pos, length); + if (!spans) throw new DiffError(); + builder.del(src.id, spans); + break; + } + } + } + } + + public diff(src: JsonNode, dst: unknown): Patch { + if (src instanceof StrNode) { + if (typeof dst !== 'string') throw new DiffError(); + this.diffStr(src, dst); + } else if (src instanceof ObjNode) { + if (!dst || typeof dst !== 'object') throw new DiffError(); + // ... + } else if (src instanceof ArrNode) { + } else if (src instanceof VecNode) { + } else if (src instanceof ValNode) { + } else throw new DiffError(); + return this.builder.flush(); + } +} diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts new file mode 100644 index 0000000000..047173e7ab --- /dev/null +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -0,0 +1,46 @@ +import {Diff} from '../Diff'; +import {InsStrOp} from '../../json-crdt-patch'; +import {Model} from '../../json-crdt/model'; + +describe('string diff', () => { + test('insert', () => { + const model = Model.create(); + const src = 'hello world'; + model.api.root({str: src}); + const str = model.api.str(['str']); + const dst = 'hello world!'; + const patch = new Diff(model).diff(str.node, dst); + expect(patch.ops.length).toBe(1); + expect(patch.ops[0].name()).toBe('ins_str'); + expect((patch.ops[0] as InsStrOp).data).toBe('!'); + expect(str.view()).toBe(src); + model.applyPatch(patch); + expect(str.view()).toBe(dst); + }); + + test('delete', () => { + const model = Model.create(); + const src = 'hello world'; + model.api.root({str: src}); + const str = model.api.str(['str']); + const dst = 'hello world'; + const patch = new Diff(model).diff(str.node, dst); + expect(patch.ops.length).toBe(1); + expect(patch.ops[0].name()).toBe('del'); + expect(str.view()).toBe(src); + model.applyPatch(patch); + expect(str.view()).toBe(dst); + }); + + test('inserts and deletes', () => { + const model = Model.create(); + const src = 'hello world'; + model.api.root({str: src}); + const str = model.api.str(['str']); + const dst = 'Hello, world!'; + const patch = new Diff(model).diff(str.node, dst); + expect(str.view()).toBe(src); + model.applyPatch(patch); + expect(str.view()).toBe(dst); + }); +}); From a0e148f0473137ffa59e263cbd794f108869d4de Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 13:04:54 +0200 Subject: [PATCH 07/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20re?= =?UTF-8?q?move=20deleted=20"obj"=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/json-crdt-diff/Diff.ts | 13 +++++++++++-- src/json-crdt-diff/__tests__/Diff.spec.ts | 23 ++++++++++++++++++++++- src/json-crdt/nodes/obj/ObjNode.ts | 12 +++++++++++- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 87a02220e4..60ea090042 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "", "demo", "json-cli", + "json-crdt-diff", "json-crdt-patch", "json-crdt-extensions", "json-crdt-peritext-ui", diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index a9c3674707..46a441c65f 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -1,4 +1,4 @@ -import {Patch, PatchBuilder} from '../json-crdt-patch'; +import {type ITimestampStruct, Patch, PatchBuilder} from '../json-crdt-patch'; import {ArrNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; import {diff, PATCH_OP_TYPE} from '../util/diff'; import type {Model} from '../json-crdt/model'; @@ -47,13 +47,22 @@ export class Diff { } } + protected diffObj(src: ObjNode, dst: Record): void { + const builder = this.builder; + const inserts: [key: string, value: ITimestampStruct][] = []; + src.forEach((key) => { + if (dst[key] === void 0) inserts.push([key, builder.const(undefined)]); + }); + if (inserts.length) builder.insObj(src.id, inserts); + } + public diff(src: JsonNode, dst: unknown): Patch { if (src instanceof StrNode) { if (typeof dst !== 'string') throw new DiffError(); this.diffStr(src, dst); } else if (src instanceof ObjNode) { if (!dst || typeof dst !== 'object') throw new DiffError(); - // ... + this.diffObj(src, dst as Record); } else if (src instanceof ArrNode) { } else if (src instanceof VecNode) { } else if (src instanceof ValNode) { diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index 047173e7ab..d1fbac3f3e 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -1,8 +1,17 @@ import {Diff} from '../Diff'; import {InsStrOp} from '../../json-crdt-patch'; import {Model} from '../../json-crdt/model'; +import {JsonNode} from '../../json-crdt/nodes'; -describe('string diff', () => { +const assertDiff = (model: Model, src: JsonNode, dst: unknown) => { + const patch = new Diff(model).diff(src, dst); + // console.log(patch + ''); + model.applyPatch(patch); + const view = src.view(); + expect(view).toEqual(dst); +}; + +describe('str', () => { test('insert', () => { const model = Model.create(); const src = 'hello world'; @@ -44,3 +53,15 @@ describe('string diff', () => { expect(str.view()).toBe(dst); }); }); + +describe('obj', () => { + test('can remove a key', () => { + const model = Model.create(); + model.api.root({ + foo: 'abc', + bar: 'xyz', + }); + const dst = {foo: 'abc'}; + assertDiff(model, model.root.child(), dst); + }); +}); diff --git a/src/json-crdt/nodes/obj/ObjNode.ts b/src/json-crdt/nodes/obj/ObjNode.ts index eef679f5e3..2cdfec26ed 100644 --- a/src/json-crdt/nodes/obj/ObjNode.ts +++ b/src/json-crdt/nodes/obj/ObjNode.ts @@ -1,8 +1,9 @@ import {printTree} from 'tree-dump/lib/printTree'; import {compare, type ITimestampStruct, printTs} from '../../../json-crdt-patch/clock'; +import {ConNode} from '../const/ConNode'; +import type {JsonNode, JsonNodeView} from '..'; import type {Model} from '../../model'; import type {Printable} from 'tree-dump/lib/types'; -import type {JsonNode, JsonNodeView} from '..'; /** * Represents a `obj` JSON CRDT node, which is a Last-write-wins (LWW) object. @@ -65,6 +66,15 @@ export class ObjNode = Record callback(index.get(id)!, key)); } + public forEach(callback: (key: string, value: JsonNode) => void) { + const index = this.doc.index; + this.keys.forEach((id, key) => { + const value = index.get(id); + if (!value || (value instanceof ConNode && value.val === void 0)) return; + callback(key, value); + }); + } + // ----------------------------------------------------------------- JsonNode /** From 14e64fbaa27fffacfa157167dea40159a98b892e Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 13:38:15 +0200 Subject: [PATCH 08/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20in?= =?UTF-8?q?sert=20missing=20keys=20into=20"obj"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 11 +++++++++++ src/json-crdt-diff/__tests__/Diff.spec.ts | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 46a441c65f..c655115962 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -50,9 +50,20 @@ export class Diff { protected diffObj(src: ObjNode, dst: Record): void { const builder = this.builder; const inserts: [key: string, value: ITimestampStruct][] = []; + const srcKeys: Record = {}; src.forEach((key) => { + srcKeys[key] = 1; if (dst[key] === void 0) inserts.push([key, builder.const(undefined)]); }); + const keys = Object.keys(dst); + const length = keys.length; + for (let i = 0; i < length; i++) { + const key = keys[i]; + if (!srcKeys[key]) { + const value = dst[key]; + inserts.push([key, builder.const(value)]); + } + } if (inserts.length) builder.insObj(src.id, inserts); } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index d1fbac3f3e..3159971ad4 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -4,11 +4,13 @@ import {Model} from '../../json-crdt/model'; import {JsonNode} from '../../json-crdt/nodes'; const assertDiff = (model: Model, src: JsonNode, dst: unknown) => { - const patch = new Diff(model).diff(src, dst); + const patch1 = new Diff(model).diff(src, dst); // console.log(patch + ''); - model.applyPatch(patch); + model.applyPatch(patch1); const view = src.view(); expect(view).toEqual(dst); + const patch2 = new Diff(model).diff(src, dst); + expect(patch2.ops.length).toBe(0); }; describe('str', () => { @@ -64,4 +66,13 @@ describe('obj', () => { const dst = {foo: 'abc'}; assertDiff(model, model.root.child(), dst); }); + + test('can add a key', () => { + const model = Model.create(); + model.api.root({ + foo: 'abc', + }); + const dst = {foo: 'abc', bar: 'xyz'}; + assertDiff(model, model.root.child(), dst); + }); }); From 036b58e90a47511e75ebe5e67a08184ef8f8aa4e Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 14:11:51 +0200 Subject: [PATCH 09/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20su?= =?UTF-8?q?pport=20entering=20"obj"=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 26 +++++++++++++++++------ src/json-crdt-diff/__tests__/Diff.spec.ts | 13 +++++++++--- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index c655115962..b645d7f3bf 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -18,6 +18,7 @@ export class Diff { protected diffStr(src: StrNode, dst: string): void { const view = src.view(); + if (view === dst) return; const patch = diff(view, dst); const builder = this.builder; const length = patch.length; @@ -25,9 +26,10 @@ export class Diff { for (let i = 0; i < length; i++) { const op = patch[i]; switch (op[0]) { - case PATCH_OP_TYPE.EQUAL: + case PATCH_OP_TYPE.EQUAL: { pos += op[1].length; break; + } case PATCH_OP_TYPE.INSERT: { const txt = op[1]; const after = !pos ? src.id : src.find(pos - 1); @@ -53,21 +55,24 @@ export class Diff { const srcKeys: Record = {}; src.forEach((key) => { srcKeys[key] = 1; - if (dst[key] === void 0) inserts.push([key, builder.const(undefined)]); + const dstValue = dst[key]; + if (dstValue === void 0) inserts.push([key, builder.const(undefined)]); }); const keys = Object.keys(dst); const length = keys.length; for (let i = 0; i < length; i++) { const key = keys[i]; - if (!srcKeys[key]) { - const value = dst[key]; - inserts.push([key, builder.const(value)]); + const dstValue = dst[key]; + if (!srcKeys[key]) inserts.push([key, builder.json(dstValue)]); + else { + const node = src.get(key); + if (node) this.diffAny(node, dstValue); } } if (inserts.length) builder.insObj(src.id, inserts); } - public diff(src: JsonNode, dst: unknown): Patch { + public diffAny(src: JsonNode, dst: unknown): void { if (src instanceof StrNode) { if (typeof dst !== 'string') throw new DiffError(); this.diffStr(src, dst); @@ -77,7 +82,14 @@ export class Diff { } else if (src instanceof ArrNode) { } else if (src instanceof VecNode) { } else if (src instanceof ValNode) { - } else throw new DiffError(); + } else { + console.log(src, dst); + throw new DiffError(); + } + } + + public diff(src: JsonNode, dst: unknown): Patch { + this.diffAny(src, dst); return this.builder.flush(); } } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index 3159971ad4..d5db835d8f 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -5,10 +5,10 @@ import {JsonNode} from '../../json-crdt/nodes'; const assertDiff = (model: Model, src: JsonNode, dst: unknown) => { const patch1 = new Diff(model).diff(src, dst); - // console.log(patch + ''); + // console.log(patch1 + ''); model.applyPatch(patch1); - const view = src.view(); - expect(view).toEqual(dst); + expect(src.view()).toEqual(dst); + // console.log(model + ''); const patch2 = new Diff(model).diff(src, dst); expect(patch2.ops.length).toBe(0); }; @@ -75,4 +75,11 @@ describe('obj', () => { const dst = {foo: 'abc', bar: 'xyz'}; assertDiff(model, model.root.child(), dst); }); + + test('can edit nested string', () => { + const model = Model.create(); + model.api.root({foo: 'abc'}); + const dst = {foo: 'abc!'}; + assertDiff(model, model.root.child(), dst); + }); }); From 8671b8c50f5ab1a935404d41c5785a13d2bd57a7 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 14:16:01 +0200 Subject: [PATCH 10/68] =?UTF-8?q?chore(json-crdt-diff):=20=F0=9F=A4=96=20a?= =?UTF-8?q?dd=20shorter=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index b645d7f3bf..763d9390a7 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -19,8 +19,18 @@ export class Diff { protected diffStr(src: StrNode, dst: string): void { const view = src.view(); if (view === dst) return; - const patch = diff(view, dst); const builder = this.builder; + // apply(diff(view, dst), (pos, txt) => { + // const after = !pos ? src.id : src.find(pos - 1); + // if (!after) throw new DiffError(); + // builder.insStr(src.id, after, txt); + // pos += txt.length; + // }, (pos, len) => { + // const spans = src.findInterval(pos, len); + // if (!spans) throw new DiffError(); + // builder.del(src.id, spans); + // }); + const patch = diff(view, dst); const length = patch.length; let pos = 0; for (let i = 0; i < length; i++) { From 587d2c5264bb6d2b99132ad2834c110130776dec Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 2 May 2025 18:01:23 +0200 Subject: [PATCH 11/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20ha?= =?UTF-8?q?ndle=20case=20where=20"obj"=20value=20not=20compatible=20with?= =?UTF-8?q?=20dst=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 18 +++++++++++++----- src/json-crdt-diff/__tests__/Diff.spec.ts | 8 +++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 763d9390a7..b12486f87a 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -73,10 +73,19 @@ export class Diff { for (let i = 0; i < length; i++) { const key = keys[i]; const dstValue = dst[key]; - if (!srcKeys[key]) inserts.push([key, builder.json(dstValue)]); - else { - const node = src.get(key); - if (node) this.diffAny(node, dstValue); + overwrite: { + if (srcKeys[key]) { + const node = src.get(key); + if (node) { + try { + this.diffAny(node, dstValue); + break overwrite; + } catch (error) { + if (!(error instanceof DiffError)) throw error; + } + } + } + inserts.push([key, builder.json(dstValue)]); } } if (inserts.length) builder.insObj(src.id, inserts); @@ -93,7 +102,6 @@ export class Diff { } else if (src instanceof VecNode) { } else if (src instanceof ValNode) { } else { - console.log(src, dst); throw new DiffError(); } } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index d5db835d8f..1b5c6ccedb 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -1,5 +1,5 @@ import {Diff} from '../Diff'; -import {InsStrOp} from '../../json-crdt-patch'; +import {InsStrOp, s} from '../../json-crdt-patch'; import {Model} from '../../json-crdt/model'; import {JsonNode} from '../../json-crdt/nodes'; @@ -82,4 +82,10 @@ describe('obj', () => { const dst = {foo: 'abc!'}; assertDiff(model, model.root.child(), dst); }); + + test('can update "con" string key', () => { + const model = Model.create(s.obj({foo: s.con('abc')})); + const dst = {foo: 'abc!'}; + assertDiff(model, model.root.child(), dst); + }); }); From 3abcfcf980833fd89fc8391c6f6979b9b0bc9cc4 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 00:28:26 +0200 Subject: [PATCH 12/68] =?UTF-8?q?feat(json-hash):=20=F0=9F=8E=B8=20produce?= =?UTF-8?q?=20hashes=20for=20binary=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-hash/__tests__/index.spec.ts | 12 ++++++++++++ src/json-hash/index.ts | 24 +++++++++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/json-hash/__tests__/index.spec.ts b/src/json-hash/__tests__/index.spec.ts index 14d5029244..86c878f8aa 100644 --- a/src/json-hash/__tests__/index.spec.ts +++ b/src/json-hash/__tests__/index.spec.ts @@ -43,6 +43,18 @@ test('returns the same hash for array with values', () => { expect(res1).toBe(res2); }); +test('same hash for binary data', () => { + const res1 = hash({data: new Uint8Array([1, 2, 3])}); + const res2 = hash({data: new Uint8Array([1, 2, 3])}); + expect(res1).toBe(res2); +}); + +test('different hash for binary data', () => { + const res1 = hash({data: new Uint8Array([1, 2, 3])}); + const res2 = hash({data: new Uint8Array([1, 2, 4])}); + expect(res1).not.toBe(res2); +}); + test('returns different hash for random JSON values', () => { for (let i = 0; i < 100; i++) { const res1 = hash(RandomJson.generate() as any); diff --git a/src/json-hash/index.ts b/src/json-hash/index.ts index 8b0e32c74c..fc05036801 100644 --- a/src/json-hash/index.ts +++ b/src/json-hash/index.ts @@ -1,4 +1,4 @@ -import type {JsonValue} from '@jsonjoy.com/json-pack/lib/types'; +import type {PackValue} from '@jsonjoy.com/json-pack/lib/types'; import {sort} from '@jsonjoy.com/util/lib/sort/insertion'; export enum CONST { @@ -10,6 +10,7 @@ export enum CONST { ARRAY = 982452259, STRING = 982453601, OBJECT = 982454533, + BINARY = 982454837, } export const updateNum = (state: number, num: number): number => { @@ -17,12 +18,24 @@ export const updateNum = (state: number, num: number): number => { }; export const updateStr = (state: number, str: string): number => { - let i = str.length; + const length = str.length; + state = updateNum(state, CONST.STRING); + state = updateNum(state, length); + let i = length; while (i) state = (state << 5) + state + str.charCodeAt(--i); return state; }; -export const updateJson = (state: number, json: JsonValue): number => { +export const updateBin = (state: number, bin: Uint8Array): number => { + const length = bin.length; + state = updateNum(state, CONST.BINARY); + state = updateNum(state, length); + let i = length; + while (i) state = (state << 5) + state + bin[--i]; + return state; +}; + +export const updateJson = (state: number, json: PackValue): number => { switch (typeof json) { case 'number': return updateNum(state, json); @@ -31,12 +44,13 @@ export const updateJson = (state: number, json: JsonValue): number => { return updateStr(state, json); case 'object': { if (json === null) return updateNum(state, CONST.NULL); - if (json instanceof Array) { + if (Array.isArray(json)) { const length = json.length; state = updateNum(state, CONST.ARRAY); for (let i = 0; i < length; i++) state = updateJson(state, json[i]); return state; } + if (json instanceof Uint8Array) return updateBin(state, json); state = updateNum(state, CONST.OBJECT); const keys = sort(Object.keys(json as object)); const length = keys.length; @@ -53,4 +67,4 @@ export const updateJson = (state: number, json: JsonValue): number => { return state; }; -export const hash = (json: JsonValue) => updateJson(CONST.START_STATE, json) >>> 0; +export const hash = (json: PackValue) => updateJson(CONST.START_STATE, json) >>> 0; From 3133a5f0ba70bac170fc384099cd239eee142bc4 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 00:43:22 +0200 Subject: [PATCH 13/68] =?UTF-8?q?test(json-hash):=20=F0=9F=92=8D=20impleme?= =?UTF-8?q?nt=20*structural=20hash*=20structHash()=20for=20any=20JSOn-like?= =?UTF-8?q?=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 2 +- .../peritext/block/Block.ts | 2 +- .../block/__tests__/Inline.key.spec.ts | 2 +- .../peritext/overlay/Overlay.ts | 2 +- .../peritext/rga/Range.ts | 2 +- .../peritext/slice/PersistedSlice.ts | 2 +- .../peritext/slice/Slices.ts | 2 +- .../peritext/util/ChunkSlice.ts | 2 +- src/json-crdt/hash.ts | 2 +- .../__tests__/{index.spec.ts => hash.spec.ts} | 8 +- src/json-hash/__tests__/structHash.spec.ts | 74 +++++++++++++++++++ src/json-hash/hash.ts | 70 ++++++++++++++++++ src/json-hash/index.ts | 72 +----------------- src/json-hash/structHash.ts | 47 ++++++++++++ 14 files changed, 209 insertions(+), 80 deletions(-) rename src/json-hash/__tests__/{index.spec.ts => hash.spec.ts} (90%) create mode 100644 src/json-hash/__tests__/structHash.spec.ts create mode 100644 src/json-hash/hash.ts create mode 100644 src/json-hash/structHash.ts diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 97547aa555..c1d3ef96f1 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -10,7 +10,7 @@ import {Overlay} from './overlay/Overlay'; import {Chars} from './constants'; import {interval, tick} from '../../json-crdt-patch/clock'; import {Model, type StrApi} from '../../json-crdt/model'; -import {CONST, updateNum} from '../../json-hash'; +import {CONST, updateNum} from '../../json-hash/hash'; import {SESSION} from '../../json-crdt-patch/constants'; import {s} from '../../json-crdt-patch'; import {ExtraSlices} from './slice/ExtraSlices'; diff --git a/src/json-crdt-extensions/peritext/block/Block.ts b/src/json-crdt-extensions/peritext/block/Block.ts index 69a6f3372a..f2e245badc 100644 --- a/src/json-crdt-extensions/peritext/block/Block.ts +++ b/src/json-crdt-extensions/peritext/block/Block.ts @@ -1,5 +1,5 @@ import {printTree} from 'tree-dump/lib/printTree'; -import {CONST, updateJson, updateNum} from '../../../json-hash'; +import {CONST, updateJson, updateNum} from '../../../json-hash/hash'; import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint'; import {UndefEndIter, type UndefIterator} from '../../../util/iterator'; import {Inline} from './Inline'; diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Inline.key.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Inline.key.spec.ts index 57cef694af..652c19b670 100644 --- a/src/json-crdt-extensions/peritext/block/__tests__/Inline.key.spec.ts +++ b/src/json-crdt-extensions/peritext/block/__tests__/Inline.key.spec.ts @@ -1,6 +1,6 @@ import {Timestamp} from '../../../../json-crdt-patch'; import {updateId} from '../../../../json-crdt/hash'; -import {updateNum} from '../../../../json-hash'; +import {updateNum} from '../../../../json-hash/hash'; import { type Kit, setupKit, diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index e0fad1b974..f6f102197d 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -8,7 +8,7 @@ import {OverlayPoint} from './OverlayPoint'; import {MarkerOverlayPoint} from './MarkerOverlayPoint'; import {OverlayRefSliceEnd, OverlayRefSliceStart} from './refs'; import {compare, type ITimestampStruct} from '../../../json-crdt-patch/clock'; -import {CONST, updateNum} from '../../../json-hash'; +import {CONST, updateNum} from '../../../json-hash/hash'; import {MarkerSlice} from '../slice/MarkerSlice'; import {UndefEndIter, type UndefIterator} from '../../../util/iterator'; import {SliceBehavior} from '../slice/constants'; diff --git a/src/json-crdt-extensions/peritext/rga/Range.ts b/src/json-crdt-extensions/peritext/rga/Range.ts index 099ec0daed..16f621eba4 100644 --- a/src/json-crdt-extensions/peritext/rga/Range.ts +++ b/src/json-crdt-extensions/peritext/rga/Range.ts @@ -1,6 +1,6 @@ import {Point} from './Point'; import {Anchor} from './constants'; -import {updateNum} from '../../../json-hash'; +import {updateNum} from '../../../json-hash/hash'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {Printable} from 'tree-dump/lib/types'; import type {AbstractRga, Chunk} from '../../../json-crdt/nodes/rga'; diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts index 1199b9d314..da841fa6a7 100644 --- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -13,7 +13,7 @@ import { SliceTypeName, SliceTypeCon, } from './constants'; -import {CONST} from '../../../json-hash'; +import {CONST} from '../../../json-hash/hash'; import {Timestamp} from '../../../json-crdt-patch/clock'; import {prettyOneLine} from '../../../json-pretty'; import {validateType} from './util'; diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts index 6752839100..8ed6856088 100644 --- a/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -3,7 +3,7 @@ import {printTree} from 'tree-dump/lib/printTree'; import {PersistedSlice} from './PersistedSlice'; import {Timespan, compare, tss} from '../../../json-crdt-patch/clock'; import {updateRga} from '../../../json-crdt/hash'; -import {CONST, updateNum} from '../../../json-hash'; +import {CONST, updateNum} from '../../../json-hash/hash'; import {SliceBehavior, SliceHeaderShift, SliceTupleIndex} from './constants'; import {MarkerSlice} from './MarkerSlice'; import {VecNode} from '../../../json-crdt/nodes'; diff --git a/src/json-crdt-extensions/peritext/util/ChunkSlice.ts b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts index 211c3a8fca..086834bd0b 100644 --- a/src/json-crdt-extensions/peritext/util/ChunkSlice.ts +++ b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts @@ -1,4 +1,4 @@ -import {CONST, updateNum} from '../../../json-hash'; +import {CONST, updateNum} from '../../../json-hash/hash'; import {updateId} from '../../../json-crdt/hash'; import {type ITimestampStruct, Timestamp, printTs} from '../../../json-crdt-patch/clock'; import type {IChunkSlice} from './types'; diff --git a/src/json-crdt/hash.ts b/src/json-crdt/hash.ts index 13955d0e82..fd1c382add 100644 --- a/src/json-crdt/hash.ts +++ b/src/json-crdt/hash.ts @@ -1,4 +1,4 @@ -import {CONST, updateNum} from '../json-hash'; +import {CONST, updateNum} from '../json-hash/hash'; import {ConNode, ValNode, ObjNode, VecNode, ArrNode} from './nodes'; import {AbstractRga} from './nodes/rga'; import {last2} from 'sonic-forest/lib/util2'; diff --git a/src/json-hash/__tests__/index.spec.ts b/src/json-hash/__tests__/hash.spec.ts similarity index 90% rename from src/json-hash/__tests__/index.spec.ts rename to src/json-hash/__tests__/hash.spec.ts index 86c878f8aa..bed2b96c0b 100644 --- a/src/json-hash/__tests__/index.spec.ts +++ b/src/json-hash/__tests__/hash.spec.ts @@ -1,4 +1,4 @@ -import {hash} from '..'; +import {hash} from '../hash'; import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; test('returns the same hash for empty objects', () => { @@ -43,6 +43,12 @@ test('returns the same hash for array with values', () => { expect(res1).toBe(res2); }); +test('different key order returns the same hash', () => { + const res1 = hash({bar: 'asdf', foo: 123}); + const res2 = hash({foo: 123, bar: 'asdf'}); + expect(res1).toBe(res2); +}); + test('same hash for binary data', () => { const res1 = hash({data: new Uint8Array([1, 2, 3])}); const res2 = hash({data: new Uint8Array([1, 2, 3])}); diff --git a/src/json-hash/__tests__/structHash.spec.ts b/src/json-hash/__tests__/structHash.spec.ts new file mode 100644 index 0000000000..32906137de --- /dev/null +++ b/src/json-hash/__tests__/structHash.spec.ts @@ -0,0 +1,74 @@ +import {clone} from '@jsonjoy.com/util/lib/json-clone'; +import {structHash} from '../structHash'; +import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; + +test('returns the same hash for empty objects', () => { + const res1 = structHash({}); + const res2 = structHash({}); + expect(res1).toBe(res2); +}); + +test('returns the same hash for empty arrays', () => { + const res1 = structHash([]); + const res2 = structHash([]); + const res3 = structHash({}); + expect(res1).toBe(res2); + expect(res1).not.toBe(res3); +}); + +test('returns the same hash for empty strings', () => { + const res1 = structHash(''); + const res2 = structHash(''); + const res3 = structHash({}); + const res4 = structHash([]); + expect(res1).toBe(res2); + expect(res1).not.toBe(res3); + expect(res1).not.toBe(res4); +}); + +test('returns the same hash for object with keys', () => { + const res1 = structHash({foo: 123, bar: 'asdf'}); + const res2 = structHash({foo: 123, bar: 'asdf'}); + expect(res1).toBe(res2); +}); + +test('different key order returns the same hash', () => { + const res1 = structHash({bar: 'asdf', foo: 123}); + const res2 = structHash({foo: 123, bar: 'asdf'}); + expect(res1).toBe(res2); +}); + +test('returns the same hash regardless of key order', () => { + const res1 = structHash({bar: 'asdf', foo: 123}); + const res2 = structHash({foo: 123, bar: 'asdf'}); + expect(res1).toBe(res2); +}); + +test('returns the same hash for array with values', () => { + const res1 = structHash([true, 'asdf', false]); + const res2 = structHash([true, 'asdf', false]); + expect(res1).toBe(res2); +}); + +test('same hash for binary data', () => { + const res1 = structHash({data: new Uint8Array([1, 2, 3])}); + const res2 = structHash({data: new Uint8Array([1, 2, 3])}); + expect(res1).toBe(res2); +}); + +test('different hash for binary data', () => { + const res1 = structHash({data: new Uint8Array([1, 2, 3])}); + const res2 = structHash({data: new Uint8Array([1, 2, 4])}); + expect(res1).not.toBe(res2); +}); + +test('returns different hash for random JSON values', () => { + for (let i = 0; i < 100; i++) { + const json1 = RandomJson.generate() as any; + const res1 = structHash(json1); + const res2 = structHash(RandomJson.generate() as any); + const res3 = structHash(clone(json1)); + expect(res1).not.toBe(res2); + expect(res1).toBe(res3); + } +}); diff --git a/src/json-hash/hash.ts b/src/json-hash/hash.ts new file mode 100644 index 0000000000..fc05036801 --- /dev/null +++ b/src/json-hash/hash.ts @@ -0,0 +1,70 @@ +import type {PackValue} from '@jsonjoy.com/json-pack/lib/types'; +import {sort} from '@jsonjoy.com/util/lib/sort/insertion'; + +export enum CONST { + START_STATE = 5381, + + NULL = 982452847, + TRUE = 982453247, + FALSE = 982454243, + ARRAY = 982452259, + STRING = 982453601, + OBJECT = 982454533, + BINARY = 982454837, +} + +export const updateNum = (state: number, num: number): number => { + return (state << 5) + state + num; +}; + +export const updateStr = (state: number, str: string): number => { + const length = str.length; + state = updateNum(state, CONST.STRING); + state = updateNum(state, length); + let i = length; + while (i) state = (state << 5) + state + str.charCodeAt(--i); + return state; +}; + +export const updateBin = (state: number, bin: Uint8Array): number => { + const length = bin.length; + state = updateNum(state, CONST.BINARY); + state = updateNum(state, length); + let i = length; + while (i) state = (state << 5) + state + bin[--i]; + return state; +}; + +export const updateJson = (state: number, json: PackValue): number => { + switch (typeof json) { + case 'number': + return updateNum(state, json); + case 'string': + state = updateNum(state, CONST.STRING); + return updateStr(state, json); + case 'object': { + if (json === null) return updateNum(state, CONST.NULL); + if (Array.isArray(json)) { + const length = json.length; + state = updateNum(state, CONST.ARRAY); + for (let i = 0; i < length; i++) state = updateJson(state, json[i]); + return state; + } + if (json instanceof Uint8Array) return updateBin(state, json); + state = updateNum(state, CONST.OBJECT); + const keys = sort(Object.keys(json as object)); + const length = keys.length; + for (let i = 0; i < length; i++) { + const key = keys[i]; + state = updateStr(state, key); + state = updateJson(state, (json as any)[key]); + } + return state; + } + case 'boolean': + return updateNum(state, json ? CONST.TRUE : CONST.FALSE); + } + return state; +}; + +export const hash = (json: PackValue) => updateJson(CONST.START_STATE, json) >>> 0; diff --git a/src/json-hash/index.ts b/src/json-hash/index.ts index fc05036801..6d361c7ea8 100644 --- a/src/json-hash/index.ts +++ b/src/json-hash/index.ts @@ -1,70 +1,2 @@ -import type {PackValue} from '@jsonjoy.com/json-pack/lib/types'; -import {sort} from '@jsonjoy.com/util/lib/sort/insertion'; - -export enum CONST { - START_STATE = 5381, - - NULL = 982452847, - TRUE = 982453247, - FALSE = 982454243, - ARRAY = 982452259, - STRING = 982453601, - OBJECT = 982454533, - BINARY = 982454837, -} - -export const updateNum = (state: number, num: number): number => { - return (state << 5) + state + num; -}; - -export const updateStr = (state: number, str: string): number => { - const length = str.length; - state = updateNum(state, CONST.STRING); - state = updateNum(state, length); - let i = length; - while (i) state = (state << 5) + state + str.charCodeAt(--i); - return state; -}; - -export const updateBin = (state: number, bin: Uint8Array): number => { - const length = bin.length; - state = updateNum(state, CONST.BINARY); - state = updateNum(state, length); - let i = length; - while (i) state = (state << 5) + state + bin[--i]; - return state; -}; - -export const updateJson = (state: number, json: PackValue): number => { - switch (typeof json) { - case 'number': - return updateNum(state, json); - case 'string': - state = updateNum(state, CONST.STRING); - return updateStr(state, json); - case 'object': { - if (json === null) return updateNum(state, CONST.NULL); - if (Array.isArray(json)) { - const length = json.length; - state = updateNum(state, CONST.ARRAY); - for (let i = 0; i < length; i++) state = updateJson(state, json[i]); - return state; - } - if (json instanceof Uint8Array) return updateBin(state, json); - state = updateNum(state, CONST.OBJECT); - const keys = sort(Object.keys(json as object)); - const length = keys.length; - for (let i = 0; i < length; i++) { - const key = keys[i]; - state = updateStr(state, key); - state = updateJson(state, (json as any)[key]); - } - return state; - } - case 'boolean': - return updateNum(state, json ? CONST.TRUE : CONST.FALSE); - } - return state; -}; - -export const hash = (json: PackValue) => updateJson(CONST.START_STATE, json) >>> 0; +export * from './hash'; +export * from './structHash'; diff --git a/src/json-hash/structHash.ts b/src/json-hash/structHash.ts new file mode 100644 index 0000000000..3e1cbe9023 --- /dev/null +++ b/src/json-hash/structHash.ts @@ -0,0 +1,47 @@ +import {hash} from "./hash"; + +/** + * Produces a *structural hash* of a JSON value. + * + * This is a hash that is not sensitive to the order of properties in object and + * it preserves spatial information of the JSON nodes. + * + * The hash is guaranteed to contain only printable ASCII characters, excluding + * the newline character. + * + * @param val A JSON value to hash. + */ +export const structHash = (val: unknown): string => { + switch (typeof val) { + case 'string': return hash(val).toString(36); + case 'number': + case 'bigint': + return val.toString(36); + case 'boolean': + return val ? 'T' : 'F'; + case 'object': + if (val === null) return 'N'; + if (Array.isArray(val)) { + const length = val.length; + let res = '['; + for (let i = 0; i < length; i++) res += structHash(val[i]) + ','; + return res + ']'; + } else if (val instanceof Uint8Array) { + return hash(val).toString(36); + } else { + const keys = Object.keys(val); + keys.sort(); + let res = '{'; + const length = keys.length; + for (let i = 0; i < length; i++) { + const key = keys[i]; + res += hash(key).toString(36) + ':' + structHash((val as Record)[key]) + ','; + } + return res + '}'; + } + case 'undefined': + return 'U'; + default: + throw new Error('Unsupported type: ' + typeof val); + } +}; From 9187472891fe91607497bbf5b46eedc0ac0bc473 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 00:47:17 +0200 Subject: [PATCH 14/68] =?UTF-8?q?feat(json-hash):=20=F0=9F=8E=B8=20assert?= =?UTF-8?q?=20structural=20hash=20in=20variants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-hash/__tests__/structHash.spec.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/json-hash/__tests__/structHash.spec.ts b/src/json-hash/__tests__/structHash.spec.ts index 32906137de..d0985fcd1c 100644 --- a/src/json-hash/__tests__/structHash.spec.ts +++ b/src/json-hash/__tests__/structHash.spec.ts @@ -1,7 +1,16 @@ import {clone} from '@jsonjoy.com/util/lib/json-clone'; -import {structHash} from '../structHash'; +import {structHash as structHash_} from '../structHash'; import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; +const isASCII = (str: string) => /^[\x00-\x7F]*$/.test(str); + +const structHash = (json: unknown): string => { + const hash = structHash_(json); + expect(hash.includes('\n')).toBe(false); + expect(isASCII(hash)).toBe(true); + return hash; +}; + test('returns the same hash for empty objects', () => { const res1 = structHash({}); const res2 = structHash({}); @@ -70,5 +79,7 @@ test('returns different hash for random JSON values', () => { const res3 = structHash(clone(json1)); expect(res1).not.toBe(res2); expect(res1).toBe(res3); + expect(res1.includes('\n')).toBe(false); + expect(res2.includes('\n')).toBe(false); } }); From ada9067690c83ecd7a1adef2975cde0e3d08b41b Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 12:39:54 +0200 Subject: [PATCH 15/68] =?UTF-8?q?feat(json-hash):=20=F0=9F=8E=B8=20impleme?= =?UTF-8?q?nt=20structural=20hashing=20for=20CRDT=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/nodes/arr/ArrNode.ts | 10 ++- src/json-hash/__tests__/assertStructHash.ts | 18 +++++ .../__tests__/structHash-automated.spec.ts | 11 +++ .../__tests__/structHash-fuzzing.spec.ts | 11 +++ .../__tests__/structHashCrdt.spec.ts | 76 +++++++++++++++++++ src/json-hash/structHash.ts | 4 +- src/json-hash/structHashCrdt.ts | 37 +++++++++ 7 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 src/json-hash/__tests__/assertStructHash.ts create mode 100644 src/json-hash/__tests__/structHash-automated.spec.ts create mode 100644 src/json-hash/__tests__/structHash-fuzzing.spec.ts create mode 100644 src/json-hash/__tests__/structHashCrdt.spec.ts create mode 100644 src/json-hash/structHashCrdt.ts diff --git a/src/json-crdt/nodes/arr/ArrNode.ts b/src/json-crdt/nodes/arr/ArrNode.ts index 6792b3c4e8..d6884558b7 100644 --- a/src/json-crdt/nodes/arr/ArrNode.ts +++ b/src/json-crdt/nodes/arr/ArrNode.ts @@ -1,8 +1,8 @@ import {AbstractRga, type Chunk} from '../rga/AbstractRga'; import {type ITimestampStruct, tick} from '../../../json-crdt-patch/clock'; -import type {Model} from '../../model'; import {printBinary} from 'tree-dump/lib/printBinary'; import {printTree} from 'tree-dump/lib/printTree'; +import type {Model} from '../../model'; import type {JsonNode, JsonNodeView} from '..'; import type {Printable} from 'tree-dump/lib/types'; @@ -176,8 +176,12 @@ export class ArrNode /** @ignore */ public children(callback: (node: JsonNode) => void) { const index = this.doc.index; - for (let chunk = this.first(); chunk; chunk = this.next(chunk)) - if (!chunk.del) for (const node of chunk.data!) callback(index.get(node)!); + for (let chunk = this.first(); chunk; chunk = this.next(chunk)) { + const data = chunk.data; + if (!data) continue; + const length = data.length; + for (let i = 0; i < length; i++) callback(index.get(data[i])!); + } } /** @ignore */ diff --git a/src/json-hash/__tests__/assertStructHash.ts b/src/json-hash/__tests__/assertStructHash.ts new file mode 100644 index 0000000000..c095032d4f --- /dev/null +++ b/src/json-hash/__tests__/assertStructHash.ts @@ -0,0 +1,18 @@ +import {structHash as structHash_} from '../structHash'; +import {structHashCrdt} from '../structHashCrdt'; +import {Model} from '../../json-crdt'; + +const isASCII = (str: string) => /^[\x00-\x7F]*$/.test(str); + +export const assertStructHash = (json: unknown): string => { + const model = Model.create(); + model.api.root(json); + const hash1 = structHashCrdt(model.root); + const hash2 = structHash_(json); + // console.log(hash1); + // console.log(hash2); + expect(hash1).toBe(hash2); + expect(hash2.includes('\n')).toBe(false); + expect(isASCII(hash2)).toBe(true); + return hash2; +}; diff --git a/src/json-hash/__tests__/structHash-automated.spec.ts b/src/json-hash/__tests__/structHash-automated.spec.ts new file mode 100644 index 0000000000..dc0c481610 --- /dev/null +++ b/src/json-hash/__tests__/structHash-automated.spec.ts @@ -0,0 +1,11 @@ +import {documents} from '../../__tests__/json-documents'; +import {binaryDocuments} from '../../__tests__/binary-documents'; +import {assertStructHash} from './assertStructHash'; + +describe('computes structural hashes on fixtures', () => { + for (const {name, json} of [...documents, ...binaryDocuments]) { + test(name, () => { + assertStructHash(json); + }); + } +}); diff --git a/src/json-hash/__tests__/structHash-fuzzing.spec.ts b/src/json-hash/__tests__/structHash-fuzzing.spec.ts new file mode 100644 index 0000000000..f83a976d7b --- /dev/null +++ b/src/json-hash/__tests__/structHash-fuzzing.spec.ts @@ -0,0 +1,11 @@ +import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; +import {assertStructHash} from './assertStructHash'; + +const iterations = 100; + +test('computes structural hashes', () => { + for (let i = 0; i < iterations; i++) { + const json = RandomJson.generate(); + assertStructHash(json); + } +}); diff --git a/src/json-hash/__tests__/structHashCrdt.spec.ts b/src/json-hash/__tests__/structHashCrdt.spec.ts new file mode 100644 index 0000000000..b13d0767e9 --- /dev/null +++ b/src/json-hash/__tests__/structHashCrdt.spec.ts @@ -0,0 +1,76 @@ +import {clone} from '@jsonjoy.com/util/lib/json-clone'; +import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; +import {assertStructHash} from './assertStructHash'; + +test('returns the same hash for empty objects', () => { + const res1 = assertStructHash({}); + const res2 = assertStructHash({}); + expect(res1).toBe(res2); +}); + +test('returns the same hash for empty arrays', () => { + const res1 = assertStructHash([]); + const res2 = assertStructHash([]); + const res3 = assertStructHash({}); + expect(res1).toBe(res2); + expect(res1).not.toBe(res3); +}); + +test('returns the same hash for empty strings', () => { + const res1 = assertStructHash(''); + const res2 = assertStructHash(''); + const res3 = assertStructHash({}); + const res4 = assertStructHash([]); + expect(res1).toBe(res2); + expect(res1).not.toBe(res3); + expect(res1).not.toBe(res4); +}); + +test('returns the same hash for object with keys', () => { + const res1 = assertStructHash({foo: 123, bar: 'asdf'}); + const res2 = assertStructHash({foo: 123, bar: 'asdf'}); + expect(res1).toBe(res2); +}); + +test('different key order returns the same hash', () => { + const res1 = assertStructHash({bar: 'asdf', foo: 123}); + const res2 = assertStructHash({foo: 123, bar: 'asdf'}); + expect(res1).toBe(res2); +}); + +test('returns the same hash regardless of key order', () => { + const res1 = assertStructHash({bar: 'asdf', foo: 123}); + const res2 = assertStructHash({foo: 123, bar: 'asdf'}); + expect(res1).toBe(res2); +}); + +test('returns the same hash for array with values', () => { + const res1 = assertStructHash([true, 'asdf', false]); + const res2 = assertStructHash([true, 'asdf', false]); + expect(res1).toBe(res2); +}); + +test('same hash for binary data', () => { + const res1 = assertStructHash({data: new Uint8Array([1, 2, 3])}); + const res2 = assertStructHash({data: new Uint8Array([1, 2, 3])}); + expect(res1).toBe(res2); +}); + +test('different hash for binary data', () => { + const res1 = assertStructHash({data: new Uint8Array([1, 2, 3])}); + const res2 = assertStructHash({data: new Uint8Array([1, 2, 4])}); + expect(res1).not.toBe(res2); +}); + +test('returns different hash for random JSON values', () => { + for (let i = 0; i < 100; i++) { + const json1 = RandomJson.generate() as any; + const res1 = assertStructHash(json1); + const res2 = assertStructHash(RandomJson.generate() as any); + const res3 = assertStructHash(clone(json1)); + expect(res1).not.toBe(res2); + expect(res1).toBe(res3); + expect(res1.includes('\n')).toBe(false); + expect(res2.includes('\n')).toBe(false); + } +}); diff --git a/src/json-hash/structHash.ts b/src/json-hash/structHash.ts index 3e1cbe9023..c7964970f8 100644 --- a/src/json-hash/structHash.ts +++ b/src/json-hash/structHash.ts @@ -39,9 +39,7 @@ export const structHash = (val: unknown): string => { } return res + '}'; } - case 'undefined': - return 'U'; default: - throw new Error('Unsupported type: ' + typeof val); + return 'U'; } }; diff --git a/src/json-hash/structHashCrdt.ts b/src/json-hash/structHashCrdt.ts new file mode 100644 index 0000000000..ac46fbe334 --- /dev/null +++ b/src/json-hash/structHashCrdt.ts @@ -0,0 +1,37 @@ +import {ArrNode, BinNode, ConNode, JsonNode, ObjNode, StrNode, ValNode, VecNode} from "../json-crdt"; +import {hash} from "./hash"; +import {structHash} from "./structHash"; + +/** + * Constructs a structural hash of the view of the node. + * + * Produces a *structural hash* of a JSON CRDT node. Works the same as + * `structHash, but uses the `JsonNode` interface instead of a generic value. + * + * @todo PERF: instead of constructing a "str" and "bin" view, iterate over + * the RGA chunks and hash them directly. + */ +export const structHashCrdt = (node?: JsonNode): string => { + if (node instanceof ConNode) return structHash(node.val); + else if (node instanceof ValNode) return structHashCrdt(node.node()); + else if (node instanceof StrNode) return hash(node.view()).toString(36); + else if (node instanceof ObjNode) { + let res = '{'; + const keys = Array.from(node.keys.keys()); + keys.sort(); + const length = keys.length; + for (let i = 0; i < length; i++) { + const key = keys[i]; + const value = node.get(key); + res += hash(key).toString(36) + ':' + structHashCrdt(value) + ','; + } + return res + '}'; + } else if (node instanceof ArrNode || node instanceof VecNode) { + let res = '['; + node.children((child) => { + res += structHashCrdt(child) + ','; + }); + return res + ']'; + } else if (node instanceof BinNode) return hash(node.view()).toString(36); + return 'U'; +}; From b55ff6abc66d0a27f74acee010d8c231f2688a8a Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 12:42:21 +0200 Subject: [PATCH 16/68] =?UTF-8?q?perf(json-hash):=20=E2=9A=A1=EF=B8=8F=20u?= =?UTF-8?q?se=20custom=20insertion=20sort=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-hash/hash.ts | 2 +- src/json-hash/structHash.ts | 3 ++- src/json-hash/structHashCrdt.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/json-hash/hash.ts b/src/json-hash/hash.ts index fc05036801..a2d3747f8d 100644 --- a/src/json-hash/hash.ts +++ b/src/json-hash/hash.ts @@ -1,5 +1,5 @@ -import type {PackValue} from '@jsonjoy.com/json-pack/lib/types'; import {sort} from '@jsonjoy.com/util/lib/sort/insertion'; +import type {PackValue} from '@jsonjoy.com/json-pack/lib/types'; export enum CONST { START_STATE = 5381, diff --git a/src/json-hash/structHash.ts b/src/json-hash/structHash.ts index c7964970f8..4ae61fe896 100644 --- a/src/json-hash/structHash.ts +++ b/src/json-hash/structHash.ts @@ -1,3 +1,4 @@ +import {sort} from '@jsonjoy.com/util/lib/sort/insertion'; import {hash} from "./hash"; /** @@ -30,7 +31,7 @@ export const structHash = (val: unknown): string => { return hash(val).toString(36); } else { const keys = Object.keys(val); - keys.sort(); + sort(keys); let res = '{'; const length = keys.length; for (let i = 0; i < length; i++) { diff --git a/src/json-hash/structHashCrdt.ts b/src/json-hash/structHashCrdt.ts index ac46fbe334..d90c1b1d6b 100644 --- a/src/json-hash/structHashCrdt.ts +++ b/src/json-hash/structHashCrdt.ts @@ -1,3 +1,4 @@ +import {sort} from '@jsonjoy.com/util/lib/sort/insertion'; import {ArrNode, BinNode, ConNode, JsonNode, ObjNode, StrNode, ValNode, VecNode} from "../json-crdt"; import {hash} from "./hash"; import {structHash} from "./structHash"; @@ -18,7 +19,7 @@ export const structHashCrdt = (node?: JsonNode): string => { else if (node instanceof ObjNode) { let res = '{'; const keys = Array.from(node.keys.keys()); - keys.sort(); + sort(keys); const length = keys.length; for (let i = 0; i < length; i++) { const key = keys[i]; From 030023b0cfd1263e8ff47061e67e257ae06cc78a Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 15:19:43 +0200 Subject: [PATCH 17/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20im?= =?UTF-8?q?plement=20"val"=20node=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 12 ++++++++++++ src/json-crdt-diff/__tests__/Diff.spec.ts | 18 ++++++++++++++++++ src/util/diff.ts | 8 ++++---- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index b12486f87a..65bd7db8ed 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -91,6 +91,17 @@ export class Diff { if (inserts.length) builder.insObj(src.id, inserts); } + protected diffVal(src: ValNode, dst: unknown): void { + try { + this.diffAny(src.node(), dst); + } catch (error) { + if (error instanceof DiffError) { + const builder = this.builder; + builder.setVal(src.id, builder.json(dst)); + } else throw error; + } + } + public diffAny(src: JsonNode, dst: unknown): void { if (src instanceof StrNode) { if (typeof dst !== 'string') throw new DiffError(); @@ -101,6 +112,7 @@ export class Diff { } else if (src instanceof ArrNode) { } else if (src instanceof VecNode) { } else if (src instanceof ValNode) { + this.diffVal(src, dst); } else { throw new DiffError(); } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index 1b5c6ccedb..a28a76e06e 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -88,4 +88,22 @@ describe('obj', () => { const dst = {foo: 'abc!'}; assertDiff(model, model.root.child(), dst); }); + + test('nested object', () => { + const src = { + nested: { + remove: 123, + edit: 'abc', + }, + }; + const dst = { + nested: { + inserted: [null], + edit: 'Abc!', + }, + }; + const model = Model.create(); + model.api.root(src); + assertDiff(model, model.root, dst); + }); }); diff --git a/src/util/diff.ts b/src/util/diff.ts index 616c5c4dd7..0fa149e261 100644 --- a/src/util/diff.ts +++ b/src/util/diff.ts @@ -462,22 +462,22 @@ export const diffEdit = (src: string, dst: string, caret: number) => { const srcSfx = src.slice(srcLen - sfxLen); if (srcSfx !== dstSfx) break edit; const isInsert = dstLen > srcLen; - if (isInsert) ins: { + if (isInsert) { const pfxLen = srcLen - sfxLen; const srcPfx = src.slice(0, pfxLen); const dstPfx = dst.slice(0, pfxLen); - if (srcPfx !== dstPfx) break ins; + if (srcPfx !== dstPfx) break edit; const insert = dst.slice(pfxLen, caret); const patch: Patch = []; if (srcPfx) patch.push([PATCH_OP_TYPE.EQUAL, srcPfx]); if (insert) patch.push([PATCH_OP_TYPE.INSERT, insert]); if (dstSfx) patch.push([PATCH_OP_TYPE.EQUAL, dstSfx]); return patch; - } else del: { + } else { const pfxLen = dstLen - sfxLen; const dstPfx = dst.slice(0, pfxLen); const srcPfx = src.slice(0, pfxLen); - if (srcPfx !== dstPfx) break del; + if (srcPfx !== dstPfx) break edit; const del = src.slice(pfxLen, srcLen - sfxLen); const patch: Patch = []; if (srcPfx) patch.push([PATCH_OP_TYPE.EQUAL, srcPfx]); From 4e3067ec6dc7532a50508599de339f12c6de97bf Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 16:25:46 +0200 Subject: [PATCH 18/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20im?= =?UTF-8?q?plement=20diff=20for=20"vec"=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 78 +++++++++++++++++------ src/json-crdt-diff/__tests__/Diff.spec.ts | 38 ++++++++++- src/json-crdt/nodes/vec/VecNode.ts | 6 +- 3 files changed, 99 insertions(+), 23 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 65bd7db8ed..372b3ba639 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -1,5 +1,6 @@ +import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import {type ITimestampStruct, Patch, PatchBuilder} from '../json-crdt-patch'; -import {ArrNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; +import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; import {diff, PATCH_OP_TYPE} from '../util/diff'; import type {Model} from '../json-crdt/model'; @@ -62,9 +63,9 @@ export class Diff { protected diffObj(src: ObjNode, dst: Record): void { const builder = this.builder; const inserts: [key: string, value: ITimestampStruct][] = []; - const srcKeys: Record = {}; + const srcKeys = new Set(); src.forEach((key) => { - srcKeys[key] = 1; + srcKeys.add(key); const dstValue = dst[key]; if (dstValue === void 0) inserts.push([key, builder.const(undefined)]); }); @@ -73,46 +74,87 @@ export class Diff { for (let i = 0; i < length; i++) { const key = keys[i]; const dstValue = dst[key]; - overwrite: { - if (srcKeys[key]) { - const node = src.get(key); - if (node) { - try { - this.diffAny(node, dstValue); - break overwrite; - } catch (error) { - if (!(error instanceof DiffError)) throw error; - } + if (srcKeys.has(key)) { + const child = src.get(key); + if (child) { + try { + this.diffAny(child, dstValue); + continue; + } catch (error) { + if (!(error instanceof DiffError)) throw error; } } - inserts.push([key, builder.json(dstValue)]); } + inserts.push([key, builder.constOrJson(dstValue)]); } if (inserts.length) builder.insObj(src.id, inserts); } + protected diffVec(src: VecNode, dst: unknown[]): void { + const builder = this.builder; + const edits: [key: number, value: ITimestampStruct][] = []; + const elements = src.elements; + const srcLength = elements.length; + const dstLength = dst.length; + const index = src.doc.index; + const min = Math.min(srcLength, dstLength); + for (let i = dstLength; i < srcLength; i++) { + const id = elements[i]; + if (id) { + const child = index.get(id); + const isDeleted = !child || (child instanceof ConNode && child.val === void 0); + if (isDeleted) return; + edits.push([i, builder.const(void 0)]); + } + } + for (let i = 0; i < min; i++) { + const value = dst[i]; + diff: { + const child = src.get(i); + if (!child) break diff; + try { + this.diffAny(child, value); + continue; + } catch (error) { + if (!(error instanceof DiffError)) throw error; + } + } + edits.push([i, builder.constOrJson(value)]); + } + for (let i = srcLength; i < dstLength; i++) edits.push([i, builder.constOrJson(dst[i])]); + if (edits.length) builder.insVec(src.id, edits); + } + protected diffVal(src: ValNode, dst: unknown): void { try { this.diffAny(src.node(), dst); } catch (error) { if (error instanceof DiffError) { const builder = this.builder; - builder.setVal(src.id, builder.json(dst)); + builder.setVal(src.id, builder.constOrJson(dst)); } else throw error; } } public diffAny(src: JsonNode, dst: unknown): void { - if (src instanceof StrNode) { + if (src instanceof ConNode) { + const val = src.val; + if ((val !== dst) && !deepEqual(src.val, dst)) throw new DiffError(); + } else if (src instanceof StrNode) { if (typeof dst !== 'string') throw new DiffError(); this.diffStr(src, dst); } else if (src instanceof ObjNode) { if (!dst || typeof dst !== 'object') throw new DiffError(); this.diffObj(src, dst as Record); - } else if (src instanceof ArrNode) { - } else if (src instanceof VecNode) { } else if (src instanceof ValNode) { this.diffVal(src, dst); + } else if (src instanceof ArrNode) { + throw new Error('not implemented'); + } else if (src instanceof VecNode) { + if (!Array.isArray(dst)) throw new DiffError(); + this.diffVec(src, dst as unknown[]); + } else if (src instanceof BinNode) { + throw new Error('not implemented'); } else { throw new DiffError(); } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index a28a76e06e..0476ce5590 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -1,15 +1,17 @@ import {Diff} from '../Diff'; import {InsStrOp, s} from '../../json-crdt-patch'; import {Model} from '../../json-crdt/model'; -import {JsonNode} from '../../json-crdt/nodes'; +import {JsonNode, ValNode} from '../../json-crdt/nodes'; const assertDiff = (model: Model, src: JsonNode, dst: unknown) => { const patch1 = new Diff(model).diff(src, dst); + // console.log(model + ''); // console.log(patch1 + ''); model.applyPatch(patch1); - expect(src.view()).toEqual(dst); // console.log(model + ''); + expect(src.view()).toEqual(dst); const patch2 = new Diff(model).diff(src, dst); + // console.log(patch2 + ''); expect(patch2.ops.length).toBe(0); }; @@ -98,7 +100,7 @@ describe('obj', () => { }; const dst = { nested: { - inserted: [null], + inserted: null, edit: 'Abc!', }, }; @@ -107,3 +109,33 @@ describe('obj', () => { assertDiff(model, model.root, dst); }); }); + +describe('vec', () => { + test('can add an element', () => { + const model = Model.create(s.vec(s.con(1))); + const dst = [1, 2]; + assertDiff(model, model.root, dst); + }); + + test('can remove an element', () => { + const model = Model.create(s.vec(s.con(1), s.con(2))); + const dst = [1]; + assertDiff(model, model.root, dst); + }); + + test('can replace element', () => { + const model = Model.create(s.vec(s.con(1))); + const dst = [2]; + assertDiff(model, model.root, dst); + expect(() => model.api.val([0])).toThrow(); + }); + + test('can replace nested "val" node', () => { + const schema = s.vec(s.val(s.con(1))); + const model = Model.create(schema); + const dst = [2]; + assertDiff(model, model.root, dst); + const node = model.api.val([0]); + expect(node.node).toBeInstanceOf(ValNode); + }); +}); diff --git a/src/json-crdt/nodes/vec/VecNode.ts b/src/json-crdt/nodes/vec/VecNode.ts index ed07859624..d860d3f747 100644 --- a/src/json-crdt/nodes/vec/VecNode.ts +++ b/src/json-crdt/nodes/vec/VecNode.ts @@ -66,6 +66,8 @@ export class VecNode implements JsonNode< return currentId; } + // ----------------------------------------------------------------- extension + /** * @ignore */ @@ -109,7 +111,7 @@ export class VecNode implements JsonNode< return buf[0]; } - // ----------------------------------------------------------------- JsonNode + /** ------------------------------------------------------ {@link JsonNode} */ /** * @ignore @@ -176,7 +178,7 @@ export class VecNode implements JsonNode< return 'vec'; } - // ---------------------------------------------------------------- Printable + /** ----------------------------------------------------- {@link Printable} */ public toString(tab: string = ''): string { const extNode = this.ext(); From d2489ad23e9f54fe520e4f7d0f24eca20f0e330f Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 16:39:21 +0200 Subject: [PATCH 19/68] =?UTF-8?q?style:=20=F0=9F=92=84=20small=20codebase?= =?UTF-8?q?=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 7 +++---- src/util/__tests__/diff-fuzz.spec.ts | 2 +- src/util/__tests__/diff.spec.ts | 2 +- src/util/__tests__/util.ts | 2 +- src/util/{diff.ts => diff/str.ts} | 0 5 files changed, 6 insertions(+), 7 deletions(-) rename src/util/{diff.ts => diff/str.ts} (100%) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 372b3ba639..4bca89b248 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -1,7 +1,7 @@ import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import {type ITimestampStruct, Patch, PatchBuilder} from '../json-crdt-patch'; import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; -import {diff, PATCH_OP_TYPE} from '../util/diff'; +import {diff, PATCH_OP_TYPE} from '../util/diff/str'; import type {Model} from '../json-crdt/model'; export class DiffError extends Error { @@ -109,9 +109,8 @@ export class Diff { } for (let i = 0; i < min; i++) { const value = dst[i]; - diff: { - const child = src.get(i); - if (!child) break diff; + const child = src.get(i); + if (child) { try { this.diffAny(child, value); continue; diff --git a/src/util/__tests__/diff-fuzz.spec.ts b/src/util/__tests__/diff-fuzz.spec.ts index ab1d5cd80d..6464a5f7b9 100644 --- a/src/util/__tests__/diff-fuzz.spec.ts +++ b/src/util/__tests__/diff-fuzz.spec.ts @@ -1,6 +1,6 @@ import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; import {assertPatch} from './util'; -import {diff, diffEdit} from '../diff'; +import {diff, diffEdit} from '../diff/str'; const fastDiff = require('fast-diff') as typeof diff; const str = () => Math.random() > .7 diff --git a/src/util/__tests__/diff.spec.ts b/src/util/__tests__/diff.spec.ts index 3354b8e6d7..7dc889dcdb 100644 --- a/src/util/__tests__/diff.spec.ts +++ b/src/util/__tests__/diff.spec.ts @@ -1,4 +1,4 @@ -import {PATCH_OP_TYPE, Patch, diff, diffEdit} from '../diff'; +import {PATCH_OP_TYPE, Patch, diff, diffEdit} from '../diff/str'; import {assertPatch} from './util'; describe('diff()', () => { diff --git a/src/util/__tests__/util.ts b/src/util/__tests__/util.ts index 016860de60..03eed4b246 100644 --- a/src/util/__tests__/util.ts +++ b/src/util/__tests__/util.ts @@ -1,4 +1,4 @@ -import * as diff from "../diff"; +import * as diff from "../diff/str"; export const assertPatch = (src: string, dst: string, patch: diff.Patch = diff.diff(src, dst)) => { const src1 = diff.src(patch); diff --git a/src/util/diff.ts b/src/util/diff/str.ts similarity index 100% rename from src/util/diff.ts rename to src/util/diff/str.ts From 67f5c77619fd8dfab323f0d3f2d69f6a7ef20d40 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 16:57:34 +0200 Subject: [PATCH 20/68] =?UTF-8?q?refactor(util):=20=F0=9F=92=A1=20move=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/{ => diff}/__tests__/diff-fuzz.spec.ts | 2 +- src/util/{ => diff}/__tests__/diff.spec.ts | 2 +- src/util/{ => diff}/__tests__/util.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/util/{ => diff}/__tests__/diff-fuzz.spec.ts (95%) rename src/util/{ => diff}/__tests__/diff.spec.ts (99%) rename src/util/{ => diff}/__tests__/util.ts (95%) diff --git a/src/util/__tests__/diff-fuzz.spec.ts b/src/util/diff/__tests__/diff-fuzz.spec.ts similarity index 95% rename from src/util/__tests__/diff-fuzz.spec.ts rename to src/util/diff/__tests__/diff-fuzz.spec.ts index 6464a5f7b9..807ca0f034 100644 --- a/src/util/__tests__/diff-fuzz.spec.ts +++ b/src/util/diff/__tests__/diff-fuzz.spec.ts @@ -1,6 +1,6 @@ import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; import {assertPatch} from './util'; -import {diff, diffEdit} from '../diff/str'; +import {diff, diffEdit} from '../str'; const fastDiff = require('fast-diff') as typeof diff; const str = () => Math.random() > .7 diff --git a/src/util/__tests__/diff.spec.ts b/src/util/diff/__tests__/diff.spec.ts similarity index 99% rename from src/util/__tests__/diff.spec.ts rename to src/util/diff/__tests__/diff.spec.ts index 7dc889dcdb..2ca22b9212 100644 --- a/src/util/__tests__/diff.spec.ts +++ b/src/util/diff/__tests__/diff.spec.ts @@ -1,4 +1,4 @@ -import {PATCH_OP_TYPE, Patch, diff, diffEdit} from '../diff/str'; +import {PATCH_OP_TYPE, Patch, diff, diffEdit} from '../str'; import {assertPatch} from './util'; describe('diff()', () => { diff --git a/src/util/__tests__/util.ts b/src/util/diff/__tests__/util.ts similarity index 95% rename from src/util/__tests__/util.ts rename to src/util/diff/__tests__/util.ts index 03eed4b246..d117afe122 100644 --- a/src/util/__tests__/util.ts +++ b/src/util/diff/__tests__/util.ts @@ -1,4 +1,4 @@ -import * as diff from "../diff/str"; +import * as diff from "../str"; export const assertPatch = (src: string, dst: string, patch: diff.Patch = diff.diff(src, dst)) => { const src1 = diff.src(patch); From 66c37c1e6523cb4c681a7329c453af2d9bdd79c1 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 17:12:29 +0200 Subject: [PATCH 21/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20implement=20?= =?UTF-8?q?binary=20to=20hex=20transforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/bin.spec.ts | 28 ++++++++++++++ .../{diff-fuzz.spec.ts => str-fuzz.spec.ts} | 0 .../__tests__/{diff.spec.ts => str.spec.ts} | 0 src/util/diff/bin.ts | 38 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 src/util/diff/__tests__/bin.spec.ts rename src/util/diff/__tests__/{diff-fuzz.spec.ts => str-fuzz.spec.ts} (100%) rename src/util/diff/__tests__/{diff.spec.ts => str.spec.ts} (100%) create mode 100644 src/util/diff/bin.ts diff --git a/src/util/diff/__tests__/bin.spec.ts b/src/util/diff/__tests__/bin.spec.ts new file mode 100644 index 0000000000..99a8aee683 --- /dev/null +++ b/src/util/diff/__tests__/bin.spec.ts @@ -0,0 +1,28 @@ +import {b} from '@jsonjoy.com/util/lib/buffers/b'; +import {toHex, fromHex} from '../bin'; + +describe('toHex()', () => { + test('can convert buffer to string', () => { + const buffer = b(1, 2, 3, 4, 5); + const hex = toHex(buffer); + expect(hex).toBe('AbAcAdAeAf'); + }); + + test('can convert buffer to string', () => { + const buffer = b(0, 127, 255); + const hex = toHex(buffer); + expect(hex).toBe('AaHpPp'); + }); +}); + +describe('fromHex()', () => { + test('can convert buffer to string', () => { + const buffer = fromHex('AbAcAdAeAf'); + expect(buffer).toEqual(b(1, 2, 3, 4, 5)); + }); + + test('can convert buffer to string', () => { + const buffer = fromHex('AaHpPp'); + expect(buffer).toEqual(b(0, 127, 255)); + }); +}); diff --git a/src/util/diff/__tests__/diff-fuzz.spec.ts b/src/util/diff/__tests__/str-fuzz.spec.ts similarity index 100% rename from src/util/diff/__tests__/diff-fuzz.spec.ts rename to src/util/diff/__tests__/str-fuzz.spec.ts diff --git a/src/util/diff/__tests__/diff.spec.ts b/src/util/diff/__tests__/str.spec.ts similarity index 100% rename from src/util/diff/__tests__/diff.spec.ts rename to src/util/diff/__tests__/str.spec.ts diff --git a/src/util/diff/bin.ts b/src/util/diff/bin.ts new file mode 100644 index 0000000000..b30e97b5ce --- /dev/null +++ b/src/util/diff/bin.ts @@ -0,0 +1,38 @@ +// Encoding of the lower 4 bits. +const QUADS1 = 'abcdefghijklmnop'; + +// Encoding of the upper 4 bits. +const QUADS2 = 'ABCDEFGHIJKLMNOP'; + +// Binary octet to hex string conversion, where lower and upper 4 bits are +// encoded using different alphabets. +const BIN_TO_HEX: string[] = []; + +for (let i = 0; i < 16; i++) { + for (let j = 0; j < 16; j++) { + BIN_TO_HEX.push(QUADS2[i] + QUADS1[j]); + } +} + +export const toHex = (buf: Uint8Array): string => { + let hex = ''; + const length = buf.length; + for (let i = 0; i < length; i++) hex += BIN_TO_HEX[buf[i]]; + return hex; +}; + +const START_CODE1 = 97; // 'a'.charCodeAt(0) +const START_CODE2 = 65; // 'A'.charCodeAt(0); + +export const fromHex = (hex: string): Uint8Array => { + const length = hex.length; + const buf = new Uint8Array(length >> 1); + let j = 0; + for (let i = 0; i < length; i += 2) { + const quad2 = hex.charCodeAt(i) - START_CODE2; + const quad1 = hex.charCodeAt(i + 1) - START_CODE1; + buf[j] = (quad2 << 4) | quad1; + j++; + } + return buf; +}; From a740d02676c100b216cf615224e16913fbc3f68a Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 17:26:16 +0200 Subject: [PATCH 22/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20implement=20?= =?UTF-8?q?diff=20for=20binary=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/bin.spec.ts | 51 ++++++++++++++++++++++++++--- src/util/diff/bin.ts | 18 ++++++++-- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/util/diff/__tests__/bin.spec.ts b/src/util/diff/__tests__/bin.spec.ts index 99a8aee683..b7cf396d5f 100644 --- a/src/util/diff/__tests__/bin.spec.ts +++ b/src/util/diff/__tests__/bin.spec.ts @@ -1,28 +1,69 @@ import {b} from '@jsonjoy.com/util/lib/buffers/b'; -import {toHex, fromHex} from '../bin'; +import {toStr, toBin, diff, src, dst} from '../bin'; +import {PATCH_OP_TYPE} from '../str'; describe('toHex()', () => { test('can convert buffer to string', () => { const buffer = b(1, 2, 3, 4, 5); - const hex = toHex(buffer); + const hex = toStr(buffer); expect(hex).toBe('AbAcAdAeAf'); }); test('can convert buffer to string', () => { const buffer = b(0, 127, 255); - const hex = toHex(buffer); + const hex = toStr(buffer); expect(hex).toBe('AaHpPp'); }); }); describe('fromHex()', () => { test('can convert buffer to string', () => { - const buffer = fromHex('AbAcAdAeAf'); + const buffer = toBin('AbAcAdAeAf'); expect(buffer).toEqual(b(1, 2, 3, 4, 5)); }); test('can convert buffer to string', () => { - const buffer = fromHex('AaHpPp'); + const buffer = toBin('AaHpPp'); expect(buffer).toEqual(b(0, 127, 255)); }); }); + +describe('diff()', () => { + test('returns a single equality tuple, when buffers are identical', () => { + const patch = diff(b(1, 2, 3), b(1, 2, 3)); + expect(patch).toEqual([[PATCH_OP_TYPE.EQUAL, toStr(b(1, 2, 3))]]); + expect(src(patch)).toEqual(b(1, 2, 3)); + expect(dst(patch)).toEqual(b(1, 2, 3)); + }); + + test('single character insert at the beginning', () => { + const patch1 = diff(b(1, 2, 3), b(0, 1, 2, 3)); + expect(patch1).toEqual([ + [PATCH_OP_TYPE.INSERT, toStr(b(0))], + [PATCH_OP_TYPE.EQUAL, toStr(b(1, 2, 3))], + ]); + expect(src(patch1)).toEqual(b(1, 2, 3)); + expect(dst(patch1)).toEqual(b(0, 1, 2, 3)); + }); + + test('single character insert at the end', () => { + const patch1 = diff(b(1, 2, 3), b(1, 2, 3, 4)); + expect(patch1).toEqual([ + [PATCH_OP_TYPE.EQUAL, toStr(b(1, 2, 3))], + [PATCH_OP_TYPE.INSERT, toStr(b(4))], + ]); + expect(src(patch1)).toEqual(b(1, 2, 3)); + expect(dst(patch1)).toEqual(b(1, 2, 3, 4)); + }); + + test('can delete char', () => { + const patch1 = diff(b(1, 2, 3), b(2, 3, 4)); + expect(patch1).toEqual([ + [PATCH_OP_TYPE.DELETE, toStr(b(1))], + [PATCH_OP_TYPE.EQUAL, toStr(b(2, 3))], + [PATCH_OP_TYPE.INSERT, toStr(b(4))], + ]); + expect(src(patch1)).toEqual(b(1, 2, 3)); + expect(dst(patch1)).toEqual(b(2, 3, 4)); + }); +}); diff --git a/src/util/diff/bin.ts b/src/util/diff/bin.ts index b30e97b5ce..baf66fb962 100644 --- a/src/util/diff/bin.ts +++ b/src/util/diff/bin.ts @@ -1,3 +1,5 @@ +import * as str from "./str"; + // Encoding of the lower 4 bits. const QUADS1 = 'abcdefghijklmnop'; @@ -14,7 +16,7 @@ for (let i = 0; i < 16; i++) { } } -export const toHex = (buf: Uint8Array): string => { +export const toStr = (buf: Uint8Array): string => { let hex = ''; const length = buf.length; for (let i = 0; i < length; i++) hex += BIN_TO_HEX[buf[i]]; @@ -24,7 +26,7 @@ export const toHex = (buf: Uint8Array): string => { const START_CODE1 = 97; // 'a'.charCodeAt(0) const START_CODE2 = 65; // 'A'.charCodeAt(0); -export const fromHex = (hex: string): Uint8Array => { +export const toBin = (hex: string): Uint8Array => { const length = hex.length; const buf = new Uint8Array(length >> 1); let j = 0; @@ -36,3 +38,15 @@ export const fromHex = (hex: string): Uint8Array => { } return buf; }; + +export const diff = (src: Uint8Array, dst: Uint8Array): str.Patch => { + const txtSrc = toStr(src); + const txtDst = toStr(dst); + return str.diff(txtSrc, txtDst); +}; + +export const apply = (patch: str.Patch, onInsert: (pos: number, str: Uint8Array) => void, onDelete: (pos: number, len: number) => void) => + str.apply(patch, (pos, str) => onInsert(pos, toBin(str)), onDelete); + +export const src = (patch: str.Patch): Uint8Array => toBin(str.src(patch)); +export const dst = (patch: str.Patch): Uint8Array => toBin(str.dst(patch)); From 1df2781ce147385dadf6b496aa372b9ebea4f90a Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 3 May 2025 21:31:53 +0200 Subject: [PATCH 23/68] =?UTF-8?q?fix(json-crdt-diff):=20=F0=9F=90=9B=20use?= =?UTF-8?q?=20unicode=20encoding=20for=20binary=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/bin-fuzz.spec.ts | 20 +++++++++++++++ src/util/diff/__tests__/bin.spec.ts | 8 +++--- src/util/diff/bin.ts | 31 +++--------------------- 3 files changed, 27 insertions(+), 32 deletions(-) create mode 100644 src/util/diff/__tests__/bin-fuzz.spec.ts diff --git a/src/util/diff/__tests__/bin-fuzz.spec.ts b/src/util/diff/__tests__/bin-fuzz.spec.ts new file mode 100644 index 0000000000..4a7f586d19 --- /dev/null +++ b/src/util/diff/__tests__/bin-fuzz.spec.ts @@ -0,0 +1,20 @@ +import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; +import {toBuf} from '@jsonjoy.com/util/lib/buffers/toBuf'; +import {assertPatch} from './util'; +import * as bin from '../bin'; + +const str = () => Math.random() > .7 + ? RandomJson.genString(Math.ceil(Math.random() * 200)) + : Math.random().toString(36).slice(2); +const iterations = 100; + +test('fuzzing diff()', () => { + for (let i = 0; i < iterations; i++) { + const src = toBuf(str()); + const dst = toBuf(str()); + const patch = bin.diff(src, dst); + assertPatch(bin.toStr(src), bin.toStr(dst), patch); + expect(bin.src(patch)).toEqual(src); + expect(bin.dst(patch)).toEqual(dst); + } +}); diff --git a/src/util/diff/__tests__/bin.spec.ts b/src/util/diff/__tests__/bin.spec.ts index b7cf396d5f..120e0be86b 100644 --- a/src/util/diff/__tests__/bin.spec.ts +++ b/src/util/diff/__tests__/bin.spec.ts @@ -6,24 +6,24 @@ describe('toHex()', () => { test('can convert buffer to string', () => { const buffer = b(1, 2, 3, 4, 5); const hex = toStr(buffer); - expect(hex).toBe('AbAcAdAeAf'); + expect(hex).toBe('\x01\x02\x03\x04\x05'); }); test('can convert buffer to string', () => { const buffer = b(0, 127, 255); const hex = toStr(buffer); - expect(hex).toBe('AaHpPp'); + expect(hex).toBe('\x00\x7f\xff'); }); }); describe('fromHex()', () => { test('can convert buffer to string', () => { - const buffer = toBin('AbAcAdAeAf'); + const buffer = toBin('\x01\x02\x03\x04\x05'); expect(buffer).toEqual(b(1, 2, 3, 4, 5)); }); test('can convert buffer to string', () => { - const buffer = toBin('AaHpPp'); + const buffer = toBin('\x00\x7f\xff'); expect(buffer).toEqual(b(0, 127, 255)); }); }); diff --git a/src/util/diff/bin.ts b/src/util/diff/bin.ts index baf66fb962..b8fce4d399 100644 --- a/src/util/diff/bin.ts +++ b/src/util/diff/bin.ts @@ -1,41 +1,16 @@ import * as str from "./str"; -// Encoding of the lower 4 bits. -const QUADS1 = 'abcdefghijklmnop'; - -// Encoding of the upper 4 bits. -const QUADS2 = 'ABCDEFGHIJKLMNOP'; - -// Binary octet to hex string conversion, where lower and upper 4 bits are -// encoded using different alphabets. -const BIN_TO_HEX: string[] = []; - -for (let i = 0; i < 16; i++) { - for (let j = 0; j < 16; j++) { - BIN_TO_HEX.push(QUADS2[i] + QUADS1[j]); - } -} - export const toStr = (buf: Uint8Array): string => { let hex = ''; const length = buf.length; - for (let i = 0; i < length; i++) hex += BIN_TO_HEX[buf[i]]; + for (let i = 0; i < length; i++) hex += String.fromCharCode(buf[i]); return hex; }; -const START_CODE1 = 97; // 'a'.charCodeAt(0) -const START_CODE2 = 65; // 'A'.charCodeAt(0); - export const toBin = (hex: string): Uint8Array => { const length = hex.length; - const buf = new Uint8Array(length >> 1); - let j = 0; - for (let i = 0; i < length; i += 2) { - const quad2 = hex.charCodeAt(i) - START_CODE2; - const quad1 = hex.charCodeAt(i + 1) - START_CODE1; - buf[j] = (quad2 << 4) | quad1; - j++; - } + const buf = new Uint8Array(length); + for (let i = 0; i < length; i ++) buf[i] = hex.charCodeAt(i); return buf; }; From c03ede50816ba7660e2eaac5a1c5e8aa8684b5c3 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 4 May 2025 10:05:45 +0200 Subject: [PATCH 24/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20im?= =?UTF-8?q?plement=20"bin"=20node=20diffing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 69 ++++++++++------------- src/json-crdt-diff/__tests__/Diff.spec.ts | 58 ++++++++++++++++++- src/util/diff/bin.ts | 4 +- src/util/diff/str.ts | 16 +++++- 4 files changed, 102 insertions(+), 45 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 4bca89b248..14752149e3 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -1,7 +1,8 @@ import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import {type ITimestampStruct, Patch, PatchBuilder} from '../json-crdt-patch'; import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; -import {diff, PATCH_OP_TYPE} from '../util/diff/str'; +import * as str from '../util/diff/str'; +import * as bin from '../util/diff/bin'; import type {Model} from '../json-crdt/model'; export class DiffError extends Error { @@ -21,43 +22,32 @@ export class Diff { const view = src.view(); if (view === dst) return; const builder = this.builder; - // apply(diff(view, dst), (pos, txt) => { - // const after = !pos ? src.id : src.find(pos - 1); - // if (!after) throw new DiffError(); - // builder.insStr(src.id, after, txt); - // pos += txt.length; - // }, (pos, len) => { - // const spans = src.findInterval(pos, len); - // if (!spans) throw new DiffError(); - // builder.del(src.id, spans); - // }); - const patch = diff(view, dst); - const length = patch.length; - let pos = 0; - for (let i = 0; i < length; i++) { - const op = patch[i]; - switch (op[0]) { - case PATCH_OP_TYPE.EQUAL: { - pos += op[1].length; - break; - } - case PATCH_OP_TYPE.INSERT: { - const txt = op[1]; - const after = !pos ? src.id : src.find(pos - 1); - if (!after) throw new DiffError(); - builder.insStr(src.id, after, txt); - pos += txt.length; - break; - } - case PATCH_OP_TYPE.DELETE: { - const length = op[1].length; - const spans = src.findInterval(pos, length); - if (!spans) throw new DiffError(); - builder.del(src.id, spans); - break; - } - } - } + str.apply(str.diff(view, dst), (pos, txt) => { + const after = !pos ? src.id : src.find(pos - 1); + if (!after) throw new DiffError(); + builder.insStr(src.id, after, txt); + pos += txt.length; + }, (pos, len) => { + const spans = src.findInterval(pos, len); + if (!spans) throw new DiffError(); + builder.del(src.id, spans); + }, true); + } + + protected diffBin(src: BinNode, dst: Uint8Array): void { + const view = src.view(); + if (view === dst) return; + const builder = this.builder; + bin.apply(bin.diff(view, dst), (pos, txt) => { + const after = !pos ? src.id : src.find(pos - 1); + if (!after) throw new DiffError(); + builder.insBin(src.id, after, txt); + pos += txt.length; + }, (pos, len) => { + const spans = src.findInterval(pos, len); + if (!spans) throw new DiffError(); + builder.del(src.id, spans); + }, true); } protected diffObj(src: ObjNode, dst: Record): void { @@ -153,7 +143,8 @@ export class Diff { if (!Array.isArray(dst)) throw new DiffError(); this.diffVec(src, dst as unknown[]); } else if (src instanceof BinNode) { - throw new Error('not implemented'); + if (!(dst instanceof Uint8Array)) throw new DiffError(); + this.diffBin(src, dst); } else { throw new DiffError(); } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index 0476ce5590..8f3f96ca9b 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -2,6 +2,7 @@ import {Diff} from '../Diff'; import {InsStrOp, s} from '../../json-crdt-patch'; import {Model} from '../../json-crdt/model'; import {JsonNode, ValNode} from '../../json-crdt/nodes'; +import {b} from '@jsonjoy.com/util/lib/buffers/b'; const assertDiff = (model: Model, src: JsonNode, dst: unknown) => { const patch1 = new Diff(model).diff(src, dst); @@ -45,12 +46,24 @@ describe('str', () => { expect(str.view()).toBe(dst); }); + test('two inserts', () => { + const model = Model.create(); + const src = '23'; + model.api.root({str: src}); + const str = model.api.str(['str']); + const dst = '2x3y'; + const patch = new Diff(model).diff(str.node, dst); + expect(str.view()).toBe(src); + model.applyPatch(patch); + expect(str.view()).toBe(dst); + }); + test('inserts and deletes', () => { const model = Model.create(); const src = 'hello world'; model.api.root({str: src}); const str = model.api.str(['str']); - const dst = 'Hello, world!'; + const dst = 'Hello world!'; const patch = new Diff(model).diff(str.node, dst); expect(str.view()).toBe(src); model.applyPatch(patch); @@ -58,6 +71,49 @@ describe('str', () => { }); }); +describe('bin', () => { + test('insert', () => { + const model = Model.create(); + const bin = b(1, 2, 3, 4, 5); + model.api.root({bin}); + const str = model.api.bin(['bin']); + const dst = b(1, 2, 3, 4, 123, 5); + const patch = new Diff(model).diff(str.node, dst); + expect(patch.ops.length).toBe(1); + expect(patch.ops[0].name()).toBe('ins_bin'); + expect((patch.ops[0] as InsStrOp).data).toEqual(b(123)); + expect(str.view()).toEqual(bin); + model.applyPatch(patch); + expect(str.view()).toEqual(dst); + }); + + test('delete', () => { + const model = Model.create(); + const src = b(1, 2, 3, 4, 5); + model.api.root({bin: src}); + const bin = model.api.bin(['bin']); + const dst = b(1, 2, 3, 4); + const patch = new Diff(model).diff(bin.node, dst); + expect(patch.ops.length).toBe(1); + expect(patch.ops[0].name()).toBe('del'); + expect(bin.view()).toEqual(src); + model.applyPatch(patch); + expect(bin.view()).toEqual(dst); + }); + + test('inserts and deletes', () => { + const model = Model.create(); + const src = b(1, 2, 3, 4, 5); + model.api.root({bin: src}); + const bin = model.api.bin(['bin']); + const dst = b(2, 3, 4, 5, 6); + const patch = new Diff(model).diff(bin.node, dst); + expect(bin.view()).toEqual(src); + model.applyPatch(patch); + expect(bin.view()).toEqual(dst); + }); +}); + describe('obj', () => { test('can remove a key', () => { const model = Model.create(); diff --git a/src/util/diff/bin.ts b/src/util/diff/bin.ts index b8fce4d399..38cc1e9592 100644 --- a/src/util/diff/bin.ts +++ b/src/util/diff/bin.ts @@ -20,8 +20,8 @@ export const diff = (src: Uint8Array, dst: Uint8Array): str.Patch => { return str.diff(txtSrc, txtDst); }; -export const apply = (patch: str.Patch, onInsert: (pos: number, str: Uint8Array) => void, onDelete: (pos: number, len: number) => void) => - str.apply(patch, (pos, str) => onInsert(pos, toBin(str)), onDelete); +export const apply = (patch: str.Patch, onInsert: (pos: number, str: Uint8Array) => void, onDelete: (pos: number, len: number) => void, delayedMaterialization?: boolean) => + str.apply(patch, (pos, str) => onInsert(pos, toBin(str)), onDelete, delayedMaterialization); export const src = (patch: str.Patch): Uint8Array => toBin(str.src(patch)); export const dst = (patch: str.Patch): Uint8Array => toBin(str.dst(patch)); diff --git a/src/util/diff/str.ts b/src/util/diff/str.ts index 0fa149e261..751d8c9f99 100644 --- a/src/util/diff/str.ts +++ b/src/util/diff/str.ts @@ -537,7 +537,15 @@ export const invert = (patch: Patch): Patch => { return inverted; }; -export const apply = (patch: Patch, onInsert: (pos: number, str: string) => void, onDelete: (pos: number, len: number) => void) => { +/** + * @param patch The patch to apply. + * @param onInsert Callback for insert operations. + * @param onDelete Callback for delete operations. + * @param delayedMaterialization Whether inserts and deletes are applied + * immediately. If `true`, it is assumed that the size of the mutated + * string does not change, the changes will be applied later. + */ +export const apply = (patch: Patch, onInsert: (pos: number, str: string) => void, onDelete: (pos: number, len: number) => void, delayedMaterialization?: boolean) => { const length = patch.length; let pos = 0; for (let i = 0; i < length; i++) { @@ -549,10 +557,12 @@ export const apply = (patch: Patch, onInsert: (pos: number, str: string) => void case PATCH_OP_TYPE.INSERT: const txt = op[1]; onInsert(pos, txt); - pos += txt.length; + if (!delayedMaterialization) pos += txt.length; break; case PATCH_OP_TYPE.DELETE: - onDelete(pos, op[1].length); + const len = op[1].length; + onDelete(pos, len); + if (delayedMaterialization) pos += len; break; } } From a26230725ee708ab51037c0ee6b83ee046d24e96 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 4 May 2025 11:23:18 +0200 Subject: [PATCH 25/68] =?UTF-8?q?refactor(util):=20=F0=9F=92=A1=20cleanup?= =?UTF-8?q?=20string=20diffing=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/str.ts | 307 +++++++++++++++++++++---------------------- 1 file changed, 151 insertions(+), 156 deletions(-) diff --git a/src/util/diff/str.ts b/src/util/diff/str.ts index 751d8c9f99..88e2f31d0e 100644 --- a/src/util/diff/str.ts +++ b/src/util/diff/str.ts @@ -1,7 +1,3 @@ -/** - * This is a port of diff-patch-match to TypeScript. - */ - export const enum PATCH_OP_TYPE { DELETE = -1, EQUAL = 0, @@ -29,73 +25,83 @@ const endsWithPairStart = (str: string): boolean => { * Any edit section can move as long as it doesn't cross an equality. * * @param diff Array of diff tuples. - * @param fix_unicode Whether to normalize to a unicode-correct diff + * @param fixUnicode Whether to normalize to a unicode-correct diff */ -const cleanupMerge = (diff: Patch, fix_unicode: boolean) => { +const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { diff.push([PATCH_OP_TYPE.EQUAL, '']); let pointer = 0; - let count_delete = 0; - let count_insert = 0; - let text_delete = ''; - let text_insert = ''; + let delCnt = 0; + let insCnt = 0; + let delTxt = ''; + let insTxt = ''; let commonLength: number = 0; while (pointer < diff.length) { if (pointer < diff.length - 1 && !diff[pointer][1]) { diff.splice(pointer, 1); continue; } - switch (diff[pointer][0]) { + const d1 = diff[pointer]; + switch (d1[0]) { case PATCH_OP_TYPE.INSERT: - count_insert++; - text_insert += diff[pointer][1]; + insCnt++; pointer++; + insTxt += d1[1]; break; case PATCH_OP_TYPE.DELETE: - count_delete++; - text_delete += diff[pointer][1]; + delCnt++; pointer++; + delTxt += d1[1]; break; case PATCH_OP_TYPE.EQUAL: { - let previous_equality = pointer - count_insert - count_delete - 1; - if (fix_unicode) { - // prevent splitting of unicode surrogate pairs. when fix_unicode is true, + let prevEq = pointer - insCnt - delCnt - 1; + if (fixUnicode) { + // prevent splitting of unicode surrogate pairs. When `fixUnicode` is true, // we assume that the old and new text in the diff are complete and correct // unicode-encoded JS strings, but the tuple boundaries may fall between - // surrogate pairs. we fix this by shaving off stray surrogates from the end - // of the previous equality and the beginning of this equality. this may create - // empty equalities or a common prefix or suffix. for example, if AB and AC are + // surrogate pairs. We fix this by shaving off stray surrogates from the end + // of the previous equality and the beginning of this equality. This may create + // empty equalities or a common prefix or suffix. For example, if AB and AC are // emojis, `[[0, 'A'], [-1, 'BA'], [0, 'C']]` would turn into deleting 'ABAC' and // inserting 'AC', and then the common suffix 'AC' will be eliminated. in this // particular case, both equalities go away, we absorb any previous inequalities, // and we keep scanning for the next equality before rewriting the tuples. - if (previous_equality >= 0 && endsWithPairStart(diff[previous_equality][1])) { - const stray = diff[previous_equality][1].slice(-1); - diff[previous_equality][1] = diff[previous_equality][1].slice(0, -1); - text_delete = stray + text_delete; - text_insert = stray + text_insert; - if (!diff[previous_equality][1]) { - // emptied out previous equality, so delete it and include previous delete/insert - diff.splice(previous_equality, 1); - pointer--; - let k = previous_equality - 1; - if (diff[k] && diff[k][0] === PATCH_OP_TYPE.INSERT) { - count_insert++; - text_insert = diff[k][1] + text_insert; - k--; - } - if (diff[k] && diff[k][0] === PATCH_OP_TYPE.DELETE) { - count_delete++; - text_delete = diff[k][1] + text_delete; - k--; + const d = diff[prevEq]; + if (prevEq >= 0) { + let str = d[1]; + if (endsWithPairStart(str)) { + const stray = str.slice(-1); + d[1] = str = str.slice(0, -1); + delTxt = stray + delTxt; + insTxt = stray + insTxt; + if (!str) { + // emptied out previous equality, so delete it and include previous delete/insert + diff.splice(prevEq, 1); + pointer--; + let k = prevEq - 1; + const dk = diff[k]; + if (dk) { + const type = dk[0]; + if (type === PATCH_OP_TYPE.INSERT) { + insCnt++; + k--; + insTxt = dk[1] + insTxt; + } else if (type === PATCH_OP_TYPE.DELETE) { + delCnt++; + k--; + delTxt = dk[1] + delTxt; + } + } + prevEq = k; } - previous_equality = k; } } - if (startsWithPairEnd(diff[pointer][1])) { - const stray = diff[pointer][1].charAt(0); - diff[pointer][1] = diff[pointer][1].slice(1); - text_delete += stray; - text_insert += stray; + const d1 = diff[pointer]; + const str1 = d1[1]; + if (startsWithPairEnd(str1)) { + const stray = str1.charAt(0); + d1[1] = str1.slice(1); + delTxt += stray; + insTxt += stray; } } if (pointer < diff.length - 1 && !diff[pointer][1]) { @@ -103,63 +109,64 @@ const cleanupMerge = (diff: Patch, fix_unicode: boolean) => { diff.splice(pointer, 1); break; } - if (text_delete.length > 0 || text_insert.length > 0) { + const hasDelTxt = delTxt.length > 0; + const hasInsTxt = insTxt.length > 0; + if (hasDelTxt || hasInsTxt) { // note that diff_commonPrefix and diff_commonSuffix are unicode-aware - if (text_delete.length > 0 && text_insert.length > 0) { + if (hasDelTxt && hasInsTxt) { // Factor out any common prefixes. - commonLength = pfx(text_insert, text_delete); + commonLength = pfx(insTxt, delTxt); if (commonLength !== 0) { - if (previous_equality >= 0) { - diff[previous_equality][1] += text_insert.substring(0, commonLength); + if (prevEq >= 0) { + diff[prevEq][1] += insTxt.slice(0, commonLength); } else { - diff.splice(0, 0, [PATCH_OP_TYPE.EQUAL, text_insert.substring(0, commonLength)]); + diff.splice(0, 0, [PATCH_OP_TYPE.EQUAL, insTxt.slice(0, commonLength)]); pointer++; } - text_insert = text_insert.substring(commonLength); - text_delete = text_delete.substring(commonLength); + insTxt = insTxt.slice(commonLength); + delTxt = delTxt.slice(commonLength); } // Factor out any common suffixes. - commonLength = sfx(text_insert, text_delete); + commonLength = sfx(insTxt, delTxt); if (commonLength !== 0) { - diff[pointer][1] = text_insert.substring(text_insert.length - commonLength) + diff[pointer][1]; - text_insert = text_insert.substring(0, text_insert.length - commonLength); - text_delete = text_delete.substring(0, text_delete.length - commonLength); + diff[pointer][1] = insTxt.slice(insTxt.length - commonLength) + diff[pointer][1]; + insTxt = insTxt.slice(0, insTxt.length - commonLength); + delTxt = delTxt.slice(0, delTxt.length - commonLength); } } // Delete the offending records and add the merged ones. - const n = count_insert + count_delete; - if (text_delete.length === 0 && text_insert.length === 0) { + const n = insCnt + delCnt; + const delTxtLen = delTxt.length; + const insTxtLen = insTxt.length; + if (delTxtLen === 0 && insTxtLen === 0) { diff.splice(pointer - n, n); pointer = pointer - n; - } else if (text_delete.length === 0) { - diff.splice(pointer - n, n, [PATCH_OP_TYPE.INSERT, text_insert]); + } else if (delTxtLen === 0) { + diff.splice(pointer - n, n, [PATCH_OP_TYPE.INSERT, insTxt]); pointer = pointer - n + 1; - } else if (text_insert.length === 0) { - diff.splice(pointer - n, n, [PATCH_OP_TYPE.DELETE, text_delete]); + } else if (insTxtLen === 0) { + diff.splice(pointer - n, n, [PATCH_OP_TYPE.DELETE, delTxt]); pointer = pointer - n + 1; } else { - diff.splice(pointer - n, n, [PATCH_OP_TYPE.DELETE, text_delete], [PATCH_OP_TYPE.INSERT, text_insert]); + diff.splice(pointer - n, n, [PATCH_OP_TYPE.DELETE, delTxt], [PATCH_OP_TYPE.INSERT, insTxt]); pointer = pointer - n + 2; } } - if (pointer !== 0 && diff[pointer - 1][0] === PATCH_OP_TYPE.EQUAL) { + const d0 = diff[pointer - 1]; + if (pointer !== 0 && d0[0] === PATCH_OP_TYPE.EQUAL) { // Merge this equality with the previous one. - diff[pointer - 1][1] += diff[pointer][1]; + d0[1] += diff[pointer][1]; diff.splice(pointer, 1); - } else { - pointer++; - } - count_insert = 0; - count_delete = 0; - text_delete = ''; - text_insert = ''; + } else pointer++; + insCnt = 0; + delCnt = 0; + delTxt = ''; + insTxt = ''; break; } } } - if (diff[diff.length - 1][1] === '') { - diff.pop(); // Remove the dummy entry at the end. - } + if (diff[diff.length - 1][1] === '') diff.pop(); // Remove the dummy entry at the end. // Second pass: look for single edits surrounded on both sides by equalities // which can be shifted sideways to eliminate an equality. @@ -168,19 +175,24 @@ const cleanupMerge = (diff: Patch, fix_unicode: boolean) => { pointer = 1; // Intentionally ignore the first and last element (don't need checking). while (pointer < diff.length - 1) { - if (diff[pointer - 1][0] === PATCH_OP_TYPE.EQUAL && diff[pointer + 1][0] === PATCH_OP_TYPE.EQUAL) { + const d0 = diff[pointer - 1]; + const d2 = diff[pointer + 1]; + if (d0[0] === PATCH_OP_TYPE.EQUAL && d2[0] === PATCH_OP_TYPE.EQUAL) { // This is a single edit surrounded by equalities. - if (diff[pointer][1].substring(diff[pointer][1].length - diff[pointer - 1][1].length) === diff[pointer - 1][1]) { + const str0 = d0[1]; + const d1 = diff[pointer]; + const str1 = d1[1]; + const str2 = d2[1]; + if (str1.slice(str1.length - str0.length) === str0) { // Shift the edit over the previous equality. - diff[pointer][1] = - diff[pointer - 1][1] + diff[pointer][1].substring(0, diff[pointer][1].length - diff[pointer - 1][1].length); - diff[pointer + 1][1] = diff[pointer - 1][1] + diff[pointer + 1][1]; + diff[pointer][1] = str0 + str1.slice(0, str1.length - str0.length); + d2[1] = str0 + str2; diff.splice(pointer - 1, 1); changes = true; - } else if (diff[pointer][1].substring(0, diff[pointer + 1][1].length) === diff[pointer + 1][1]) { + } else if (str1.slice(0, str2.length) === str2) { // Shift the edit over the next equality. - diff[pointer - 1][1] += diff[pointer + 1][1]; - diff[pointer][1] = diff[pointer][1].substring(diff[pointer + 1][1].length) + diff[pointer + 1][1]; + d0[1] += d2[1]; + d1[1] = str1.slice(str2.length) + str2; diff.splice(pointer + 1, 1); changes = true; } @@ -188,7 +200,7 @@ const cleanupMerge = (diff: Patch, fix_unicode: boolean) => { pointer++; } // If shifts were made, the diff needs reordering and another shift sweep. - if (changes) cleanupMerge(diff, fix_unicode); + if (changes) cleanupMerge(diff, fixUnicode); }; /** @@ -202,14 +214,16 @@ const cleanupMerge = (diff: Patch, fix_unicode: boolean) => { * @return Array of diff tuples. */ const bisectSplit = (text1: string, text2: string, x: number, y: number): Patch => { - const diffsA = diff_(text1.substring(0, x), text2.substring(0, y), false); - const diffsB = diff_(text1.substring(x), text2.substring(y), false); + const diffsA = diff_(text1.slice(0, x), text2.slice(0, y), false); + const diffsB = diff_(text1.slice(x), text2.slice(y), false); return diffsA.concat(diffsB); }; /** * Find the 'middle snake' of a diff, split the problem in two * and return the recursively constructed diff. + * + * This is a port of `diff-patch-match` implementation to TypeScript. * * @see http://www.xmailserver.org/diff2.pdf EUGENE W. MYERS 1986 paper: An * O(ND) Difference Algorithm and Its Variations. @@ -246,8 +260,10 @@ const bisect = (text1: string, text2: string): Patch => { for (let k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { const k1_offset = vOffset + k1; let x1: number = 0; - if (k1 === -d || (k1 !== d && v1[k1_offset - 1] < v1[k1_offset + 1])) x1 = v1[k1_offset + 1]; - else x1 = v1[k1_offset - 1] + 1; + const v10 = v1[k1_offset - 1]; + const v11 = v1[k1_offset + 1]; + if (k1 === -d || (k1 !== d && v10 < v11)) x1 = v11; + else x1 = v10 + 1; let y1 = x1 - k1; while (x1 < text1Length && y1 < text2Length && text1.charAt(x1) === text2.charAt(y1)) { x1++; @@ -258,17 +274,16 @@ const bisect = (text1: string, text2: string): Patch => { else if (y1 > text2Length) k1start += 2; else if (front) { const k2Offset = vOffset + delta - k1; - if (k2Offset >= 0 && k2Offset < vLength && v2[k2Offset] !== -1) { - if (x1 >= text1Length - v2[k2Offset]) return bisectSplit(text1, text2, x1, y1); + const v2Offset = v2[k2Offset]; + if (k2Offset >= 0 && k2Offset < vLength && v2Offset !== -1) { + if (x1 >= text1Length - v2Offset) return bisectSplit(text1, text2, x1, y1); } } } // Walk the reverse path one step. for (let k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { const k2_offset = vOffset + k2; - let x2: number = 0; - if (k2 === -d || (k2 !== d && v2[k2_offset - 1] < v2[k2_offset + 1])) x2 = v2[k2_offset + 1]; - else x2 = v2[k2_offset - 1] + 1; + let x2 = k2 === -d || (k2 !== d && v2[k2_offset - 1] < v2[k2_offset + 1]) ? v2[k2_offset + 1] : v2[k2_offset - 1] + 1; let y2 = x2 - k2; while ( x2 < text1Length && @@ -283,8 +298,8 @@ const bisect = (text1: string, text2: string): Patch => { else if (y2 > text2Length) k2start += 2; else if (!front) { const k1_offset = vOffset + delta - k2; - if (k1_offset >= 0 && k1_offset < vLength && v1[k1_offset] !== -1) { - const x1 = v1[k1_offset]; + const x1 = v1[k1_offset]; + if (k1_offset >= 0 && k1_offset < vLength && x1 !== -1) { const y1 = vOffset + x1 - k1_offset; x2 = text1Length - x2; if (x1 >= x2) return bisectSplit(text1, text2, x1, y1); @@ -316,8 +331,8 @@ const diffNoCommonAffix = (src: string, dst: string): Patch => { const shortTextLength = short.length; const indexOfContainedShort = long.indexOf(short); if (indexOfContainedShort >= 0) { - const start = long.substring(0, indexOfContainedShort); - const end = long.substring(indexOfContainedShort + shortTextLength); + const start = long.slice(0, indexOfContainedShort); + const end = long.slice(indexOfContainedShort + shortTextLength); return text1Length > text2Length ? [ [PATCH_OP_TYPE.DELETE, start], @@ -352,7 +367,7 @@ export const pfx = (txt1: string, txt2: string) => { let mid = max; let start = 0; while (min < mid) { - if (txt1.substring(start, mid) === txt2.substring(start, mid)) { + if (txt1.slice(start, mid) === txt2.slice(start, mid)) { min = mid; start = min; } else max = mid; @@ -379,8 +394,8 @@ export const sfx = (txt1: string, txt2: string): number => { let end = 0; while (min < mid) { if ( - txt1.substring(txt1.length - mid, txt1.length - end) === - txt2.substring(txt2.length - mid, txt2.length - end) + txt1.slice(txt1.length - mid, txt1.length - end) === + txt2.slice(txt2.length - mid, txt2.length - end) ) { min = mid; end = min; @@ -407,15 +422,15 @@ const diff_ = (src: string, dst: string, fixUnicode: boolean): Patch => { // Trim off common prefix (speedup). const prefixLength = pfx(src, dst); - const prefix = src.substring(0, prefixLength); - src = src.substring(prefixLength); - dst = dst.substring(prefixLength); + const prefix = src.slice(0, prefixLength); + src = src.slice(prefixLength); + dst = dst.slice(prefixLength); // Trim off common suffix (speedup). const suffixLength = sfx(src, dst); - const suffix = src.substring(src.length - suffixLength); - src = src.substring(0, src.length - suffixLength); - dst = dst.substring(0, dst.length - suffixLength); + const suffix = src.slice(src.length - suffixLength); + src = src.slice(0, src.length - suffixLength); + dst = dst.slice(0, dst.length - suffixLength); // Compute the diff on the middle block. const diff: Patch = diffNoCommonAffix(src, dst); @@ -494,12 +509,7 @@ export const src = (patch: Patch): string => { const length = patch.length; for (let i = 0; i < length; i++) { const op = patch[i]; - switch (op[0]) { - case PATCH_OP_TYPE.EQUAL: - case PATCH_OP_TYPE.DELETE: - txt += op[1]; - break; - } + if (op[0] !== PATCH_OP_TYPE.INSERT) txt += op[1]; } return txt; }; @@ -509,61 +519,46 @@ export const dst = (patch: Patch): string => { const length = patch.length; for (let i = 0; i < length; i++) { const op = patch[i]; - switch (op[0]) { - case PATCH_OP_TYPE.EQUAL: - case PATCH_OP_TYPE.INSERT: - txt += op[1]; - break; - } + if (op[0] !== PATCH_OP_TYPE.DELETE) txt += op[1]; } return txt; }; -export const invertOp = (op: PatchOperation): PatchOperation => { - switch (op[0]) { - case PATCH_OP_TYPE.EQUAL: - return op; - case PATCH_OP_TYPE.INSERT: - return [PATCH_OP_TYPE.DELETE, op[1]]; - case PATCH_OP_TYPE.DELETE: - return [PATCH_OP_TYPE.INSERT, op[1]]; - } +const invertOp = (op: PatchOperation): PatchOperation => { + const type = op[0]; + return type === PATCH_OP_TYPE.EQUAL + ? op + : type === PATCH_OP_TYPE.INSERT + ? [PATCH_OP_TYPE.DELETE, op[1]] + : [PATCH_OP_TYPE.INSERT, op[1]]; }; -export const invert = (patch: Patch): Patch => { - const inverted: Patch = []; - const length = patch.length; - for (let i = 0; i < length; i++) inverted.push(invertOp(patch[i])); - return inverted; -}; +/** + * Inverts patch such that it can be applied to `dst` to get `src` (instead of + * `src` to get `dst`). + * + * @param patch The patch to invert. + * @returns Inverted patch. + */ +export const invert = (patch: Patch): Patch => patch.map(invertOp); /** * @param patch The patch to apply. + * @param srcLen The length of the source string. * @param onInsert Callback for insert operations. * @param onDelete Callback for delete operations. - * @param delayedMaterialization Whether inserts and deletes are applied - * immediately. If `true`, it is assumed that the size of the mutated - * string does not change, the changes will be applied later. */ -export const apply = (patch: Patch, onInsert: (pos: number, str: string) => void, onDelete: (pos: number, len: number) => void, delayedMaterialization?: boolean) => { +export const apply = (patch: Patch, srcLen: number, onInsert: (pos: number, str: string) => void, onDelete: (pos: number, len: number) => void) => { const length = patch.length; - let pos = 0; - for (let i = 0; i < length; i++) { - const op = patch[i]; - switch (op[0]) { - case PATCH_OP_TYPE.EQUAL: - pos += op[1].length; - break; - case PATCH_OP_TYPE.INSERT: - const txt = op[1]; - onInsert(pos, txt); - if (!delayedMaterialization) pos += txt.length; - break; - case PATCH_OP_TYPE.DELETE: - const len = op[1].length; - onDelete(pos, len); - if (delayedMaterialization) pos += len; - break; + let pos = srcLen; + for (let i = length - 1; i >= 0; i--) { + const [type, str] = patch[i]; + if (type === PATCH_OP_TYPE.EQUAL) pos -= str.length; + else if (type === PATCH_OP_TYPE.INSERT) onInsert(pos, str); + else { + const len = str.length; + pos -= len; + onDelete(pos, len); } } }; From 5968a473e6e69c254f3c0beb74dd249d211e57b8 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 4 May 2025 11:23:46 +0200 Subject: [PATCH 26/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20pa?= =?UTF-8?q?ss=20in=20source=20length=20on=20patch=20application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 8 ++++---- src/util/diff/__tests__/util.ts | 4 ++-- src/util/diff/bin.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 14752149e3..ac5fb4cc49 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -22,7 +22,7 @@ export class Diff { const view = src.view(); if (view === dst) return; const builder = this.builder; - str.apply(str.diff(view, dst), (pos, txt) => { + str.apply(str.diff(view, dst), view.length, (pos, txt) => { const after = !pos ? src.id : src.find(pos - 1); if (!after) throw new DiffError(); builder.insStr(src.id, after, txt); @@ -31,14 +31,14 @@ export class Diff { const spans = src.findInterval(pos, len); if (!spans) throw new DiffError(); builder.del(src.id, spans); - }, true); + }); } protected diffBin(src: BinNode, dst: Uint8Array): void { const view = src.view(); if (view === dst) return; const builder = this.builder; - bin.apply(bin.diff(view, dst), (pos, txt) => { + bin.apply(bin.diff(view, dst), view.length, (pos, txt) => { const after = !pos ? src.id : src.find(pos - 1); if (!after) throw new DiffError(); builder.insBin(src.id, after, txt); @@ -47,7 +47,7 @@ export class Diff { const spans = src.findInterval(pos, len); if (!spans) throw new DiffError(); builder.del(src.id, spans); - }, true); + }); } protected diffObj(src: ObjNode, dst: Record): void { diff --git a/src/util/diff/__tests__/util.ts b/src/util/diff/__tests__/util.ts index d117afe122..5aab090eba 100644 --- a/src/util/diff/__tests__/util.ts +++ b/src/util/diff/__tests__/util.ts @@ -4,7 +4,7 @@ export const assertPatch = (src: string, dst: string, patch: diff.Patch = diff.d const src1 = diff.src(patch); const dst1 = diff.dst(patch); let dst2 = src; - diff.apply(patch, (pos, str) => { + diff.apply(patch, dst2.length, (pos, str) => { dst2 = dst2.slice(0, pos) + str + dst2.slice(pos); }, (pos, len) => { dst2 = dst2.slice(0, pos) + dst2.slice(pos + len); @@ -13,7 +13,7 @@ export const assertPatch = (src: string, dst: string, patch: diff.Patch = diff.d const src2 = diff.dst(inverted); const dst3 = diff.src(inverted); let src3 = dst; - diff.apply(inverted, (pos, str) => { + diff.apply(inverted, src3.length, (pos, str) => { src3 = src3.slice(0, pos) + str + src3.slice(pos); }, (pos, len) => { src3 = src3.slice(0, pos) + src3.slice(pos + len); diff --git a/src/util/diff/bin.ts b/src/util/diff/bin.ts index 38cc1e9592..87b71b0605 100644 --- a/src/util/diff/bin.ts +++ b/src/util/diff/bin.ts @@ -20,8 +20,8 @@ export const diff = (src: Uint8Array, dst: Uint8Array): str.Patch => { return str.diff(txtSrc, txtDst); }; -export const apply = (patch: str.Patch, onInsert: (pos: number, str: Uint8Array) => void, onDelete: (pos: number, len: number) => void, delayedMaterialization?: boolean) => - str.apply(patch, (pos, str) => onInsert(pos, toBin(str)), onDelete, delayedMaterialization); +export const apply = (patch: str.Patch, srcLen: number, onInsert: (pos: number, str: Uint8Array) => void, onDelete: (pos: number, len: number) => void) => + str.apply(patch, srcLen, (pos, str) => onInsert(pos, toBin(str)), onDelete); export const src = (patch: str.Patch): Uint8Array => toBin(str.src(patch)); export const dst = (patch: str.Patch): Uint8Array => toBin(str.dst(patch)); From fd88982f17978d44fe9fa6827d436b154156bca2 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 4 May 2025 16:33:03 +0200 Subject: [PATCH 27/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20im?= =?UTF-8?q?plement=20initial=20"arr"=20node=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 126 ++++++++++++++++---- src/json-crdt-diff/__tests__/Diff.spec.ts | 137 ++++++++++++++++++++++ src/util/__tests__/strCnt.spec.ts | 41 +++++++ src/util/strCnt.ts | 11 ++ 4 files changed, 294 insertions(+), 21 deletions(-) create mode 100644 src/util/__tests__/strCnt.spec.ts create mode 100644 src/util/strCnt.ts diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index ac5fb4cc49..49fa31c65f 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -1,9 +1,12 @@ import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; -import {type ITimestampStruct, Patch, PatchBuilder} from '../json-crdt-patch'; +import {ITimespanStruct, type ITimestampStruct, Patch, PatchBuilder, Timespan} from '../json-crdt-patch'; import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; import * as str from '../util/diff/str'; import * as bin from '../util/diff/bin'; import type {Model} from '../json-crdt/model'; +import {structHashCrdt} from '../json-hash/structHashCrdt'; +import {structHash} from '../json-hash'; +import {strCnt} from '../util/strCnt'; export class DiffError extends Error { constructor(message: string = 'DIFF') { @@ -22,32 +25,112 @@ export class Diff { const view = src.view(); if (view === dst) return; const builder = this.builder; - str.apply(str.diff(view, dst), view.length, (pos, txt) => { - const after = !pos ? src.id : src.find(pos - 1); - if (!after) throw new DiffError(); - builder.insStr(src.id, after, txt); - pos += txt.length; - }, (pos, len) => { - const spans = src.findInterval(pos, len); - if (!spans) throw new DiffError(); - builder.del(src.id, spans); - }); + str.apply(str.diff(view, dst), view.length, + (pos, txt) => builder.insStr(src.id, !pos ? src.id : src.find(pos - 1)!, txt), + (pos, len) => builder.del(src.id, src.findInterval(pos, len)), + ); } protected diffBin(src: BinNode, dst: Uint8Array): void { const view = src.view(); if (view === dst) return; const builder = this.builder; - bin.apply(bin.diff(view, dst), view.length, (pos, txt) => { - const after = !pos ? src.id : src.find(pos - 1); - if (!after) throw new DiffError(); - builder.insBin(src.id, after, txt); - pos += txt.length; - }, (pos, len) => { - const spans = src.findInterval(pos, len); - if (!spans) throw new DiffError(); - builder.del(src.id, spans); + bin.apply(bin.diff(view, dst), view.length, + (pos, txt) => builder.insBin(src.id, !pos ? src.id : src.find(pos - 1)!, txt), + (pos, len) => builder.del(src.id, src.findInterval(pos, len)), + ); + } + + protected diffArr(src: ArrNode, dst: unknown[]): void { + let txtSrc = ''; + let txtDst = ''; + const srcLen = src.length(); + const dstLen = dst.length; + const inserts: [after: ITimestampStruct, views: unknown[]][] = []; + const trailingInserts: unknown[] = []; + const deletes: ITimespanStruct[] = []; + src.children(node => { + txtSrc += structHashCrdt(node) + '\n'; }); + for (let i = 0; i < dstLen; i++) txtDst += structHash(dst[i]) + '\n'; + txtSrc = txtSrc.slice(0, -1); + txtDst = txtDst.slice(0, -1); + const patch = str.diff(txtSrc, txtDst); + console.log(txtSrc); + console.log(txtDst); + console.log(patch); + let srcIdx = 0; + let dstIdx = 0; + const patchLen = patch.length; + const lastOpIndex = patchLen - 1; + let inTheMiddleOfLine = false; + for (let i = 0; i <= lastOpIndex; i++) { + const isLastOp = i === lastOpIndex; + const op = patch[i]; + const [type, txt] = op; + if (!txt) continue; + let lineStartOffset = 0; + if (inTheMiddleOfLine) { + const index = txt.indexOf('\n'); + if (index < 0 && !isLastOp) continue; + inTheMiddleOfLine = false; + lineStartOffset = index + 1; + const view = dst[dstIdx]; + if (srcIdx >= srcLen) { + console.log('PUSH', op, view); + trailingInserts.push(view); + } else { + console.log('DIFF', op, srcIdx, dstIdx, view); + try { + this.diffAny(src.getNode(srcIdx)!, view); + } catch (error) { + if (error instanceof DiffError) { + const id = src.find(srcIdx)!; + const span = new Timespan(id.sid, id.time, 1); + deletes.push(span); + const after = srcIdx ? src.find(srcIdx - 1)! : src.id; + inserts.push([after, [view]]); + } else throw error; + } + srcIdx++; + } + if (isLastOp && index < 0) break; + dstIdx++; + } + inTheMiddleOfLine = txt[txt.length - 1] !== '\n'; + const lineCount = strCnt('\n', txt, lineStartOffset) + (isLastOp ? 1 : 0); + if (!lineCount) continue; + if (type === str.PATCH_OP_TYPE.EQUAL) { + console.log('EQUAL', op); + srcIdx += lineCount; + dstIdx += lineCount; + } else if (type === str.PATCH_OP_TYPE.INSERT) { + const views: unknown[] = dst.slice(dstIdx, dstIdx + lineCount); + console.log('INSERT', op, views); + const after = srcIdx ? src.find(srcIdx - 1)! : src.id; + dstIdx += lineCount; + inserts.push([after, views]); + } else { // DELETE + console.log('DELETE', op); + for (let i = 0; i < lineCount; i++) { + const id = src.find(srcIdx)!; + const span = new Timespan(id.sid, id.time, 1); + deletes.push(span); + srcIdx++; + } + } + } + const builder = this.builder; + const length = inserts.length; + for (let i = 0; i < length; i++) { + const [after, views] = inserts[i]; + builder.insArr(src.id, after, views.map(view => builder.json(view))) + } + if (trailingInserts.length) { + const after = srcLen ? src.find(srcLen - 1)! : src.id; + builder.insArr(src.id, after, trailingInserts.map(view => builder.json(view))); + } + if (deletes.length) builder.del(src.id, deletes); } protected diffObj(src: ObjNode, dst: Record): void { @@ -138,7 +221,8 @@ export class Diff { } else if (src instanceof ValNode) { this.diffVal(src, dst); } else if (src instanceof ArrNode) { - throw new Error('not implemented'); + if (!Array.isArray(dst)) throw new DiffError(); + this.diffArr(src, dst as unknown[]); } else if (src instanceof VecNode) { if (!Array.isArray(dst)) throw new DiffError(); this.diffVec(src, dst as unknown[]); diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index 8f3f96ca9b..2420e46b0d 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -195,3 +195,140 @@ describe('vec', () => { expect(node.node).toBeInstanceOf(ValNode); }); }); + +describe('arr', () => { + describe('insert', () => { + test('can add an element', () => { + const model = Model.create(); + model.api.root([1]); + const dst = [1, 2]; + assertDiff(model, model.root, dst); + }); + + test('can add an element (when list of "con")', () => { + const model = Model.create(s.arr([s.con(1)])); + const dst = [1, 2]; + assertDiff(model, model.root, dst); + }); + + test('can add two elements sequentially', () => { + const model = Model.create(); + model.api.root([1, 4]); + const dst = [1, 2, 3, 4]; + assertDiff(model, model.root, dst); + }); + }); + + describe('delete', () => { + test('can remove an element (end of list)', () => { + const model = Model.create(); + model.api.root([1, 2, 3]); + const dst = [1, 2]; + assertDiff(model, model.root, dst); + }); + + test('can remove a "con" element (end of list)', () => { + const model = Model.create(s.arr([s.con(1), s.con(2), s.con(3)])); + const dst = [1, 2]; + assertDiff(model, model.root, dst); + }); + + test('can remove an element (start of list)', () => { + const model = Model.create(); + model.api.root([1, 2]); + const dst = [2]; + assertDiff(model, model.root, dst); + }); + + test('can remove an element (middle list)', () => { + const model = Model.create(); + model.api.root([1, 2, 3]); + const dst = [1, 3]; + assertDiff(model, model.root, dst); + }); + + test('can remove whole list', () => { + const model = Model.create(); + model.api.root([1, 2, 3]); + const dst: number[] = []; + assertDiff(model, model.root, dst); + }); + }); + + describe('replace', () => { + test('can replace an element', () => { + const model = Model.create(); + model.api.root([1, 2, 3]); + const dst: number[] = [1, 0, 3]; + assertDiff(model, model.root, dst); + }); + + test('can replace an element (when elements are "con")', () => { + const model = Model.create(s.arr([s.con(1), s.con(2), s.con(3)])); + const dst: number[] = [1, 0, 3]; + assertDiff(model, model.root, dst); + }); + + test('can replace an element (different type)', () => { + const model = Model.create(); + model.api.root([1, 2, 3]); + const dst: unknown[] = [1, 'aha', 3]; + assertDiff(model, model.root, dst); + }); + + test('can replace an element (when elements are "con" and different type)', () => { + const model = Model.create(s.arr([s.con(1), s.con(2), s.con(3)])); + const dst: unknown[] = [1, 'asdf', 3]; + assertDiff(model, model.root, dst); + }); + + test('replace nested array - 1', () => { + const model = Model.create(); + model.api.root([[2]]); + const dst: unknown[] = [2]; + assertDiff(model, model.root, dst); + }); + + test('replace nested array - 2', () => { + const model = Model.create(); + model.api.root([[2]]); + const dst: unknown[] = [2, 1]; + assertDiff(model, model.root, dst); + }); + + test('replace nested array - 3', () => { + const model = Model.create(); + model.api.root([[2]]); + const dst: unknown[] = [1, 2, 3]; + assertDiff(model, model.root, dst); + }); + + test('replace nested array - 4', () => { + const model = Model.create(); + model.api.root([1, [2], 3]); + const dst: unknown[] = [1, 2, 3, 4]; + assertDiff(model, model.root, dst); + }); + + test('replace nested array - 5', () => { + const model = Model.create(); + model.api.root([1, [2, 2.4], 3]); + const dst: unknown[] = [1, 2, 3, 4]; + assertDiff(model, model.root, dst); + }); + + test.only('xxx', () => { + const model = Model.create(); + model.api.root([[1, 2, 3, 4, 5], 4, 5, 6, 7, 9, 0]); + const dst: unknown[] = [[1, 2], 4, 77, 7, 'xyz']; + assertDiff(model, model.root, dst); + }); + + // test('nested changes', () => { + // const model = Model.create(); + // model.api.root([1, 2, [1, 2, 3, 4, 5, 6], 4, 5, 6, 7, 8, 9, 0]); + // const dst: unknown[] = ['2', [1, 2, 34, 5], 4, 77, 7, 8, 'xyz']; + // assertDiff(model, model.root, dst); + // }); + }); +}); diff --git a/src/util/__tests__/strCnt.spec.ts b/src/util/__tests__/strCnt.spec.ts new file mode 100644 index 0000000000..8d2aadba24 --- /dev/null +++ b/src/util/__tests__/strCnt.spec.ts @@ -0,0 +1,41 @@ +import {strCnt} from '../strCnt'; + +test('edge cases', () => { + expect(strCnt('', 'xyz')).toBe(0); + expect(strCnt('xyz', '')).toBe(0); + expect(strCnt('', '')).toBe(0); +}); + +test('can find no occurrences', () => { + expect(strCnt('abc', 'xyz')).toBe(0); + expect(strCnt('a', 'xyz')).toBe(0); + expect(strCnt('xyz', 'xy')).toBe(0); +}); + +test('one occurrence', () => { + expect(strCnt('1', '123')).toBe(1); + expect(strCnt('1', '0123')).toBe(1); + expect(strCnt('1', '01')).toBe(1); + expect(strCnt('aa', 'aa')).toBe(1); + expect(strCnt('aa', 'aaa')).toBe(1); + expect(strCnt('aa', 'aaab')).toBe(1); + expect(strCnt('aa', 'xaaab')).toBe(1); + expect(strCnt('aa', 'xaabc')).toBe(1); +}); + +test('two occurrence', () => { + expect(strCnt('1', '1213')).toBe(2); + expect(strCnt('1', '01123')).toBe(2); + expect(strCnt('1', '101')).toBe(2); + expect(strCnt('aa', 'aaaa')).toBe(2); + expect(strCnt('aa', 'aaabaa')).toBe(2); + expect(strCnt('aa', 'xaaabaaa')).toBe(2); + expect(strCnt('aa', 'xaaaabc')).toBe(2); +}); + +test('can search at offset', () => { + expect(strCnt('1', '1213', 1)).toBe(1); + expect(strCnt('1', '01123', 1)).toBe(2); + expect(strCnt('1', '101', 2)).toBe(1); + expect(strCnt('1', '101', 3)).toBe(0); +}); diff --git a/src/util/strCnt.ts b/src/util/strCnt.ts new file mode 100644 index 0000000000..2c3db6965d --- /dev/null +++ b/src/util/strCnt.ts @@ -0,0 +1,11 @@ +export const strCnt = (needle: string, haystack: string, offset: number = 0): number => { + let cnt = 0; + const needleLen = needle.length; + if (needleLen === 0) return 0; + while (true) { + const index = haystack.indexOf(needle, offset); + if (index < 0) return cnt; + cnt++; + offset = index + needleLen; + } +}; From 2a40471cbbed24f6b283b3a50be667f2a52b9276 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 4 May 2025 22:14:21 +0200 Subject: [PATCH 28/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20initial,=20o?= =?UTF-8?q?uter,=20implementation=20of=20array=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/arr.spec.ts | 133 ++++++++++++++++++++++++++++ src/util/diff/arr.ts | 80 +++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 src/util/diff/__tests__/arr.spec.ts create mode 100644 src/util/diff/arr.ts diff --git a/src/util/diff/__tests__/arr.spec.ts b/src/util/diff/__tests__/arr.spec.ts new file mode 100644 index 0000000000..5a4d9d3279 --- /dev/null +++ b/src/util/diff/__tests__/arr.spec.ts @@ -0,0 +1,133 @@ +import * as arr from '../arr'; + +test('insert into empty list', () => { + const patch = arr.diff('', '1'); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.INSERT, 1]); +}); + +test('delete the only element', () => { + const patch = arr.diff('1', ''); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 1]); +}); + +test('delete the only two element', () => { + const patch = arr.diff('1\n{}', ''); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 2]); +}); + +test('both empty', () => { + const patch = arr.diff('', ''); + expect(patch).toEqual([]); +}); + +test('keep one', () => { + const patch = arr.diff('1', '1'); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.EQUAL, 1]); +}); + +test('keep two', () => { + const patch = arr.diff('1\n1', '1\n1'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + ]); +}); + +test('keep three', () => { + const patch = arr.diff('1\n1\n2', '1\n1\n2'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 3, + ]); +}); + +test('keep two, delete one', () => { + const patch = arr.diff('1\n1\n2', '1\n1'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + ]); +}); + +test('keep two, delete in the middle', () => { + const patch = arr.diff('1\n2\n3', '1\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); +}); + +test('keep two, delete the first one', () => { + const patch = arr.diff('1\n2\n3', '2\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + ]); +}); + +test('delete two and three in a row', () => { + const patch = arr.diff('1\n2\n3\n4\n5\n6', '3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 3, + ]); +}); + +test('delete the first one', () => { + const patch = arr.diff('1\n2\n3', '2\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + ]); +}); + +test('delete the middle element', () => { + const patch = arr.diff('1\n2\n3', '1\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); +}); + +test('delete the last element', () => { + const patch = arr.diff('1\n2\n3', '1\n2'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + ]); +}); + +test('delete two first elements', () => { + const patch = arr.diff('1\n2\n3\n4', '3\n4'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + ]); +}); + +test('preserve one and delete one', () => { + const patch = arr.diff('1\n2', '1'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + ]); +}); + +test('preserve one and delete one (reverse)', () => { + const patch = arr.diff('1\n2', '2'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); +}); + +test('various deletes and inserts', () => { + const patch = arr.diff('1\n2\n[3]\n3\n5\n{a:4}\n5\n"6"', '1\n2\n[3]\n5\n{a:4}\n5\n"6"\n6'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 3, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 4, + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + ]); +}); diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts new file mode 100644 index 0000000000..3fca114a8f --- /dev/null +++ b/src/util/diff/arr.ts @@ -0,0 +1,80 @@ +import {strCnt} from "../strCnt"; +import * as str from "./str"; + +export const enum ARR_PATCH_OP_TYPE { + DELETE = str.PATCH_OP_TYPE.DELETE, + EQUAL = str.PATCH_OP_TYPE.EQUAL, + INSERT = str.PATCH_OP_TYPE.INSERT, + PUSH = 2, + DIFF = 3, +} + +export type ArrPatch = number[]; + +const enum PARTIAL_TYPE { + NONE = 24, +} + +export const diff = (txtSrc: string, txtDst: string): ArrPatch => { + const arrPatch: ArrPatch = []; + const patch = str.diff(txtSrc, txtDst); + if (patch.length === 1) { + if ((patch[0][0] as unknown as ARR_PATCH_OP_TYPE) === ARR_PATCH_OP_TYPE.INSERT) { + arrPatch.push(ARR_PATCH_OP_TYPE.INSERT, strCnt("\n", txtDst) + 1); + return arrPatch; + } + } + const push = (type: ARR_PATCH_OP_TYPE, count: number) => { + const length = arrPatch.length; + if (length !== 0) { + const lastType = arrPatch[length - 2] as unknown as ARR_PATCH_OP_TYPE; + if (lastType === type) { + arrPatch[length - 1] = (arrPatch[length - 1] as unknown as number) + count; + return; + } + } + arrPatch.push(type, count); + }; + // console.log(txtSrc); + // console.log(txtDst); + // console.log(patch); + const patchLen = patch.length; + const lastOpIndex = patchLen - 1; + let partial: ARR_PATCH_OP_TYPE | PARTIAL_TYPE = PARTIAL_TYPE.NONE; + for (let i = 0; i <= lastOpIndex; i++) { + const isLastOp = i === lastOpIndex; + const op = patch[i]; + const type: ARR_PATCH_OP_TYPE = op[0] as unknown as ARR_PATCH_OP_TYPE; + const txt = op[1]; + if (!txt) continue; + let lineStartOffset = 0; + if (partial !== PARTIAL_TYPE.NONE) { + const index = txt.indexOf("\n"); + if (index === 0) { + lineStartOffset = 1; + push(partial, 1); + partial = PARTIAL_TYPE.NONE; + } else { + lineStartOffset = index + 1; + // console.log("DIFF"); + throw new Error("Not implemented"); + } + } + const lineCount = strCnt("\n", txt, lineStartOffset) + (isLastOp ? 1 : 0); + const isPartial = txt[txt.length - 1] !== "\n"; + if (isPartial) { + if (partial === PARTIAL_TYPE.NONE) partial = type; + else partial = (partial as unknown as ARR_PATCH_OP_TYPE) === type + ? (type as unknown as ARR_PATCH_OP_TYPE) : ARR_PATCH_OP_TYPE.DIFF; + } + if (!lineCount) continue; + if (type === ARR_PATCH_OP_TYPE.EQUAL) { + push(ARR_PATCH_OP_TYPE.EQUAL, lineCount); + } else if (type === ARR_PATCH_OP_TYPE.INSERT) { + push(ARR_PATCH_OP_TYPE.INSERT, lineCount); + } else { + push(ARR_PATCH_OP_TYPE.DELETE, lineCount); + } + } + return arrPatch; +}; From 18536339d01e5e08dc4ec3370752e964a8f6682e Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 4 May 2025 23:45:08 +0200 Subject: [PATCH 29/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20implement=20?= =?UTF-8?q?array=20diff=20child=20element=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/arr.spec.ts | 187 +++++++++++++++++----------- src/util/diff/arr.ts | 19 +-- 2 files changed, 121 insertions(+), 85 deletions(-) diff --git a/src/util/diff/__tests__/arr.spec.ts b/src/util/diff/__tests__/arr.spec.ts index 5a4d9d3279..1fdcc7e26e 100644 --- a/src/util/diff/__tests__/arr.spec.ts +++ b/src/util/diff/__tests__/arr.spec.ts @@ -5,16 +5,6 @@ test('insert into empty list', () => { expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.INSERT, 1]); }); -test('delete the only element', () => { - const patch = arr.diff('1', ''); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 1]); -}); - -test('delete the only two element', () => { - const patch = arr.diff('1\n{}', ''); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 2]); -}); - test('both empty', () => { const patch = arr.diff('', ''); expect(patch).toEqual([]); @@ -64,70 +54,115 @@ test('keep two, delete the first one', () => { ]); }); -test('delete two and three in a row', () => { - const patch = arr.diff('1\n2\n3\n4\n5\n6', '3'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 3, - ]); -}); - -test('delete the first one', () => { - const patch = arr.diff('1\n2\n3', '2\n3'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); -}); - -test('delete the middle element', () => { - const patch = arr.diff('1\n2\n3', '1\n3'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); -}); - -test('delete the last element', () => { - const patch = arr.diff('1\n2\n3', '1\n2'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - ]); -}); - -test('delete two first elements', () => { - const patch = arr.diff('1\n2\n3\n4', '3\n4'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); -}); - -test('preserve one and delete one', () => { - const patch = arr.diff('1\n2', '1'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - ]); -}); - -test('preserve one and delete one (reverse)', () => { - const patch = arr.diff('1\n2', '2'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); -}); - -test('various deletes and inserts', () => { - const patch = arr.diff('1\n2\n[3]\n3\n5\n{a:4}\n5\n"6"', '1\n2\n[3]\n5\n{a:4}\n5\n"6"\n6'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 3, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 4, - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - ]); +describe('delete', () => { + test('delete the only element', () => { + const patch = arr.diff('1', ''); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 1]); + }); + + test('delete the only two element', () => { + const patch = arr.diff('1\n{}', ''); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 2]); + }); + + test('delete two and three in a row', () => { + const patch = arr.diff('1\n2\n3\n4\n5\n6', '3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 3, + ]); + }); + + test('delete the first one', () => { + const patch = arr.diff('1\n2\n3', '2\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + ]); + }); + + test('delete the middle element', () => { + const patch = arr.diff('1\n2\n3', '1\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('delete the last element', () => { + const patch = arr.diff('1\n2\n3', '1\n2'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + ]); + }); + + test('delete two first elements', () => { + const patch = arr.diff('1\n2\n3\n4', '3\n4'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + ]); + }); + + test('preserve one and delete one', () => { + const patch = arr.diff('1\n2', '1'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + ]); + }); + + test('preserve one and delete one (reverse)', () => { + const patch = arr.diff('1\n2', '2'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('various deletes and inserts', () => { + const patch = arr.diff('1\n2\n[3]\n3\n5\n{a:4}\n5\n"6"', '1\n2\n[3]\n5\n{a:4}\n5\n"6"\n6'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 3, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 4, + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + ]); + }); +}); + +describe('diff', () => { + test('diffs partially matching single element', () => { + const patch = arr.diff('[]', '[1]'); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DIFF, 1]); + }); + + test('diffs second element', () => { + const patch = arr.diff('1\n[]', '1\n[1]'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + ]); + }); + + test('diffs middle element', () => { + const patch = arr.diff('1\n2\n3', '1\n[2]\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('diffs middle element - 2', () => { + const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,3,455]\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); }); diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index 3fca114a8f..925507c8cd 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -55,9 +55,14 @@ export const diff = (txtSrc: string, txtDst: string): ArrPatch => { push(partial, 1); partial = PARTIAL_TYPE.NONE; } else { + if (index < 0 && !isLastOp) { + partial = ARR_PATCH_OP_TYPE.DIFF; + continue; + } + push(ARR_PATCH_OP_TYPE.DIFF, 1); + if (index < 0) break; lineStartOffset = index + 1; - // console.log("DIFF"); - throw new Error("Not implemented"); + partial = PARTIAL_TYPE.NONE; } } const lineCount = strCnt("\n", txt, lineStartOffset) + (isLastOp ? 1 : 0); @@ -68,13 +73,9 @@ export const diff = (txtSrc: string, txtDst: string): ArrPatch => { ? (type as unknown as ARR_PATCH_OP_TYPE) : ARR_PATCH_OP_TYPE.DIFF; } if (!lineCount) continue; - if (type === ARR_PATCH_OP_TYPE.EQUAL) { - push(ARR_PATCH_OP_TYPE.EQUAL, lineCount); - } else if (type === ARR_PATCH_OP_TYPE.INSERT) { - push(ARR_PATCH_OP_TYPE.INSERT, lineCount); - } else { - push(ARR_PATCH_OP_TYPE.DELETE, lineCount); - } + if (type === ARR_PATCH_OP_TYPE.EQUAL) push(ARR_PATCH_OP_TYPE.EQUAL, lineCount); + else if (type === ARR_PATCH_OP_TYPE.INSERT) push(ARR_PATCH_OP_TYPE.INSERT, lineCount); + else push(ARR_PATCH_OP_TYPE.DELETE, lineCount); } return arrPatch; }; From bc25ff6e56afb26fe3b7e70159bdeefc8147e768 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 5 May 2025 01:31:37 +0200 Subject: [PATCH 30/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20im?= =?UTF-8?q?plement=20array=20diff=20apply(),=20start=20using=20arrayd=20di?= =?UTF-8?q?ff=20in=20CRDT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 84 ++++++----------------- src/json-crdt-diff/__tests__/Diff.spec.ts | 2 +- src/util/diff/__tests__/arr.spec.ts | 26 +++++++ src/util/diff/arr.ts | 29 ++++++++ 4 files changed, 76 insertions(+), 65 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 49fa31c65f..1b997d8cc6 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -3,10 +3,10 @@ import {ITimespanStruct, type ITimestampStruct, Patch, PatchBuilder, Timespan} f import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; import * as str from '../util/diff/str'; import * as bin from '../util/diff/bin'; +import * as arr from '../util/diff/arr'; import type {Model} from '../json-crdt/model'; import {structHashCrdt} from '../json-hash/structHashCrdt'; import {structHash} from '../json-hash'; -import {strCnt} from '../util/strCnt'; export class DiffError extends Error { constructor(message: string = 'DIFF') { @@ -46,90 +46,46 @@ export class Diff { let txtDst = ''; const srcLen = src.length(); const dstLen = dst.length; - const inserts: [after: ITimestampStruct, views: unknown[]][] = []; - const trailingInserts: unknown[] = []; - const deletes: ITimespanStruct[] = []; src.children(node => { txtSrc += structHashCrdt(node) + '\n'; }); for (let i = 0; i < dstLen; i++) txtDst += structHash(dst[i]) + '\n'; txtSrc = txtSrc.slice(0, -1); txtDst = txtDst.slice(0, -1); - const patch = str.diff(txtSrc, txtDst); - console.log(txtSrc); - console.log(txtDst); - console.log(patch); - let srcIdx = 0; - let dstIdx = 0; - const patchLen = patch.length; - const lastOpIndex = patchLen - 1; - let inTheMiddleOfLine = false; - for (let i = 0; i <= lastOpIndex; i++) { - const isLastOp = i === lastOpIndex; - const op = patch[i]; - const [type, txt] = op; - if (!txt) continue; - let lineStartOffset = 0; - if (inTheMiddleOfLine) { - const index = txt.indexOf('\n'); - if (index < 0 && !isLastOp) continue; - inTheMiddleOfLine = false; - lineStartOffset = index + 1; - const view = dst[dstIdx]; - if (srcIdx >= srcLen) { - console.log('PUSH', op, view); - trailingInserts.push(view); - } else { - console.log('DIFF', op, srcIdx, dstIdx, view); + const patch = arr.diff(txtSrc, txtDst); + const inserts: [after: ITimestampStruct, views: unknown[]][] = []; + const deletes: ITimespanStruct[] = []; + arr.apply(patch, + (posSrc, posDst, len) => { + const views: unknown[] = dst.slice(posDst, posDst + len); + const after = posSrc ? src.find(posSrc - 1)! : src.id; + inserts.push([after, views]); + }, + (pos, len) => deletes.push(...src.findInterval(pos, len)!), + (posSrc, posDst, len) => { + for (let i = 0; i < len; i++) { + const srcIdx = posSrc + i; + const dstIdx = posDst + i; + const view = dst[dstIdx]; try { this.diffAny(src.getNode(srcIdx)!, view); } catch (error) { if (error instanceof DiffError) { - const id = src.find(srcIdx)!; - const span = new Timespan(id.sid, id.time, 1); - deletes.push(span); + const span = src.findInterval(srcIdx, 1)!; + deletes.push(...span); const after = srcIdx ? src.find(srcIdx - 1)! : src.id; inserts.push([after, [view]]); } else throw error; } - srcIdx++; - } - if (isLastOp && index < 0) break; - dstIdx++; - } - inTheMiddleOfLine = txt[txt.length - 1] !== '\n'; - const lineCount = strCnt('\n', txt, lineStartOffset) + (isLastOp ? 1 : 0); - if (!lineCount) continue; - if (type === str.PATCH_OP_TYPE.EQUAL) { - console.log('EQUAL', op); - srcIdx += lineCount; - dstIdx += lineCount; - } else if (type === str.PATCH_OP_TYPE.INSERT) { - const views: unknown[] = dst.slice(dstIdx, dstIdx + lineCount); - console.log('INSERT', op, views); - const after = srcIdx ? src.find(srcIdx - 1)! : src.id; - dstIdx += lineCount; - inserts.push([after, views]); - } else { // DELETE - console.log('DELETE', op); - for (let i = 0; i < lineCount; i++) { - const id = src.find(srcIdx)!; - const span = new Timespan(id.sid, id.time, 1); - deletes.push(span); - srcIdx++; } - } - } + }, + ); const builder = this.builder; const length = inserts.length; for (let i = 0; i < length; i++) { const [after, views] = inserts[i]; builder.insArr(src.id, after, views.map(view => builder.json(view))) } - if (trailingInserts.length) { - const after = srcLen ? src.find(srcLen - 1)! : src.id; - builder.insArr(src.id, after, trailingInserts.map(view => builder.json(view))); - } if (deletes.length) builder.del(src.id, deletes); } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index 2420e46b0d..fa08322744 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -317,7 +317,7 @@ describe('arr', () => { assertDiff(model, model.root, dst); }); - test.only('xxx', () => { + test('xxx', () => { const model = Model.create(); model.api.root([[1, 2, 3, 4, 5], 4, 5, 6, 7, 9, 0]); const dst: unknown[] = [[1, 2], 4, 77, 7, 'xyz']; diff --git a/src/util/diff/__tests__/arr.spec.ts b/src/util/diff/__tests__/arr.spec.ts index 1fdcc7e26e..58b05c0d2a 100644 --- a/src/util/diff/__tests__/arr.spec.ts +++ b/src/util/diff/__tests__/arr.spec.ts @@ -165,4 +165,30 @@ describe('diff', () => { arr.ARR_PATCH_OP_TYPE.EQUAL, 1, ]); }); + + test('diffs two consecutive elements', () => { + const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,3,455]\n[3]'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 2, + ]); + }); + + test('diffs middle element', () => { + const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,2,3,5]\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('diffs middle element - 2', () => { + const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,4,3,5]\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); }); diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index 925507c8cd..c650dc084a 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -79,3 +79,32 @@ export const diff = (txtSrc: string, txtDst: string): ArrPatch => { } return arrPatch; }; + +export const apply = ( + patch: ArrPatch, + onInsert: (posSrc: number, posDst: number, len: number) => void, + onDelete: (pos: number, len: number) => void, + onDiff: (posSrc: number, posDst: number, len: number) => void, +) => { + const length = patch.length; + let posSrc = 0; + let posDst = 0; + for (let i = 0; i < length; i += 2) { + const type = patch[i] as ARR_PATCH_OP_TYPE; + const len = patch[i + 1] as unknown as number; + if (type === ARR_PATCH_OP_TYPE.EQUAL) { + posSrc += len; + posDst += len; + } else if (type === ARR_PATCH_OP_TYPE.INSERT) { + onInsert(posSrc, posDst, len); + posDst += len; + } else if (type === ARR_PATCH_OP_TYPE.DELETE) { + onDelete(posSrc, len); + posSrc += len; + } else if (type === ARR_PATCH_OP_TYPE.DIFF) { + onDiff(posSrc, posDst, len); + posSrc += len; + posDst += len; + } + } +}; From 7d68bc14202e229da54e4b17836bbb3fe2e8fde0 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 5 May 2025 10:16:41 +0200 Subject: [PATCH 31/68] =?UTF-8?q?fix(json-crdt-diff):=20=F0=9F=90=9B=20imp?= =?UTF-8?q?rove=20array=20diff=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 6 +++--- src/json-crdt-diff/__tests__/Diff.spec.ts | 21 +++++++++++++------- src/util/diff/__tests__/arr.spec.ts | 17 ++++++++++++++++ src/util/diff/arr.ts | 24 ++++++++++++++++++++++- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 1b997d8cc6..e4d50f461c 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -44,7 +44,6 @@ export class Diff { protected diffArr(src: ArrNode, dst: unknown[]): void { let txtSrc = ''; let txtDst = ''; - const srcLen = src.length(); const dstLen = dst.length; src.children(node => { txtSrc += structHashCrdt(node) + '\n'; @@ -52,9 +51,10 @@ export class Diff { for (let i = 0; i < dstLen; i++) txtDst += structHash(dst[i]) + '\n'; txtSrc = txtSrc.slice(0, -1); txtDst = txtDst.slice(0, -1); - const patch = arr.diff(txtSrc, txtDst); const inserts: [after: ITimestampStruct, views: unknown[]][] = []; const deletes: ITimespanStruct[] = []; + const patch = arr.diff(txtSrc, txtDst); + // console.log(patch); arr.apply(patch, (posSrc, posDst, len) => { const views: unknown[] = dst.slice(posDst, posDst + len); @@ -82,7 +82,7 @@ export class Diff { ); const builder = this.builder; const length = inserts.length; - for (let i = 0; i < length; i++) { + for (let i = length - 1; i >= 0; i--) { const [after, views] = inserts[i]; builder.insArr(src.id, after, views.map(view => builder.json(view))) } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index fa08322744..22b1fd90b8 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -317,18 +317,25 @@ describe('arr', () => { assertDiff(model, model.root, dst); }); - test('xxx', () => { + test('diff first element, and various replacements later', () => { const model = Model.create(); model.api.root([[1, 2, 3, 4, 5], 4, 5, 6, 7, 9, 0]); const dst: unknown[] = [[1, 2], 4, 77, 7, 'xyz']; assertDiff(model, model.root, dst); }); - // test('nested changes', () => { - // const model = Model.create(); - // model.api.root([1, 2, [1, 2, 3, 4, 5, 6], 4, 5, 6, 7, 8, 9, 0]); - // const dst: unknown[] = ['2', [1, 2, 34, 5], 4, 77, 7, 8, 'xyz']; - // assertDiff(model, model.root, dst); - // }); + test('replaces both elements', () => { + const model = Model.create(); + model.api.root([9, 0]); + const dst: unknown[] = ['xyz']; + assertDiff(model, model.root, dst); + }); + + test('nested changes', () => { + const model = Model.create(); + model.api.root([1, 2, [1, 2, 3, 4, 5, 6], 4, 5, 6, 7, 8, 9, 0]); + const dst: unknown[] = ['2', [1, 2, 34, 5], 4, 77, 7, 8, 'xyz']; + assertDiff(model, model.root, dst); + }); }); }); diff --git a/src/util/diff/__tests__/arr.spec.ts b/src/util/diff/__tests__/arr.spec.ts index 58b05c0d2a..30a9e0b7fa 100644 --- a/src/util/diff/__tests__/arr.spec.ts +++ b/src/util/diff/__tests__/arr.spec.ts @@ -132,6 +132,14 @@ describe('delete', () => { arr.ARR_PATCH_OP_TYPE.INSERT, 1, ]); }); + + test('deletes both elements and replaces by one', () => { + const patch = arr.diff('0\n1', 'xyz'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + ]); + }); }); describe('diff', () => { @@ -191,4 +199,13 @@ describe('diff', () => { arr.ARR_PATCH_OP_TYPE.EQUAL, 1, ]); }); + + test('insert first element, diff second', () => { + const patch = arr.diff('[2]', '1\n2\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + ]); + }); }); diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index c650dc084a..9125b2e819 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -50,7 +50,8 @@ export const diff = (txtSrc: string, txtDst: string): ArrPatch => { let lineStartOffset = 0; if (partial !== PARTIAL_TYPE.NONE) { const index = txt.indexOf("\n"); - if (index === 0) { + const flushPartial = txt.indexOf("\n") === 0 || (isLastOp && partial === ARR_PATCH_OP_TYPE.DELETE && type === ARR_PATCH_OP_TYPE.INSERT); + if (flushPartial) { lineStartOffset = 1; push(partial, 1); partial = PARTIAL_TYPE.NONE; @@ -59,6 +60,11 @@ export const diff = (txtSrc: string, txtDst: string): ArrPatch => { partial = ARR_PATCH_OP_TYPE.DIFF; continue; } + if (partial === ARR_PATCH_OP_TYPE.DELETE && type === ARR_PATCH_OP_TYPE.INSERT) { + const lineCount = strCnt("\n", txt, lineStartOffset) + (isLastOp ? 1 : 0); + push(ARR_PATCH_OP_TYPE.INSERT, lineCount); + continue; + } push(ARR_PATCH_OP_TYPE.DIFF, 1); if (index < 0) break; lineStartOffset = index + 1; @@ -80,6 +86,22 @@ export const diff = (txtSrc: string, txtDst: string): ArrPatch => { return arrPatch; }; +/** + * Applies the array patch to the source array. The source array is assumed to + * be materialized after the patch application, i.e., the positions in the + * patch are relative to the source array, they do not shift during the + * application. + * + * @param patch Array patch to apply. + * @param onInsert Callback for insert operations. `posSrc` is the position + * between the source elements, starting from 0. `posDst` is the destination + * element position, starting from 0. + * @param onDelete Callback for delete operations. `pos` is the position of + * the source element, starting from 0. + * @param onDiff Callback for diff operations. `posSrc` and `posDst` are the + * positions of the source and destination elements, respectively, starting + * from 0. + */ export const apply = ( patch: ArrPatch, onInsert: (posSrc: number, posDst: number, len: number) => void, From c6fbe17d3e71b3b78c383eb252b2e3f685e50062 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 5 May 2025 10:52:53 +0200 Subject: [PATCH 32/68] =?UTF-8?q?fix(json-crdt-diff):=20=F0=9F=90=9B=20cor?= =?UTF-8?q?rect=20issues=20found=20by=20fuzzer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 8 ++++-- .../__tests__/Diff-fuzzing.spec.ts | 28 +++++++++++++++++++ src/json-crdt-diff/__tests__/Diff.spec.ts | 14 ++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index e4d50f461c..67892d1aa6 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -58,7 +58,8 @@ export class Diff { arr.apply(patch, (posSrc, posDst, len) => { const views: unknown[] = dst.slice(posDst, posDst + len); - const after = posSrc ? src.find(posSrc - 1)! : src.id; + const after = posSrc ? src.find(posSrc - 1) : src.id; + if (!after) throw new DiffError(); inserts.push([after, views]); }, (pos, len) => deletes.push(...src.findInterval(pos, len)!), @@ -73,7 +74,8 @@ export class Diff { if (error instanceof DiffError) { const span = src.findInterval(srcIdx, 1)!; deletes.push(...span); - const after = srcIdx ? src.find(srcIdx - 1)! : src.id; + const after = srcIdx ? src.find(srcIdx - 1) : src.id; + if (!after) throw new DiffError(); inserts.push([after, [view]]); } else throw error; } @@ -172,7 +174,7 @@ export class Diff { if (typeof dst !== 'string') throw new DiffError(); this.diffStr(src, dst); } else if (src instanceof ObjNode) { - if (!dst || typeof dst !== 'object') throw new DiffError(); + if (!dst || typeof dst !== 'object' || Array.isArray(dst)) throw new DiffError(); this.diffObj(src, dst as Record); } else if (src instanceof ValNode) { this.diffVal(src, dst); diff --git a/src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts b/src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts new file mode 100644 index 0000000000..ca55ab6a0f --- /dev/null +++ b/src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts @@ -0,0 +1,28 @@ +import {Diff} from '../Diff'; +import {Model} from '../../json-crdt/model'; +import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; + +const assertDiff = (src: unknown, dst: unknown) => { + const model = Model.create(); + model.api.root(src); + const patch1 = new Diff(model).diff(model.root, dst); + // console.log(model + ''); + // console.log(patch1 + ''); + model.applyPatch(patch1); + // console.log(model + ''); + expect(model.view()).toEqual(dst); + const patch2 = new Diff(model).diff(model.root, dst); + expect(patch2.ops.length).toBe(0); +}; + +const iterations = 1000; + +test('from random JSON to random JSON', () => { + for (let i = 0; i < iterations; i++) { + const src = RandomJson.generate(); + const dst = RandomJson.generate(); + // console.log(src); + // console.log(dst); + assertDiff(src, dst); + } +}); diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index 22b1fd90b8..86f87c0de6 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -339,3 +339,17 @@ describe('arr', () => { }); }); }); + +describe('scenarios', () => { + test('link element annotation', () => { + const model = Model.create(s.obj({ + href: s.str('http://example.com/page?tab=1'), + title: s.str('example'), + })); + const dst = { + href: 'https://example.com/page-2', + title: 'Example page', + }; + assertDiff(model, model.root, dst); + }); +}); From 0427a1a780c79d99f55f6c672eb436760e9a2d95 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 5 May 2025 13:38:11 +0200 Subject: [PATCH 33/68] =?UTF-8?q?feat(json-patch-diff):=20=F0=9F=8E=B8=20s?= =?UTF-8?q?etup=20JSON=20Patch=20diff=20algorithm=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/json-patch-diff/Diff.ts | 75 ++++++++++++++++++++++ src/json-patch-diff/__tests__/Diff.spec.ts | 42 ++++++++++++ src/util/diff/str.ts | 4 +- 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/json-patch-diff/Diff.ts create mode 100644 src/json-patch-diff/__tests__/Diff.spec.ts diff --git a/package.json b/package.json index 60ea090042..b34a00a152 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "json-ot", "json-patch-ot", "json-patch", + "json-patch-diff", "json-stable", "json-text", "json-walk", diff --git a/src/json-patch-diff/Diff.ts b/src/json-patch-diff/Diff.ts new file mode 100644 index 0000000000..51b3e55bfb --- /dev/null +++ b/src/json-patch-diff/Diff.ts @@ -0,0 +1,75 @@ +// import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; +import type {Operation} from '../json-patch/codec/json/types'; +import * as str from '../util/diff/str'; +import * as bin from '../util/diff/bin'; +import * as arr from '../util/diff/arr'; +import {structHash} from '../json-hash'; + +export class DiffError extends Error { + constructor(message: string = 'DIFF') { + super(message); + } +} + +export class Diff { + protected patch: Operation[] = []; + + protected diffStr(path: string, src: string, dst: string): void { + if (src === dst) return; + const patch = this.patch; + str.apply(str.diff(src, dst), src.length, + (pos, str) => patch.push({op: 'str_ins', path, pos, str}), + (pos, len, str) => patch.push({op: 'str_del', path, pos, len, str}), + ); + } + + protected diffBin(src: Uint8Array, dst: Uint8Array): void { + throw new Error('Not implemented'); + } + + protected diffArr(src: unknown[], dst: unknown[]): void { + throw new Error('Not implemented'); + } + + protected diffObj(src: Record, dst: Record): void { + throw new Error('Not implemented'); + } + + public diffAny(path: string, src: unknown, dst: unknown): void { + switch (typeof src) { + case 'string': { + if (typeof dst !== 'string') throw new DiffError(); + this.diffStr(path, src, dst); + break; + } + default: throw new DiffError(); + } + // if (src instanceof ConNode) { + // const val = src.val; + // if ((val !== dst) && !deepEqual(src.val, dst)) throw new DiffError(); + // } else if (src instanceof StrNode) { + // + // } else if (src instanceof ObjNode) { + // if (!dst || typeof dst !== 'object' || Array.isArray(dst)) throw new DiffError(); + // this.diffObj(src, dst as Record); + // } else if (src instanceof ValNode) { + // this.diffVal(src, dst); + // } else if (src instanceof ArrNode) { + // if (!Array.isArray(dst)) throw new DiffError(); + // this.diffArr(src, dst as unknown[]); + // } else if (src instanceof VecNode) { + // if (!Array.isArray(dst)) throw new DiffError(); + // this.diffVec(src, dst as unknown[]); + // } else if (src instanceof BinNode) { + // if (!(dst instanceof Uint8Array)) throw new DiffError(); + // this.diffBin(src, dst); + // } else { + // + // } + } + + public diff(path: string, src: unknown, dst: unknown): Operation[] { + this.diffAny(path, src, dst); + return this.patch; + } +} diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/Diff.spec.ts new file mode 100644 index 0000000000..0b433167c7 --- /dev/null +++ b/src/json-patch-diff/__tests__/Diff.spec.ts @@ -0,0 +1,42 @@ +import {Diff} from '../Diff'; +import {applyPatch} from '../../json-patch'; + +const assertDiff = (src: unknown, dst: unknown) => { + const srcNested = {src}; + const patch1 = new Diff().diff('/src', src, dst); + const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); + // console.log(src); + // console.log(dst); + // console.log(patch1); + // console.log(res); + expect(res).toEqual({src: dst}); + const patch2 = new Diff().diff('/src', (res as any)['src'], dst); + // console.log(patch2); + expect(patch2.length).toBe(0); +}; + +describe('str', () => { + test('insert', () => { + const src = 'hello world'; + const dst = 'hello world!'; + assertDiff(src, dst); + }); + + test('delete', () => { + const src = 'hello worldz'; + const dst = 'hello world'; + assertDiff(src, dst); + }); + + test('replace', () => { + const src = 'hello world'; + const dst = 'Hello world'; + assertDiff(src, dst); + }); + + test('various edits', () => { + const src = 'helloo vorldz!'; + const dst = 'Hello, world, buddy!'; + assertDiff(src, dst); + }); +}); diff --git a/src/util/diff/str.ts b/src/util/diff/str.ts index 88e2f31d0e..3da606cba4 100644 --- a/src/util/diff/str.ts +++ b/src/util/diff/str.ts @@ -548,7 +548,7 @@ export const invert = (patch: Patch): Patch => patch.map(invertOp); * @param onInsert Callback for insert operations. * @param onDelete Callback for delete operations. */ -export const apply = (patch: Patch, srcLen: number, onInsert: (pos: number, str: string) => void, onDelete: (pos: number, len: number) => void) => { +export const apply = (patch: Patch, srcLen: number, onInsert: (pos: number, str: string) => void, onDelete: (pos: number, len: number, str: string) => void) => { const length = patch.length; let pos = srcLen; for (let i = length - 1; i >= 0; i--) { @@ -558,7 +558,7 @@ export const apply = (patch: Patch, srcLen: number, onInsert: (pos: number, str: else { const len = str.length; pos -= len; - onDelete(pos, len); + onDelete(pos, len, str); } } }; From d7066d383d22678731275347194af8c2e1261f58 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 5 May 2025 14:23:37 +0200 Subject: [PATCH 34/68] =?UTF-8?q?feat(json-patch-diff):=20=F0=9F=8E=B8=20i?= =?UTF-8?q?mplement=20diff=20for=20object=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/Diff.ts | 40 ++++++++++++++-- src/json-patch-diff/__tests__/Diff.spec.ts | 55 ++++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/json-patch-diff/Diff.ts b/src/json-patch-diff/Diff.ts index 51b3e55bfb..d8f6ec26ea 100644 --- a/src/json-patch-diff/Diff.ts +++ b/src/json-patch-diff/Diff.ts @@ -1,5 +1,6 @@ // import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import type {Operation} from '../json-patch/codec/json/types'; +import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import * as str from '../util/diff/str'; import * as bin from '../util/diff/bin'; import * as arr from '../util/diff/arr'; @@ -14,6 +15,11 @@ export class DiffError extends Error { export class Diff { protected patch: Operation[] = []; + protected diffVal(path: string, src: unknown, dst: unknown): void { + if (deepEqual(src, dst)) return; + this.patch.push({op: 'add', path, value: dst}) + } + protected diffStr(path: string, src: string, dst: string): void { if (src === dst) return; const patch = this.patch; @@ -23,15 +29,29 @@ export class Diff { ); } - protected diffBin(src: Uint8Array, dst: Uint8Array): void { + protected diffBin(path: string, src: Uint8Array, dst: Uint8Array): void { throw new Error('Not implemented'); } - protected diffArr(src: unknown[], dst: unknown[]): void { - throw new Error('Not implemented'); + protected diffObj(path: string, src: Record, dst: Record): void { + const patch = this.patch; + for (const key in src) { + if (key in dst) { + const val1 = src[key]; + const val2 = dst[key]; + if (val1 === val2) continue; + this.diffAny(path + '/' + key, val1, val2); + } else { + patch.push({op: 'remove', path: path + '/' + key}); + } + } + for (const key in dst) { + if (key in src) continue; + patch.push({op: 'add', path: path + '/' + key, value: dst[key]}); + } } - protected diffObj(src: Record, dst: Record): void { + protected diffArr(path: string, src: unknown[], dst: unknown[]): void { throw new Error('Not implemented'); } @@ -42,6 +62,18 @@ export class Diff { this.diffStr(path, src, dst); break; } + case 'number': + case 'boolean': + case 'bigint': { + this.diffVal(path, src, dst); + break; + } + case 'object': { + if (!src || !dst || typeof dst !== 'object') return this.diffVal(path, src, dst); + if (Array.isArray(src) && Array.isArray(dst)) return this.diffArr(path, src, dst); + this.diffObj(path, src as Record, dst as Record); + break; + } default: throw new DiffError(); } // if (src instanceof ConNode) { diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/Diff.spec.ts index 0b433167c7..e62d0710fa 100644 --- a/src/json-patch-diff/__tests__/Diff.spec.ts +++ b/src/json-patch-diff/__tests__/Diff.spec.ts @@ -40,3 +40,58 @@ describe('str', () => { assertDiff(src, dst); }); }); + +describe('num', () => { + test('insert', () => { + const src = 1; + const dst = 2; + assertDiff(src, dst); + }); +}); + +describe('obj', () => { + test('can remove single key', () => { + const src = {foo: 1}; + const dst = {}; + assertDiff(src, dst); + }); + + test('replace key', () => { + const src = {foo: 1}; + const dst = {foo: 2}; + assertDiff(src, dst); + }); + + test('diff inner string', () => { + const src = {foo: 'hello'}; + const dst = {foo: 'hello!'}; + assertDiff(src, dst); + }); + + test('can insert new key', () => { + const src = {}; + const dst = {foo: 'hello!'}; + assertDiff(src, dst); + }); + + test('can diff nested objects', () => { + const src = { + id: 1, + name: 'hello', + nested: { + id: 2, + name: 'world', + description: 'blablabla' + }, + }; + const dst = { + id: 3, + name: 'hello!', + nested: { + id: 2, + description: 'Please dont use "blablabla"' + }, + }; + assertDiff(src, dst); + }); +}); From 0ebed6ec7551ff5bfb2300c4feed96f8d03a187f Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 5 May 2025 15:39:44 +0200 Subject: [PATCH 35/68] =?UTF-8?q?chore:=20=F0=9F=A4=96=20bump=20utils=20li?= =?UTF-8?q?brary=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/json-patch/codegen/ops/test.ts | 4 ++-- yarn.lock | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b34a00a152..7bcf75be71 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@jsonjoy.com/json-pack": "^1.1.0", "@jsonjoy.com/json-pointer": "^1.0.0", "@jsonjoy.com/json-type": "^1.0.0", - "@jsonjoy.com/util": "^1.4.0", + "@jsonjoy.com/util": "^1.6.0", "arg": "^5.0.2", "hyperdyperid": "^1.2.0", "nano-css": "^5.6.2", diff --git a/src/json-patch/codegen/ops/test.ts b/src/json-patch/codegen/ops/test.ts index 4877640e2b..d6ae1c43fe 100644 --- a/src/json-patch/codegen/ops/test.ts +++ b/src/json-patch/codegen/ops/test.ts @@ -1,6 +1,6 @@ import type {OpTest} from '../../op'; import {$$find} from '@jsonjoy.com/json-pointer/lib/codegen/find'; -import {$$deepEqual} from '@jsonjoy.com/util/lib/json-equal/$$deepEqual'; +import {deepEqualCodegen} from '@jsonjoy.com/util/lib/json-equal/deepEqualCodegen'; import {type JavaScriptLinked, compileClosure, type JavaScript} from '@jsonjoy.com/util/lib/codegen'; import {predicateOpWrapper} from '../util'; import type {ApplyFn} from '../types'; @@ -9,7 +9,7 @@ export const $$test = (op: OpTest): JavaScriptLinked => { const js = /* js */ ` (function(wrapper){ var find = ${$$find(op.path)}; - var deepEqual = ${$$deepEqual(op.value)}; + var deepEqual = ${deepEqualCodegen(op.value)}; return wrapper(function(doc){ var val = find(doc); if (val === undefined) return ${op.not ? 'true' : 'false'}; diff --git a/yarn.lock b/yarn.lock index 12ae85d662..d19a8cf8ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -678,6 +678,11 @@ resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.5.0.tgz#6008e35b9d9d8ee27bc4bfaa70c8cbf33a537b4c" integrity sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA== +"@jsonjoy.com/util@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.6.0.tgz#23991b2fe12cb3a006573d9dc97c768d3ed2c9f1" + integrity sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.5" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" @@ -2165,7 +2170,6 @@ dunder-proto@^1.0.1: "editing-traces@https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b": version "0.0.0" - uid "6494020428530a6e382378b98d1d7e31334e2d7b" resolved "https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b" ee-first@1.1.1: @@ -3514,7 +3518,6 @@ jsesc@^3.0.2: "json-crdt-traces@https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d": version "0.0.1" - uid ec825401dc05cbb74b9e0b3c4d6527399f54d54d resolved "https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d" json-logic-js@^2.0.2: From 9ae5c9090934ca04b544f6c7357234f829d1837f Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 5 May 2025 19:00:16 +0200 Subject: [PATCH 36/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20im?= =?UTF-8?q?prove=20binary=20node=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 6 ++++-- src/json-crdt-diff/__tests__/Diff.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 67892d1aa6..24324a7f52 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -1,4 +1,5 @@ import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; +import {cmpUint8Array} from '@jsonjoy.com/util/lib/buffers/cmpUint8Array'; import {ITimespanStruct, type ITimestampStruct, Patch, PatchBuilder, Timespan} from '../json-crdt-patch'; import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; import * as str from '../util/diff/str'; @@ -33,7 +34,7 @@ export class Diff { protected diffBin(src: BinNode, dst: Uint8Array): void { const view = src.view(); - if (view === dst) return; + if (cmpUint8Array(view, dst)) return; const builder = this.builder; bin.apply(bin.diff(view, dst), view.length, (pos, txt) => builder.insBin(src.id, !pos ? src.id : src.find(pos - 1)!, txt), @@ -116,7 +117,8 @@ export class Diff { } } } - inserts.push([key, builder.constOrJson(dstValue)]); + inserts.push([key, src.get(key) instanceof ConNode + ? builder.const(dstValue) : builder.constOrJson(dstValue)]); } if (inserts.length) builder.insObj(src.id, inserts); } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index 86f87c0de6..11662acdd6 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -16,6 +16,18 @@ const assertDiff = (model: Model, src: JsonNode, dst: unknown) => { expect(patch2.ops.length).toBe(0); }; +describe('con', () => { + test('binary in "con"', () => { + const model = Model.create(s.obj({ + field: s.con(new Uint8Array([1, 2, 3])), + })); + const dst = { + field: new Uint8Array([1, 2, 3, 4]), + }; + assertDiff(model, model.root, dst); + }); +}); + describe('str', () => { test('insert', () => { const model = Model.create(); @@ -87,6 +99,16 @@ describe('bin', () => { expect(str.view()).toEqual(dst); }); + test('creates empty patch for equal values', () => { + const model = Model.create(); + const bin = b(1, 2, 3, 4, 5); + model.api.root({bin}); + const str = model.api.bin(['bin']); + const dst = b(1, 2, 3, 4, 5); + const patch = new Diff(model).diff(str.node, dst); + expect(patch.ops.length).toBe(0); + }); + test('delete', () => { const model = Model.create(); const src = b(1, 2, 3, 4, 5); From a6a28ef54303bfe6aff96e18209266c0cf729d56 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 5 May 2025 22:11:19 +0200 Subject: [PATCH 37/68] =?UTF-8?q?feat(json-patch-diff):=20=F0=9F=8E=B8=20a?= =?UTF-8?q?dd=20array=20node=20diff=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/Diff.ts | 70 +++++++++-------- src/json-patch-diff/__tests__/Diff.spec.ts | 87 +++++++++++++++++++++- src/util/diff/arr.ts | 5 +- 3 files changed, 128 insertions(+), 34 deletions(-) diff --git a/src/json-patch-diff/Diff.ts b/src/json-patch-diff/Diff.ts index d8f6ec26ea..bf0c8f4b83 100644 --- a/src/json-patch-diff/Diff.ts +++ b/src/json-patch-diff/Diff.ts @@ -1,10 +1,8 @@ -// import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; -import type {Operation} from '../json-patch/codec/json/types'; import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import * as str from '../util/diff/str'; -import * as bin from '../util/diff/bin'; import * as arr from '../util/diff/arr'; import {structHash} from '../json-hash'; +import type {Operation} from '../json-patch/codec/json/types'; export class DiffError extends Error { constructor(message: string = 'DIFF') { @@ -17,7 +15,7 @@ export class Diff { protected diffVal(path: string, src: unknown, dst: unknown): void { if (deepEqual(src, dst)) return; - this.patch.push({op: 'add', path, value: dst}) + this.patch.push({op: 'replace', path, value: dst}) } protected diffStr(path: string, src: string, dst: string): void { @@ -52,7 +50,37 @@ export class Diff { } protected diffArr(path: string, src: unknown[], dst: unknown[]): void { - throw new Error('Not implemented'); + let txtSrc = ''; + let txtDst = ''; + const srcLen = src.length; + const dstLen = dst.length; + for (let i = 0; i < srcLen; i++) txtSrc += structHash(src[i]) + '\n'; + for (let i = 0; i < dstLen; i++) txtDst += structHash(dst[i]) + '\n'; + txtSrc = txtSrc.slice(0, -1); + txtDst = txtDst.slice(0, -1); + const pfx = path + '/'; + let srcShift = 0; + const patch = this.patch; + arr.apply(arr.diff(txtSrc, txtDst), + (posSrc, posDst, len) => { + for (let i = 0; i < len; i++) { + patch.push({op: 'add', path: pfx + (posSrc + srcShift + i), value: dst[posDst + i]}); + } + }, + (pos, len) => { + for (let i = 0; i < len; i++) { + patch.push({op: 'remove', path: pfx + (pos + srcShift + i)}); + srcShift--; + } + }, + (posSrc, posDst, len) => { + for (let i = 0; i < len; i++) { + const pos = posSrc + srcShift + i; + const value = dst[posDst + i]; + this.diff(pfx + pos, src[pos], value); + } + }, + ); } public diffAny(path: string, src: unknown, dst: unknown): void { @@ -70,34 +98,18 @@ export class Diff { } case 'object': { if (!src || !dst || typeof dst !== 'object') return this.diffVal(path, src, dst); - if (Array.isArray(src) && Array.isArray(dst)) return this.diffArr(path, src, dst); + if (Array.isArray(src)) { + if (Array.isArray(dst)) this.diffArr(path, src, dst); + else this.diffVal(path, src, dst); + return; + } this.diffObj(path, src as Record, dst as Record); break; } - default: throw new DiffError(); + default: + this.diffVal(path, src, dst); + break; } - // if (src instanceof ConNode) { - // const val = src.val; - // if ((val !== dst) && !deepEqual(src.val, dst)) throw new DiffError(); - // } else if (src instanceof StrNode) { - // - // } else if (src instanceof ObjNode) { - // if (!dst || typeof dst !== 'object' || Array.isArray(dst)) throw new DiffError(); - // this.diffObj(src, dst as Record); - // } else if (src instanceof ValNode) { - // this.diffVal(src, dst); - // } else if (src instanceof ArrNode) { - // if (!Array.isArray(dst)) throw new DiffError(); - // this.diffArr(src, dst as unknown[]); - // } else if (src instanceof VecNode) { - // if (!Array.isArray(dst)) throw new DiffError(); - // this.diffVec(src, dst as unknown[]); - // } else if (src instanceof BinNode) { - // if (!(dst instanceof Uint8Array)) throw new DiffError(); - // this.diffBin(src, dst); - // } else { - // - // } } public diff(path: string, src: unknown, dst: unknown): Operation[] { diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/Diff.spec.ts index e62d0710fa..9e6239b76f 100644 --- a/src/json-patch-diff/__tests__/Diff.spec.ts +++ b/src/json-patch-diff/__tests__/Diff.spec.ts @@ -4,10 +4,10 @@ import {applyPatch} from '../../json-patch'; const assertDiff = (src: unknown, dst: unknown) => { const srcNested = {src}; const patch1 = new Diff().diff('/src', src, dst); - const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); // console.log(src); - // console.log(dst); // console.log(patch1); + // console.log(dst); + const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); // console.log(res); expect(res).toEqual({src: dst}); const patch2 = new Diff().diff('/src', (res as any)['src'], dst); @@ -74,6 +74,27 @@ describe('obj', () => { assertDiff(src, dst); }); + test('can change all primitive types', () => { + const src = { + obj: { + nil: null, + bool: true, + num: 1, + str: 'hello', + }, + }; + const dst = { + obj: { + nil: 1, + bool: false, + num: null, + num2: 2, + str: 'hello!', + }, + }; + assertDiff(src, dst); + }); + test('can diff nested objects', () => { const src = { id: 1, @@ -95,3 +116,65 @@ describe('obj', () => { assertDiff(src, dst); }); }); + +describe('arr', () => { + test('can add element to an empty array', () => { + const src: unknown[] = []; + const dst: unknown[] = [1]; + assertDiff(src, dst); + }); + + test('can add two elements to an empty array', () => { + const src: unknown[] = []; + const dst: unknown[] = [0, 1]; + assertDiff(src, dst); + }); + + test('can add three elements to an empty array', () => { + const src: unknown[] = []; + const dst: unknown[] = [0, 1, 2]; + assertDiff(src, dst); + }); + + test('can add multiple elements to an empty array', () => { + const src: unknown[] = []; + const dst: unknown[] = [0, 1, 2, 3, 4, 5]; + assertDiff(src, dst); + }); + + test('can remove and add element', () => { + const src: unknown[] = [0]; + const dst: unknown[] = [1]; + assertDiff(src, dst); + }); + + test('can remove and add two elements', () => { + const src: unknown[] = [0]; + const dst: unknown[] = [1, 2]; + assertDiff(src, dst); + }); + + test('can overwrite the only element', () => { + const src: unknown[] = [0]; + const dst: unknown[] = [2]; + assertDiff(src, dst); + }); + + test('can overwrite second element', () => { + const src: unknown[] = [1, 0]; + const dst: unknown[] = [1, 2]; + assertDiff(src, dst); + }); + + test('can overwrite two elements', () => { + const src: unknown[] = [1, 2, 3, 4]; + const dst: unknown[] = [1, 'x', 'x', 4]; + assertDiff(src, dst); + }); + + test('can overwrite three elements, and add two more', () => { + const src: unknown[] = [1, 2, 3, 4]; + const dst: unknown[] = ['x', 'x', 'x', 4, true, false]; + assertDiff(src, dst); + }); +}); diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index 9125b2e819..38684f820e 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -5,14 +5,13 @@ export const enum ARR_PATCH_OP_TYPE { DELETE = str.PATCH_OP_TYPE.DELETE, EQUAL = str.PATCH_OP_TYPE.EQUAL, INSERT = str.PATCH_OP_TYPE.INSERT, - PUSH = 2, - DIFF = 3, + DIFF = 2, } export type ArrPatch = number[]; const enum PARTIAL_TYPE { - NONE = 24, + NONE = 9, } export const diff = (txtSrc: string, txtDst: string): ArrPatch => { From b75cbfe6906fadd9ebe237712e42358a639a5edf Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 6 May 2025 00:46:03 +0200 Subject: [PATCH 38/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20implement=20?= =?UTF-8?q?line=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/arr.spec.ts | 370 ++++++++++++++++------------ src/util/diff/arr.ts | 29 +++ 2 files changed, 236 insertions(+), 163 deletions(-) diff --git a/src/util/diff/__tests__/arr.spec.ts b/src/util/diff/__tests__/arr.spec.ts index 30a9e0b7fa..6802f2072d 100644 --- a/src/util/diff/__tests__/arr.spec.ts +++ b/src/util/diff/__tests__/arr.spec.ts @@ -1,211 +1,255 @@ import * as arr from '../arr'; -test('insert into empty list', () => { - const patch = arr.diff('', '1'); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.INSERT, 1]); -}); - -test('both empty', () => { - const patch = arr.diff('', ''); - expect(patch).toEqual([]); -}); - -test('keep one', () => { - const patch = arr.diff('1', '1'); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.EQUAL, 1]); -}); - -test('keep two', () => { - const patch = arr.diff('1\n1', '1\n1'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); -}); - -test('keep three', () => { - const patch = arr.diff('1\n1\n2', '1\n1\n2'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 3, - ]); -}); - -test('keep two, delete one', () => { - const patch = arr.diff('1\n1\n2', '1\n1'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - ]); -}); - -test('keep two, delete in the middle', () => { - const patch = arr.diff('1\n2\n3', '1\n3'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); -}); - -test('keep two, delete the first one', () => { - const patch = arr.diff('1\n2\n3', '2\n3'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); -}); - -describe('delete', () => { - test('delete the only element', () => { - const patch = arr.diff('1', ''); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 1]); +describe('matchLines()', () => { + test('empty', () => { + const matches = arr.matchLines([], []); + expect(matches).toEqual([]); }); - test('delete the only two element', () => { - const patch = arr.diff('1\n{}', ''); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 2]); + test('empty - 2', () => { + const matches = arr.matchLines(['1'], []); + expect(matches).toEqual([]); }); - test('delete two and three in a row', () => { - const patch = arr.diff('1\n2\n3\n4\n5\n6', '3'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 3, - ]); + test('empty - 3', () => { + const matches = arr.matchLines([], ['1']); + expect(matches).toEqual([]); }); - test('delete the first one', () => { - const patch = arr.diff('1\n2\n3', '2\n3'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); + test('single element', () => { + const matches = arr.matchLines(['1'], ['1']); + expect(matches).toEqual([0, 0]); }); - test('delete the middle element', () => { - const patch = arr.diff('1\n2\n3', '1\n3'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); + test('two elements', () => { + const matches = arr.matchLines(['1', '2'], ['1', '2']); + expect(matches).toEqual([0, 0, 1, 1]); }); - test('delete the last element', () => { - const patch = arr.diff('1\n2\n3', '1\n2'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - ]); + test('two elements with one in the middle', () => { + const matches = arr.matchLines(['1', '2'], ['1', '3', '2']); + expect(matches).toEqual([0, 0, 1, 2]); }); - test('delete two first elements', () => { - const patch = arr.diff('1\n2\n3\n4', '3\n4'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); + test('two elements with one in the middle - 2', () => { + const matches = arr.matchLines(['1', '3', '2'], ['1', '2']); + expect(matches).toEqual([0, 0, 2, 1]); }); - test('preserve one and delete one', () => { - const patch = arr.diff('1\n2', '1'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - ]); - }); - - test('preserve one and delete one (reverse)', () => { - const patch = arr.diff('1\n2', '2'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); - }); - - test('various deletes and inserts', () => { - const patch = arr.diff('1\n2\n[3]\n3\n5\n{a:4}\n5\n"6"', '1\n2\n[3]\n5\n{a:4}\n5\n"6"\n6'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 3, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 4, - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - ]); + test('complex case', () => { + const matches = arr.matchLines(['1', '2', '3', '4', '5', '6', '7'], ['0', '1', '2', '5', 'x', 'y', 'z', 'a', 'b', '7', '8']); + expect(matches).toEqual([0, 1, 1, 2, 4, 3, 6, 9]); }); +}); - test('deletes both elements and replaces by one', () => { - const patch = arr.diff('0\n1', 'xyz'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - ]); +describe('diff()', () => { + test('insert into empty list', () => { + const patch = arr.diff('', '1'); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.INSERT, 1]); }); -}); -describe('diff', () => { - test('diffs partially matching single element', () => { - const patch = arr.diff('[]', '[1]'); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DIFF, 1]); + test('both empty', () => { + const patch = arr.diff('', ''); + expect(patch).toEqual([]); }); - test('diffs second element', () => { - const patch = arr.diff('1\n[]', '1\n[1]'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - ]); + test('keep one', () => { + const patch = arr.diff('1', '1'); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.EQUAL, 1]); }); - test('diffs middle element', () => { - const patch = arr.diff('1\n2\n3', '1\n[2]\n3'); + test('keep two', () => { + const patch = arr.diff('1\n1', '1\n1'); expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, ]); }); - test('diffs middle element - 2', () => { - const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,3,455]\n3'); + test('keep three', () => { + const patch = arr.diff('1\n1\n2', '1\n1\n2'); expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 3, ]); }); - test('diffs two consecutive elements', () => { - const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,3,455]\n[3]'); + test('keep two, delete one', () => { + const patch = arr.diff('1\n1\n2', '1\n1'); expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 2, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, ]); }); - test('diffs middle element', () => { - const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,2,3,5]\n3'); + test('keep two, delete in the middle', () => { + const patch = arr.diff('1\n2\n3', '1\n3'); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, arr.ARR_PATCH_OP_TYPE.EQUAL, 1, ]); }); - test('diffs middle element - 2', () => { - const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,4,3,5]\n3'); + test('keep two, delete the first one', () => { + const patch = arr.diff('1\n2\n3', '2\n3'); expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, ]); }); - test('insert first element, diff second', () => { - const patch = arr.diff('[2]', '1\n2\n3'); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - ]); + describe('delete', () => { + test('delete the only element', () => { + const patch = arr.diff('1', ''); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 1]); + }); + + test('delete the only two element', () => { + const patch = arr.diff('1\n{}', ''); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 2]); + }); + + test('delete two and three in a row', () => { + const patch = arr.diff('1\n2\n3\n4\n5\n6', '3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 3, + ]); + }); + + test('delete the first one', () => { + const patch = arr.diff('1\n2\n3', '2\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + ]); + }); + + test('delete the middle element', () => { + const patch = arr.diff('1\n2\n3', '1\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('delete the last element', () => { + const patch = arr.diff('1\n2\n3', '1\n2'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + ]); + }); + + test('delete two first elements', () => { + const patch = arr.diff('1\n2\n3\n4', '3\n4'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.EQUAL, 2, + ]); + }); + + test('preserve one and delete one', () => { + const patch = arr.diff('1\n2', '1'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + ]); + }); + + test('preserve one and delete one (reverse)', () => { + const patch = arr.diff('1\n2', '2'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('various deletes and inserts', () => { + const patch = arr.diff('1\n2\n[3]\n3\n5\n{a:4}\n5\n"6"', '1\n2\n[3]\n5\n{a:4}\n5\n"6"\n6'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 3, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 4, + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + ]); + }); + + test('deletes both elements and replaces by one', () => { + const patch = arr.diff('0\n1', 'xyz'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + ]); + }); + }); + + describe('diff', () => { + test('diffs partially matching single element', () => { + const patch = arr.diff('[]', '[1]'); + expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DIFF, 1]); + }); + + test('diffs second element', () => { + const patch = arr.diff('1\n[]', '1\n[1]'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + ]); + }); + + test('diffs middle element', () => { + const patch = arr.diff('1\n2\n3', '1\n[2]\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('diffs middle element - 2', () => { + const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,3,455]\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('diffs two consecutive elements', () => { + const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,3,455]\n[3]'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 2, + ]); + }); + + test('diffs middle element', () => { + const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,2,3,5]\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('diffs middle element - 2', () => { + const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,4,3,5]\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('insert first element, diff second', () => { + const patch = arr.diff('[2]', '1\n2\n3'); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + arr.ARR_PATCH_OP_TYPE.DIFF, 1, + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + ]); + }); }); }); diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index 38684f820e..444119e5d1 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -129,3 +129,32 @@ export const apply = ( } } }; + +/** + * Matches exact lines in the source and destination arrays. + * + * @param src Source array of lines. + * @param dst Destination array of lines. + * @returns An even length array of numbers, where each pair of numbers + * an index in the source array and an index in the destination array. + */ +export const matchLines = (src: string[], dst: string[]): number[] => { + let dstIndex = 0; + const slen = src.length; + const dlen = dst.length; + // const min = Math.min(slen, dlen); + const result: number[] = []; + SRC: for (let srcIndex = 0; srcIndex < slen; srcIndex++) { + const s = src[srcIndex]; + DST: for (let i = dstIndex; i < dlen; i++) { + const d = dst[i]; + if (s === d) { + result.push(srcIndex, i); + dstIndex = i + 1; + if (dstIndex >= dlen) break SRC; + continue SRC; + } + } + } + return result; +}; From d89fb9ecf1cef5a0383a701a094de0186344d28e Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 6 May 2025 11:58:03 +0200 Subject: [PATCH 39/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20add=20initia?= =?UTF-8?q?l=20implementation=20of=20line=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/__tests__/Diff.spec.ts | 90 +++++++++++++++++- src/util/diff/__tests__/arr.spec.ts | 76 +++++++++------ src/util/diff/arr.ts | 104 ++++++++++++++------- 3 files changed, 210 insertions(+), 60 deletions(-) diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/Diff.spec.ts index 9e6239b76f..9d2c73b9bf 100644 --- a/src/json-patch-diff/__tests__/Diff.spec.ts +++ b/src/json-patch-diff/__tests__/Diff.spec.ts @@ -5,7 +5,7 @@ const assertDiff = (src: unknown, dst: unknown) => { const srcNested = {src}; const patch1 = new Diff().diff('/src', src, dst); // console.log(src); - // console.log(patch1); + console.log(patch1); // console.log(dst); const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); // console.log(res); @@ -177,4 +177,92 @@ describe('arr', () => { const dst: unknown[] = ['x', 'x', 'x', 4, true, false]; assertDiff(src, dst); }); + + test('delete last element', () => { + const src: unknown[] = [1, 2, 3, 4]; + const dst: unknown[] = [1, 2, 3]; + assertDiff(src, dst); + }); + + test('delete first element', () => { + const src: unknown[] = [1, 2, 3, 4]; + const dst: unknown[] = [2, 3, 4]; + assertDiff(src, dst); + }); + + test('delete first two element', () => { + const src: unknown[] = [1, 2, 3, 4]; + const dst: unknown[] = [3, 4]; + assertDiff(src, dst); + }); +}); + +test.only('complex case', () => { + const src = { + id: 'xxxx-xxxxxx-xxxx-xxxx', + name: 'Ivan', + tags: ['tag1', 'tag2'], + age: 30, + approved: true, + interests: [ + { + id: 'xxxx', + name: 'Programming', + description: 'I love programming', + }, + { + id: '123', + name: 'Cookies', + description: 'I love cookies', + }, + { + id: 'xxxx', + name: 'Music', + description: 'I love music', + } + ], + address: { + city: 'New York', + state: 'NY', + zip: '10001', + location: { + lat: 40.7128, + lng: -74.0060, + } + }, + }; + const dst = { + id: 'yyyy-yyyyyy-yyyy-yyyy', + name: 'Ivans', + tags: ['tag2', 'tag3', 'tag4'], + age: 31, + approved: false, + interests: [ + { + id: '123', + name: 'Cookies', + description: 'I love cookies', + }, + { + id: 'yyyy', + name: 'Music', + description: 'I love music.', + }, + { + id: 'xxxx', + name: 'Sports', + description: 'I love sports', + } + ], + address: { + city: 'New York City', + state: 'NY', + zip: '10002', + location: { + lat: 40.7128, + lng: 123.4567, + } + }, + }; + assertDiff(src, dst); }); diff --git a/src/util/diff/__tests__/arr.spec.ts b/src/util/diff/__tests__/arr.spec.ts index 6802f2072d..310f40977d 100644 --- a/src/util/diff/__tests__/arr.spec.ts +++ b/src/util/diff/__tests__/arr.spec.ts @@ -43,37 +43,59 @@ describe('matchLines()', () => { }); describe('diff()', () => { + test.only('...', () => { + const patch = arr.diff( + ['0', '1', '3', 'x', 'y', '4', '5'], + ['1', '2', '3', '4', 'a', 'b', 'c', '5'], + ); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + arr.ARR_PATCH_OP_TYPE.INSERT, 3, + arr.ARR_PATCH_OP_TYPE.EQUAL, 1, + ]); + }); + + test('TODO', () => { + const patch = arr.diff([ 'a', 'x' ], [ 'b', 'c', 'd' ]); + // expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.INSERT, 1]); + }); + test('insert into empty list', () => { - const patch = arr.diff('', '1'); + const patch = arr.diff([], ['1']); expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.INSERT, 1]); }); test('both empty', () => { - const patch = arr.diff('', ''); + const patch = arr.diff([], []); expect(patch).toEqual([]); }); test('keep one', () => { - const patch = arr.diff('1', '1'); + const patch = arr.diff(['1'], ['1']); expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.EQUAL, 1]); }); test('keep two', () => { - const patch = arr.diff('1\n1', '1\n1'); + const patch = arr.diff(['1', '1'], ['1', '1']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 2, ]); }); test('keep three', () => { - const patch = arr.diff('1\n1\n2', '1\n1\n2'); + const patch = arr.diff(['1', '1', '2'], ['1', '1', '2']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 3, ]); }); test('keep two, delete one', () => { - const patch = arr.diff('1\n1\n2', '1\n1'); + const patch = arr.diff(['1', '1', '2'], ['1', '1']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 2, arr.ARR_PATCH_OP_TYPE.DELETE, 1, @@ -81,7 +103,7 @@ describe('diff()', () => { }); test('keep two, delete in the middle', () => { - const patch = arr.diff('1\n2\n3', '1\n3'); + const patch = arr.diff(['1', '2', '3'], ['1', '3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, arr.ARR_PATCH_OP_TYPE.DELETE, 1, @@ -90,7 +112,7 @@ describe('diff()', () => { }); test('keep two, delete the first one', () => { - const patch = arr.diff('1\n2\n3', '2\n3'); + const patch = arr.diff(['1', '2', '3'], ['2', '3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.DELETE, 1, arr.ARR_PATCH_OP_TYPE.EQUAL, 2, @@ -99,17 +121,17 @@ describe('diff()', () => { describe('delete', () => { test('delete the only element', () => { - const patch = arr.diff('1', ''); + const patch = arr.diff(['1'], []); expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 1]); }); test('delete the only two element', () => { - const patch = arr.diff('1\n{}', ''); + const patch = arr.diff(['1', '{}'], []); expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 2]); }); test('delete two and three in a row', () => { - const patch = arr.diff('1\n2\n3\n4\n5\n6', '3'); + const patch = arr.diff(['1', '2', '3', '4', '5', '6'], ['3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.DELETE, 2, arr.ARR_PATCH_OP_TYPE.EQUAL, 1, @@ -118,7 +140,7 @@ describe('diff()', () => { }); test('delete the first one', () => { - const patch = arr.diff('1\n2\n3', '2\n3'); + const patch = arr.diff(['1', '2', '3'], ['2', '3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.DELETE, 1, arr.ARR_PATCH_OP_TYPE.EQUAL, 2, @@ -126,7 +148,7 @@ describe('diff()', () => { }); test('delete the middle element', () => { - const patch = arr.diff('1\n2\n3', '1\n3'); + const patch = arr.diff(['1', '2', '3'], ['1', '3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, arr.ARR_PATCH_OP_TYPE.DELETE, 1, @@ -135,7 +157,7 @@ describe('diff()', () => { }); test('delete the last element', () => { - const patch = arr.diff('1\n2\n3', '1\n2'); + const patch = arr.diff(['1', '2', '3'], ['1', '2']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 2, arr.ARR_PATCH_OP_TYPE.DELETE, 1, @@ -143,7 +165,7 @@ describe('diff()', () => { }); test('delete two first elements', () => { - const patch = arr.diff('1\n2\n3\n4', '3\n4'); + const patch = arr.diff(['1', '2', '3', '4'], ['3', '4']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.DELETE, 2, arr.ARR_PATCH_OP_TYPE.EQUAL, 2, @@ -151,7 +173,7 @@ describe('diff()', () => { }); test('preserve one and delete one', () => { - const patch = arr.diff('1\n2', '1'); + const patch = arr.diff(['1', '2'], ['1']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, arr.ARR_PATCH_OP_TYPE.DELETE, 1, @@ -159,7 +181,7 @@ describe('diff()', () => { }); test('preserve one and delete one (reverse)', () => { - const patch = arr.diff('1\n2', '2'); + const patch = arr.diff(['1', '2'], ['2']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.DELETE, 1, arr.ARR_PATCH_OP_TYPE.EQUAL, 1, @@ -167,7 +189,7 @@ describe('diff()', () => { }); test('various deletes and inserts', () => { - const patch = arr.diff('1\n2\n[3]\n3\n5\n{a:4}\n5\n"6"', '1\n2\n[3]\n5\n{a:4}\n5\n"6"\n6'); + const patch = arr.diff(['1', '2', '3', '3', '5', '{a:4}', '5', '"6"'], ['1', '2', '3', '5', '{a:4}', '5', '"6"', '6']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 3, arr.ARR_PATCH_OP_TYPE.DELETE, 1, @@ -177,7 +199,7 @@ describe('diff()', () => { }); test('deletes both elements and replaces by one', () => { - const patch = arr.diff('0\n1', 'xyz'); + const patch = arr.diff(['0', '1'], ['xyz']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.DELETE, 2, arr.ARR_PATCH_OP_TYPE.INSERT, 1, @@ -187,12 +209,12 @@ describe('diff()', () => { describe('diff', () => { test('diffs partially matching single element', () => { - const patch = arr.diff('[]', '[1]'); + const patch = arr.diff(['[]'], ['[1]']); expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DIFF, 1]); }); test('diffs second element', () => { - const patch = arr.diff('1\n[]', '1\n[1]'); + const patch = arr.diff(['1', '[]'], ['1', '[1]']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, arr.ARR_PATCH_OP_TYPE.DIFF, 1, @@ -200,7 +222,7 @@ describe('diff()', () => { }); test('diffs middle element', () => { - const patch = arr.diff('1\n2\n3', '1\n[2]\n3'); + const patch = arr.diff(['1', '2', '3'], ['1', '[2]', '3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, arr.ARR_PATCH_OP_TYPE.DIFF, 1, @@ -209,7 +231,7 @@ describe('diff()', () => { }); test('diffs middle element - 2', () => { - const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,3,455]\n3'); + const patch = arr.diff(['1', '[1,2,3,4]', '3'], ['1', '[1,3,455]', '3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, arr.ARR_PATCH_OP_TYPE.DIFF, 1, @@ -218,7 +240,7 @@ describe('diff()', () => { }); test('diffs two consecutive elements', () => { - const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,3,455]\n[3]'); + const patch = arr.diff(['1', '[1,2,3,4]', '3'], ['1', '[1,3,455]', '[3]']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, arr.ARR_PATCH_OP_TYPE.DIFF, 2, @@ -226,7 +248,7 @@ describe('diff()', () => { }); test('diffs middle element', () => { - const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,2,3,5]\n3'); + const patch = arr.diff(['1', '[1,2,3,4]', '3'], ['1', '[1,2,3,5]', '3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, arr.ARR_PATCH_OP_TYPE.DIFF, 1, @@ -235,7 +257,7 @@ describe('diff()', () => { }); test('diffs middle element - 2', () => { - const patch = arr.diff('1\n[1,2,3,4]\n3', '1\n[1,4,3,5]\n3'); + const patch = arr.diff(['1', '[1,2,3,4]', '3'], ['1', '[1,4,3,5]', '3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.EQUAL, 1, arr.ARR_PATCH_OP_TYPE.DIFF, 1, @@ -244,7 +266,7 @@ describe('diff()', () => { }); test('insert first element, diff second', () => { - const patch = arr.diff('[2]', '1\n2\n3'); + const patch = arr.diff(['[2]'], ['1', '2', '3']); expect(patch).toEqual([ arr.ARR_PATCH_OP_TYPE.INSERT, 1, arr.ARR_PATCH_OP_TYPE.DIFF, 1, diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index 444119e5d1..28c4af0f71 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -8,18 +8,52 @@ export const enum ARR_PATCH_OP_TYPE { DIFF = 2, } +/** + * The patch type for the array diff. Consists of an even length array of + * numbers, where the first element of the pair is the operation type + * {@link ARR_PATCH_OP_TYPE} and the second element is the length of the + * operation. + */ export type ArrPatch = number[]; +/** + * Matches exact lines in the source and destination arrays. + * + * @param src Source array of lines. + * @param dst Destination array of lines. + * @returns An even length array of numbers, where each pair of numbers + * an index in the source array and an index in the destination array. + */ +export const matchLines = (src: string[], dst: string[]): number[] => { + let dstIndex = 0; + const slen = src.length; + const dlen = dst.length; + const result: number[] = []; + SRC: for (let srcIndex = 0; srcIndex < slen; srcIndex++) { + const s = src[srcIndex]; + DST: for (let i = dstIndex; i < dlen; i++) { + const d = dst[i]; + if (s === d) { + result.push(srcIndex, i); + dstIndex = i + 1; + if (dstIndex >= dlen) break SRC; + continue SRC; + } + } + } + return result; +}; + const enum PARTIAL_TYPE { NONE = 9, } -export const diff = (txtSrc: string, txtDst: string): ArrPatch => { +const diffLines = (srcTxt: string, dstTxt: string): ArrPatch => { const arrPatch: ArrPatch = []; - const patch = str.diff(txtSrc, txtDst); + const patch = str.diff(srcTxt, dstTxt); if (patch.length === 1) { if ((patch[0][0] as unknown as ARR_PATCH_OP_TYPE) === ARR_PATCH_OP_TYPE.INSERT) { - arrPatch.push(ARR_PATCH_OP_TYPE.INSERT, strCnt("\n", txtDst) + 1); + arrPatch.push(ARR_PATCH_OP_TYPE.INSERT, strCnt("\n", dstTxt) + 1); return arrPatch; } } @@ -85,6 +119,41 @@ export const diff = (txtSrc: string, txtDst: string): ArrPatch => { return arrPatch; }; +export const diff = (src: string[], dst: string[]): ArrPatch => { + const matches = matchLines(src, dst); + const length = matches.length; + let lastSrcIndex = -1; + let lastDstIndex = -1; + let patch: ArrPatch = []; + for (let i = 0; i <= length; i += 2) { + const isLast = i === length; + const srcIndex = isLast ? src.length : matches[i]; + const dstIndex = isLast ? dst.length : matches[i + 1]; + if (lastSrcIndex + 1 !== srcIndex && lastDstIndex + 1 === dstIndex) { + patch.push(ARR_PATCH_OP_TYPE.DELETE, srcIndex - lastSrcIndex - 1); + } else if (lastSrcIndex + 1 === srcIndex && lastDstIndex + 1 !== dstIndex) { + patch.push(ARR_PATCH_OP_TYPE.INSERT, dstIndex - lastDstIndex - 1); + } else if (lastSrcIndex + 1 !== srcIndex && lastDstIndex + 1 !== dstIndex) { + const srcLines = src.slice(lastSrcIndex + 1, srcIndex); + const dstLines = dst.slice(lastDstIndex + 1, dstIndex); + const diffPatch = diffLines(srcLines.join("\n"), dstLines.join("\n")); + if (diffPatch.length) { + const patchLength = patch.length; + if (patchLength > 0 && patch[patchLength - 2] === diffPatch[0]) { + patch[patchLength - 1] += diffPatch[1]; + patch = patch.concat(diffPatch.slice(2)); + } else patch = patch.concat(diffPatch); + } + } + if (isLast) break; + if (patch.length > 0 && patch[patch.length - 2] === ARR_PATCH_OP_TYPE.EQUAL) patch[patch.length - 1]++; + else patch.push(ARR_PATCH_OP_TYPE.EQUAL, 1); + lastSrcIndex = srcIndex; + lastDstIndex = dstIndex; + } + return patch; +}; + /** * Applies the array patch to the source array. The source array is assumed to * be materialized after the patch application, i.e., the positions in the @@ -129,32 +198,3 @@ export const apply = ( } } }; - -/** - * Matches exact lines in the source and destination arrays. - * - * @param src Source array of lines. - * @param dst Destination array of lines. - * @returns An even length array of numbers, where each pair of numbers - * an index in the source array and an index in the destination array. - */ -export const matchLines = (src: string[], dst: string[]): number[] => { - let dstIndex = 0; - const slen = src.length; - const dlen = dst.length; - // const min = Math.min(slen, dlen); - const result: number[] = []; - SRC: for (let srcIndex = 0; srcIndex < slen; srcIndex++) { - const s = src[srcIndex]; - DST: for (let i = dstIndex; i < dlen; i++) { - const d = dst[i]; - if (s === d) { - result.push(srcIndex, i); - dstIndex = i + 1; - if (dstIndex >= dlen) break SRC; - continue SRC; - } - } - } - return result; -}; From 64fc4b789c8e2fcac78a9a9f182bc527834cffe9 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 6 May 2025 12:40:40 +0200 Subject: [PATCH 40/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20cleanup=20ar?= =?UTF-8?q?ray=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/arr.spec.ts | 11 ++++++++--- src/util/diff/arr.ts | 13 ++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/util/diff/__tests__/arr.spec.ts b/src/util/diff/__tests__/arr.spec.ts index 310f40977d..1d19d2130a 100644 --- a/src/util/diff/__tests__/arr.spec.ts +++ b/src/util/diff/__tests__/arr.spec.ts @@ -43,7 +43,7 @@ describe('matchLines()', () => { }); describe('diff()', () => { - test.only('...', () => { + test('can match various equal lines', () => { const patch = arr.diff( ['0', '1', '3', 'x', 'y', '4', '5'], ['1', '2', '3', '4', 'a', 'b', 'c', '5'], @@ -60,9 +60,14 @@ describe('diff()', () => { ]); }); - test('TODO', () => { + test('replace whole list', () => { const patch = arr.diff([ 'a', 'x' ], [ 'b', 'c', 'd' ]); - // expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.INSERT, 1]); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.INSERT, 1, + arr.ARR_PATCH_OP_TYPE.DELETE, 1, + arr.ARR_PATCH_OP_TYPE.INSERT, 2, + ]); }); test('insert into empty list', () => { diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index 28c4af0f71..dfc29a7e24 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -45,6 +45,7 @@ export const matchLines = (src: string[], dst: string[]): number[] => { }; const enum PARTIAL_TYPE { + REPLACE = 8, NONE = 9, } @@ -83,14 +84,20 @@ const diffLines = (srcTxt: string, dstTxt: string): ArrPatch => { let lineStartOffset = 0; if (partial !== PARTIAL_TYPE.NONE) { const index = txt.indexOf("\n"); - const flushPartial = txt.indexOf("\n") === 0 || (isLastOp && partial === ARR_PATCH_OP_TYPE.DELETE && type === ARR_PATCH_OP_TYPE.INSERT); + const isImmediateFlush = index === 0; + const flushPartial = isImmediateFlush || (isLastOp && partial === ARR_PATCH_OP_TYPE.DELETE && type === ARR_PATCH_OP_TYPE.INSERT); if (flushPartial) { lineStartOffset = 1; - push(partial, 1); + if (isImmediateFlush && partial === PARTIAL_TYPE.REPLACE) { + push(ARR_PATCH_OP_TYPE.DELETE, 1); + push(ARR_PATCH_OP_TYPE.INSERT, 1); + } else { + push(partial, 1); + } partial = PARTIAL_TYPE.NONE; } else { if (index < 0 && !isLastOp) { - partial = ARR_PATCH_OP_TYPE.DIFF; + partial = partial === ARR_PATCH_OP_TYPE.DELETE && (type === ARR_PATCH_OP_TYPE.INSERT) ? PARTIAL_TYPE.REPLACE : ARR_PATCH_OP_TYPE.DIFF; continue; } if (partial === ARR_PATCH_OP_TYPE.DELETE && type === ARR_PATCH_OP_TYPE.INSERT) { From cb247c8980dac73be918c8c213a00e31e670a57c Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 6 May 2025 13:50:06 +0200 Subject: [PATCH 41/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20correctly=20c?= =?UTF-8?q?ompute=20diff=20source=20offset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/Diff.ts | 17 ++++----- src/json-patch-diff/__tests__/Diff.spec.ts | 44 ++++++++++++++++++---- src/util/diff/arr.ts | 3 ++ 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/json-patch-diff/Diff.ts b/src/json-patch-diff/Diff.ts index bf0c8f4b83..f554404b1c 100644 --- a/src/json-patch-diff/Diff.ts +++ b/src/json-patch-diff/Diff.ts @@ -50,18 +50,16 @@ export class Diff { } protected diffArr(path: string, src: unknown[], dst: unknown[]): void { - let txtSrc = ''; - let txtDst = ''; + const srcLines: string[] = []; + const dstLines: string[] = []; const srcLen = src.length; const dstLen = dst.length; - for (let i = 0; i < srcLen; i++) txtSrc += structHash(src[i]) + '\n'; - for (let i = 0; i < dstLen; i++) txtDst += structHash(dst[i]) + '\n'; - txtSrc = txtSrc.slice(0, -1); - txtDst = txtDst.slice(0, -1); + for (let i = 0; i < srcLen; i++) srcLines.push(structHash(src[i])); + for (let i = 0; i < dstLen; i++) dstLines.push(structHash(dst[i])); const pfx = path + '/'; let srcShift = 0; const patch = this.patch; - arr.apply(arr.diff(txtSrc, txtDst), + arr.apply(arr.diff(srcLines, dstLines), (posSrc, posDst, len) => { for (let i = 0; i < len; i++) { patch.push({op: 'add', path: pfx + (posSrc + srcShift + i), value: dst[posDst + i]}); @@ -76,8 +74,9 @@ export class Diff { (posSrc, posDst, len) => { for (let i = 0; i < len; i++) { const pos = posSrc + srcShift + i; - const value = dst[posDst + i]; - this.diff(pfx + pos, src[pos], value); + const srcValue = src[posSrc + i]; + const dstValue = dst[posDst + i]; + this.diff(pfx + pos, srcValue, dstValue); } }, ); diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/Diff.spec.ts index 9d2c73b9bf..59e287821c 100644 --- a/src/json-patch-diff/__tests__/Diff.spec.ts +++ b/src/json-patch-diff/__tests__/Diff.spec.ts @@ -5,7 +5,7 @@ const assertDiff = (src: unknown, dst: unknown) => { const srcNested = {src}; const patch1 = new Diff().diff('/src', src, dst); // console.log(src); - console.log(patch1); + // console.log(patch1); // console.log(dst); const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); // console.log(res); @@ -197,7 +197,40 @@ describe('arr', () => { }); }); -test.only('complex case', () => { +test('array of objects diff', () => { + const src = [ + { + id: 'xxxx', + name: 'Programming', + description: 'I love programming', + }, + { + id: '123', + name: 'Cookies', + description: 'I love cookies', + }, + { + id: 'xxxx', + name: 'Music', + description: 'I love music', + } + ]; + const dst = [ + { + id: '123', + name: 'Cookies', + description: 'I love cookies', + }, + { + id: 'yyyy', + name: 'Music', + description: 'I love music', + }, + ]; + assertDiff(src, dst); +}); + +test('complex case', () => { const src = { id: 'xxxx-xxxxxx-xxxx-xxxx', name: 'Ivan', @@ -246,13 +279,8 @@ test.only('complex case', () => { { id: 'yyyy', name: 'Music', - description: 'I love music.', + description: 'I love music', }, - { - id: 'xxxx', - name: 'Sports', - description: 'I love sports', - } ], address: { city: 'New York City', diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index dfc29a7e24..d47f43b56f 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -127,7 +127,10 @@ const diffLines = (srcTxt: string, dstTxt: string): ArrPatch => { }; export const diff = (src: string[], dst: string[]): ArrPatch => { + // console.log(src); + // console.log(dst); const matches = matchLines(src, dst); + // console.log('MATCHES', matches); const length = matches.length; let lastSrcIndex = -1; let lastDstIndex = -1; From 1cbc8a5063e2a093cafa3dcab35e33f20ff43737 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 6 May 2025 17:24:54 +0200 Subject: [PATCH 42/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20handle=20corr?= =?UTF-8?q?ectly=20array=20shifts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/Diff.ts | 18 +++++++++--------- src/json-patch-diff/__tests__/Diff.spec.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/json-patch-diff/Diff.ts b/src/json-patch-diff/Diff.ts index f554404b1c..0a1ee77cdb 100644 --- a/src/json-patch-diff/Diff.ts +++ b/src/json-patch-diff/Diff.ts @@ -57,23 +57,23 @@ export class Diff { for (let i = 0; i < srcLen; i++) srcLines.push(structHash(src[i])); for (let i = 0; i < dstLen; i++) dstLines.push(structHash(dst[i])); const pfx = path + '/'; - let srcShift = 0; + let shift = 0; const patch = this.patch; + // let deletes: number = 0; arr.apply(arr.diff(srcLines, dstLines), (posSrc, posDst, len) => { - for (let i = 0; i < len; i++) { - patch.push({op: 'add', path: pfx + (posSrc + srcShift + i), value: dst[posDst + i]}); - } + for (let i = 0; i < len; i++) + patch.push({op: 'add', path: pfx + (posSrc + shift + i), value: dst[posDst + i]}); + shift += len;; }, (pos, len) => { - for (let i = 0; i < len; i++) { - patch.push({op: 'remove', path: pfx + (pos + srcShift + i)}); - srcShift--; - } + for (let i = 0; i < len; i++) + patch.push({op: 'remove', path: pfx + (pos + shift)}); + shift -= len; }, (posSrc, posDst, len) => { for (let i = 0; i < len; i++) { - const pos = posSrc + srcShift + i; + const pos = posSrc + shift + i; const srcValue = src[posSrc + i]; const dstValue = dst[posDst + i]; this.diff(pfx + pos, srcValue, dstValue); diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/Diff.spec.ts index 59e287821c..594f41a8fc 100644 --- a/src/json-patch-diff/__tests__/Diff.spec.ts +++ b/src/json-patch-diff/__tests__/Diff.spec.ts @@ -190,7 +190,7 @@ describe('arr', () => { assertDiff(src, dst); }); - test('delete first two element', () => { + test('delete first two elements', () => { const src: unknown[] = [1, 2, 3, 4]; const dst: unknown[] = [3, 4]; assertDiff(src, dst); From d786e53ebfd5ec4a322aff8f23dedd7f0a976ce4 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 7 May 2025 15:39:25 +0200 Subject: [PATCH 43/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20initial=20by?= =?UTF-8?q?-line=20diff=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/str.spec.ts | 57 +++++++- src/util/diff/str.ts | 207 ++++++++++++++++++++++++---- 2 files changed, 240 insertions(+), 24 deletions(-) diff --git a/src/util/diff/__tests__/str.spec.ts b/src/util/diff/__tests__/str.spec.ts index 2ca22b9212..e91269f9e0 100644 --- a/src/util/diff/__tests__/str.spec.ts +++ b/src/util/diff/__tests__/str.spec.ts @@ -1,6 +1,61 @@ -import {PATCH_OP_TYPE, Patch, diff, diffEdit} from '../str'; +import {PATCH_OP_TYPE, Patch, byLine, diff, diffEdit} from '../str'; import {assertPatch} from './util'; +test('normalize line beginnings', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ].join('\n'); + const dst = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "abc", "name": "Merry Jane"}', + ].join('\n'); + const patch = diff(src, dst); + const lines = byLine(patch); + expect(lines).toEqual([ + [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}\n' ] ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}\n' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}\n' ] ], + [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ] + ]); +}); + +test('normalize line endings', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "hello world!"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ].join('\n'); + const dst = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "abc", "name": "Merry Jane!"}', + ].join('\n'); + const patch = diff(src, dst); + const lines = byLine(patch); + expect(lines).toEqual([ + [ + [ 0, '{"id": "xxx-xxxxxxx", "name": "' ], + [ -1, 'h' ], + [ 1, 'H' ], + [ 0, 'ello' ], + [ 1, ',' ], + [ 0, ' world' ], + [ -1, '!' ], + [ 0, '"}\n' ] + ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}\n' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}\n' ] ], + [ + [ 0, '{"id": "abc", "name": "Merry Jane' ], + [ 1, '!' ], + [ 0, '"}' ] + ] + ]); +}); + describe('diff()', () => { test('returns a single equality tuple, when strings are identical', () => { const patch = diffEdit('hello', 'hello', 1); diff --git a/src/util/diff/str.ts b/src/util/diff/str.ts index 3da606cba4..6b49501219 100644 --- a/src/util/diff/str.ts +++ b/src/util/diff/str.ts @@ -5,7 +5,10 @@ export const enum PATCH_OP_TYPE { } export type Patch = PatchOperation[]; -export type PatchOperation = PatchOperationDelete | PatchOperationEqual | PatchOperationInsert; +export type PatchOperation = + | PatchOperationDelete + | PatchOperationEqual + | PatchOperationInsert; export type PatchOperationDelete = [PATCH_OP_TYPE.DELETE, string]; export type PatchOperationEqual = [PATCH_OP_TYPE.EQUAL, string]; export type PatchOperationInsert = [PATCH_OP_TYPE.INSERT, string]; @@ -28,12 +31,12 @@ const endsWithPairStart = (str: string): boolean => { * @param fixUnicode Whether to normalize to a unicode-correct diff */ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { - diff.push([PATCH_OP_TYPE.EQUAL, '']); + diff.push([PATCH_OP_TYPE.EQUAL, ""]); let pointer = 0; let delCnt = 0; let insCnt = 0; - let delTxt = ''; - let insTxt = ''; + let delTxt = ""; + let insTxt = ""; let commonLength: number = 0; while (pointer < diff.length) { if (pointer < diff.length - 1 && !diff[pointer][1]) { @@ -120,7 +123,10 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { if (prevEq >= 0) { diff[prevEq][1] += insTxt.slice(0, commonLength); } else { - diff.splice(0, 0, [PATCH_OP_TYPE.EQUAL, insTxt.slice(0, commonLength)]); + diff.splice(0, 0, [ + PATCH_OP_TYPE.EQUAL, + insTxt.slice(0, commonLength), + ]); pointer++; } insTxt = insTxt.slice(commonLength); @@ -129,7 +135,8 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { // Factor out any common suffixes. commonLength = sfx(insTxt, delTxt); if (commonLength !== 0) { - diff[pointer][1] = insTxt.slice(insTxt.length - commonLength) + diff[pointer][1]; + diff[pointer][1] = + insTxt.slice(insTxt.length - commonLength) + diff[pointer][1]; insTxt = insTxt.slice(0, insTxt.length - commonLength); delTxt = delTxt.slice(0, delTxt.length - commonLength); } @@ -148,7 +155,12 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { diff.splice(pointer - n, n, [PATCH_OP_TYPE.DELETE, delTxt]); pointer = pointer - n + 1; } else { - diff.splice(pointer - n, n, [PATCH_OP_TYPE.DELETE, delTxt], [PATCH_OP_TYPE.INSERT, insTxt]); + diff.splice( + pointer - n, + n, + [PATCH_OP_TYPE.DELETE, delTxt], + [PATCH_OP_TYPE.INSERT, insTxt] + ); pointer = pointer - n + 2; } } @@ -160,13 +172,13 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { } else pointer++; insCnt = 0; delCnt = 0; - delTxt = ''; - insTxt = ''; + delTxt = ""; + insTxt = ""; break; } } } - if (diff[diff.length - 1][1] === '') diff.pop(); // Remove the dummy entry at the end. + if (diff[diff.length - 1][1] === "") diff.pop(); // Remove the dummy entry at the end. // Second pass: look for single edits surrounded on both sides by equalities // which can be shifted sideways to eliminate an equality. @@ -213,7 +225,12 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { * @param y Index of split point in text2. * @return Array of diff tuples. */ -const bisectSplit = (text1: string, text2: string, x: number, y: number): Patch => { +const bisectSplit = ( + text1: string, + text2: string, + x: number, + y: number +): Patch => { const diffsA = diff_(text1.slice(0, x), text2.slice(0, y), false); const diffsB = diff_(text1.slice(x), text2.slice(y), false); return diffsA.concat(diffsB); @@ -222,7 +239,7 @@ const bisectSplit = (text1: string, text2: string, x: number, y: number): Patch /** * Find the 'middle snake' of a diff, split the problem in two * and return the recursively constructed diff. - * + * * This is a port of `diff-patch-match` implementation to TypeScript. * * @see http://www.xmailserver.org/diff2.pdf EUGENE W. MYERS 1986 paper: An @@ -265,7 +282,11 @@ const bisect = (text1: string, text2: string): Patch => { if (k1 === -d || (k1 !== d && v10 < v11)) x1 = v11; else x1 = v10 + 1; let y1 = x1 - k1; - while (x1 < text1Length && y1 < text2Length && text1.charAt(x1) === text2.charAt(y1)) { + while ( + x1 < text1Length && + y1 < text2Length && + text1.charAt(x1) === text2.charAt(y1) + ) { x1++; y1++; } @@ -276,19 +297,24 @@ const bisect = (text1: string, text2: string): Patch => { const k2Offset = vOffset + delta - k1; const v2Offset = v2[k2Offset]; if (k2Offset >= 0 && k2Offset < vLength && v2Offset !== -1) { - if (x1 >= text1Length - v2Offset) return bisectSplit(text1, text2, x1, y1); + if (x1 >= text1Length - v2Offset) + return bisectSplit(text1, text2, x1, y1); } } } // Walk the reverse path one step. for (let k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { const k2_offset = vOffset + k2; - let x2 = k2 === -d || (k2 !== d && v2[k2_offset - 1] < v2[k2_offset + 1]) ? v2[k2_offset + 1] : v2[k2_offset - 1] + 1; + let x2 = + k2 === -d || (k2 !== d && v2[k2_offset - 1] < v2[k2_offset + 1]) + ? v2[k2_offset + 1] + : v2[k2_offset - 1] + 1; let y2 = x2 - k2; while ( x2 < text1Length && y2 < text2Length && - text1.charAt(text1Length - x2 - 1) === text2.charAt(text2Length - y2 - 1) + text1.charAt(text1Length - x2 - 1) === + text2.charAt(text2Length - y2 - 1) ) { x2++; y2++; @@ -453,12 +479,12 @@ export const diff = (src: string, dst: string): Patch => diff_(src, dst, true); * Considers simple insertion and deletion cases around the caret position in * the destination string. If the fast patch cannot be constructed, it falls * back to the default full implementation. - * + * * Cases considered: - * + * * 1. Insertion of a single or multiple characters right before the caret. * 2. Deletion of one or more characters right before the caret. - * + * * @param src Old string to be diffed. * @param dst New string to be diffed. * @param caret The position of the caret in the new string. Set to -1 to @@ -500,12 +526,142 @@ export const diffEdit = (src: string, dst: string, caret: number) => { if (dstSfx) patch.push([PATCH_OP_TYPE.EQUAL, dstSfx]); return patch; } - } + } return diff(src, dst); }; +export const byLine = (patch: Patch): Patch[] => { + const lines: Patch[] = []; + const length = patch.length; + let line: Patch = []; + const push = (type: PATCH_OP_TYPE, str: string) => { + const length = line.length; + if (length) { + const lastOp = line[length - 1]; + if (lastOp[0] === type) { + lastOp[1] += str; + return; + } + } + line.push([type, str]); + }; + LINES: for (let i = 0; i < length; i++) { + const op = patch[i]; + const type = op[0]; + const str = op[1]; + const index = str.indexOf("\n"); + if (index < 0) { + push(type, str); + continue LINES; + } else { + push(type, str.slice(0, index + 1)); + lines.push(line); + line = []; + } + let prevIndex = index; + const strLen = str.length; + LINE: while (prevIndex < strLen) { + let nextIndex = str.indexOf("\n", prevIndex + 1); + if (nextIndex < 0) { + push(type, str.slice(prevIndex + 1)); + break LINE; + } + lines.push([[type, str.slice(prevIndex + 1, nextIndex + 1)]]); + prevIndex = nextIndex; + } + } + if (line.length) lines.push(line); + // console.log(lines); + NORMALIZE_LINE_ENDINGS: { + const length = lines.length; + for (let i = 0; i < length; i++) { + const line = lines[i]; + const lineLength = line.length; + NORMALIZE_LINE_START: { + if (lineLength < 2) break NORMALIZE_LINE_START; + const firstOp = line[0]; + const secondOp = line[1]; + if ( + firstOp[0] === PATCH_OP_TYPE.EQUAL && + secondOp[0] === PATCH_OP_TYPE.DELETE + ) { + for (let j = 2; j < lineLength; j++) + if (line[j][0] !== PATCH_OP_TYPE.DELETE) break NORMALIZE_LINE_START; + for (let j = i + 1; j < length; j++) { + const targetLine = lines[j]; + const targetLineLength = targetLine.length; + if (targetLineLength <= 1) { + if (targetLine[0][0] !== PATCH_OP_TYPE.DELETE) + break NORMALIZE_LINE_START; + } else { + const firstTargetLineOp = targetLine[0]; + const secondTargetLineOp = targetLine[1]; + const pfx = firstOp[1]; + if ( + firstTargetLineOp[0] === PATCH_OP_TYPE.DELETE && + secondTargetLineOp[0] === PATCH_OP_TYPE.EQUAL && + pfx === firstTargetLineOp[1] + ) { + line.splice(0, 1); + secondOp[1] = pfx + secondOp[1]; + targetLine.splice(0, 1); + secondTargetLineOp[1] = pfx + secondTargetLineOp[1]; + } + } + } + } + } + NORMALIZE_LINE_END: { + if (lineLength < 2) break NORMALIZE_LINE_END; + const lastOp = line[line.length - 1]; + const lastOpStr = lastOp[1]; + const secondLastOp = line[line.length - 2]; + if (lastOp[0] === PATCH_OP_TYPE.DELETE) { + // if (lastOp[0] === PATCH_OP_TYPE.DELETE && secondLastOp[0] === PATCH_OP_TYPE.EQUAL) { + for (let j = i + 1; j < length; j++) { + const targetLine = lines[j]; + const targetLineLength = targetLine.length; + if (targetLineLength <= 1) { + if (targetLine[0][0] !== PATCH_OP_TYPE.DELETE) + break NORMALIZE_LINE_END; + } else { + const targetLineLastOp = targetLine[targetLine.length - 1]; + if (targetLineLastOp[0] !== PATCH_OP_TYPE.EQUAL) + break NORMALIZE_LINE_END; + for (let k = 0; k < targetLine.length - 1; k++) + if (targetLine[k][0] !== PATCH_OP_TYPE.DELETE) + break NORMALIZE_LINE_END; + const keepStr = targetLineLastOp[1]; + if (keepStr.length > lastOpStr.length) break NORMALIZE_LINE_END; + const index = lastOpStr.lastIndexOf(keepStr); + if (index < 0) { + (lastOp[0] as PATCH_OP_TYPE) = PATCH_OP_TYPE.EQUAL; + if (secondLastOp[0] === PATCH_OP_TYPE.EQUAL) { + secondLastOp[1] += lastOpStr; + line.splice(lineLength - 1, 1); + } + } else { + lastOp[1] = lastOpStr.slice(0, index); + line.push([PATCH_OP_TYPE.EQUAL, keepStr]); + } + const targetLineSecondLastOp = targetLine[targetLine.length - 2]; + if (targetLineSecondLastOp[0] === PATCH_OP_TYPE.DELETE) { + targetLineSecondLastOp[1] += keepStr; + targetLine.splice(targetLineLength - 1, 1); + } else { + (targetLineLastOp[0] as PATCH_OP_TYPE) = PATCH_OP_TYPE.DELETE; + } + } + } + } + } + } + } + return lines; +}; + export const src = (patch: Patch): string => { - let txt = ''; + let txt = ""; const length = patch.length; for (let i = 0; i < length; i++) { const op = patch[i]; @@ -515,7 +671,7 @@ export const src = (patch: Patch): string => { }; export const dst = (patch: Patch): string => { - let txt = ''; + let txt = ""; const length = patch.length; for (let i = 0; i < length; i++) { const op = patch[i]; @@ -548,7 +704,12 @@ export const invert = (patch: Patch): Patch => patch.map(invertOp); * @param onInsert Callback for insert operations. * @param onDelete Callback for delete operations. */ -export const apply = (patch: Patch, srcLen: number, onInsert: (pos: number, str: string) => void, onDelete: (pos: number, len: number, str: string) => void) => { +export const apply = ( + patch: Patch, + srcLen: number, + onInsert: (pos: number, str: string) => void, + onDelete: (pos: number, len: number, str: string) => void +) => { const length = patch.length; let pos = srcLen; for (let i = length - 1; i >= 0; i--) { From 5e500c1814195028b415ec9709c6c235529f9a06 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 7 May 2025 16:22:43 +0200 Subject: [PATCH 44/68] =?UTF-8?q?test(json-patch-diff):=20=F0=9F=92=8D=20a?= =?UTF-8?q?dd=20fuzz=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/Diff.ts | 1 - .../__tests__/Diff-fuzzing.spec.ts | 17 +++++++++++ src/json-patch-diff/__tests__/Diff.spec.ts | 29 +++++++++---------- src/json-patch-diff/__tests__/util.ts | 25 ++++++++++++++++ src/util/diff/__tests__/arr.spec.ts | 16 ++++++++++ src/util/diff/arr.ts | 4 +-- 6 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 src/json-patch-diff/__tests__/Diff-fuzzing.spec.ts create mode 100644 src/json-patch-diff/__tests__/util.ts diff --git a/src/json-patch-diff/Diff.ts b/src/json-patch-diff/Diff.ts index 0a1ee77cdb..ed4b1294a0 100644 --- a/src/json-patch-diff/Diff.ts +++ b/src/json-patch-diff/Diff.ts @@ -59,7 +59,6 @@ export class Diff { const pfx = path + '/'; let shift = 0; const patch = this.patch; - // let deletes: number = 0; arr.apply(arr.diff(srcLines, dstLines), (posSrc, posDst, len) => { for (let i = 0; i < len; i++) diff --git a/src/json-patch-diff/__tests__/Diff-fuzzing.spec.ts b/src/json-patch-diff/__tests__/Diff-fuzzing.spec.ts new file mode 100644 index 0000000000..43d4709de3 --- /dev/null +++ b/src/json-patch-diff/__tests__/Diff-fuzzing.spec.ts @@ -0,0 +1,17 @@ +import {assertDiff, randomArray} from './util'; + +const iterations = 100; + +test('two random arrays of integers', () => { + for (let i = 0; i < iterations; i++) { + const src = randomArray(); + const dst = randomArray(); + try { + assertDiff(src, dst); + } catch (error) { + console.error('src', src); + console.error('dst', dst); + throw error; + } + } +}); diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/Diff.spec.ts index 594f41a8fc..0fb4a65fa8 100644 --- a/src/json-patch-diff/__tests__/Diff.spec.ts +++ b/src/json-patch-diff/__tests__/Diff.spec.ts @@ -1,19 +1,4 @@ -import {Diff} from '../Diff'; -import {applyPatch} from '../../json-patch'; - -const assertDiff = (src: unknown, dst: unknown) => { - const srcNested = {src}; - const patch1 = new Diff().diff('/src', src, dst); - // console.log(src); - // console.log(patch1); - // console.log(dst); - const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); - // console.log(res); - expect(res).toEqual({src: dst}); - const patch2 = new Diff().diff('/src', (res as any)['src'], dst); - // console.log(patch2); - expect(patch2.length).toBe(0); -}; +import {assertDiff} from './util'; describe('str', () => { test('insert', () => { @@ -195,6 +180,18 @@ describe('arr', () => { const dst: unknown[] = [3, 4]; assertDiff(src, dst); }); + + test.only('fuzzer - 1', () => { + const src: unknown[] = [ + 11, 10, 4, 6, + 3, 1, 5 + ]; + const dst: unknown[] = [ + 7, 3, 13, 7, 9, + 9, 9, 4, 9 + ]; + assertDiff(src, dst); + }); }); test('array of objects diff', () => { diff --git a/src/json-patch-diff/__tests__/util.ts b/src/json-patch-diff/__tests__/util.ts new file mode 100644 index 0000000000..e278f8d017 --- /dev/null +++ b/src/json-patch-diff/__tests__/util.ts @@ -0,0 +1,25 @@ +import {Diff} from '../Diff'; +import {applyPatch} from '../../json-patch'; + +export const assertDiff = (src: unknown, dst: unknown) => { + const srcNested = {src}; + const patch1 = new Diff().diff('/src', src, dst); + console.log(src); + console.log(patch1); + console.log(dst); + const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); + console.log(res); + expect(res).toEqual({src: dst}); + const patch2 = new Diff().diff('/src', (res as any)['src'], dst); + // console.log(patch2); + expect(patch2.length).toBe(0); +}; + +export const randomArray = () => { + const len = Math.floor(Math.random() * 10); + const arr: unknown[] = []; + for (let i = 0; i < len; i++) { + arr.push(Math.ceil(Math.random() * 13)); + } + return arr; +}; diff --git a/src/util/diff/__tests__/arr.spec.ts b/src/util/diff/__tests__/arr.spec.ts index 1d19d2130a..5739743bab 100644 --- a/src/util/diff/__tests__/arr.spec.ts +++ b/src/util/diff/__tests__/arr.spec.ts @@ -124,6 +124,22 @@ describe('diff()', () => { ]); }); + test('fuzzer - 1', () => { + const src = [ 'b', 'a' ]; + const dst = [ + '7', '3', 'd', + '7', '9', '9', + '9' + ]; + // [ 1, 2, -1, 2, 1, 4 ] + const patch = arr.diff(src, dst); + expect(patch).toEqual([ + arr.ARR_PATCH_OP_TYPE.INSERT, 2, + arr.ARR_PATCH_OP_TYPE.DELETE, 2, + arr.ARR_PATCH_OP_TYPE.INSERT, 5, + ]); + }); + describe('delete', () => { test('delete the only element', () => { const patch = arr.diff(['1'], []); diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index d47f43b56f..cf0cff5ae9 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -69,8 +69,8 @@ const diffLines = (srcTxt: string, dstTxt: string): ArrPatch => { } arrPatch.push(type, count); }; - // console.log(txtSrc); - // console.log(txtDst); + // console.log(srcTxt); + // console.log(dstTxt); // console.log(patch); const patchLen = patch.length; const lastOpIndex = patchLen - 1; From ca9ae6594ef53c6cc4c15c1ae225d8e57dc9fe7e Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 7 May 2025 16:39:58 +0200 Subject: [PATCH 45/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20create=20lin?= =?UTF-8?q?e-by-line=20patch=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 56 ++++++++++ src/util/diff/__tests__/str.spec.ts | 57 +---------- src/util/diff/line.ts | 147 +++++++++++++++++++++++++++ src/util/diff/str.ts | 130 ----------------------- 4 files changed, 204 insertions(+), 186 deletions(-) create mode 100644 src/util/diff/__tests__/line.spec.ts create mode 100644 src/util/diff/line.ts diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts new file mode 100644 index 0000000000..fe0dd2b559 --- /dev/null +++ b/src/util/diff/__tests__/line.spec.ts @@ -0,0 +1,56 @@ +import {diff} from '../line'; + +describe('diff', () => { + test('normalize line beginnings', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ].join('\n'); + const dst = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "abc", "name": "Merry Jane"}', + ].join('\n'); + const patch = diff(src, dst); + expect(patch).toEqual([ + [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}\n' ] ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}\n' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}\n' ] ], + [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ] + ]); + }); + + test('normalize line endings', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "hello world!"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ].join('\n'); + const dst = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "abc", "name": "Merry Jane!"}', + ].join('\n'); + const patch = diff(src, dst); + expect(patch).toEqual([ + [ + [ 0, '{"id": "xxx-xxxxxxx", "name": "' ], + [ -1, 'h' ], + [ 1, 'H' ], + [ 0, 'ello' ], + [ 1, ',' ], + [ 0, ' world' ], + [ -1, '!' ], + [ 0, '"}\n' ] + ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}\n' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}\n' ] ], + [ + [ 0, '{"id": "abc", "name": "Merry Jane' ], + [ 1, '!' ], + [ 0, '"}' ] + ] + ]); + }); +}); diff --git a/src/util/diff/__tests__/str.spec.ts b/src/util/diff/__tests__/str.spec.ts index e91269f9e0..2ca22b9212 100644 --- a/src/util/diff/__tests__/str.spec.ts +++ b/src/util/diff/__tests__/str.spec.ts @@ -1,61 +1,6 @@ -import {PATCH_OP_TYPE, Patch, byLine, diff, diffEdit} from '../str'; +import {PATCH_OP_TYPE, Patch, diff, diffEdit} from '../str'; import {assertPatch} from './util'; -test('normalize line beginnings', () => { - const src = [ - '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', - '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', - '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', - '{"id": "abc", "name": "Merry Jane"}', - ].join('\n'); - const dst = [ - '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', - '{"id": "abc", "name": "Merry Jane"}', - ].join('\n'); - const patch = diff(src, dst); - const lines = byLine(patch); - expect(lines).toEqual([ - [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}\n' ] ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}\n' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}\n' ] ], - [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ] - ]); -}); - -test('normalize line endings', () => { - const src = [ - '{"id": "xxx-xxxxxxx", "name": "hello world!"}', - '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', - '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', - '{"id": "abc", "name": "Merry Jane"}', - ].join('\n'); - const dst = [ - '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', - '{"id": "abc", "name": "Merry Jane!"}', - ].join('\n'); - const patch = diff(src, dst); - const lines = byLine(patch); - expect(lines).toEqual([ - [ - [ 0, '{"id": "xxx-xxxxxxx", "name": "' ], - [ -1, 'h' ], - [ 1, 'H' ], - [ 0, 'ello' ], - [ 1, ',' ], - [ 0, ' world' ], - [ -1, '!' ], - [ 0, '"}\n' ] - ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}\n' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}\n' ] ], - [ - [ 0, '{"id": "abc", "name": "Merry Jane' ], - [ 1, '!' ], - [ 0, '"}' ] - ] - ]); -}); - describe('diff()', () => { test('returns a single equality tuple, when strings are identical', () => { const patch = diffEdit('hello', 'hello', 1); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts new file mode 100644 index 0000000000..a5576bc41d --- /dev/null +++ b/src/util/diff/line.ts @@ -0,0 +1,147 @@ +import * as str from "./str"; + +export type LinePatch = str.Patch[]; + +/** + * Aggregate character-by-character patch into a line-by-line patch. + * + * @param patch Character-level patch + * @returns Line-level patch + */ +export const agg = (patch: str.Patch): LinePatch => { + console.log(patch); + const lines: str.Patch[] = []; + const length = patch.length; + let line: str.Patch = []; + const push = (type: str.PATCH_OP_TYPE, str: string) => { + const length = line.length; + if (length) { + const lastOp = line[length - 1]; + if (lastOp[0] === type) { + lastOp[1] += str; + return; + } + } + line.push([type, str]); + }; + LINES: for (let i = 0; i < length; i++) { + const op = patch[i]; + const type = op[0]; + const str = op[1]; + const index = str.indexOf("\n"); + if (index < 0) { + push(type, str); + continue LINES; + } else { + push(type, str.slice(0, index + 1)); + lines.push(line); + line = []; + } + let prevIndex = index; + const strLen = str.length; + LINE: while (prevIndex < strLen) { + let nextIndex = str.indexOf("\n", prevIndex + 1); + if (nextIndex < 0) { + push(type, str.slice(prevIndex + 1)); + break LINE; + } + lines.push([[type, str.slice(prevIndex + 1, nextIndex + 1)]]); + prevIndex = nextIndex; + } + } + if (line.length) lines.push(line); + console.log(lines); + NORMALIZE_LINE_ENDINGS: { + const length = lines.length; + for (let i = 0; i < length; i++) { + const line = lines[i]; + const lineLength = line.length; + NORMALIZE_LINE_START: { + if (lineLength < 2) break NORMALIZE_LINE_START; + const firstOp = line[0]; + const secondOp = line[1]; + if ( + firstOp[0] === str.PATCH_OP_TYPE.EQUAL && + secondOp[0] === str.PATCH_OP_TYPE.DELETE + ) { + for (let j = 2; j < lineLength; j++) + if (line[j][0] !== str.PATCH_OP_TYPE.DELETE) break NORMALIZE_LINE_START; + for (let j = i + 1; j < length; j++) { + const targetLine = lines[j]; + const targetLineLength = targetLine.length; + if (targetLineLength <= 1) { + if (targetLine[0][0] !== str.PATCH_OP_TYPE.DELETE) + break NORMALIZE_LINE_START; + } else { + const firstTargetLineOp = targetLine[0]; + const secondTargetLineOp = targetLine[1]; + const pfx = firstOp[1]; + if ( + firstTargetLineOp[0] === str.PATCH_OP_TYPE.DELETE && + secondTargetLineOp[0] === str.PATCH_OP_TYPE.EQUAL && + pfx === firstTargetLineOp[1] + ) { + line.splice(0, 1); + secondOp[1] = pfx + secondOp[1]; + targetLine.splice(0, 1); + secondTargetLineOp[1] = pfx + secondTargetLineOp[1]; + } + } + } + } + } + NORMALIZE_LINE_END: { + if (lineLength < 2) break NORMALIZE_LINE_END; + const lastOp = line[line.length - 1]; + const lastOpStr = lastOp[1]; + const secondLastOp = line[line.length - 2]; + if (lastOp[0] === str.PATCH_OP_TYPE.DELETE) { + // if (lastOp[0] === PATCH_OP_TYPE.DELETE && secondLastOp[0] === PATCH_OP_TYPE.EQUAL) { + for (let j = i + 1; j < length; j++) { + const targetLine = lines[j]; + const targetLineLength = targetLine.length; + if (targetLineLength <= 1) { + if (targetLine[0][0] !== str.PATCH_OP_TYPE.DELETE) + break NORMALIZE_LINE_END; + } else { + const targetLineLastOp = targetLine[targetLine.length - 1]; + if (targetLineLastOp[0] !== str.PATCH_OP_TYPE.EQUAL) + break NORMALIZE_LINE_END; + for (let k = 0; k < targetLine.length - 1; k++) + if (targetLine[k][0] !== str.PATCH_OP_TYPE.DELETE) + break NORMALIZE_LINE_END; + const keepStr = targetLineLastOp[1]; + if (keepStr.length > lastOpStr.length) break NORMALIZE_LINE_END; + const index = lastOpStr.lastIndexOf(keepStr); + if (index < 0) { + (lastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.EQUAL; + if (secondLastOp[0] === str.PATCH_OP_TYPE.EQUAL) { + secondLastOp[1] += lastOpStr; + line.splice(lineLength - 1, 1); + } + } else { + lastOp[1] = lastOpStr.slice(0, index); + line.push([str.PATCH_OP_TYPE.EQUAL, keepStr]); + } + const targetLineSecondLastOp = targetLine[targetLine.length - 2]; + if (targetLineSecondLastOp[0] === str.PATCH_OP_TYPE.DELETE) { + targetLineSecondLastOp[1] += keepStr; + targetLine.splice(targetLineLength - 1, 1); + } else { + (targetLineLastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.DELETE; + } + } + } + } + } + } + } + console.log(lines); + return lines; +}; + +export const diff = (src: string, dst: string): LinePatch => { + const strPatch = str.diff(src, dst); + const linePatch = agg(strPatch); + return linePatch; +}; diff --git a/src/util/diff/str.ts b/src/util/diff/str.ts index 6b49501219..005912e1c7 100644 --- a/src/util/diff/str.ts +++ b/src/util/diff/str.ts @@ -530,136 +530,6 @@ export const diffEdit = (src: string, dst: string, caret: number) => { return diff(src, dst); }; -export const byLine = (patch: Patch): Patch[] => { - const lines: Patch[] = []; - const length = patch.length; - let line: Patch = []; - const push = (type: PATCH_OP_TYPE, str: string) => { - const length = line.length; - if (length) { - const lastOp = line[length - 1]; - if (lastOp[0] === type) { - lastOp[1] += str; - return; - } - } - line.push([type, str]); - }; - LINES: for (let i = 0; i < length; i++) { - const op = patch[i]; - const type = op[0]; - const str = op[1]; - const index = str.indexOf("\n"); - if (index < 0) { - push(type, str); - continue LINES; - } else { - push(type, str.slice(0, index + 1)); - lines.push(line); - line = []; - } - let prevIndex = index; - const strLen = str.length; - LINE: while (prevIndex < strLen) { - let nextIndex = str.indexOf("\n", prevIndex + 1); - if (nextIndex < 0) { - push(type, str.slice(prevIndex + 1)); - break LINE; - } - lines.push([[type, str.slice(prevIndex + 1, nextIndex + 1)]]); - prevIndex = nextIndex; - } - } - if (line.length) lines.push(line); - // console.log(lines); - NORMALIZE_LINE_ENDINGS: { - const length = lines.length; - for (let i = 0; i < length; i++) { - const line = lines[i]; - const lineLength = line.length; - NORMALIZE_LINE_START: { - if (lineLength < 2) break NORMALIZE_LINE_START; - const firstOp = line[0]; - const secondOp = line[1]; - if ( - firstOp[0] === PATCH_OP_TYPE.EQUAL && - secondOp[0] === PATCH_OP_TYPE.DELETE - ) { - for (let j = 2; j < lineLength; j++) - if (line[j][0] !== PATCH_OP_TYPE.DELETE) break NORMALIZE_LINE_START; - for (let j = i + 1; j < length; j++) { - const targetLine = lines[j]; - const targetLineLength = targetLine.length; - if (targetLineLength <= 1) { - if (targetLine[0][0] !== PATCH_OP_TYPE.DELETE) - break NORMALIZE_LINE_START; - } else { - const firstTargetLineOp = targetLine[0]; - const secondTargetLineOp = targetLine[1]; - const pfx = firstOp[1]; - if ( - firstTargetLineOp[0] === PATCH_OP_TYPE.DELETE && - secondTargetLineOp[0] === PATCH_OP_TYPE.EQUAL && - pfx === firstTargetLineOp[1] - ) { - line.splice(0, 1); - secondOp[1] = pfx + secondOp[1]; - targetLine.splice(0, 1); - secondTargetLineOp[1] = pfx + secondTargetLineOp[1]; - } - } - } - } - } - NORMALIZE_LINE_END: { - if (lineLength < 2) break NORMALIZE_LINE_END; - const lastOp = line[line.length - 1]; - const lastOpStr = lastOp[1]; - const secondLastOp = line[line.length - 2]; - if (lastOp[0] === PATCH_OP_TYPE.DELETE) { - // if (lastOp[0] === PATCH_OP_TYPE.DELETE && secondLastOp[0] === PATCH_OP_TYPE.EQUAL) { - for (let j = i + 1; j < length; j++) { - const targetLine = lines[j]; - const targetLineLength = targetLine.length; - if (targetLineLength <= 1) { - if (targetLine[0][0] !== PATCH_OP_TYPE.DELETE) - break NORMALIZE_LINE_END; - } else { - const targetLineLastOp = targetLine[targetLine.length - 1]; - if (targetLineLastOp[0] !== PATCH_OP_TYPE.EQUAL) - break NORMALIZE_LINE_END; - for (let k = 0; k < targetLine.length - 1; k++) - if (targetLine[k][0] !== PATCH_OP_TYPE.DELETE) - break NORMALIZE_LINE_END; - const keepStr = targetLineLastOp[1]; - if (keepStr.length > lastOpStr.length) break NORMALIZE_LINE_END; - const index = lastOpStr.lastIndexOf(keepStr); - if (index < 0) { - (lastOp[0] as PATCH_OP_TYPE) = PATCH_OP_TYPE.EQUAL; - if (secondLastOp[0] === PATCH_OP_TYPE.EQUAL) { - secondLastOp[1] += lastOpStr; - line.splice(lineLength - 1, 1); - } - } else { - lastOp[1] = lastOpStr.slice(0, index); - line.push([PATCH_OP_TYPE.EQUAL, keepStr]); - } - const targetLineSecondLastOp = targetLine[targetLine.length - 2]; - if (targetLineSecondLastOp[0] === PATCH_OP_TYPE.DELETE) { - targetLineSecondLastOp[1] += keepStr; - targetLine.splice(targetLineLength - 1, 1); - } else { - (targetLineLastOp[0] as PATCH_OP_TYPE) = PATCH_OP_TYPE.DELETE; - } - } - } - } - } - } - } - return lines; -}; - export const src = (patch: Patch): string => { let txt = ""; const length = patch.length; From 5ae14bb64e7ed7e28d5e3ca0c4d6447672249211 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 7 May 2025 16:43:06 +0200 Subject: [PATCH 46/68] =?UTF-8?q?refactor(util):=20=F0=9F=92=A1=20rename?= =?UTF-8?q?=20string=20diff=20operation=20type=20enum=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/bin.spec.ts | 16 ++--- src/util/diff/__tests__/str.spec.ts | 68 ++++++++++---------- src/util/diff/arr.ts | 6 +- src/util/diff/line.ts | 30 ++++----- src/util/diff/str.ts | 96 ++++++++++++++--------------- 5 files changed, 108 insertions(+), 108 deletions(-) diff --git a/src/util/diff/__tests__/bin.spec.ts b/src/util/diff/__tests__/bin.spec.ts index 120e0be86b..03d1f3a017 100644 --- a/src/util/diff/__tests__/bin.spec.ts +++ b/src/util/diff/__tests__/bin.spec.ts @@ -31,7 +31,7 @@ describe('fromHex()', () => { describe('diff()', () => { test('returns a single equality tuple, when buffers are identical', () => { const patch = diff(b(1, 2, 3), b(1, 2, 3)); - expect(patch).toEqual([[PATCH_OP_TYPE.EQUAL, toStr(b(1, 2, 3))]]); + expect(patch).toEqual([[PATCH_OP_TYPE.EQL, toStr(b(1, 2, 3))]]); expect(src(patch)).toEqual(b(1, 2, 3)); expect(dst(patch)).toEqual(b(1, 2, 3)); }); @@ -39,8 +39,8 @@ describe('diff()', () => { test('single character insert at the beginning', () => { const patch1 = diff(b(1, 2, 3), b(0, 1, 2, 3)); expect(patch1).toEqual([ - [PATCH_OP_TYPE.INSERT, toStr(b(0))], - [PATCH_OP_TYPE.EQUAL, toStr(b(1, 2, 3))], + [PATCH_OP_TYPE.INS, toStr(b(0))], + [PATCH_OP_TYPE.EQL, toStr(b(1, 2, 3))], ]); expect(src(patch1)).toEqual(b(1, 2, 3)); expect(dst(patch1)).toEqual(b(0, 1, 2, 3)); @@ -49,8 +49,8 @@ describe('diff()', () => { test('single character insert at the end', () => { const patch1 = diff(b(1, 2, 3), b(1, 2, 3, 4)); expect(patch1).toEqual([ - [PATCH_OP_TYPE.EQUAL, toStr(b(1, 2, 3))], - [PATCH_OP_TYPE.INSERT, toStr(b(4))], + [PATCH_OP_TYPE.EQL, toStr(b(1, 2, 3))], + [PATCH_OP_TYPE.INS, toStr(b(4))], ]); expect(src(patch1)).toEqual(b(1, 2, 3)); expect(dst(patch1)).toEqual(b(1, 2, 3, 4)); @@ -59,9 +59,9 @@ describe('diff()', () => { test('can delete char', () => { const patch1 = diff(b(1, 2, 3), b(2, 3, 4)); expect(patch1).toEqual([ - [PATCH_OP_TYPE.DELETE, toStr(b(1))], - [PATCH_OP_TYPE.EQUAL, toStr(b(2, 3))], - [PATCH_OP_TYPE.INSERT, toStr(b(4))], + [PATCH_OP_TYPE.DEL, toStr(b(1))], + [PATCH_OP_TYPE.EQL, toStr(b(2, 3))], + [PATCH_OP_TYPE.INS, toStr(b(4))], ]); expect(src(patch1)).toEqual(b(1, 2, 3)); expect(dst(patch1)).toEqual(b(2, 3, 4)); diff --git a/src/util/diff/__tests__/str.spec.ts b/src/util/diff/__tests__/str.spec.ts index 2ca22b9212..605e6b580e 100644 --- a/src/util/diff/__tests__/str.spec.ts +++ b/src/util/diff/__tests__/str.spec.ts @@ -4,7 +4,7 @@ import {assertPatch} from './util'; describe('diff()', () => { test('returns a single equality tuple, when strings are identical', () => { const patch = diffEdit('hello', 'hello', 1); - expect(patch).toEqual([[PATCH_OP_TYPE.EQUAL, 'hello']]); + expect(patch).toEqual([[PATCH_OP_TYPE.EQL, 'hello']]); assertPatch('hello', 'hello', patch); }); @@ -13,16 +13,16 @@ describe('diff()', () => { const patch2 = diffEdit('hello', '_hello', 1); const patch3 = diffEdit('hello', '_hello', 4); expect(patch1).toEqual([ - [PATCH_OP_TYPE.INSERT, '_'], - [PATCH_OP_TYPE.EQUAL, 'hello'], + [PATCH_OP_TYPE.INS, '_'], + [PATCH_OP_TYPE.EQL, 'hello'], ]); expect(patch2).toEqual([ - [PATCH_OP_TYPE.INSERT, '_'], - [PATCH_OP_TYPE.EQUAL, 'hello'], + [PATCH_OP_TYPE.INS, '_'], + [PATCH_OP_TYPE.EQL, 'hello'], ]); expect(patch3).toEqual([ - [PATCH_OP_TYPE.INSERT, '_'], - [PATCH_OP_TYPE.EQUAL, 'hello'], + [PATCH_OP_TYPE.INS, '_'], + [PATCH_OP_TYPE.EQL, 'hello'], ]); assertPatch('hello', '_hello', patch1); assertPatch('hello', '_hello', patch2); @@ -34,16 +34,16 @@ describe('diff()', () => { const patch2 = diffEdit('hello', 'hello!', 6); const patch3 = diffEdit('hello', 'hello!', 2); expect(patch1).toEqual([ - [PATCH_OP_TYPE.EQUAL, 'hello'], - [PATCH_OP_TYPE.INSERT, '!'], + [PATCH_OP_TYPE.EQL, 'hello'], + [PATCH_OP_TYPE.INS, '!'], ]); expect(patch2).toEqual([ - [PATCH_OP_TYPE.EQUAL, 'hello'], - [PATCH_OP_TYPE.INSERT, '!'], + [PATCH_OP_TYPE.EQL, 'hello'], + [PATCH_OP_TYPE.INS, '!'], ]); expect(patch3).toEqual([ - [PATCH_OP_TYPE.EQUAL, 'hello'], - [PATCH_OP_TYPE.INSERT, '!'], + [PATCH_OP_TYPE.EQL, 'hello'], + [PATCH_OP_TYPE.INS, '!'], ]); assertPatch('hello', 'hello!', patch1); assertPatch('hello', 'hello!', patch2); @@ -53,8 +53,8 @@ describe('diff()', () => { test('single character removal at the beginning', () => { const patch = diff('hello', 'ello'); expect(patch).toEqual([ - [PATCH_OP_TYPE.DELETE, 'h'], - [PATCH_OP_TYPE.EQUAL, 'ello'], + [PATCH_OP_TYPE.DEL, 'h'], + [PATCH_OP_TYPE.EQL, 'ello'], ]); assertPatch('hello', 'ello', patch); }); @@ -63,12 +63,12 @@ describe('diff()', () => { const patch1 = diff('hello', 'hell'); const patch2 = diffEdit('hello', 'hell', 4); expect(patch1).toEqual([ - [PATCH_OP_TYPE.EQUAL, 'hell'], - [PATCH_OP_TYPE.DELETE, 'o'], + [PATCH_OP_TYPE.EQL, 'hell'], + [PATCH_OP_TYPE.DEL, 'o'], ]); expect(patch2).toEqual([ - [PATCH_OP_TYPE.EQUAL, 'hell'], - [PATCH_OP_TYPE.DELETE, 'o'], + [PATCH_OP_TYPE.EQL, 'hell'], + [PATCH_OP_TYPE.DEL, 'o'], ]); assertPatch('hello', 'hell', patch1); assertPatch('hello', 'hell', patch2); @@ -78,14 +78,14 @@ describe('diff()', () => { const patch1 = diff('hello', 'Hello'); const patch2 = diffEdit('hello', 'Hello', 1); expect(patch1).toEqual([ - [PATCH_OP_TYPE.DELETE, 'h'], - [PATCH_OP_TYPE.INSERT, 'H'], - [PATCH_OP_TYPE.EQUAL, 'ello'], + [PATCH_OP_TYPE.DEL, 'h'], + [PATCH_OP_TYPE.INS, 'H'], + [PATCH_OP_TYPE.EQL, 'ello'], ]); expect(patch2).toEqual([ - [PATCH_OP_TYPE.DELETE, 'h'], - [PATCH_OP_TYPE.INSERT, 'H'], - [PATCH_OP_TYPE.EQUAL, 'ello'], + [PATCH_OP_TYPE.DEL, 'h'], + [PATCH_OP_TYPE.INS, 'H'], + [PATCH_OP_TYPE.EQL, 'ello'], ]); assertPatch('hello', 'Hello', patch1); assertPatch('hello', 'Hello', patch2); @@ -94,9 +94,9 @@ describe('diff()', () => { test('single character replacement at the end', () => { const patch = diff('hello', 'hellO'); expect(patch).toEqual([ - [PATCH_OP_TYPE.EQUAL, 'hell'], - [PATCH_OP_TYPE.DELETE, 'o'], - [PATCH_OP_TYPE.INSERT, 'O'], + [PATCH_OP_TYPE.EQL, 'hell'], + [PATCH_OP_TYPE.DEL, 'o'], + [PATCH_OP_TYPE.INS, 'O'], ]); assertPatch('hello', 'hellO', patch); }); @@ -187,9 +187,9 @@ describe('diffEdit()', () => { const patch1 = diffEdit(src1, dst1, cursor1); assertPatch(src1, dst1, patch1); const patch1Expected: Patch = []; - if (prefix) patch1Expected.push([PATCH_OP_TYPE.EQUAL, prefix]); - if (edit) patch1Expected.push([PATCH_OP_TYPE.INSERT, edit]); - if (suffix) patch1Expected.push([PATCH_OP_TYPE.EQUAL, suffix]); + if (prefix) patch1Expected.push([PATCH_OP_TYPE.EQL, prefix]); + if (edit) patch1Expected.push([PATCH_OP_TYPE.INS, edit]); + if (suffix) patch1Expected.push([PATCH_OP_TYPE.EQL, suffix]); expect(patch1).toEqual(patch1Expected); const src2 = prefix + edit + suffix; const dst2 = prefix + suffix; @@ -197,9 +197,9 @@ describe('diffEdit()', () => { const patch2 = diffEdit(src2, dst2, cursor2); assertPatch(src2, dst2, patch2); const patch2Expected: Patch = []; - if (prefix) patch2Expected.push([PATCH_OP_TYPE.EQUAL, prefix]); - if (edit) patch2Expected.push([PATCH_OP_TYPE.DELETE, edit]); - if (suffix) patch2Expected.push([PATCH_OP_TYPE.EQUAL, suffix]); + if (prefix) patch2Expected.push([PATCH_OP_TYPE.EQL, prefix]); + if (edit) patch2Expected.push([PATCH_OP_TYPE.DEL, edit]); + if (suffix) patch2Expected.push([PATCH_OP_TYPE.EQL, suffix]); expect(patch2).toEqual(patch2Expected); }; diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts index cf0cff5ae9..f7f00917ec 100644 --- a/src/util/diff/arr.ts +++ b/src/util/diff/arr.ts @@ -2,9 +2,9 @@ import {strCnt} from "../strCnt"; import * as str from "./str"; export const enum ARR_PATCH_OP_TYPE { - DELETE = str.PATCH_OP_TYPE.DELETE, - EQUAL = str.PATCH_OP_TYPE.EQUAL, - INSERT = str.PATCH_OP_TYPE.INSERT, + DELETE = str.PATCH_OP_TYPE.DEL, + EQUAL = str.PATCH_OP_TYPE.EQL, + INSERT = str.PATCH_OP_TYPE.INS, DIFF = 2, } diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index a5576bc41d..662a495a47 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -61,24 +61,24 @@ export const agg = (patch: str.Patch): LinePatch => { const firstOp = line[0]; const secondOp = line[1]; if ( - firstOp[0] === str.PATCH_OP_TYPE.EQUAL && - secondOp[0] === str.PATCH_OP_TYPE.DELETE + firstOp[0] === str.PATCH_OP_TYPE.EQL && + secondOp[0] === str.PATCH_OP_TYPE.DEL ) { for (let j = 2; j < lineLength; j++) - if (line[j][0] !== str.PATCH_OP_TYPE.DELETE) break NORMALIZE_LINE_START; + if (line[j][0] !== str.PATCH_OP_TYPE.DEL) break NORMALIZE_LINE_START; for (let j = i + 1; j < length; j++) { const targetLine = lines[j]; const targetLineLength = targetLine.length; if (targetLineLength <= 1) { - if (targetLine[0][0] !== str.PATCH_OP_TYPE.DELETE) + if (targetLine[0][0] !== str.PATCH_OP_TYPE.DEL) break NORMALIZE_LINE_START; } else { const firstTargetLineOp = targetLine[0]; const secondTargetLineOp = targetLine[1]; const pfx = firstOp[1]; if ( - firstTargetLineOp[0] === str.PATCH_OP_TYPE.DELETE && - secondTargetLineOp[0] === str.PATCH_OP_TYPE.EQUAL && + firstTargetLineOp[0] === str.PATCH_OP_TYPE.DEL && + secondTargetLineOp[0] === str.PATCH_OP_TYPE.EQL && pfx === firstTargetLineOp[1] ) { line.splice(0, 1); @@ -95,40 +95,40 @@ export const agg = (patch: str.Patch): LinePatch => { const lastOp = line[line.length - 1]; const lastOpStr = lastOp[1]; const secondLastOp = line[line.length - 2]; - if (lastOp[0] === str.PATCH_OP_TYPE.DELETE) { + if (lastOp[0] === str.PATCH_OP_TYPE.DEL) { // if (lastOp[0] === PATCH_OP_TYPE.DELETE && secondLastOp[0] === PATCH_OP_TYPE.EQUAL) { for (let j = i + 1; j < length; j++) { const targetLine = lines[j]; const targetLineLength = targetLine.length; if (targetLineLength <= 1) { - if (targetLine[0][0] !== str.PATCH_OP_TYPE.DELETE) + if (targetLine[0][0] !== str.PATCH_OP_TYPE.DEL) break NORMALIZE_LINE_END; } else { const targetLineLastOp = targetLine[targetLine.length - 1]; - if (targetLineLastOp[0] !== str.PATCH_OP_TYPE.EQUAL) + if (targetLineLastOp[0] !== str.PATCH_OP_TYPE.EQL) break NORMALIZE_LINE_END; for (let k = 0; k < targetLine.length - 1; k++) - if (targetLine[k][0] !== str.PATCH_OP_TYPE.DELETE) + if (targetLine[k][0] !== str.PATCH_OP_TYPE.DEL) break NORMALIZE_LINE_END; const keepStr = targetLineLastOp[1]; if (keepStr.length > lastOpStr.length) break NORMALIZE_LINE_END; const index = lastOpStr.lastIndexOf(keepStr); if (index < 0) { - (lastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.EQUAL; - if (secondLastOp[0] === str.PATCH_OP_TYPE.EQUAL) { + (lastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.EQL; + if (secondLastOp[0] === str.PATCH_OP_TYPE.EQL) { secondLastOp[1] += lastOpStr; line.splice(lineLength - 1, 1); } } else { lastOp[1] = lastOpStr.slice(0, index); - line.push([str.PATCH_OP_TYPE.EQUAL, keepStr]); + line.push([str.PATCH_OP_TYPE.EQL, keepStr]); } const targetLineSecondLastOp = targetLine[targetLine.length - 2]; - if (targetLineSecondLastOp[0] === str.PATCH_OP_TYPE.DELETE) { + if (targetLineSecondLastOp[0] === str.PATCH_OP_TYPE.DEL) { targetLineSecondLastOp[1] += keepStr; targetLine.splice(targetLineLength - 1, 1); } else { - (targetLineLastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.DELETE; + (targetLineLastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.DEL; } } } diff --git a/src/util/diff/str.ts b/src/util/diff/str.ts index 005912e1c7..baef6112f7 100644 --- a/src/util/diff/str.ts +++ b/src/util/diff/str.ts @@ -1,7 +1,7 @@ export const enum PATCH_OP_TYPE { - DELETE = -1, - EQUAL = 0, - INSERT = 1, + DEL = -1, + EQL = 0, + INS = 1, } export type Patch = PatchOperation[]; @@ -9,9 +9,9 @@ export type PatchOperation = | PatchOperationDelete | PatchOperationEqual | PatchOperationInsert; -export type PatchOperationDelete = [PATCH_OP_TYPE.DELETE, string]; -export type PatchOperationEqual = [PATCH_OP_TYPE.EQUAL, string]; -export type PatchOperationInsert = [PATCH_OP_TYPE.INSERT, string]; +export type PatchOperationDelete = [PATCH_OP_TYPE.DEL, string]; +export type PatchOperationEqual = [PATCH_OP_TYPE.EQL, string]; +export type PatchOperationInsert = [PATCH_OP_TYPE.INS, string]; const startsWithPairEnd = (str: string) => { const code = str.charCodeAt(0); @@ -31,7 +31,7 @@ const endsWithPairStart = (str: string): boolean => { * @param fixUnicode Whether to normalize to a unicode-correct diff */ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { - diff.push([PATCH_OP_TYPE.EQUAL, ""]); + diff.push([PATCH_OP_TYPE.EQL, ""]); let pointer = 0; let delCnt = 0; let insCnt = 0; @@ -45,17 +45,17 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { } const d1 = diff[pointer]; switch (d1[0]) { - case PATCH_OP_TYPE.INSERT: + case PATCH_OP_TYPE.INS: insCnt++; pointer++; insTxt += d1[1]; break; - case PATCH_OP_TYPE.DELETE: + case PATCH_OP_TYPE.DEL: delCnt++; pointer++; delTxt += d1[1]; break; - case PATCH_OP_TYPE.EQUAL: { + case PATCH_OP_TYPE.EQL: { let prevEq = pointer - insCnt - delCnt - 1; if (fixUnicode) { // prevent splitting of unicode surrogate pairs. When `fixUnicode` is true, @@ -84,11 +84,11 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { const dk = diff[k]; if (dk) { const type = dk[0]; - if (type === PATCH_OP_TYPE.INSERT) { + if (type === PATCH_OP_TYPE.INS) { insCnt++; k--; insTxt = dk[1] + insTxt; - } else if (type === PATCH_OP_TYPE.DELETE) { + } else if (type === PATCH_OP_TYPE.DEL) { delCnt++; k--; delTxt = dk[1] + delTxt; @@ -124,7 +124,7 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { diff[prevEq][1] += insTxt.slice(0, commonLength); } else { diff.splice(0, 0, [ - PATCH_OP_TYPE.EQUAL, + PATCH_OP_TYPE.EQL, insTxt.slice(0, commonLength), ]); pointer++; @@ -149,23 +149,23 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { diff.splice(pointer - n, n); pointer = pointer - n; } else if (delTxtLen === 0) { - diff.splice(pointer - n, n, [PATCH_OP_TYPE.INSERT, insTxt]); + diff.splice(pointer - n, n, [PATCH_OP_TYPE.INS, insTxt]); pointer = pointer - n + 1; } else if (insTxtLen === 0) { - diff.splice(pointer - n, n, [PATCH_OP_TYPE.DELETE, delTxt]); + diff.splice(pointer - n, n, [PATCH_OP_TYPE.DEL, delTxt]); pointer = pointer - n + 1; } else { diff.splice( pointer - n, n, - [PATCH_OP_TYPE.DELETE, delTxt], - [PATCH_OP_TYPE.INSERT, insTxt] + [PATCH_OP_TYPE.DEL, delTxt], + [PATCH_OP_TYPE.INS, insTxt] ); pointer = pointer - n + 2; } } const d0 = diff[pointer - 1]; - if (pointer !== 0 && d0[0] === PATCH_OP_TYPE.EQUAL) { + if (pointer !== 0 && d0[0] === PATCH_OP_TYPE.EQL) { // Merge this equality with the previous one. d0[1] += diff[pointer][1]; diff.splice(pointer, 1); @@ -189,7 +189,7 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { while (pointer < diff.length - 1) { const d0 = diff[pointer - 1]; const d2 = diff[pointer + 1]; - if (d0[0] === PATCH_OP_TYPE.EQUAL && d2[0] === PATCH_OP_TYPE.EQUAL) { + if (d0[0] === PATCH_OP_TYPE.EQL && d2[0] === PATCH_OP_TYPE.EQL) { // This is a single edit surrounded by equalities. const str0 = d0[1]; const d1 = diff[pointer]; @@ -334,8 +334,8 @@ const bisect = (text1: string, text2: string): Patch => { } } return [ - [PATCH_OP_TYPE.DELETE, text1], - [PATCH_OP_TYPE.INSERT, text2], + [PATCH_OP_TYPE.DEL, text1], + [PATCH_OP_TYPE.INS, text2], ]; }; @@ -348,8 +348,8 @@ const bisect = (text1: string, text2: string): Patch => { * @return A {@link Patch} - an array of patch operations. */ const diffNoCommonAffix = (src: string, dst: string): Patch => { - if (!src) return [[PATCH_OP_TYPE.INSERT, dst]]; - if (!dst) return [[PATCH_OP_TYPE.DELETE, src]]; + if (!src) return [[PATCH_OP_TYPE.INS, dst]]; + if (!dst) return [[PATCH_OP_TYPE.DEL, src]]; const text1Length = src.length; const text2Length = dst.length; const long = text1Length > text2Length ? src : dst; @@ -361,20 +361,20 @@ const diffNoCommonAffix = (src: string, dst: string): Patch => { const end = long.slice(indexOfContainedShort + shortTextLength); return text1Length > text2Length ? [ - [PATCH_OP_TYPE.DELETE, start], - [PATCH_OP_TYPE.EQUAL, short], - [PATCH_OP_TYPE.DELETE, end], + [PATCH_OP_TYPE.DEL, start], + [PATCH_OP_TYPE.EQL, short], + [PATCH_OP_TYPE.DEL, end], ] : [ - [PATCH_OP_TYPE.INSERT, start], - [PATCH_OP_TYPE.EQUAL, short], - [PATCH_OP_TYPE.INSERT, end], + [PATCH_OP_TYPE.INS, start], + [PATCH_OP_TYPE.EQL, short], + [PATCH_OP_TYPE.INS, end], ]; } if (shortTextLength === 1) return [ - [PATCH_OP_TYPE.DELETE, src], - [PATCH_OP_TYPE.INSERT, dst], + [PATCH_OP_TYPE.DEL, src], + [PATCH_OP_TYPE.INS, dst], ]; return bisect(src, dst); }; @@ -444,7 +444,7 @@ export const sfx = (txt1: string, txt2: string): number => { * @return A {@link Patch} - an array of patch operations. */ const diff_ = (src: string, dst: string, fixUnicode: boolean): Patch => { - if (src === dst) return src ? [[PATCH_OP_TYPE.EQUAL, src]] : []; + if (src === dst) return src ? [[PATCH_OP_TYPE.EQL, src]] : []; // Trim off common prefix (speedup). const prefixLength = pfx(src, dst); @@ -460,8 +460,8 @@ const diff_ = (src: string, dst: string, fixUnicode: boolean): Patch => { // Compute the diff on the middle block. const diff: Patch = diffNoCommonAffix(src, dst); - if (prefix) diff.unshift([PATCH_OP_TYPE.EQUAL, prefix]); - if (suffix) diff.push([PATCH_OP_TYPE.EQUAL, suffix]); + if (prefix) diff.unshift([PATCH_OP_TYPE.EQL, prefix]); + if (suffix) diff.push([PATCH_OP_TYPE.EQL, suffix]); cleanupMerge(diff, fixUnicode); return diff; }; @@ -510,9 +510,9 @@ export const diffEdit = (src: string, dst: string, caret: number) => { if (srcPfx !== dstPfx) break edit; const insert = dst.slice(pfxLen, caret); const patch: Patch = []; - if (srcPfx) patch.push([PATCH_OP_TYPE.EQUAL, srcPfx]); - if (insert) patch.push([PATCH_OP_TYPE.INSERT, insert]); - if (dstSfx) patch.push([PATCH_OP_TYPE.EQUAL, dstSfx]); + if (srcPfx) patch.push([PATCH_OP_TYPE.EQL, srcPfx]); + if (insert) patch.push([PATCH_OP_TYPE.INS, insert]); + if (dstSfx) patch.push([PATCH_OP_TYPE.EQL, dstSfx]); return patch; } else { const pfxLen = dstLen - sfxLen; @@ -521,9 +521,9 @@ export const diffEdit = (src: string, dst: string, caret: number) => { if (srcPfx !== dstPfx) break edit; const del = src.slice(pfxLen, srcLen - sfxLen); const patch: Patch = []; - if (srcPfx) patch.push([PATCH_OP_TYPE.EQUAL, srcPfx]); - if (del) patch.push([PATCH_OP_TYPE.DELETE, del]); - if (dstSfx) patch.push([PATCH_OP_TYPE.EQUAL, dstSfx]); + if (srcPfx) patch.push([PATCH_OP_TYPE.EQL, srcPfx]); + if (del) patch.push([PATCH_OP_TYPE.DEL, del]); + if (dstSfx) patch.push([PATCH_OP_TYPE.EQL, dstSfx]); return patch; } } @@ -535,7 +535,7 @@ export const src = (patch: Patch): string => { const length = patch.length; for (let i = 0; i < length; i++) { const op = patch[i]; - if (op[0] !== PATCH_OP_TYPE.INSERT) txt += op[1]; + if (op[0] !== PATCH_OP_TYPE.INS) txt += op[1]; } return txt; }; @@ -545,18 +545,18 @@ export const dst = (patch: Patch): string => { const length = patch.length; for (let i = 0; i < length; i++) { const op = patch[i]; - if (op[0] !== PATCH_OP_TYPE.DELETE) txt += op[1]; + if (op[0] !== PATCH_OP_TYPE.DEL) txt += op[1]; } return txt; }; const invertOp = (op: PatchOperation): PatchOperation => { const type = op[0]; - return type === PATCH_OP_TYPE.EQUAL + return type === PATCH_OP_TYPE.EQL ? op - : type === PATCH_OP_TYPE.INSERT - ? [PATCH_OP_TYPE.DELETE, op[1]] - : [PATCH_OP_TYPE.INSERT, op[1]]; + : type === PATCH_OP_TYPE.INS + ? [PATCH_OP_TYPE.DEL, op[1]] + : [PATCH_OP_TYPE.INS, op[1]]; }; /** @@ -584,8 +584,8 @@ export const apply = ( let pos = srcLen; for (let i = length - 1; i >= 0; i--) { const [type, str] = patch[i]; - if (type === PATCH_OP_TYPE.EQUAL) pos -= str.length; - else if (type === PATCH_OP_TYPE.INSERT) onInsert(pos, str); + if (type === PATCH_OP_TYPE.EQL) pos -= str.length; + else if (type === PATCH_OP_TYPE.INS) onInsert(pos, str); else { const len = str.length; pos -= len; From 5ff05bfe26039bf0558dc4257da571839cccfa2a Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 7 May 2025 17:22:57 +0200 Subject: [PATCH 47/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20handle=20new?= =?UTF-8?q?=20line=20chars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 62 ++++++++++++++++++++++------ src/util/diff/line.ts | 57 +++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index fe0dd2b559..4109e22c83 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -1,22 +1,58 @@ -import {diff} from '../line'; +import * as line from '../line'; describe('diff', () => { + test('delete all lines', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst: string[] = []; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ -1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ] + ]); + }); + + test('delete all but first line', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + ]; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ] + ]); + }); + test('normalize line beginnings', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', '{"id": "abc", "name": "Merry Jane"}', - ].join('\n'); + ]; const dst = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "abc", "name": "Merry Jane"}', - ].join('\n'); - const patch = diff(src, dst); + ]; + const patch = line.diffLines(src, dst); expect(patch).toEqual([ - [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}\n' ] ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}\n' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}\n' ] ], + [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ] ]); }); @@ -27,12 +63,12 @@ describe('diff', () => { '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', '{"id": "abc", "name": "Merry Jane"}', - ].join('\n'); + ]; const dst = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "abc", "name": "Merry Jane!"}', - ].join('\n'); - const patch = diff(src, dst); + ]; + const patch = line.diffLines(src, dst); expect(patch).toEqual([ [ [ 0, '{"id": "xxx-xxxxxxx", "name": "' ], @@ -42,10 +78,10 @@ describe('diff', () => { [ 1, ',' ], [ 0, ' world' ], [ -1, '!' ], - [ 0, '"}\n' ] + [ 0, '"}' ] ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}\n' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}\n' ] ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], [ [ 0, '{"id": "abc", "name": "Merry Jane' ], [ 1, '!' ], diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 662a495a47..40a31f34f6 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -2,6 +2,29 @@ import * as str from "./str"; export type LinePatch = str.Patch[]; +// const alignDeletesWithLines = (patch: str.Patch): str.Patch => { +// const length = patch.length; +// for (let i = 0; i < length - 2; i++) { +// const o1 = patch[i]; +// const o1s = o1[1]; +// if (o1[0] === str.PATCH_OP_TYPE.EQL && o1s[o1s.length - 1] !== '\n') { +// const o2 = patch[i + 1]; +// const o3 = patch[i + 2]; +// if (o2[0] === str.PATCH_OP_TYPE.DEL && o3[0] === str.PATCH_OP_TYPE.EQL) { +// const index = o1s.lastIndexOf("\n"); +// if (index < 0) continue; +// const sfx = o1s.slice(index + 1); +// const o2s = o2[1]; +// if (!o2s.endsWith(sfx)) continue; +// o1[1] = o1s.slice(0, index + 1); +// o2[1] = sfx + o2s.slice(0, o2s.length - sfx.length); +// o3[1] = sfx + o3[1]; +// } +// } +// } +// return patch; +// }; + /** * Aggregate character-by-character patch into a line-by-line patch. * @@ -9,7 +32,9 @@ export type LinePatch = str.Patch[]; * @returns Line-level patch */ export const agg = (patch: str.Patch): LinePatch => { - console.log(patch); + // console.log(patch); + // patch = alignDeletesWithLines(patch); + // console.log(patch); const lines: str.Patch[] = []; const length = patch.length; let line: str.Patch = []; @@ -50,7 +75,7 @@ export const agg = (patch: str.Patch): LinePatch => { } } if (line.length) lines.push(line); - console.log(lines); + // console.log(lines); NORMALIZE_LINE_ENDINGS: { const length = lines.length; for (let i = 0; i < length; i++) { @@ -136,7 +161,7 @@ export const agg = (patch: str.Patch): LinePatch => { } } } - console.log(lines); + // console.log(lines); return lines; }; @@ -145,3 +170,29 @@ export const diff = (src: string, dst: string): LinePatch => { const linePatch = agg(strPatch); return linePatch; }; + +const removeNewlines = (patch: LinePatch): void => { + const length = patch.length; + for (let i = 0; i < length; i++) { + const line = patch[i]; + const lineLength = line.length; + if (!lineLength) continue; + const lastOp = line[lineLength - 1]; + const str = lastOp[1]; + const strLength = str.length; + const endsWithNewline = str[strLength - 1] === "\n"; + if (endsWithNewline) { + if (strLength === 1) { + line.splice(lineLength - 1, 1); + } else { + lastOp[1] = str.slice(0, strLength - 1); + } + } + } +}; + +export const diffLines = (src: string[], dst: string[]): LinePatch => { + const patch = diff(src.join('\n'), dst.join('\n')); + removeNewlines(patch); + return patch; +}; From bdde34547b060f58ee07137c3a5dccd92b50cc28 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 7 May 2025 18:06:50 +0200 Subject: [PATCH 48/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20do=20not=20em?= =?UTF-8?q?it=20empty=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 39 ++++++++++++++++++++++++++++ src/util/diff/line.ts | 5 ++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index 4109e22c83..5bee0b9c9d 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -37,6 +37,45 @@ describe('diff', () => { ]); }); + test('delete all but middle lines line', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst = [ + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + ]; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ -1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ] + ]); + }); + + test('delete all but the last line', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst = [ + '{"id": "abc", "name": "Merry Jane"}', + ]; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ -1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ] + ]); + }); + test('normalize line beginnings', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 40a31f34f6..63cdf69ab8 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -39,6 +39,7 @@ export const agg = (patch: str.Patch): LinePatch => { const length = patch.length; let line: str.Patch = []; const push = (type: str.PATCH_OP_TYPE, str: string) => { + if (!str.length) return; const length = line.length; if (length) { const lastOp = line[length - 1]; @@ -58,8 +59,8 @@ export const agg = (patch: str.Patch): LinePatch => { push(type, str); continue LINES; } else { - push(type, str.slice(0, index + 1)); - lines.push(line); + if (index > 0) push(type, str.slice(0, index + 1)); + if (line.length) lines.push(line); line = []; } let prevIndex = index; From b6f142a46f8af929536b32023466ac322e7ba386 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 7 May 2025 18:24:04 +0200 Subject: [PATCH 49/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20normalize=20?= =?UTF-8?q?line=20beginnings=20across=20inserts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 69 ++++++++++++++++++++++++++++ src/util/diff/line.ts | 9 ++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index 5bee0b9c9d..fe1ab0db7c 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -128,4 +128,73 @@ describe('diff', () => { ] ]); }); + + test('move first line to the end', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst = [ + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + ]; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ -1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ], + [ [ 1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + ]); + }); + + test('move second line to the end', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + ]; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ], + [ [ 1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + ]); + }); + + test('swap third and fourth lines', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "abc", "name": "Merry Jane"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + ]; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ 1, '{"id": "abc", "name": "Merry Jane"}' ] ], + [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ], + ]); + }); }); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 63cdf69ab8..ec5d69c069 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -86,24 +86,25 @@ export const agg = (patch: str.Patch): LinePatch => { if (lineLength < 2) break NORMALIZE_LINE_START; const firstOp = line[0]; const secondOp = line[1]; + const secondOpType = secondOp[0]; if ( firstOp[0] === str.PATCH_OP_TYPE.EQL && - secondOp[0] === str.PATCH_OP_TYPE.DEL + (secondOpType === str.PATCH_OP_TYPE.DEL || secondOpType === str.PATCH_OP_TYPE.INS) ) { for (let j = 2; j < lineLength; j++) - if (line[j][0] !== str.PATCH_OP_TYPE.DEL) break NORMALIZE_LINE_START; + if (line[j][0] !== secondOpType) break NORMALIZE_LINE_START; for (let j = i + 1; j < length; j++) { const targetLine = lines[j]; const targetLineLength = targetLine.length; if (targetLineLength <= 1) { - if (targetLine[0][0] !== str.PATCH_OP_TYPE.DEL) + if (targetLine[0][0] !== secondOpType) break NORMALIZE_LINE_START; } else { const firstTargetLineOp = targetLine[0]; const secondTargetLineOp = targetLine[1]; const pfx = firstOp[1]; if ( - firstTargetLineOp[0] === str.PATCH_OP_TYPE.DEL && + firstTargetLineOp[0] === secondOpType && secondTargetLineOp[0] === str.PATCH_OP_TYPE.EQL && pfx === firstTargetLineOp[1] ) { From 7383d954325643150796eb6dee517f154e76342e Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 7 May 2025 18:50:40 +0200 Subject: [PATCH 50/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20do=20not=20em?= =?UTF-8?q?it=20empty=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 69 ++++++++++++++++++++++++++++ src/util/diff/line.ts | 32 +++---------- 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index fe1ab0db7c..c69d353928 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -197,4 +197,73 @@ describe('diff', () => { [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ], ]); }); + + test('move last line to the beginning', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst = [ + '{"id": "abc", "name": "Merry Jane"}', + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + ]; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ 1, '{"id": "abc", "name": "Merry Jane"}' ] ], + [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ], + ]); + }); + + test('move second to last line to the beginning', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst = [ + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ 1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ], + ]); + }); + + test('swap first and second lines', () => { + const src = [ + '{"id": "xxx-xxxxxxx", "name": "Hello, world!!!!!!!!!!!!!!!!!!!!!!!!!"}', + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const dst = [ + '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', + '{"id": "xxx-xxxxxxx", "name": "Hello, world!!!!!!!!!!!!!!!!!!!!!!!!!"}', + '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', + '{"id": "abc", "name": "Merry Jane"}', + ]; + const patch = line.diffLines(src, dst); + expect(patch).toEqual([ + [ [ 1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world!!!!!!!!!!!!!!!!!!!!!!!!!"}' ] ], + [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], + [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ], + ]); + }); }); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index ec5d69c069..7f85b7dc9b 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -2,29 +2,6 @@ import * as str from "./str"; export type LinePatch = str.Patch[]; -// const alignDeletesWithLines = (patch: str.Patch): str.Patch => { -// const length = patch.length; -// for (let i = 0; i < length - 2; i++) { -// const o1 = patch[i]; -// const o1s = o1[1]; -// if (o1[0] === str.PATCH_OP_TYPE.EQL && o1s[o1s.length - 1] !== '\n') { -// const o2 = patch[i + 1]; -// const o3 = patch[i + 2]; -// if (o2[0] === str.PATCH_OP_TYPE.DEL && o3[0] === str.PATCH_OP_TYPE.EQL) { -// const index = o1s.lastIndexOf("\n"); -// if (index < 0) continue; -// const sfx = o1s.slice(index + 1); -// const o2s = o2[1]; -// if (!o2s.endsWith(sfx)) continue; -// o1[1] = o1s.slice(0, index + 1); -// o2[1] = sfx + o2s.slice(0, o2s.length - sfx.length); -// o3[1] = sfx + o3[1]; -// } -// } -// } -// return patch; -// }; - /** * Aggregate character-by-character patch into a line-by-line patch. * @@ -32,8 +9,6 @@ export type LinePatch = str.Patch[]; * @returns Line-level patch */ export const agg = (patch: str.Patch): LinePatch => { - // console.log(patch); - // patch = alignDeletesWithLines(patch); // console.log(patch); const lines: str.Patch[] = []; const length = patch.length; @@ -146,6 +121,13 @@ export const agg = (patch: str.Patch): LinePatch => { secondLastOp[1] += lastOpStr; line.splice(lineLength - 1, 1); } + } else if (index === 0) { + line.splice(lineLength - 1, 1); + if (secondLastOp[0] === str.PATCH_OP_TYPE.EQL) { + secondLastOp[1] += keepStr; + } else { + line.push([str.PATCH_OP_TYPE.EQL, keepStr]); + } } else { lastOp[1] = lastOpStr.slice(0, index); line.push([str.PATCH_OP_TYPE.EQL, keepStr]); From b5a9b99c6f11a18686de47af6e6b1a9c3a05ca2a Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 8 May 2025 09:48:42 +0200 Subject: [PATCH 51/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20keep=20new-li?= =?UTF-8?q?ne=20on=20line=20end=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/line.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 7f85b7dc9b..47dee63507 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -51,7 +51,7 @@ export const agg = (patch: str.Patch): LinePatch => { } } if (line.length) lines.push(line); - // console.log(lines); + // console.log('LINES', lines); NORMALIZE_LINE_ENDINGS: { const length = lines.length; for (let i = 0; i < length; i++) { @@ -112,9 +112,12 @@ export const agg = (patch: str.Patch): LinePatch => { for (let k = 0; k < targetLine.length - 1; k++) if (targetLine[k][0] !== str.PATCH_OP_TYPE.DEL) break NORMALIZE_LINE_END; - const keepStr = targetLineLastOp[1]; + let keepStr = targetLineLastOp[1]; + const keepStrEndsWithNl = keepStr.endsWith('\n'); + if (!keepStrEndsWithNl) keepStr += '\n'; if (keepStr.length > lastOpStr.length) break NORMALIZE_LINE_END; - const index = lastOpStr.lastIndexOf(keepStr); + if (!lastOpStr.endsWith(keepStr)) break NORMALIZE_LINE_END; + const index = lastOpStr.length - keepStr.length; if (index < 0) { (lastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.EQL; if (secondLastOp[0] === str.PATCH_OP_TYPE.EQL) { @@ -134,7 +137,7 @@ export const agg = (patch: str.Patch): LinePatch => { } const targetLineSecondLastOp = targetLine[targetLine.length - 2]; if (targetLineSecondLastOp[0] === str.PATCH_OP_TYPE.DEL) { - targetLineSecondLastOp[1] += keepStr; + targetLineSecondLastOp[1] += keepStrEndsWithNl ? keepStr : keepStr.slice(0, -1); targetLine.splice(targetLineLength - 1, 1); } else { (targetLineLastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.DEL; @@ -145,7 +148,7 @@ export const agg = (patch: str.Patch): LinePatch => { } } } - // console.log(lines); + // console.log('NORMALIZED LINES', lines); return lines; }; @@ -176,7 +179,12 @@ const removeNewlines = (patch: LinePatch): void => { }; export const diffLines = (src: string[], dst: string[]): LinePatch => { - const patch = diff(src.join('\n'), dst.join('\n')); + const srcTxt = src.join('\n'); + const dstTxt = dst.join('\n'); + // console.log(srcTxt); + // console.log(dstTxt); + const patch = diff(srcTxt, dstTxt); + console.log(patch); removeNewlines(patch); return patch; }; From 68f131e93d8c80712afbe7d8851163df127879a6 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 8 May 2025 12:55:07 +0200 Subject: [PATCH 52/68] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20match=20line?= =?UTF-8?q?=20in=20line=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 358 ++++++++++++++++++++------- src/util/diff/line.ts | 115 +++++++-- src/util/diff/str.ts | 6 +- 3 files changed, 355 insertions(+), 124 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index c69d353928..e9fbb1df71 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -1,7 +1,7 @@ -import * as line from '../line'; +import * as line from "../line"; -describe('diff', () => { - test('delete all lines', () => { +describe("diff", () => { + test("delete all lines", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -9,35 +9,43 @@ describe('diff', () => { '{"id": "abc", "name": "Merry Jane"}', ]; const dst: string[] = []; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ -1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ] + [-1, 0, -1, [[-1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [-1, 1, -1, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [ + -1, + 2, + -1, + [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], + ], + [-1, 3, -1, [[-1, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test('delete all but first line', () => { + test("delete all but first line", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', '{"id": "abc", "name": "Merry Jane"}', ]; - const dst = [ - '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', - ]; - const patch = line.diffLines(src, dst); + const dst = ['{"id": "xxx-xxxxxxx", "name": "Hello, world"}']; + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ] + [0, 0, 0, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [-1, 1, 0, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [ + -1, + 2, + 0, + [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], + ], + [-1, 3, 0, [[-1, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test('delete all but middle lines line', () => { + test("delete all but middle lines line", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -48,35 +56,38 @@ describe('diff', () => { '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', ]; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ -1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ] + [-1, 0, -1, [[-1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [0, 1, 0, [[0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [0, 2, 1, [[0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], + [-1, 3, 1, [[-1, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test('delete all but the last line', () => { + test("delete all but the last line", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', '{"id": "abc", "name": "Merry Jane"}', ]; - const dst = [ - '{"id": "abc", "name": "Merry Jane"}', - ]; - const patch = line.diffLines(src, dst); + const dst = ['{"id": "abc", "name": "Merry Jane"}']; + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ -1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ] + [-1, 0, -1, [[-1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [-1, 1, -1, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [ + -1, + 2, + -1, + [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], + ], + [0, 3, 0, [[0, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test('normalize line beginnings', () => { + test("normalize line beginnings (delete two middle ones)", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -87,16 +98,21 @@ describe('diff', () => { '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "abc", "name": "Merry Jane"}', ]; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ] + [0, 0, 0, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [-1, 1, 0, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [ + -1, + 2, + 0, + [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], + ], + [0, 3, 1, [[0, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test('normalize line endings', () => { + test("normalize line endings", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "hello world!"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -107,29 +123,44 @@ describe('diff', () => { '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "abc", "name": "Merry Jane!"}', ]; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); expect(patch).toEqual([ [ - [ 0, '{"id": "xxx-xxxxxxx", "name": "' ], - [ -1, 'h' ], - [ 1, 'H' ], - [ 0, 'ello' ], - [ 1, ',' ], - [ 0, ' world' ], - [ -1, '!' ], - [ 0, '"}' ] + 2, + 0, + 0, + [ + [0, '{"id": "xxx-xxxxxxx", "name": "'], + [-1, "h"], + [1, "H"], + [0, "ello"], + [1, ","], + [0, " world"], + [-1, "!"], + [0, '"}'], + ], + ], + [-1, 1, 0, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [ + -1, + 2, + 0, + [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], [ - [ 0, '{"id": "abc", "name": "Merry Jane' ], - [ 1, '!' ], - [ 0, '"}' ] - ] + 2, + 3, + 1, + [ + [0, '{"id": "abc", "name": "Merry Jane'], + [1, "!"], + [0, '"}'], + ], + ], ]); }); - test('move first line to the end', () => { + test("move first line to the end", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -142,17 +173,17 @@ describe('diff', () => { '{"id": "abc", "name": "Merry Jane"}', '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', ]; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ -1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ], - [ [ 1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], + [-1, 0, -1, [[-1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [0, 1, 0, [[0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [0, 2, 1, [[0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], + [0, 3, 2, [[0, '{"id": "abc", "name": "Merry Jane"}']]], + [1, 3, 3, [[1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], ]); }); - test('move second line to the end', () => { + test("move second line to the end", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -165,17 +196,17 @@ describe('diff', () => { '{"id": "abc", "name": "Merry Jane"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', ]; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ], - [ [ 1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], + [0, 0, 0, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [-1, 1, 0, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [0, 2, 1, [[0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], + [0, 3, 2, [[0, '{"id": "abc", "name": "Merry Jane"}']]], + [1, 3, 3, [[1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], ]); }); - test('swap third and fourth lines', () => { + test("swap third and fourth lines", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -188,17 +219,17 @@ describe('diff', () => { '{"id": "abc", "name": "Merry Jane"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', ]; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ 1, '{"id": "abc", "name": "Merry Jane"}' ] ], - [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ], + [0, 0, 0, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [0, 1, 1, [[0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [1, 1, 2, [[1, '{"id": "abc", "name": "Merry Jane"}']]], + [0, 2, 3, [[0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], + [-1, 3, 3, [[-1, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test('move last line to the beginning', () => { + test("move last line to the beginning", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -211,17 +242,17 @@ describe('diff', () => { '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', ]; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ 1, '{"id": "abc", "name": "Merry Jane"}' ] ], - [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ -1, '{"id": "abc", "name": "Merry Jane"}' ] ], + [1, -1, 0, [[1, '{"id": "abc", "name": "Merry Jane"}']]], + [0, 0, 1, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [0, 1, 2, [[0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [0, 2, 3, [[0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], + [-1, 3, 3, [[-1, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test('move second to last line to the beginning', () => { + test("move second to last line to the beginning", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -234,17 +265,27 @@ describe('diff', () => { '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "abc", "name": "Merry Jane"}', ]; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ 1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}' ] ], - [ [ 0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ -1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ], + [ + 1, + -1, + 0, + [[1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], + ], + [0, 0, 1, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], + [0, 1, 2, [[0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [ + -1, + 2, + 2, + [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], + ], + [0, 3, 3, [[0, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test('swap first and second lines', () => { + test("swap first and second lines", () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world!!!!!!!!!!!!!!!!!!!!!!!!!"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -257,13 +298,138 @@ describe('diff', () => { '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', '{"id": "abc", "name": "Merry Jane"}', ]; - const patch = line.diffLines(src, dst); + const patch = line.diff(src, dst); + expect(patch).toEqual([ + [1, -1, 0, [[1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [ + 0, + 0, + 1, + [ + [ + 0, + '{"id": "xxx-xxxxxxx", "name": "Hello, world!!!!!!!!!!!!!!!!!!!!!!!!!"}', + ], + ], + ], + [-1, 1, 1, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], + [0, 2, 2, [[0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], + [0, 3, 3, [[0, '{"id": "abc", "name": "Merry Jane"}']]], + ]); + }); + + test("fuze two elements into one", () => { + const src = [ + '{"asdfasdfasdf": 2398239234, "aaaa": "aaaaaaa"}', + '{"bbbb": "bbbbbbbbbbbbbbb", "cccc": "ccccccccccccccccc"}', + '{"this": "is a test", "number": 1234567890}', + ]; + const dst = [ + '{"aaaa": "aaaaaaa", "bbbb": "bbbbbbbbbbbbbbb"}', + '{"this": "is a test", "number": 1234567890}', + ]; + const patch = line.diff(src, dst); expect(patch).toEqual([ - [ [ 1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ 0, '{"id": "xxx-xxxxxxx", "name": "Hello, world!!!!!!!!!!!!!!!!!!!!!!!!!"}' ] ], - [ [ -1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}' ] ], - [ [ 0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}' ] ], - [ [ 0, '{"id": "abc", "name": "Merry Jane"}' ] ], + [ + -1, + 0, + -1, + [ + [0, '{"a'], + [-1, 'sdfasdfasdf": 2398239234, "a'], + [0, 'aaa": "aaaaaaa"'], + [-1, "}"], + ], + ], + [ + 2, + 1, + 0, + [ + [-1, "{"], + [1, ", "], + [0, '"bbbb": "bbbbbbbbbbbbbbb'], + [-1, '", "cccc": "ccccccccccccccccc'], + [0, '"}'], + ], + ], + [0, 2, 1, [[0, '{"this": "is a test", "number": 1234567890}']]], + ]); + }); + + test("split two elements into one", () => { + const src = [ + '{"aaaa": "aaaaaaa", "bbbb": "bbbbbbbbbbbbbbb"}', + '{"this": "is a test", "number": 1234567890}', + ]; + const dst = [ + '{"asdfasdfasdf": 2398239234, "aaaa": "aaaaaaa"}', + '{"bbbb": "bbbbbbbbbbbbbbb", "cccc": "ccccccccccccccccc"}', + '{"this": "is a test", "number": 1234567890}', + ]; + const patch = line.diff(src, dst); + expect(patch).toEqual([ + [ + 1, + -1, + 0, + [ + [0, '{"a'], + [1, 'sdfasdfasdf": 2398239234, "a'], + [0, 'aaa": "aaaaaaa"'], + [-1, ", "], + [1, "}"], + ], + ], + [ + 2, + 0, + 1, + [ + [1, "{"], + [0, '"bbbb": "bbbbbbbbbbbbbbb'], + [1, '", "cccc": "ccccccccccccccccc'], + [0, '"}'], + ], + ], + [0, 1, 2, [[0, '{"this": "is a test", "number": 1234567890}']]], + ]); + }); + + test("fuzzer - 1", () => { + const src = [ + '{"KW*V":"Wj6/Y1mgmm6n","uP1`NNND":{")zR8r|^KR":{}},"YYyO7.+>#.6AQ?U":"1%EA(q+S!}*","b\\nyc*o.":487228790.90332836}', + '{"CO:_":238498277.2025599,"Gu4":{"pv`6^#.%9ka1*":true},"(x@cpBcAWb!_\\"{":963865518.3697702,"/Pda+3}:s(/sG{":"fj`({"}', + '{".yk_":201,"KV1C":"yq#Af","b+Cö.EOa":["DDDDDDDDDDDDDDDD"],"%":[]}', + ]; + const dst = [ + '{"Vv.FuN3P}K4*>;":false,".7gC":701259576.4875442,"3r;yV6<;$2i)+Fl":"TS7A1-WLm|U\'Exo","&G/$Ikre-aE`MsL":158207813.24797496,"i|":1927223283245736}', + ]; + const patch = line.diff(src, dst); + expect(patch).toEqual([ + [ + -1, + 0, + -1, + expect.any(Array), + ], + [ + 2, + 1, + 0, + expect.any(Array), + ], + [ + -1, + 2, + 0, + [ + [ + -1, + '{".yk_":201,"KV1C":"yq#Af","b+Cö.EOa":["DDDDDDDDDDDDDDDD"],"%":[]}', + ], + ], + ], ]); }); }); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 47dee63507..6d2cd2f6e8 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -1,6 +1,51 @@ import * as str from "./str"; -export type LinePatch = str.Patch[]; +export const enum LINE_PATCH_OP_TYPE { + /** + * The whole line is deleted. Delete the current src line and advance the src + * counter. + */ + DEL = -1, + + /** + * Lines are equal in src and dst. Keep the line in src and advance, both, src + * and dst counters. + */ + EQL = 0, + + /** + * The whole line is inserted. Insert the current dst line and advance the dst + * counter. + */ + INS = 1, + + /** + * The line is modified. Execute inner diff between the current src and dst + * lines. Keep the line in src and advance the src and dst counters. + */ + MIX = 2, +} + +export type LinePatchOp = [ + type: LINE_PATCH_OP_TYPE, + + /** + * Assignment of this operation to the line in the `src` array. + */ + src: number, + + /** + * Assignment of this operation to the line in the `dst` array. + */ + dst: number, + + /** + * Character-level patch. + */ + patch: str.Patch, +]; + +export type LinePatch = LinePatchOp[]; /** * Aggregate character-by-character patch into a line-by-line patch. @@ -8,7 +53,7 @@ export type LinePatch = str.Patch[]; * @param patch Character-level patch * @returns Line-level patch */ -export const agg = (patch: str.Patch): LinePatch => { +export const agg = (patch: str.Patch): str.Patch[] => { // console.log(patch); const lines: str.Patch[] = []; const length = patch.length; @@ -152,39 +197,59 @@ export const agg = (patch: str.Patch): LinePatch => { return lines; }; -export const diff = (src: string, dst: string): LinePatch => { - const strPatch = str.diff(src, dst); - const linePatch = agg(strPatch); - return linePatch; -}; - -const removeNewlines = (patch: LinePatch): void => { - const length = patch.length; +export const diff = (src: string[], dst: string[]): LinePatch => { + const srcTxt = src.join('\n'); + const dstTxt = dst.join('\n'); + const strPatch = str.diff(srcTxt, dstTxt); + const lines = agg(strPatch); + const length = lines.length; + const patch: LinePatch = []; + let srcIdx = -1; + let dstIdx = -1; for (let i = 0; i < length; i++) { - const line = patch[i]; + const line = lines[i]; const lineLength = line.length; if (!lineLength) continue; const lastOp = line[lineLength - 1]; - const str = lastOp[1]; - const strLength = str.length; - const endsWithNewline = str[strLength - 1] === "\n"; + const txt = lastOp[1]; + const strLength = txt.length; + const endsWithNewline = txt[strLength - 1] === "\n"; if (endsWithNewline) { if (strLength === 1) { line.splice(lineLength - 1, 1); } else { - lastOp[1] = str.slice(0, strLength - 1); + lastOp[1] = txt.slice(0, strLength - 1); + } + } + let lineType: LINE_PATCH_OP_TYPE = LINE_PATCH_OP_TYPE.EQL; + if (lineLength === 1) { + const op = line[0]; + const type = op[0]; + if (type === str.PATCH_OP_TYPE.EQL) { + srcIdx++; + dstIdx++; + } else if (type === str.PATCH_OP_TYPE.INS) { + dstIdx++; + lineType = LINE_PATCH_OP_TYPE.INS; + } else if (type === str.PATCH_OP_TYPE.DEL) { + srcIdx++; + lineType = LINE_PATCH_OP_TYPE.DEL; + } + } else { + const lastOpType = lastOp[0]; + if (lastOpType === str.PATCH_OP_TYPE.EQL) { + lineType = LINE_PATCH_OP_TYPE.MIX; + srcIdx++; + dstIdx++; + } else if (lastOpType === str.PATCH_OP_TYPE.INS) { + lineType = LINE_PATCH_OP_TYPE.INS; + dstIdx++; + } else if (lastOpType === str.PATCH_OP_TYPE.DEL) { + lineType = LINE_PATCH_OP_TYPE.DEL; + srcIdx++; } } + patch.push([lineType, srcIdx, dstIdx, line]); } -}; - -export const diffLines = (src: string[], dst: string[]): LinePatch => { - const srcTxt = src.join('\n'); - const dstTxt = dst.join('\n'); - // console.log(srcTxt); - // console.log(dstTxt); - const patch = diff(srcTxt, dstTxt); - console.log(patch); - removeNewlines(patch); return patch; }; diff --git a/src/util/diff/str.ts b/src/util/diff/str.ts index baef6112f7..69e8a53d6d 100644 --- a/src/util/diff/str.ts +++ b/src/util/diff/str.ts @@ -9,9 +9,9 @@ export type PatchOperation = | PatchOperationDelete | PatchOperationEqual | PatchOperationInsert; -export type PatchOperationDelete = [PATCH_OP_TYPE.DEL, string]; -export type PatchOperationEqual = [PATCH_OP_TYPE.EQL, string]; -export type PatchOperationInsert = [PATCH_OP_TYPE.INS, string]; +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]; const startsWithPairEnd = (str: string) => { const code = str.charCodeAt(0); From a29b4b770df838e78dc4602686f5766e39c40d41 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 8 May 2025 14:08:32 +0200 Subject: [PATCH 53/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20correct=20lin?= =?UTF-8?q?e=20diff=20line=20beginning=20normalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 50 +++++++++++++++++++++++ src/util/diff/line.ts | 60 +++++++++++++++------------- 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index e9fbb1df71..3d207bdf6c 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -432,4 +432,54 @@ describe("diff", () => { ], ]); }); + + test("fuzzer - 2 (simplified)", () => { + const src = [ + '{asdfasdfasdf}', + '{12341234123412341234}', + `{zzzzzzzzzzzzzzzzzz}`, + '{12341234123412341234}', + '{00000000000000000000}', + '{12341234123412341234}' + ]; + const dst = [ + '{asdfasdfasdf}', + `{zzzzzzzzzzzzzzzzzz}`, + '{00000000000000000000}', + ]; + const patch = line.diff(src, dst); + expect(patch).toEqual([ + [ 0, 0, 0, expect.any(Array) ], + [ -1, 1, 0, expect.any(Array) ], + [ 0, 2, 1, expect.any(Array) ], + [ -1, 3, 1, expect.any(Array) ], + [ 0, 4, 2, expect.any(Array) ], + [ -1, 5, 2, expect.any(Array) ], + ]); + }); + + test("fuzzer - 2", () => { + const src = [ + '{"qED5","Zoypj-Ock^\'":714499113.6419818,"j::O\\"ON.^iud#":{}}', + '{"{\\\\^]wa":[",M/u= |Nu=,2J"],"\\\\D6;;h-,O\\\\-|":181373753.3018791,"[n6[!Z)4":"6H:p-N(uM","sK\\\\8C":[]}' + ]; + const dst = [ + '{"qED5","Zoypj-Ock^\'":714499113.6419818,"j::O\\"ON.^iud#":{}}' + ]; + const patch = line.diff(src, dst); + expect(patch).toEqual([ + [ 0, 0, 0, expect.any(Array) ], + [ -1, 1, 0, expect.any(Array) ], + [ 0, 2, 1, expect.any(Array) ], + [ -1, 3, 1, expect.any(Array) ], + [ 0, 4, 2, expect.any(Array) ], + [ -1, 5, 2, expect.any(Array) ], + ]); + }); }); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 6d2cd2f6e8..bd05267fc1 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -42,14 +42,14 @@ export type LinePatchOp = [ /** * Character-level patch. */ - patch: str.Patch, + patch: str.Patch ]; export type LinePatch = LinePatchOp[]; /** * Aggregate character-by-character patch into a line-by-line patch. - * + * * @param patch Character-level patch * @returns Line-level patch */ @@ -96,12 +96,12 @@ export const agg = (patch: str.Patch): str.Patch[] => { } } if (line.length) lines.push(line); - // console.log('LINES', lines); + // console.log("LINES", lines); NORMALIZE_LINE_ENDINGS: { const length = lines.length; for (let i = 0; i < length; i++) { const line = lines[i]; - const lineLength = line.length; + let lineLength = line.length; NORMALIZE_LINE_START: { if (lineLength < 2) break NORMALIZE_LINE_START; const firstOp = line[0]; @@ -109,34 +109,35 @@ export const agg = (patch: str.Patch): str.Patch[] => { const secondOpType = secondOp[0]; if ( firstOp[0] === str.PATCH_OP_TYPE.EQL && - (secondOpType === str.PATCH_OP_TYPE.DEL || secondOpType === str.PATCH_OP_TYPE.INS) + (secondOpType === str.PATCH_OP_TYPE.DEL || + secondOpType === str.PATCH_OP_TYPE.INS) ) { for (let j = 2; j < lineLength; j++) if (line[j][0] !== secondOpType) break NORMALIZE_LINE_START; for (let j = i + 1; j < length; j++) { const targetLine = lines[j]; const targetLineLength = targetLine.length; - if (targetLineLength <= 1) { - if (targetLine[0][0] !== secondOpType) - break NORMALIZE_LINE_START; + const pfx = firstOp[1]; + let targetLineFirstOp; + let targetLineSecondOp; + if ( + targetLine.length > 1 && + (targetLineFirstOp = targetLine[0])[0] === secondOpType && + (targetLineSecondOp = targetLine[1])[0] === str.PATCH_OP_TYPE.EQL && + pfx === targetLineFirstOp[1] + ) { + line.splice(0, 1); + secondOp[1] = pfx + secondOp[1]; + targetLineSecondOp[1] = pfx + targetLineSecondOp[1]; + targetLine.splice(0, 1); } else { - const firstTargetLineOp = targetLine[0]; - const secondTargetLineOp = targetLine[1]; - const pfx = firstOp[1]; - if ( - firstTargetLineOp[0] === secondOpType && - secondTargetLineOp[0] === str.PATCH_OP_TYPE.EQL && - pfx === firstTargetLineOp[1] - ) { - line.splice(0, 1); - secondOp[1] = pfx + secondOp[1]; - targetLine.splice(0, 1); - secondTargetLineOp[1] = pfx + secondTargetLineOp[1]; - } + for (let k = 0; k < targetLineLength; k++) + if (targetLine[k][0] !== secondOpType) break NORMALIZE_LINE_START; } } } } + lineLength = line.length; NORMALIZE_LINE_END: { if (lineLength < 2) break NORMALIZE_LINE_END; const lastOp = line[line.length - 1]; @@ -158,8 +159,8 @@ export const agg = (patch: str.Patch): str.Patch[] => { if (targetLine[k][0] !== str.PATCH_OP_TYPE.DEL) break NORMALIZE_LINE_END; let keepStr = targetLineLastOp[1]; - const keepStrEndsWithNl = keepStr.endsWith('\n'); - if (!keepStrEndsWithNl) keepStr += '\n'; + const keepStrEndsWithNl = keepStr.endsWith("\n"); + if (!keepStrEndsWithNl) keepStr += "\n"; if (keepStr.length > lastOpStr.length) break NORMALIZE_LINE_END; if (!lastOpStr.endsWith(keepStr)) break NORMALIZE_LINE_END; const index = lastOpStr.length - keepStr.length; @@ -182,10 +183,13 @@ export const agg = (patch: str.Patch): str.Patch[] => { } const targetLineSecondLastOp = targetLine[targetLine.length - 2]; if (targetLineSecondLastOp[0] === str.PATCH_OP_TYPE.DEL) { - targetLineSecondLastOp[1] += keepStrEndsWithNl ? keepStr : keepStr.slice(0, -1); + targetLineSecondLastOp[1] += keepStrEndsWithNl + ? keepStr + : keepStr.slice(0, -1); targetLine.splice(targetLineLength - 1, 1); } else { - (targetLineLastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.DEL; + (targetLineLastOp[0] as str.PATCH_OP_TYPE) = + str.PATCH_OP_TYPE.DEL; } } } @@ -193,13 +197,13 @@ export const agg = (patch: str.Patch): str.Patch[] => { } } } - // console.log('NORMALIZED LINES', lines); + // console.log("NORMALIZED LINES", lines); return lines; }; export const diff = (src: string[], dst: string[]): LinePatch => { - const srcTxt = src.join('\n'); - const dstTxt = dst.join('\n'); + const srcTxt = src.join("\n"); + const dstTxt = dst.join("\n"); const strPatch = str.diff(srcTxt, dstTxt); const lines = agg(strPatch); const length = lines.length; From 5019ddfa6bb2bb37c53dc56bc647238f343b5d3f Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 8 May 2025 14:09:43 +0200 Subject: [PATCH 54/68] =?UTF-8?q?test(util):=20=F0=9F=92=8D=20add=20line?= =?UTF-8?q?=20diff=20fuzzer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line-fuzzer.spec.ts | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/util/diff/__tests__/line-fuzzer.spec.ts diff --git a/src/util/diff/__tests__/line-fuzzer.spec.ts b/src/util/diff/__tests__/line-fuzzer.spec.ts new file mode 100644 index 0000000000..f72c68df3a --- /dev/null +++ b/src/util/diff/__tests__/line-fuzzer.spec.ts @@ -0,0 +1,44 @@ +import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; +import * as line from '../line'; + +const assertDiff = (src: string[], dst: string[]) => { + const diff = line.diff(src, dst); + const res: string[] = []; + for (let [type, srcIdx, dstIdx, patch] of diff) { + if (type === line.LINE_PATCH_OP_TYPE.DEL) { + } else if (type === line.LINE_PATCH_OP_TYPE.INS) { + res.push(dst[dstIdx]); + } else if (type === line.LINE_PATCH_OP_TYPE.EQL) { + res.push(src[srcIdx]); + } else if (type === line.LINE_PATCH_OP_TYPE.MIX) { + res.push(dst[dstIdx]); + } + } + expect(res).toEqual(dst); +}; + +const iterations = 100; + +test('produces valid patch', () => { + for (let i = 0; i < iterations; i++) { + const elements = 2 + Math.ceil(Math.random() * 5); + const src: string[] = []; + const dst: string[] = []; + for (let i = 0; i < elements; i++) { + const json = RandomJson.generate({nodeCount: 5}); + if (Math.random() > 0.5) { + src.push(JSON.stringify(json)); + } + if (Math.random() > 0.5) { + dst.push(JSON.stringify(json)); + } + } + try { + assertDiff(src, dst); + } catch (error) { + console.log('SRC', src); + console.log('DST', dst); + throw error; + } + } +}); From 33a83168f9cd3f63e9ca4e4d3378781331d9594e Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 8 May 2025 14:29:26 +0200 Subject: [PATCH 55/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20improve=20han?= =?UTF-8?q?dling=20of=20line=20separators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 22 ++++++++++++++++++++++ src/util/diff/line.ts | 23 ++++++++++++++--------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index 3d207bdf6c..a1eefebae2 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -482,4 +482,26 @@ describe("diff", () => { [ -1, 5, 2, expect.any(Array) ], ]); }); + + test("fuzzer - 3", () => { + const src = [ + '{aaaaaaaaaaa}', + '{bbbbbbbbbbb}', + '{"75":259538477846144,"dadqM`0I":322795818.54331195,"<":"f*ßlwäm&=_y@w\\n","53aghXOyD%lC2":373122194.60806453,"\\\\9=M!\\"\\\\Tl-":"r.VdPY`mOQ"}', + '{11111111111111111111}', + ]; + const dst = [ + '{"\\\\ 3[9}0dz+FaW\\"M":"rX?","P.Ed-s-VgiQDuNk":"18","}56zyy3FnC":[" [x[0], x[1], x[2]]) + // console.log(patch); + expect(patch).toEqual([ + [ -1, 0, -1 ], + [ 2, 1, 0 ], + [ 0, 2, 1 ], + [ 2, 3, 2 ], + ]); + }); }); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index bd05267fc1..7fae550832 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -70,6 +70,7 @@ export const agg = (patch: str.Patch): str.Patch[] => { } line.push([type, str]); }; + // console.log("PATCH", patch); LINES: for (let i = 0; i < length; i++) { const op = patch[i]; const type = op[0]; @@ -79,7 +80,7 @@ export const agg = (patch: str.Patch): str.Patch[] => { push(type, str); continue LINES; } else { - if (index > 0) push(type, str.slice(0, index + 1)); + push(type, str.slice(0, index + 1)); if (line.length) lines.push(line); line = []; } @@ -212,19 +213,23 @@ export const diff = (src: string[], dst: string[]): LinePatch => { let dstIdx = -1; for (let i = 0; i < length; i++) { const line = lines[i]; - const lineLength = line.length; + let lineLength = line.length; if (!lineLength) continue; const lastOp = line[lineLength - 1]; const txt = lastOp[1]; - const strLength = txt.length; - const endsWithNewline = txt[strLength - 1] === "\n"; - if (endsWithNewline) { - if (strLength === 1) { - line.splice(lineLength - 1, 1); - } else { - lastOp[1] = txt.slice(0, strLength - 1); + if (txt === "\n") { + line.splice(lineLength - 1, 1); + } else { + const strLength = txt.length; + if (txt[strLength - 1] === "\n") { + if (strLength === 1) { + line.splice(lineLength - 1, 1); + } else { + lastOp[1] = txt.slice(0, strLength - 1); + } } } + lineLength = line.length; let lineType: LINE_PATCH_OP_TYPE = LINE_PATCH_OP_TYPE.EQL; if (lineLength === 1) { const op = line[0]; From 82a97fea844bdc819dac48bcc1d0d8fd562e3816 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 8 May 2025 14:57:09 +0200 Subject: [PATCH 56/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20correctly=20d?= =?UTF-8?q?etermini=20line=20operation=20on=20last=20line=20no-newline=20o?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 25 +++++++++++++++++- src/util/diff/line.ts | 38 ++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index a1eefebae2..01b40731a9 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -496,7 +496,6 @@ describe("diff", () => { '{222222222222222222222}', ]; const patch = line.diff(src, dst).map(x => [x[0], x[1], x[2]]) - // console.log(patch); expect(patch).toEqual([ [ -1, 0, -1 ], [ 2, 1, 0 ], @@ -504,4 +503,28 @@ describe("diff", () => { [ 2, 3, 2 ], ]); }); + + test("fuzzer - 4", () => { + const src = [ + '{"fE#vTih,M!q+TTR":-8702114011119315,"`F\\"M9":true,"]9+FC9f{48NnX":{"+\\\\]IQ7":"a;br-^_m"},"s&":"%n18QdrUewc8Nh8<"}', + '{"<\\"R}d\\"HY65":[53195032.194879085,710289417.4711887],"WH]":"qqqqqqqqqq","W&0fQhOd8":96664625.24402197}', + '{"!2{:XVc3":[814507837.3286607,"A+m+}=p$Y&T"],"?[Tks9wg,pRLz.G":[[]]}', + '{"X^бbAq,":247853730.363063,"+ Mkjq_":-7253373307869407,"`J\\"[^)W KVFk":{"I&a?\\\\\\"1q\\\\":{"66666666666666":">}v1I7y48`JJIG5{"}}}' + ]; + const dst = [ + '{"fE#vTih,M!q+TTR":-8702114011119315,"`F\\"M9":true,"]9+FC9f{48NnX":{"+\\\\]IQ7":"a;br-^_m"},"s&":"%n18QdrUewc8Nh8<"}', + '{"!2{:XVc3":[814507837.3286607,"A+m+}=p$Y&T"],"?[Tks9wg,pRLz.G":[[]]}', + `{"}'-":["o=^\\\\tXk@4",false],"*nF(tbVE=L\\"LiA":-17541,"5a,?p8=]TBLT_x^":916988130.3227228}`, + `{"+.i5D's>W4#EJ%7B":">IYF9h","IeK?Dg{/3>hq7\\\\B[":64967,"KI,cnб!Ty%":2913242861126036,"rv9O@j":false,"dj":"N>"}` + ]; + const patch = line.diff(src, dst).map(x => [x[0], x[1], x[2]]) + // console.log(patch); + expect(patch).toEqual([ + [ 0, 0, 0 ], + [ -1, 1, 0 ], + [ 0, 2, 1 ], + [ 1, 2, 2 ], + [ 2, 3, 3 ], + ]); + }); }); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 7fae550832..c6a035963b 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -216,6 +216,7 @@ export const diff = (src: string[], dst: string[]): LinePatch => { let lineLength = line.length; if (!lineLength) continue; const lastOp = line[lineLength - 1]; + const lastOpType = lastOp[0]; const txt = lastOp[1]; if (txt === "\n") { line.splice(lineLength - 1, 1); @@ -245,17 +246,32 @@ export const diff = (src: string[], dst: string[]): LinePatch => { lineType = LINE_PATCH_OP_TYPE.DEL; } } else { - const lastOpType = lastOp[0]; - if (lastOpType === str.PATCH_OP_TYPE.EQL) { - lineType = LINE_PATCH_OP_TYPE.MIX; - srcIdx++; - dstIdx++; - } else if (lastOpType === str.PATCH_OP_TYPE.INS) { - lineType = LINE_PATCH_OP_TYPE.INS; - dstIdx++; - } else if (lastOpType === str.PATCH_OP_TYPE.DEL) { - lineType = LINE_PATCH_OP_TYPE.DEL; - srcIdx++; + if (i + 1 === length) { + if (srcIdx + 1 < src.length) { + if (dstIdx + 1 < dst.length) { + lineType = LINE_PATCH_OP_TYPE.MIX; + srcIdx++; + dstIdx++; + } else { + lineType = LINE_PATCH_OP_TYPE.DEL; + srcIdx++; + } + } else { + lineType = LINE_PATCH_OP_TYPE.INS; + dstIdx++; + } + } else { + if (lastOpType === str.PATCH_OP_TYPE.EQL) { + lineType = LINE_PATCH_OP_TYPE.MIX; + srcIdx++; + dstIdx++; + } else if (lastOpType === str.PATCH_OP_TYPE.INS) { + lineType = LINE_PATCH_OP_TYPE.INS; + dstIdx++; + } else if (lastOpType === str.PATCH_OP_TYPE.DEL) { + lineType = LINE_PATCH_OP_TYPE.DEL; + srcIdx++; + } } } patch.push([lineType, srcIdx, dstIdx, line]); From 20d11994cff5fac370c37021069d23a744a5a6ff Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 9 May 2025 14:44:47 +0200 Subject: [PATCH 57/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20correctly=20a?= =?UTF-8?q?ssign=20last=20line=20patch=20operation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 19 +++++++++- src/util/diff/line.ts | 52 +++++++++++++++------------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index 01b40731a9..aee53e24f5 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -518,7 +518,6 @@ describe("diff", () => { `{"+.i5D's>W4#EJ%7B":">IYF9h","IeK?Dg{/3>hq7\\\\B[":64967,"KI,cnб!Ty%":2913242861126036,"rv9O@j":false,"dj":"N>"}` ]; const patch = line.diff(src, dst).map(x => [x[0], x[1], x[2]]) - // console.log(patch); expect(patch).toEqual([ [ 0, 0, 0 ], [ -1, 1, 0 ], @@ -527,4 +526,22 @@ describe("diff", () => { [ 2, 3, 3 ], ]); }); + + test("fuzzer - 5", () => { + const src = [ + '{"1111":[true,true],"111111111111111":-34785,"YRb#H`%Q`9yQ;":"S@>/8#"}', + '{"$?":145566270.31451553,"&;\\\\V":729010872.7196132,"B4Xm[[X4":"WLFBc>*popRot]Y",") 8a%d@":811080332.6947087,"LnRab_vKhgz":"%"}' + ]; + const dst = [ + `{"YC9rf7Kg3fI(":"=aEe5Jw7R)m\\\\0Q","b-)-xPNm3":"1%","MHPcv?h\\"'j\\\\z;$?>":[],"LybE:":"|xWDk9r|s%:O0%(","/y@Uz433>:l[%":true}`, + '{"1111":[true,true],"111111111111111":-34785,"YRb#H`%Q`9yQ;":"S@>/8#"}' + ]; + const patch = line.diff(src, dst).map(x => [x[0], x[1], x[2]]) + // console.log(patch); + expect(patch).toEqual([ + [ 1, -1, 0 ], + [ -1, 0, 0 ], + [ 2, 1, 1 ], + ]); + }); }); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index c6a035963b..5d7860b5da 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -230,35 +230,39 @@ export const diff = (src: string[], dst: string[]): LinePatch => { } } } - lineLength = line.length; let lineType: LINE_PATCH_OP_TYPE = LINE_PATCH_OP_TYPE.EQL; - if (lineLength === 1) { - const op = line[0]; - const type = op[0]; - if (type === str.PATCH_OP_TYPE.EQL) { - srcIdx++; - dstIdx++; - } else if (type === str.PATCH_OP_TYPE.INS) { - dstIdx++; - lineType = LINE_PATCH_OP_TYPE.INS; - } else if (type === str.PATCH_OP_TYPE.DEL) { - srcIdx++; - lineType = LINE_PATCH_OP_TYPE.DEL; - } - } else { - if (i + 1 === length) { - if (srcIdx + 1 < src.length) { - if (dstIdx + 1 < dst.length) { - lineType = LINE_PATCH_OP_TYPE.MIX; - srcIdx++; - dstIdx++; + lineLength = line.length; + if (i + 1 === length) { + if (srcIdx + 1 < src.length) { + if (dstIdx + 1 < dst.length) { + if (lineLength === 1 && line[0][0] === str.PATCH_OP_TYPE.EQL) { + lineType = LINE_PATCH_OP_TYPE.EQL; } else { - lineType = LINE_PATCH_OP_TYPE.DEL; - srcIdx++; + lineType = LINE_PATCH_OP_TYPE.MIX; } + srcIdx++; + dstIdx++; } else { - lineType = LINE_PATCH_OP_TYPE.INS; + lineType = LINE_PATCH_OP_TYPE.DEL; + srcIdx++; + } + } else { + lineType = LINE_PATCH_OP_TYPE.INS; + dstIdx++; + } + } else { + if (lineLength === 1) { + const op = line[0]; + const type = op[0]; + if (type === str.PATCH_OP_TYPE.EQL) { + srcIdx++; + dstIdx++; + } else if (type === str.PATCH_OP_TYPE.INS) { dstIdx++; + lineType = LINE_PATCH_OP_TYPE.INS; + } else if (type === str.PATCH_OP_TYPE.DEL) { + srcIdx++; + lineType = LINE_PATCH_OP_TYPE.DEL; } } else { if (lastOpType === str.PATCH_OP_TYPE.EQL) { From 33c6e577be4d2c9abefd2834832a9f35a382e31f Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Fri, 9 May 2025 14:50:37 +0200 Subject: [PATCH 58/68] =?UTF-8?q?test(util):=20=F0=9F=92=8D=20pass=20line?= =?UTF-8?q?=20diff=20fuzz=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/__tests__/util.ts | 8 ++++---- src/util/diff/__tests__/line-fuzzer.spec.ts | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/json-patch-diff/__tests__/util.ts b/src/json-patch-diff/__tests__/util.ts index e278f8d017..b4aa66a9b8 100644 --- a/src/json-patch-diff/__tests__/util.ts +++ b/src/json-patch-diff/__tests__/util.ts @@ -4,11 +4,11 @@ import {applyPatch} from '../../json-patch'; export const assertDiff = (src: unknown, dst: unknown) => { const srcNested = {src}; const patch1 = new Diff().diff('/src', src, dst); - console.log(src); - console.log(patch1); - console.log(dst); + // console.log(src); + // console.log(patch1); + // console.log(dst); const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); - console.log(res); + // console.log(res); expect(res).toEqual({src: dst}); const patch2 = new Diff().diff('/src', (res as any)['src'], dst); // console.log(patch2); diff --git a/src/util/diff/__tests__/line-fuzzer.spec.ts b/src/util/diff/__tests__/line-fuzzer.spec.ts index f72c68df3a..56eb380360 100644 --- a/src/util/diff/__tests__/line-fuzzer.spec.ts +++ b/src/util/diff/__tests__/line-fuzzer.spec.ts @@ -17,11 +17,13 @@ const assertDiff = (src: string[], dst: string[]) => { expect(res).toEqual(dst); }; -const iterations = 100; +const iterations = 1000; +const minElements = 2; +const maxElements = 6; test('produces valid patch', () => { for (let i = 0; i < iterations; i++) { - const elements = 2 + Math.ceil(Math.random() * 5); + const elements = minElements + Math.ceil(Math.random() * (maxElements - minElements)); const src: string[] = []; const dst: string[] = []; for (let i = 0; i < elements; i++) { From 83694adacd1828f089264c3253bee6619821a2b0 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 01:17:29 +0200 Subject: [PATCH 59/68] =?UTF-8?q?feat(json-patch-diff):=20=F0=9F=8E=B8=20u?= =?UTF-8?q?se=20line=20diff=20in=20JSON=20Patch=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/Diff.ts | 43 +++++++++++----------- src/json-patch-diff/__tests__/Diff.spec.ts | 2 +- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/json-patch-diff/Diff.ts b/src/json-patch-diff/Diff.ts index ed4b1294a0..663c1e31e9 100644 --- a/src/json-patch-diff/Diff.ts +++ b/src/json-patch-diff/Diff.ts @@ -1,6 +1,6 @@ import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import * as str from '../util/diff/str'; -import * as arr from '../util/diff/arr'; +import * as line from '../util/diff/line'; import {structHash} from '../json-hash'; import type {Operation} from '../json-patch/codec/json/types'; @@ -57,28 +57,27 @@ export class Diff { for (let i = 0; i < srcLen; i++) srcLines.push(structHash(src[i])); for (let i = 0; i < dstLen; i++) dstLines.push(structHash(dst[i])); const pfx = path + '/'; - let shift = 0; const patch = this.patch; - arr.apply(arr.diff(srcLines, dstLines), - (posSrc, posDst, len) => { - for (let i = 0; i < len; i++) - patch.push({op: 'add', path: pfx + (posSrc + shift + i), value: dst[posDst + i]}); - shift += len;; - }, - (pos, len) => { - for (let i = 0; i < len; i++) - patch.push({op: 'remove', path: pfx + (pos + shift)}); - shift -= len; - }, - (posSrc, posDst, len) => { - for (let i = 0; i < len; i++) { - const pos = posSrc + shift + i; - const srcValue = src[posSrc + i]; - const dstValue = dst[posDst + i]; - this.diff(pfx + pos, srcValue, dstValue); - } - }, - ); + const linePatch = line.diff(srcLines, dstLines); + const length = linePatch.length; + for (let i = length - 1; i >= 0; i--) { + const [type, srcIdx, dstIdx] = linePatch[i]; + switch (type) { + case line.LINE_PATCH_OP_TYPE.EQL: + break; + case line.LINE_PATCH_OP_TYPE.MIX: + const srcValue = src[srcIdx]; + const dstValue = dst[dstIdx]; + this.diff(pfx + srcIdx, srcValue, dstValue); + break; + case line.LINE_PATCH_OP_TYPE.INS: + patch.push({op: 'add', path: pfx + (srcIdx + 1), value: dst[dstIdx]}); + break; + case line.LINE_PATCH_OP_TYPE.DEL: + patch.push({op: 'remove', path: pfx + srcIdx}); + break; + } + } } public diffAny(path: string, src: unknown, dst: unknown): void { diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/Diff.spec.ts index 0fb4a65fa8..38af40974d 100644 --- a/src/json-patch-diff/__tests__/Diff.spec.ts +++ b/src/json-patch-diff/__tests__/Diff.spec.ts @@ -181,7 +181,7 @@ describe('arr', () => { assertDiff(src, dst); }); - test.only('fuzzer - 1', () => { + test('fuzzer - 1', () => { const src: unknown[] = [ 11, 10, 4, 6, 3, 1, 5 From 11eaaa8f62a7d08f9ebbe29e8d262b0b439357c5 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 01:17:58 +0200 Subject: [PATCH 60/68] =?UTF-8?q?fix(util):=20=F0=9F=90=9B=20improve=20lin?= =?UTF-8?q?e=20diff=20assignment,=20use=20a=20newline=20char=20per=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/line.spec.ts | 4 ++-- src/util/diff/line.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index aee53e24f5..c722ea69a0 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -540,8 +540,8 @@ describe("diff", () => { // console.log(patch); expect(patch).toEqual([ [ 1, -1, 0 ], - [ -1, 0, 0 ], - [ 2, 1, 1 ], + [ 2, 0, 1 ], + [ -1, 1, 1 ], ]); }); }); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 5d7860b5da..f4aa9ddee9 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -203,8 +203,8 @@ export const agg = (patch: str.Patch): str.Patch[] => { }; export const diff = (src: string[], dst: string[]): LinePatch => { - const srcTxt = src.join("\n"); - const dstTxt = dst.join("\n"); + const srcTxt = src.join("\n") + '\n'; + const dstTxt = dst.join("\n") + '\n'; const strPatch = str.diff(srcTxt, dstTxt); const lines = agg(strPatch); const length = lines.length; @@ -251,9 +251,9 @@ export const diff = (src: string[], dst: string[]): LinePatch => { dstIdx++; } } else { - if (lineLength === 1) { - const op = line[0]; - const type = op[0]; + const op = line[0]; + const type = op[0]; + if (lineLength === 1 && type === lastOpType) { if (type === str.PATCH_OP_TYPE.EQL) { srcIdx++; dstIdx++; From ff9719248c2a3c189fcae84613835248088fe38a Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 11:09:31 +0200 Subject: [PATCH 61/68] =?UTF-8?q?feat(json-crdt-diff):=20=F0=9F=8E=B8=20us?= =?UTF-8?q?e=20line=20diff=20in=20JSON=20CRDT=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/Diff.ts | 66 ++++++++++++----------- src/json-crdt-diff/__tests__/Diff.spec.ts | 1 + src/util/diff/line.ts | 1 + 3 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/Diff.ts index 24324a7f52..f34adb40a5 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/Diff.ts @@ -4,10 +4,10 @@ import {ITimespanStruct, type ITimestampStruct, Patch, PatchBuilder, Timespan} f import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; import * as str from '../util/diff/str'; import * as bin from '../util/diff/bin'; -import * as arr from '../util/diff/arr'; -import type {Model} from '../json-crdt/model'; +import * as line from '../util/diff/line'; import {structHashCrdt} from '../json-hash/structHashCrdt'; import {structHash} from '../json-hash'; +import type {Model} from '../json-crdt/model'; export class DiffError extends Error { constructor(message: string = 'DIFF') { @@ -43,49 +43,55 @@ export class Diff { } protected diffArr(src: ArrNode, dst: unknown[]): void { - let txtSrc = ''; - let txtDst = ''; - const dstLen = dst.length; + const srcLines: string[] = []; src.children(node => { - txtSrc += structHashCrdt(node) + '\n'; + srcLines.push(structHashCrdt(node)); }); - for (let i = 0; i < dstLen; i++) txtDst += structHash(dst[i]) + '\n'; - txtSrc = txtSrc.slice(0, -1); - txtDst = txtDst.slice(0, -1); + const dstLines: string[] = []; + const dstLength = dst.length; + for (let i = 0; i < dstLength; i++) dstLines.push(structHash(dst[i])); + const linePatch = line.diff(srcLines, dstLines); + if (!linePatch.length) return; const inserts: [after: ITimestampStruct, views: unknown[]][] = []; const deletes: ITimespanStruct[] = []; - const patch = arr.diff(txtSrc, txtDst); - // console.log(patch); - arr.apply(patch, - (posSrc, posDst, len) => { - const views: unknown[] = dst.slice(posDst, posDst + len); - const after = posSrc ? src.find(posSrc - 1) : src.id; - if (!after) throw new DiffError(); - inserts.push([after, views]); - }, - (pos, len) => deletes.push(...src.findInterval(pos, len)!), - (posSrc, posDst, len) => { - for (let i = 0; i < len; i++) { - const srcIdx = posSrc + i; - const dstIdx = posDst + i; - const view = dst[dstIdx]; + const patchLength = linePatch.length; + for (let i = patchLength - 1; i >= 0; i--) { + const [type, posSrc, posDst] = linePatch[i]; + switch (type) { + case line.LINE_PATCH_OP_TYPE.EQL: + break; + case line.LINE_PATCH_OP_TYPE.INS: { + const view = dst[posDst]; + const after = posSrc >= 0 ? src.find(posSrc) : src.id; + if (!after) throw new DiffError(); + inserts.push([after, [view]]); + break; + } + case line.LINE_PATCH_OP_TYPE.DEL: { + const span = src.findInterval(posSrc, 1); + if (!span || !span.length) throw new DiffError(); + deletes.push(...span); + break; + } + case line.LINE_PATCH_OP_TYPE.MIX: { + const view = dst[posDst]; try { - this.diffAny(src.getNode(srcIdx)!, view); + this.diffAny(src.getNode(posSrc)!, view); } catch (error) { if (error instanceof DiffError) { - const span = src.findInterval(srcIdx, 1)!; + const span = src.findInterval(posSrc, 1)!; deletes.push(...span); - const after = srcIdx ? src.find(srcIdx - 1) : src.id; + const after = posSrc ? src.find(posSrc - 1) : src.id; if (!after) throw new DiffError(); inserts.push([after, [view]]); } else throw error; } } - }, - ); + } + } const builder = this.builder; const length = inserts.length; - for (let i = length - 1; i >= 0; i--) { + for (let i = 0; i < length; i++) { const [after, views] = inserts[i]; builder.insArr(src.id, after, views.map(view => builder.json(view))) } diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/Diff.spec.ts index 11662acdd6..3a0526eef0 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff.spec.ts @@ -7,6 +7,7 @@ import {b} from '@jsonjoy.com/util/lib/buffers/b'; const assertDiff = (model: Model, src: JsonNode, dst: unknown) => { const patch1 = new Diff(model).diff(src, dst); // console.log(model + ''); + // console.log(dst); // console.log(patch1 + ''); model.applyPatch(patch1); // console.log(model + ''); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index f4aa9ddee9..3e2da064d1 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -205,6 +205,7 @@ export const agg = (patch: str.Patch): str.Patch[] => { export const diff = (src: string[], dst: string[]): LinePatch => { const srcTxt = src.join("\n") + '\n'; const dstTxt = dst.join("\n") + '\n'; + if (srcTxt === dstTxt) return []; const strPatch = str.diff(srcTxt, dstTxt); const lines = agg(strPatch); const length = lines.length; From cc166d97bb5913ac86ba5227bd8b9f33ba7ff5fe Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 11:12:29 +0200 Subject: [PATCH 62/68] =?UTF-8?q?test(json-crdt-diff):=20=F0=9F=92=8D=20ad?= =?UTF-8?q?d=20fuzz=20testing=20for=20arrays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/Diff-fuzzing.spec.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts b/src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts index ca55ab6a0f..4421c790a8 100644 --- a/src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts +++ b/src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts @@ -26,3 +26,28 @@ test('from random JSON to random JSON', () => { assertDiff(src, dst); } }); + +test('two random arrays of integers', () => { + const iterations = 100; + + const randomArray = () => { + const len = Math.floor(Math.random() * 10); + const arr: unknown[] = []; + for (let i = 0; i < len; i++) { + arr.push(Math.ceil(Math.random() * 13)); + } + return arr; + }; + + for (let i = 0; i < iterations; i++) { + const src = randomArray(); + const dst = randomArray(); + try { + assertDiff(src, dst); + } catch (error) { + console.error('src', src); + console.error('dst', dst); + throw error; + } + } +}); From 941dbd513f7ed9909078dedd092fbf995eaac943 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 11:26:32 +0200 Subject: [PATCH 63/68] =?UTF-8?q?test:=20=F0=9F=92=8D=20fixup=20all=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/__tests__/arr.spec.ts | 298 -------------------- src/util/diff/__tests__/line-fuzzer.spec.ts | 18 +- src/util/diff/__tests__/line.spec.ts | 154 ++++++---- src/util/diff/__tests__/line.ts | 24 ++ src/util/diff/arr.ts | 210 -------------- 5 files changed, 123 insertions(+), 581 deletions(-) delete mode 100644 src/util/diff/__tests__/arr.spec.ts create mode 100644 src/util/diff/__tests__/line.ts delete mode 100644 src/util/diff/arr.ts diff --git a/src/util/diff/__tests__/arr.spec.ts b/src/util/diff/__tests__/arr.spec.ts deleted file mode 100644 index 5739743bab..0000000000 --- a/src/util/diff/__tests__/arr.spec.ts +++ /dev/null @@ -1,298 +0,0 @@ -import * as arr from '../arr'; - -describe('matchLines()', () => { - test('empty', () => { - const matches = arr.matchLines([], []); - expect(matches).toEqual([]); - }); - - test('empty - 2', () => { - const matches = arr.matchLines(['1'], []); - expect(matches).toEqual([]); - }); - - test('empty - 3', () => { - const matches = arr.matchLines([], ['1']); - expect(matches).toEqual([]); - }); - - test('single element', () => { - const matches = arr.matchLines(['1'], ['1']); - expect(matches).toEqual([0, 0]); - }); - - test('two elements', () => { - const matches = arr.matchLines(['1', '2'], ['1', '2']); - expect(matches).toEqual([0, 0, 1, 1]); - }); - - test('two elements with one in the middle', () => { - const matches = arr.matchLines(['1', '2'], ['1', '3', '2']); - expect(matches).toEqual([0, 0, 1, 2]); - }); - - test('two elements with one in the middle - 2', () => { - const matches = arr.matchLines(['1', '3', '2'], ['1', '2']); - expect(matches).toEqual([0, 0, 2, 1]); - }); - - test('complex case', () => { - const matches = arr.matchLines(['1', '2', '3', '4', '5', '6', '7'], ['0', '1', '2', '5', 'x', 'y', 'z', 'a', 'b', '7', '8']); - expect(matches).toEqual([0, 1, 1, 2, 4, 3, 6, 9]); - }); -}); - -describe('diff()', () => { - test('can match various equal lines', () => { - const patch = arr.diff( - ['0', '1', '3', 'x', 'y', '4', '5'], - ['1', '2', '3', '4', 'a', 'b', 'c', '5'], - ); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.INSERT, 3, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); - }); - - test('replace whole list', () => { - const patch = arr.diff([ 'a', 'x' ], [ 'b', 'c', 'd' ]); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.INSERT, 2, - ]); - }); - - test('insert into empty list', () => { - const patch = arr.diff([], ['1']); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.INSERT, 1]); - }); - - test('both empty', () => { - const patch = arr.diff([], []); - expect(patch).toEqual([]); - }); - - test('keep one', () => { - const patch = arr.diff(['1'], ['1']); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.EQUAL, 1]); - }); - - test('keep two', () => { - const patch = arr.diff(['1', '1'], ['1', '1']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); - }); - - test('keep three', () => { - const patch = arr.diff(['1', '1', '2'], ['1', '1', '2']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 3, - ]); - }); - - test('keep two, delete one', () => { - const patch = arr.diff(['1', '1', '2'], ['1', '1']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - ]); - }); - - test('keep two, delete in the middle', () => { - const patch = arr.diff(['1', '2', '3'], ['1', '3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); - }); - - test('keep two, delete the first one', () => { - const patch = arr.diff(['1', '2', '3'], ['2', '3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); - }); - - test('fuzzer - 1', () => { - const src = [ 'b', 'a' ]; - const dst = [ - '7', '3', 'd', - '7', '9', '9', - '9' - ]; - // [ 1, 2, -1, 2, 1, 4 ] - const patch = arr.diff(src, dst); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.INSERT, 2, - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.INSERT, 5, - ]); - }); - - describe('delete', () => { - test('delete the only element', () => { - const patch = arr.diff(['1'], []); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 1]); - }); - - test('delete the only two element', () => { - const patch = arr.diff(['1', '{}'], []); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DELETE, 2]); - }); - - test('delete two and three in a row', () => { - const patch = arr.diff(['1', '2', '3', '4', '5', '6'], ['3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 3, - ]); - }); - - test('delete the first one', () => { - const patch = arr.diff(['1', '2', '3'], ['2', '3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); - }); - - test('delete the middle element', () => { - const patch = arr.diff(['1', '2', '3'], ['1', '3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); - }); - - test('delete the last element', () => { - const patch = arr.diff(['1', '2', '3'], ['1', '2']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - ]); - }); - - test('delete two first elements', () => { - const patch = arr.diff(['1', '2', '3', '4'], ['3', '4']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.EQUAL, 2, - ]); - }); - - test('preserve one and delete one', () => { - const patch = arr.diff(['1', '2'], ['1']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - ]); - }); - - test('preserve one and delete one (reverse)', () => { - const patch = arr.diff(['1', '2'], ['2']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); - }); - - test('various deletes and inserts', () => { - const patch = arr.diff(['1', '2', '3', '3', '5', '{a:4}', '5', '"6"'], ['1', '2', '3', '5', '{a:4}', '5', '"6"', '6']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 3, - arr.ARR_PATCH_OP_TYPE.DELETE, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 4, - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - ]); - }); - - test('deletes both elements and replaces by one', () => { - const patch = arr.diff(['0', '1'], ['xyz']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.DELETE, 2, - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - ]); - }); - }); - - describe('diff', () => { - test('diffs partially matching single element', () => { - const patch = arr.diff(['[]'], ['[1]']); - expect(patch).toEqual([arr.ARR_PATCH_OP_TYPE.DIFF, 1]); - }); - - test('diffs second element', () => { - const patch = arr.diff(['1', '[]'], ['1', '[1]']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - ]); - }); - - test('diffs middle element', () => { - const patch = arr.diff(['1', '2', '3'], ['1', '[2]', '3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); - }); - - test('diffs middle element - 2', () => { - const patch = arr.diff(['1', '[1,2,3,4]', '3'], ['1', '[1,3,455]', '3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); - }); - - test('diffs two consecutive elements', () => { - const patch = arr.diff(['1', '[1,2,3,4]', '3'], ['1', '[1,3,455]', '[3]']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 2, - ]); - }); - - test('diffs middle element', () => { - const patch = arr.diff(['1', '[1,2,3,4]', '3'], ['1', '[1,2,3,5]', '3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); - }); - - test('diffs middle element - 2', () => { - const patch = arr.diff(['1', '[1,2,3,4]', '3'], ['1', '[1,4,3,5]', '3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - arr.ARR_PATCH_OP_TYPE.EQUAL, 1, - ]); - }); - - test('insert first element, diff second', () => { - const patch = arr.diff(['[2]'], ['1', '2', '3']); - expect(patch).toEqual([ - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - arr.ARR_PATCH_OP_TYPE.DIFF, 1, - arr.ARR_PATCH_OP_TYPE.INSERT, 1, - ]); - }); - }); -}); diff --git a/src/util/diff/__tests__/line-fuzzer.spec.ts b/src/util/diff/__tests__/line-fuzzer.spec.ts index 56eb380360..5d7c552113 100644 --- a/src/util/diff/__tests__/line-fuzzer.spec.ts +++ b/src/util/diff/__tests__/line-fuzzer.spec.ts @@ -1,21 +1,5 @@ import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; -import * as line from '../line'; - -const assertDiff = (src: string[], dst: string[]) => { - const diff = line.diff(src, dst); - const res: string[] = []; - for (let [type, srcIdx, dstIdx, patch] of diff) { - if (type === line.LINE_PATCH_OP_TYPE.DEL) { - } else if (type === line.LINE_PATCH_OP_TYPE.INS) { - res.push(dst[dstIdx]); - } else if (type === line.LINE_PATCH_OP_TYPE.EQL) { - res.push(src[srcIdx]); - } else if (type === line.LINE_PATCH_OP_TYPE.MIX) { - res.push(dst[dstIdx]); - } - } - expect(res).toEqual(dst); -}; +import {assertDiff} from './line'; const iterations = 1000; const minElements = 2; diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index c722ea69a0..b60c24472c 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -1,4 +1,5 @@ import * as line from "../line"; +import { assertDiff } from "./line"; describe("diff", () => { test("delete all lines", () => { @@ -396,6 +397,57 @@ describe("diff", () => { ]); }); + test("various examples", () => { + assertDiff( + ["0", "1", "3", "x", "y", "4", "5"], + ["1", "2", "3", "4", "a", "b", "c", "5"] + ); + assertDiff(["a", "x"], ["b", "c", "d"]); + assertDiff([], []); + assertDiff(["1"], []); + assertDiff([], ["1"]); + assertDiff(["1"], ["1"]); + assertDiff(["1", "2"], ["1", "2"]); + assertDiff(["1", "2"], ["1", "3", "2"]); + assertDiff(["1", "3", "2"], ["1", "2"]); + assertDiff( + ["1", "2", "3", "4", "5", "6", "7"], + ["0", "1", "2", "5", "x", "y", "z", "a", "b", "7", "8"] + ); + assertDiff([], ["1"]); + assertDiff([], []); + assertDiff(["1"], ["1"]); + assertDiff(["1", "1"], ["1", "1"]); + assertDiff(["1", "1", "2"], ["1", "1", "2"]); + assertDiff(["1", "1", "2"], ["1", "1"]); + assertDiff(["1", "2", "3"], ["1", "3"]); + assertDiff(["1", "2", "3"], ["2", "3"]); + assertDiff(["b", "a"], ["7", "3", "d", "7", "9", "9", "9"]); + assertDiff(["1"], []); + assertDiff(["1", "{}"], []); + assertDiff(["1", "2", "3", "4", "5", "6"], ["3"]); + assertDiff(["1", "2", "3"], ["2", "3"]); + assertDiff(["1", "2", "3"], ["1", "3"]); + assertDiff(["1", "2", "3"], ["1", "2"]); + assertDiff(["1", "2", "3", "4"], ["3", "4"]); + assertDiff(["1", "2"], ["1"]); + assertDiff(["1", "2"], ["2"]); + assertDiff( + ["1", "2", "3", "3", "5", "{a:4}", "5", '"6"'], + ["1", "2", "3", "5", "{a:4}", "5", '"6"', "6"] + ); + assertDiff(["0", "1"], ["xyz"]); + + assertDiff(["[]"], ["[1]"]); + assertDiff(["1", "[]"], ["1", "[1]"]); + assertDiff(["1", "2", "3"], ["1", "[2]", "3"]); + assertDiff(["1", "[1,2,3,4]", "3"], ["1", "[1,3,455]", "3"]); + assertDiff(["1", "[1,2,3,4]", "3"], ["1", "[1,3,455]", "[3]"]); + assertDiff(["1", "[1,2,3,4]", "3"], ["1", "[1,2,3,5]", "3"]); + assertDiff(["1", "[1,2,3,4]", "3"], ["1", "[1,4,3,5]", "3"]); + assertDiff(["[2]"], ["1", "2", "3"]); + }); + test("fuzzer - 1", () => { const src = [ '{"KW*V":"Wj6/Y1mgmm6n","uP1`NNND":{")zR8r|^KR":{}},"YYyO7.+>#.6AQ?U":"1%EA(q+S!}*","b\\nyc*o.":487228790.90332836}', @@ -407,18 +459,8 @@ describe("diff", () => { ]; const patch = line.diff(src, dst); expect(patch).toEqual([ - [ - -1, - 0, - -1, - expect.any(Array), - ], - [ - 2, - 1, - 0, - expect.any(Array), - ], + [-1, 0, -1, expect.any(Array)], + [2, 1, 0, expect.any(Array)], [ -1, 2, @@ -435,26 +477,26 @@ describe("diff", () => { test("fuzzer - 2 (simplified)", () => { const src = [ - '{asdfasdfasdf}', - '{12341234123412341234}', + "{asdfasdfasdf}", + "{12341234123412341234}", `{zzzzzzzzzzzzzzzzzz}`, - '{12341234123412341234}', - '{00000000000000000000}', - '{12341234123412341234}' + "{12341234123412341234}", + "{00000000000000000000}", + "{12341234123412341234}", ]; const dst = [ - '{asdfasdfasdf}', + "{asdfasdfasdf}", `{zzzzzzzzzzzzzzzzzz}`, - '{00000000000000000000}', + "{00000000000000000000}", ]; const patch = line.diff(src, dst); expect(patch).toEqual([ - [ 0, 0, 0, expect.any(Array) ], - [ -1, 1, 0, expect.any(Array) ], - [ 0, 2, 1, expect.any(Array) ], - [ -1, 3, 1, expect.any(Array) ], - [ 0, 4, 2, expect.any(Array) ], - [ -1, 5, 2, expect.any(Array) ], + [0, 0, 0, expect.any(Array)], + [-1, 1, 0, expect.any(Array)], + [0, 2, 1, expect.any(Array)], + [-1, 3, 1, expect.any(Array)], + [0, 4, 2, expect.any(Array)], + [-1, 5, 2, expect.any(Array)], ]); }); @@ -465,42 +507,42 @@ describe("diff", () => { `{"c25}_Q/jJsc":"JE4\\\\{","f} ":"\\"D='qW]Lq#v^","md{*%1y[":81520766.60595253,"e[3OT]-N-!*g90K1":320733106.7235495,"\\"yteVM6&PI":"8fC Og8:+6(A"}`, '{"T{Ugtn}B-]Wm`ZK":{"*VJlpfRw":697504436.1312399},"s.BOS9;bv_ZA3oD":{},"|Ir":[879007792.6766524]}', '{"K!Lr|=PykM":"Q8W6","{K. i`e{;M{)C=@b":-97,"+[":";\'}HLR4Q2To:Gw>","Zoypj-Ock^\'":714499113.6419818,"j::O\\"ON.^iud#":{}}', - '{"{\\\\^]wa":[",M/u= |Nu=,2J"],"\\\\D6;;h-,O\\\\-|":181373753.3018791,"[n6[!Z)4":"6H:p-N(uM","sK\\\\8C":[]}' + '{"{\\\\^]wa":[",M/u= |Nu=,2J"],"\\\\D6;;h-,O\\\\-|":181373753.3018791,"[n6[!Z)4":"6H:p-N(uM","sK\\\\8C":[]}', ]; const dst = [ '{"qED5","Zoypj-Ock^\'":714499113.6419818,"j::O\\"ON.^iud#":{}}' + '{"K!Lr|=PykM":"Q8W6","{K. i`e{;M{)C=@b":-97,"+[":";\'}HLR4Q2To:Gw>","Zoypj-Ock^\'":714499113.6419818,"j::O\\"ON.^iud#":{}}', ]; const patch = line.diff(src, dst); expect(patch).toEqual([ - [ 0, 0, 0, expect.any(Array) ], - [ -1, 1, 0, expect.any(Array) ], - [ 0, 2, 1, expect.any(Array) ], - [ -1, 3, 1, expect.any(Array) ], - [ 0, 4, 2, expect.any(Array) ], - [ -1, 5, 2, expect.any(Array) ], + [0, 0, 0, expect.any(Array)], + [-1, 1, 0, expect.any(Array)], + [0, 2, 1, expect.any(Array)], + [-1, 3, 1, expect.any(Array)], + [0, 4, 2, expect.any(Array)], + [-1, 5, 2, expect.any(Array)], ]); }); test("fuzzer - 3", () => { const src = [ - '{aaaaaaaaaaa}', - '{bbbbbbbbbbb}', + "{aaaaaaaaaaa}", + "{bbbbbbbbbbb}", '{"75":259538477846144,"dadqM`0I":322795818.54331195,"<":"f*ßlwäm&=_y@w\\n","53aghXOyD%lC2":373122194.60806453,"\\\\9=M!\\"\\\\Tl-":"r.VdPY`mOQ"}', - '{11111111111111111111}', + "{11111111111111111111}", ]; const dst = [ '{"\\\\ 3[9}0dz+FaW\\"M":"rX?","P.Ed-s-VgiQDuNk":"18","}56zyy3FnC":[" [x[0], x[1], x[2]]) + const patch = line.diff(src, dst).map((x) => [x[0], x[1], x[2]]); expect(patch).toEqual([ - [ -1, 0, -1 ], - [ 2, 1, 0 ], - [ 0, 2, 1 ], - [ 2, 3, 2 ], + [-1, 0, -1], + [2, 1, 0], + [0, 2, 1], + [2, 3, 2], ]); }); @@ -509,39 +551,39 @@ describe("diff", () => { '{"fE#vTih,M!q+TTR":-8702114011119315,"`F\\"M9":true,"]9+FC9f{48NnX":{"+\\\\]IQ7":"a;br-^_m"},"s&":"%n18QdrUewc8Nh8<"}', '{"<\\"R}d\\"HY65":[53195032.194879085,710289417.4711887],"WH]":"qqqqqqqqqq","W&0fQhOd8":96664625.24402197}', '{"!2{:XVc3":[814507837.3286607,"A+m+}=p$Y&T"],"?[Tks9wg,pRLz.G":[[]]}', - '{"X^бbAq,":247853730.363063,"+ Mkjq_":-7253373307869407,"`J\\"[^)W KVFk":{"I&a?\\\\\\"1q\\\\":{"66666666666666":">}v1I7y48`JJIG5{"}}}' + '{"X^бbAq,":247853730.363063,"+ Mkjq_":-7253373307869407,"`J\\"[^)W KVFk":{"I&a?\\\\\\"1q\\\\":{"66666666666666":">}v1I7y48`JJIG5{"}}}', ]; const dst = [ '{"fE#vTih,M!q+TTR":-8702114011119315,"`F\\"M9":true,"]9+FC9f{48NnX":{"+\\\\]IQ7":"a;br-^_m"},"s&":"%n18QdrUewc8Nh8<"}', '{"!2{:XVc3":[814507837.3286607,"A+m+}=p$Y&T"],"?[Tks9wg,pRLz.G":[[]]}', `{"}'-":["o=^\\\\tXk@4",false],"*nF(tbVE=L\\"LiA":-17541,"5a,?p8=]TBLT_x^":916988130.3227228}`, - `{"+.i5D's>W4#EJ%7B":">IYF9h","IeK?Dg{/3>hq7\\\\B[":64967,"KI,cnб!Ty%":2913242861126036,"rv9O@j":false,"dj":"N>"}` + `{"+.i5D's>W4#EJ%7B":">IYF9h","IeK?Dg{/3>hq7\\\\B[":64967,"KI,cnб!Ty%":2913242861126036,"rv9O@j":false,"dj":"N>"}`, ]; - const patch = line.diff(src, dst).map(x => [x[0], x[1], x[2]]) + const patch = line.diff(src, dst).map((x) => [x[0], x[1], x[2]]); expect(patch).toEqual([ - [ 0, 0, 0 ], - [ -1, 1, 0 ], - [ 0, 2, 1 ], - [ 1, 2, 2 ], - [ 2, 3, 3 ], + [0, 0, 0], + [-1, 1, 0], + [0, 2, 1], + [1, 2, 2], + [2, 3, 3], ]); }); test("fuzzer - 5", () => { const src = [ '{"1111":[true,true],"111111111111111":-34785,"YRb#H`%Q`9yQ;":"S@>/8#"}', - '{"$?":145566270.31451553,"&;\\\\V":729010872.7196132,"B4Xm[[X4":"WLFBc>*popRot]Y",") 8a%d@":811080332.6947087,"LnRab_vKhgz":"%"}' + '{"$?":145566270.31451553,"&;\\\\V":729010872.7196132,"B4Xm[[X4":"WLFBc>*popRot]Y",") 8a%d@":811080332.6947087,"LnRab_vKhgz":"%"}', ]; const dst = [ `{"YC9rf7Kg3fI(":"=aEe5Jw7R)m\\\\0Q","b-)-xPNm3":"1%","MHPcv?h\\"'j\\\\z;$?>":[],"LybE:":"|xWDk9r|s%:O0%(","/y@Uz433>:l[%":true}`, - '{"1111":[true,true],"111111111111111":-34785,"YRb#H`%Q`9yQ;":"S@>/8#"}' + '{"1111":[true,true],"111111111111111":-34785,"YRb#H`%Q`9yQ;":"S@>/8#"}', ]; - const patch = line.diff(src, dst).map(x => [x[0], x[1], x[2]]) + const patch = line.diff(src, dst).map((x) => [x[0], x[1], x[2]]); // console.log(patch); expect(patch).toEqual([ - [ 1, -1, 0 ], - [ 2, 0, 1 ], - [ -1, 1, 1 ], + [1, -1, 0], + [2, 0, 1], + [-1, 1, 1], ]); }); }); diff --git a/src/util/diff/__tests__/line.ts b/src/util/diff/__tests__/line.ts new file mode 100644 index 0000000000..bde03bc658 --- /dev/null +++ b/src/util/diff/__tests__/line.ts @@ -0,0 +1,24 @@ +import * as line from '../line'; + +export const assertDiff = (src: string[], dst: string[]) => { + // console.log('src', src); + // console.log('dst', dst); + const diff = line.diff(src, dst); + // console.log(diff); + const res: string[] = []; + if (diff.length) { + for (let [type, srcIdx, dstIdx, patch] of diff) { + if (type === line.LINE_PATCH_OP_TYPE.DEL) { + } else if (type === line.LINE_PATCH_OP_TYPE.INS) { + res.push(dst[dstIdx]); + } else if (type === line.LINE_PATCH_OP_TYPE.EQL) { + res.push(src[srcIdx]); + } else if (type === line.LINE_PATCH_OP_TYPE.MIX) { + res.push(dst[dstIdx]); + } + } + } else { + res.push(...src); + } + expect(res).toEqual(dst); +}; diff --git a/src/util/diff/arr.ts b/src/util/diff/arr.ts deleted file mode 100644 index f7f00917ec..0000000000 --- a/src/util/diff/arr.ts +++ /dev/null @@ -1,210 +0,0 @@ -import {strCnt} from "../strCnt"; -import * as str from "./str"; - -export const enum ARR_PATCH_OP_TYPE { - DELETE = str.PATCH_OP_TYPE.DEL, - EQUAL = str.PATCH_OP_TYPE.EQL, - INSERT = str.PATCH_OP_TYPE.INS, - DIFF = 2, -} - -/** - * The patch type for the array diff. Consists of an even length array of - * numbers, where the first element of the pair is the operation type - * {@link ARR_PATCH_OP_TYPE} and the second element is the length of the - * operation. - */ -export type ArrPatch = number[]; - -/** - * Matches exact lines in the source and destination arrays. - * - * @param src Source array of lines. - * @param dst Destination array of lines. - * @returns An even length array of numbers, where each pair of numbers - * an index in the source array and an index in the destination array. - */ -export const matchLines = (src: string[], dst: string[]): number[] => { - let dstIndex = 0; - const slen = src.length; - const dlen = dst.length; - const result: number[] = []; - SRC: for (let srcIndex = 0; srcIndex < slen; srcIndex++) { - const s = src[srcIndex]; - DST: for (let i = dstIndex; i < dlen; i++) { - const d = dst[i]; - if (s === d) { - result.push(srcIndex, i); - dstIndex = i + 1; - if (dstIndex >= dlen) break SRC; - continue SRC; - } - } - } - return result; -}; - -const enum PARTIAL_TYPE { - REPLACE = 8, - NONE = 9, -} - -const diffLines = (srcTxt: string, dstTxt: string): ArrPatch => { - const arrPatch: ArrPatch = []; - const patch = str.diff(srcTxt, dstTxt); - if (patch.length === 1) { - if ((patch[0][0] as unknown as ARR_PATCH_OP_TYPE) === ARR_PATCH_OP_TYPE.INSERT) { - arrPatch.push(ARR_PATCH_OP_TYPE.INSERT, strCnt("\n", dstTxt) + 1); - return arrPatch; - } - } - const push = (type: ARR_PATCH_OP_TYPE, count: number) => { - const length = arrPatch.length; - if (length !== 0) { - const lastType = arrPatch[length - 2] as unknown as ARR_PATCH_OP_TYPE; - if (lastType === type) { - arrPatch[length - 1] = (arrPatch[length - 1] as unknown as number) + count; - return; - } - } - arrPatch.push(type, count); - }; - // console.log(srcTxt); - // console.log(dstTxt); - // console.log(patch); - const patchLen = patch.length; - const lastOpIndex = patchLen - 1; - let partial: ARR_PATCH_OP_TYPE | PARTIAL_TYPE = PARTIAL_TYPE.NONE; - for (let i = 0; i <= lastOpIndex; i++) { - const isLastOp = i === lastOpIndex; - const op = patch[i]; - const type: ARR_PATCH_OP_TYPE = op[0] as unknown as ARR_PATCH_OP_TYPE; - const txt = op[1]; - if (!txt) continue; - let lineStartOffset = 0; - if (partial !== PARTIAL_TYPE.NONE) { - const index = txt.indexOf("\n"); - const isImmediateFlush = index === 0; - const flushPartial = isImmediateFlush || (isLastOp && partial === ARR_PATCH_OP_TYPE.DELETE && type === ARR_PATCH_OP_TYPE.INSERT); - if (flushPartial) { - lineStartOffset = 1; - if (isImmediateFlush && partial === PARTIAL_TYPE.REPLACE) { - push(ARR_PATCH_OP_TYPE.DELETE, 1); - push(ARR_PATCH_OP_TYPE.INSERT, 1); - } else { - push(partial, 1); - } - partial = PARTIAL_TYPE.NONE; - } else { - if (index < 0 && !isLastOp) { - partial = partial === ARR_PATCH_OP_TYPE.DELETE && (type === ARR_PATCH_OP_TYPE.INSERT) ? PARTIAL_TYPE.REPLACE : ARR_PATCH_OP_TYPE.DIFF; - continue; - } - if (partial === ARR_PATCH_OP_TYPE.DELETE && type === ARR_PATCH_OP_TYPE.INSERT) { - const lineCount = strCnt("\n", txt, lineStartOffset) + (isLastOp ? 1 : 0); - push(ARR_PATCH_OP_TYPE.INSERT, lineCount); - continue; - } - push(ARR_PATCH_OP_TYPE.DIFF, 1); - if (index < 0) break; - lineStartOffset = index + 1; - partial = PARTIAL_TYPE.NONE; - } - } - const lineCount = strCnt("\n", txt, lineStartOffset) + (isLastOp ? 1 : 0); - const isPartial = txt[txt.length - 1] !== "\n"; - if (isPartial) { - if (partial === PARTIAL_TYPE.NONE) partial = type; - else partial = (partial as unknown as ARR_PATCH_OP_TYPE) === type - ? (type as unknown as ARR_PATCH_OP_TYPE) : ARR_PATCH_OP_TYPE.DIFF; - } - if (!lineCount) continue; - if (type === ARR_PATCH_OP_TYPE.EQUAL) push(ARR_PATCH_OP_TYPE.EQUAL, lineCount); - else if (type === ARR_PATCH_OP_TYPE.INSERT) push(ARR_PATCH_OP_TYPE.INSERT, lineCount); - else push(ARR_PATCH_OP_TYPE.DELETE, lineCount); - } - return arrPatch; -}; - -export const diff = (src: string[], dst: string[]): ArrPatch => { - // console.log(src); - // console.log(dst); - const matches = matchLines(src, dst); - // console.log('MATCHES', matches); - const length = matches.length; - let lastSrcIndex = -1; - let lastDstIndex = -1; - let patch: ArrPatch = []; - for (let i = 0; i <= length; i += 2) { - const isLast = i === length; - const srcIndex = isLast ? src.length : matches[i]; - const dstIndex = isLast ? dst.length : matches[i + 1]; - if (lastSrcIndex + 1 !== srcIndex && lastDstIndex + 1 === dstIndex) { - patch.push(ARR_PATCH_OP_TYPE.DELETE, srcIndex - lastSrcIndex - 1); - } else if (lastSrcIndex + 1 === srcIndex && lastDstIndex + 1 !== dstIndex) { - patch.push(ARR_PATCH_OP_TYPE.INSERT, dstIndex - lastDstIndex - 1); - } else if (lastSrcIndex + 1 !== srcIndex && lastDstIndex + 1 !== dstIndex) { - const srcLines = src.slice(lastSrcIndex + 1, srcIndex); - const dstLines = dst.slice(lastDstIndex + 1, dstIndex); - const diffPatch = diffLines(srcLines.join("\n"), dstLines.join("\n")); - if (diffPatch.length) { - const patchLength = patch.length; - if (patchLength > 0 && patch[patchLength - 2] === diffPatch[0]) { - patch[patchLength - 1] += diffPatch[1]; - patch = patch.concat(diffPatch.slice(2)); - } else patch = patch.concat(diffPatch); - } - } - if (isLast) break; - if (patch.length > 0 && patch[patch.length - 2] === ARR_PATCH_OP_TYPE.EQUAL) patch[patch.length - 1]++; - else patch.push(ARR_PATCH_OP_TYPE.EQUAL, 1); - lastSrcIndex = srcIndex; - lastDstIndex = dstIndex; - } - return patch; -}; - -/** - * Applies the array patch to the source array. The source array is assumed to - * be materialized after the patch application, i.e., the positions in the - * patch are relative to the source array, they do not shift during the - * application. - * - * @param patch Array patch to apply. - * @param onInsert Callback for insert operations. `posSrc` is the position - * between the source elements, starting from 0. `posDst` is the destination - * element position, starting from 0. - * @param onDelete Callback for delete operations. `pos` is the position of - * the source element, starting from 0. - * @param onDiff Callback for diff operations. `posSrc` and `posDst` are the - * positions of the source and destination elements, respectively, starting - * from 0. - */ -export const apply = ( - patch: ArrPatch, - onInsert: (posSrc: number, posDst: number, len: number) => void, - onDelete: (pos: number, len: number) => void, - onDiff: (posSrc: number, posDst: number, len: number) => void, -) => { - const length = patch.length; - let posSrc = 0; - let posDst = 0; - for (let i = 0; i < length; i += 2) { - const type = patch[i] as ARR_PATCH_OP_TYPE; - const len = patch[i + 1] as unknown as number; - if (type === ARR_PATCH_OP_TYPE.EQUAL) { - posSrc += len; - posDst += len; - } else if (type === ARR_PATCH_OP_TYPE.INSERT) { - onInsert(posSrc, posDst, len); - posDst += len; - } else if (type === ARR_PATCH_OP_TYPE.DELETE) { - onDelete(posSrc, len); - posSrc += len; - } else if (type === ARR_PATCH_OP_TYPE.DIFF) { - onDiff(posSrc, posDst, len); - posSrc += len; - posDst += len; - } - } -}; From 0330f0d4c1ce7c13b49926c249f50e844dd65d62 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 11:44:59 +0200 Subject: [PATCH 64/68] =?UTF-8?q?perf(util):=20=E2=9A=A1=EF=B8=8F=20cleanu?= =?UTF-8?q?p=20line=20diff=20algorithm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/diff/line.ts | 61 ++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 38 deletions(-) diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 3e2da064d1..8a056d3481 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -212,6 +212,8 @@ export const diff = (src: string[], dst: string[]): LinePatch => { const patch: LinePatch = []; let srcIdx = -1; let dstIdx = -1; + const srcLength = src.length; + const dstLength = dst.length; for (let i = 0; i < length; i++) { const line = lines[i]; let lineLength = line.length; @@ -219,28 +221,21 @@ export const diff = (src: string[], dst: string[]): LinePatch => { const lastOp = line[lineLength - 1]; const lastOpType = lastOp[0]; const txt = lastOp[1]; - if (txt === "\n") { - line.splice(lineLength - 1, 1); - } else { + if (txt === "\n") line.splice(lineLength - 1, 1); + else { const strLength = txt.length; if (txt[strLength - 1] === "\n") { - if (strLength === 1) { - line.splice(lineLength - 1, 1); - } else { - lastOp[1] = txt.slice(0, strLength - 1); - } + if (strLength === 1) line.splice(lineLength - 1, 1); + else lastOp[1] = txt.slice(0, strLength - 1); } } let lineType: LINE_PATCH_OP_TYPE = LINE_PATCH_OP_TYPE.EQL; lineLength = line.length; if (i + 1 === length) { - if (srcIdx + 1 < src.length) { - if (dstIdx + 1 < dst.length) { - if (lineLength === 1 && line[0][0] === str.PATCH_OP_TYPE.EQL) { - lineType = LINE_PATCH_OP_TYPE.EQL; - } else { - lineType = LINE_PATCH_OP_TYPE.MIX; - } + if (srcIdx + 1 < srcLength) { + if (dstIdx + 1 < dstLength) { + lineType = lineLength === 1 && line[0][0] === str.PATCH_OP_TYPE.EQL + ? LINE_PATCH_OP_TYPE.EQL : LINE_PATCH_OP_TYPE.MIX; srcIdx++; dstIdx++; } else { @@ -254,29 +249,19 @@ export const diff = (src: string[], dst: string[]): LinePatch => { } else { const op = line[0]; const type = op[0]; - if (lineLength === 1 && type === lastOpType) { - if (type === str.PATCH_OP_TYPE.EQL) { - srcIdx++; - dstIdx++; - } else if (type === str.PATCH_OP_TYPE.INS) { - dstIdx++; - lineType = LINE_PATCH_OP_TYPE.INS; - } else if (type === str.PATCH_OP_TYPE.DEL) { - srcIdx++; - lineType = LINE_PATCH_OP_TYPE.DEL; - } - } else { - if (lastOpType === str.PATCH_OP_TYPE.EQL) { - lineType = LINE_PATCH_OP_TYPE.MIX; - srcIdx++; - dstIdx++; - } else if (lastOpType === str.PATCH_OP_TYPE.INS) { - lineType = LINE_PATCH_OP_TYPE.INS; - dstIdx++; - } else if (lastOpType === str.PATCH_OP_TYPE.DEL) { - lineType = LINE_PATCH_OP_TYPE.DEL; - srcIdx++; - } + if (lineLength === 1 && type === lastOpType && type === str.PATCH_OP_TYPE.EQL) { + srcIdx++; + dstIdx++; + } else if (lastOpType === str.PATCH_OP_TYPE.EQL) { + lineType = LINE_PATCH_OP_TYPE.MIX; + srcIdx++; + dstIdx++; + } else if (lastOpType === str.PATCH_OP_TYPE.INS) { + lineType = LINE_PATCH_OP_TYPE.INS; + dstIdx++; + } else if (lastOpType === str.PATCH_OP_TYPE.DEL) { + lineType = LINE_PATCH_OP_TYPE.DEL; + srcIdx++; } } patch.push([lineType, srcIdx, dstIdx, line]); From 68b8b27a91921d2374458e9808d2eebb36b9e7ff Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 11:49:59 +0200 Subject: [PATCH 65/68] =?UTF-8?q?fix(json-patch-diff):=20=F0=9F=90=9B=20al?= =?UTF-8?q?low=20string=20node=20type=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/Diff.ts | 10 ++-------- src/json-patch-diff/__tests__/Diff.spec.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/json-patch-diff/Diff.ts b/src/json-patch-diff/Diff.ts index 663c1e31e9..91384e39a4 100644 --- a/src/json-patch-diff/Diff.ts +++ b/src/json-patch-diff/Diff.ts @@ -4,12 +4,6 @@ import * as line from '../util/diff/line'; import {structHash} from '../json-hash'; import type {Operation} from '../json-patch/codec/json/types'; -export class DiffError extends Error { - constructor(message: string = 'DIFF') { - super(message); - } -} - export class Diff { protected patch: Operation[] = []; @@ -83,8 +77,8 @@ export class Diff { public diffAny(path: string, src: unknown, dst: unknown): void { switch (typeof src) { case 'string': { - if (typeof dst !== 'string') throw new DiffError(); - this.diffStr(path, src, dst); + if (typeof dst == 'string') this.diffStr(path, src, dst); + else this.diffVal(path, src, dst); break; } case 'number': diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/Diff.spec.ts index 38af40974d..a2be7bbd84 100644 --- a/src/json-patch-diff/__tests__/Diff.spec.ts +++ b/src/json-patch-diff/__tests__/Diff.spec.ts @@ -53,6 +53,11 @@ describe('obj', () => { assertDiff(src, dst); }); + test('string key type change', () => { + assertDiff({foo: 'asdf'}, {foo: 123}); + assertDiff({foo: 123}, {foo: 'asdf'}); + }); + test('can insert new key', () => { const src = {}; const dst = {foo: 'hello!'}; @@ -103,6 +108,11 @@ describe('obj', () => { }); describe('arr', () => { + test('string element type change', () => { + assertDiff(['asdf'], [123]); + assertDiff([123], ['asdf']); + }); + test('can add element to an empty array', () => { const src: unknown[] = []; const dst: unknown[] = [1]; From 12998eae22827a0d967a6833f368c11b43acead8 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 11:52:15 +0200 Subject: [PATCH 66/68] =?UTF-8?q?refactor(json-patch-diff):=20=F0=9F=92=A1?= =?UTF-8?q?=20rename=20Diff=20to=20JsonPatchDiff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-patch-diff/{Diff.ts => JsonPatchDiff.ts} | 2 +- .../{Diff-fuzzing.spec.ts => JsonPatchDiff-fuzzing.spec.ts} | 0 .../__tests__/{Diff.spec.ts => JsonPatchDiff.spec.ts} | 0 src/json-patch-diff/__tests__/util.ts | 6 +++--- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/json-patch-diff/{Diff.ts => JsonPatchDiff.ts} (99%) rename src/json-patch-diff/__tests__/{Diff-fuzzing.spec.ts => JsonPatchDiff-fuzzing.spec.ts} (100%) rename src/json-patch-diff/__tests__/{Diff.spec.ts => JsonPatchDiff.spec.ts} (100%) diff --git a/src/json-patch-diff/Diff.ts b/src/json-patch-diff/JsonPatchDiff.ts similarity index 99% rename from src/json-patch-diff/Diff.ts rename to src/json-patch-diff/JsonPatchDiff.ts index 91384e39a4..b600a557df 100644 --- a/src/json-patch-diff/Diff.ts +++ b/src/json-patch-diff/JsonPatchDiff.ts @@ -4,7 +4,7 @@ import * as line from '../util/diff/line'; import {structHash} from '../json-hash'; import type {Operation} from '../json-patch/codec/json/types'; -export class Diff { +export class JsonPatchDiff { protected patch: Operation[] = []; protected diffVal(path: string, src: unknown, dst: unknown): void { diff --git a/src/json-patch-diff/__tests__/Diff-fuzzing.spec.ts b/src/json-patch-diff/__tests__/JsonPatchDiff-fuzzing.spec.ts similarity index 100% rename from src/json-patch-diff/__tests__/Diff-fuzzing.spec.ts rename to src/json-patch-diff/__tests__/JsonPatchDiff-fuzzing.spec.ts diff --git a/src/json-patch-diff/__tests__/Diff.spec.ts b/src/json-patch-diff/__tests__/JsonPatchDiff.spec.ts similarity index 100% rename from src/json-patch-diff/__tests__/Diff.spec.ts rename to src/json-patch-diff/__tests__/JsonPatchDiff.spec.ts diff --git a/src/json-patch-diff/__tests__/util.ts b/src/json-patch-diff/__tests__/util.ts index b4aa66a9b8..3eeb67b70e 100644 --- a/src/json-patch-diff/__tests__/util.ts +++ b/src/json-patch-diff/__tests__/util.ts @@ -1,16 +1,16 @@ -import {Diff} from '../Diff'; +import {JsonPatchDiff} from '../JsonPatchDiff'; import {applyPatch} from '../../json-patch'; export const assertDiff = (src: unknown, dst: unknown) => { const srcNested = {src}; - const patch1 = new Diff().diff('/src', src, dst); + const patch1 = new JsonPatchDiff().diff('/src', src, dst); // console.log(src); // console.log(patch1); // console.log(dst); const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); // console.log(res); expect(res).toEqual({src: dst}); - const patch2 = new Diff().diff('/src', (res as any)['src'], dst); + const patch2 = new JsonPatchDiff().diff('/src', (res as any)['src'], dst); // console.log(patch2); expect(patch2.length).toBe(0); }; From 414944c0bd1bbbd07cc84e2b83aa96230cf5b4a5 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 11:57:56 +0200 Subject: [PATCH 67/68] =?UTF-8?q?refactor(json-crdt-diff):=20=F0=9F=92=A1?= =?UTF-8?q?=20rename=20Diff=20to=20JsonCrdtDiff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{Diff.ts => JsonCrdtDiff.ts} | 2 +- ...g.spec.ts => JsonCrdtDiff-fuzzing.spec.ts} | 6 ++-- .../{Diff.spec.ts => JsonCrdtDiff.spec.ts} | 33 ++++++++++++------- 3 files changed, 26 insertions(+), 15 deletions(-) rename src/json-crdt-diff/{Diff.ts => JsonCrdtDiff.ts} (99%) rename src/json-crdt-diff/__tests__/{Diff-fuzzing.spec.ts => JsonCrdtDiff-fuzzing.spec.ts} (87%) rename src/json-crdt-diff/__tests__/{Diff.spec.ts => JsonCrdtDiff.spec.ts} (91%) diff --git a/src/json-crdt-diff/Diff.ts b/src/json-crdt-diff/JsonCrdtDiff.ts similarity index 99% rename from src/json-crdt-diff/Diff.ts rename to src/json-crdt-diff/JsonCrdtDiff.ts index f34adb40a5..49a05dcd5e 100644 --- a/src/json-crdt-diff/Diff.ts +++ b/src/json-crdt-diff/JsonCrdtDiff.ts @@ -15,7 +15,7 @@ export class DiffError extends Error { } } -export class Diff { +export class JsonCrdtDiff { protected builder: PatchBuilder; public constructor(protected readonly model: Model) { diff --git a/src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts b/src/json-crdt-diff/__tests__/JsonCrdtDiff-fuzzing.spec.ts similarity index 87% rename from src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts rename to src/json-crdt-diff/__tests__/JsonCrdtDiff-fuzzing.spec.ts index 4421c790a8..b82d94e7f1 100644 --- a/src/json-crdt-diff/__tests__/Diff-fuzzing.spec.ts +++ b/src/json-crdt-diff/__tests__/JsonCrdtDiff-fuzzing.spec.ts @@ -1,17 +1,17 @@ -import {Diff} from '../Diff'; +import {JsonCrdtDiff} from '../JsonCrdtDiff'; import {Model} from '../../json-crdt/model'; import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; const assertDiff = (src: unknown, dst: unknown) => { const model = Model.create(); model.api.root(src); - const patch1 = new Diff(model).diff(model.root, dst); + const patch1 = new JsonCrdtDiff(model).diff(model.root, dst); // console.log(model + ''); // console.log(patch1 + ''); model.applyPatch(patch1); // console.log(model + ''); expect(model.view()).toEqual(dst); - const patch2 = new Diff(model).diff(model.root, dst); + const patch2 = new JsonCrdtDiff(model).diff(model.root, dst); expect(patch2.ops.length).toBe(0); }; diff --git a/src/json-crdt-diff/__tests__/Diff.spec.ts b/src/json-crdt-diff/__tests__/JsonCrdtDiff.spec.ts similarity index 91% rename from src/json-crdt-diff/__tests__/Diff.spec.ts rename to src/json-crdt-diff/__tests__/JsonCrdtDiff.spec.ts index 3a0526eef0..05dbc492a4 100644 --- a/src/json-crdt-diff/__tests__/Diff.spec.ts +++ b/src/json-crdt-diff/__tests__/JsonCrdtDiff.spec.ts @@ -1,22 +1,28 @@ -import {Diff} from '../Diff'; +import {JsonCrdtDiff} from '../JsonCrdtDiff'; import {InsStrOp, s} from '../../json-crdt-patch'; import {Model} from '../../json-crdt/model'; import {JsonNode, ValNode} from '../../json-crdt/nodes'; import {b} from '@jsonjoy.com/util/lib/buffers/b'; const assertDiff = (model: Model, src: JsonNode, dst: unknown) => { - const patch1 = new Diff(model).diff(src, dst); + const patch1 = new JsonCrdtDiff(model).diff(src, dst); // console.log(model + ''); // console.log(dst); // console.log(patch1 + ''); model.applyPatch(patch1); // console.log(model + ''); expect(src.view()).toEqual(dst); - const patch2 = new Diff(model).diff(src, dst); + const patch2 = new JsonCrdtDiff(model).diff(src, dst); // console.log(patch2 + ''); expect(patch2.ops.length).toBe(0); }; +const assertDiff2 = (src: unknown, dst: unknown) => { + const model = Model.create(); + model.api.root(src); + assertDiff(model, model.root.child(), dst); +}; + describe('con', () => { test('binary in "con"', () => { const model = Model.create(s.obj({ @@ -36,7 +42,7 @@ describe('str', () => { model.api.root({str: src}); const str = model.api.str(['str']); const dst = 'hello world!'; - const patch = new Diff(model).diff(str.node, dst); + const patch = new JsonCrdtDiff(model).diff(str.node, dst); expect(patch.ops.length).toBe(1); expect(patch.ops[0].name()).toBe('ins_str'); expect((patch.ops[0] as InsStrOp).data).toBe('!'); @@ -51,7 +57,7 @@ describe('str', () => { model.api.root({str: src}); const str = model.api.str(['str']); const dst = 'hello world'; - const patch = new Diff(model).diff(str.node, dst); + const patch = new JsonCrdtDiff(model).diff(str.node, dst); expect(patch.ops.length).toBe(1); expect(patch.ops[0].name()).toBe('del'); expect(str.view()).toBe(src); @@ -65,7 +71,7 @@ describe('str', () => { model.api.root({str: src}); const str = model.api.str(['str']); const dst = '2x3y'; - const patch = new Diff(model).diff(str.node, dst); + const patch = new JsonCrdtDiff(model).diff(str.node, dst); expect(str.view()).toBe(src); model.applyPatch(patch); expect(str.view()).toBe(dst); @@ -77,7 +83,7 @@ describe('str', () => { model.api.root({str: src}); const str = model.api.str(['str']); const dst = 'Hello world!'; - const patch = new Diff(model).diff(str.node, dst); + const patch = new JsonCrdtDiff(model).diff(str.node, dst); expect(str.view()).toBe(src); model.applyPatch(patch); expect(str.view()).toBe(dst); @@ -91,7 +97,7 @@ describe('bin', () => { model.api.root({bin}); const str = model.api.bin(['bin']); const dst = b(1, 2, 3, 4, 123, 5); - const patch = new Diff(model).diff(str.node, dst); + const patch = new JsonCrdtDiff(model).diff(str.node, dst); expect(patch.ops.length).toBe(1); expect(patch.ops[0].name()).toBe('ins_bin'); expect((patch.ops[0] as InsStrOp).data).toEqual(b(123)); @@ -106,7 +112,7 @@ describe('bin', () => { model.api.root({bin}); const str = model.api.bin(['bin']); const dst = b(1, 2, 3, 4, 5); - const patch = new Diff(model).diff(str.node, dst); + const patch = new JsonCrdtDiff(model).diff(str.node, dst); expect(patch.ops.length).toBe(0); }); @@ -116,7 +122,7 @@ describe('bin', () => { model.api.root({bin: src}); const bin = model.api.bin(['bin']); const dst = b(1, 2, 3, 4); - const patch = new Diff(model).diff(bin.node, dst); + const patch = new JsonCrdtDiff(model).diff(bin.node, dst); expect(patch.ops.length).toBe(1); expect(patch.ops[0].name()).toBe('del'); expect(bin.view()).toEqual(src); @@ -130,7 +136,7 @@ describe('bin', () => { model.api.root({bin: src}); const bin = model.api.bin(['bin']); const dst = b(2, 3, 4, 5, 6); - const patch = new Diff(model).diff(bin.node, dst); + const patch = new JsonCrdtDiff(model).diff(bin.node, dst); expect(bin.view()).toEqual(src); model.applyPatch(patch); expect(bin.view()).toEqual(dst); @@ -187,6 +193,11 @@ describe('obj', () => { model.api.root(src); assertDiff(model, model.root, dst); }); + + test('can change "str" key to number or back', () => { + assertDiff2({foo: 'abc'}, {foo: 123}); + assertDiff2({foo: 123}, {foo: 'abc'}); + }); }); describe('vec', () => { From cda9d78b1e7801908c3a23cefe40e53b878bdeb1 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sat, 10 May 2025 12:03:08 +0200 Subject: [PATCH 68/68] =?UTF-8?q?style:=20=F0=9F=92=84=20fix=20linter=20is?= =?UTF-8?q?sues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-diff/JsonCrdtDiff.ts | 24 +- .../__tests__/JsonCrdtDiff.spec.ts | 22 +- src/json-hash/__tests__/assertStructHash.ts | 1 + src/json-hash/__tests__/structHash.spec.ts | 1 + src/json-hash/structHash.ts | 7 +- src/json-hash/structHashCrdt.ts | 8 +- src/json-patch-diff/JsonPatchDiff.ts | 16 +- .../__tests__/JsonPatchDiff.spec.ts | 24 +- src/json-patch-diff/__tests__/util.ts | 2 +- src/util/diff/__tests__/bin-fuzz.spec.ts | 5 +- src/util/diff/__tests__/line.spec.ts | 281 ++++++------------ src/util/diff/__tests__/line.ts | 2 +- src/util/diff/__tests__/str-fuzz.spec.ts | 5 +- src/util/diff/__tests__/str.spec.ts | 4 +- src/util/diff/__tests__/util.ts | 32 +- src/util/diff/bin.ts | 12 +- src/util/diff/line.ts | 55 ++-- src/util/diff/str.ts | 70 ++--- 18 files changed, 238 insertions(+), 333 deletions(-) diff --git a/src/json-crdt-diff/JsonCrdtDiff.ts b/src/json-crdt-diff/JsonCrdtDiff.ts index 49a05dcd5e..c119cb767e 100644 --- a/src/json-crdt-diff/JsonCrdtDiff.ts +++ b/src/json-crdt-diff/JsonCrdtDiff.ts @@ -1,6 +1,6 @@ import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import {cmpUint8Array} from '@jsonjoy.com/util/lib/buffers/cmpUint8Array'; -import {ITimespanStruct, type ITimestampStruct, Patch, PatchBuilder, Timespan} from '../json-crdt-patch'; +import {type ITimespanStruct, type ITimestampStruct, type Patch, PatchBuilder, Timespan} from '../json-crdt-patch'; import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes'; import * as str from '../util/diff/str'; import * as bin from '../util/diff/bin'; @@ -26,7 +26,9 @@ export class JsonCrdtDiff { const view = src.view(); if (view === dst) return; const builder = this.builder; - str.apply(str.diff(view, dst), view.length, + str.apply( + str.diff(view, dst), + view.length, (pos, txt) => builder.insStr(src.id, !pos ? src.id : src.find(pos - 1)!, txt), (pos, len) => builder.del(src.id, src.findInterval(pos, len)), ); @@ -36,7 +38,9 @@ export class JsonCrdtDiff { const view = src.view(); if (cmpUint8Array(view, dst)) return; const builder = this.builder; - bin.apply(bin.diff(view, dst), view.length, + bin.apply( + bin.diff(view, dst), + view.length, (pos, txt) => builder.insBin(src.id, !pos ? src.id : src.find(pos - 1)!, txt), (pos, len) => builder.del(src.id, src.findInterval(pos, len)), ); @@ -44,7 +48,7 @@ export class JsonCrdtDiff { protected diffArr(src: ArrNode, dst: unknown[]): void { const srcLines: string[] = []; - src.children(node => { + src.children((node) => { srcLines.push(structHashCrdt(node)); }); const dstLines: string[] = []; @@ -93,7 +97,11 @@ export class JsonCrdtDiff { const length = inserts.length; for (let i = 0; i < length; i++) { const [after, views] = inserts[i]; - builder.insArr(src.id, after, views.map(view => builder.json(view))) + builder.insArr( + src.id, + after, + views.map((view) => builder.json(view)), + ); } if (deletes.length) builder.del(src.id, deletes); } @@ -102,6 +110,7 @@ export class JsonCrdtDiff { const builder = this.builder; const inserts: [key: string, value: ITimestampStruct][] = []; const srcKeys = new Set(); + // biome-ignore lint: .forEach is fastest here src.forEach((key) => { srcKeys.add(key); const dstValue = dst[key]; @@ -123,8 +132,7 @@ export class JsonCrdtDiff { } } } - inserts.push([key, src.get(key) instanceof ConNode - ? builder.const(dstValue) : builder.constOrJson(dstValue)]); + inserts.push([key, src.get(key) instanceof ConNode ? builder.const(dstValue) : builder.constOrJson(dstValue)]); } if (inserts.length) builder.insObj(src.id, inserts); } @@ -177,7 +185,7 @@ export class JsonCrdtDiff { public diffAny(src: JsonNode, dst: unknown): void { if (src instanceof ConNode) { const val = src.val; - if ((val !== dst) && !deepEqual(src.val, dst)) throw new DiffError(); + if (val !== dst && !deepEqual(src.val, dst)) throw new DiffError(); } else if (src instanceof StrNode) { if (typeof dst !== 'string') throw new DiffError(); this.diffStr(src, dst); diff --git a/src/json-crdt-diff/__tests__/JsonCrdtDiff.spec.ts b/src/json-crdt-diff/__tests__/JsonCrdtDiff.spec.ts index 05dbc492a4..fba503a584 100644 --- a/src/json-crdt-diff/__tests__/JsonCrdtDiff.spec.ts +++ b/src/json-crdt-diff/__tests__/JsonCrdtDiff.spec.ts @@ -1,7 +1,7 @@ import {JsonCrdtDiff} from '../JsonCrdtDiff'; -import {InsStrOp, s} from '../../json-crdt-patch'; +import {type InsStrOp, s} from '../../json-crdt-patch'; import {Model} from '../../json-crdt/model'; -import {JsonNode, ValNode} from '../../json-crdt/nodes'; +import {type JsonNode, ValNode} from '../../json-crdt/nodes'; import {b} from '@jsonjoy.com/util/lib/buffers/b'; const assertDiff = (model: Model, src: JsonNode, dst: unknown) => { @@ -25,9 +25,11 @@ const assertDiff2 = (src: unknown, dst: unknown) => { describe('con', () => { test('binary in "con"', () => { - const model = Model.create(s.obj({ - field: s.con(new Uint8Array([1, 2, 3])), - })); + const model = Model.create( + s.obj({ + field: s.con(new Uint8Array([1, 2, 3])), + }), + ); const dst = { field: new Uint8Array([1, 2, 3, 4]), }; @@ -376,10 +378,12 @@ describe('arr', () => { describe('scenarios', () => { test('link element annotation', () => { - const model = Model.create(s.obj({ - href: s.str('http://example.com/page?tab=1'), - title: s.str('example'), - })); + const model = Model.create( + s.obj({ + href: s.str('http://example.com/page?tab=1'), + title: s.str('example'), + }), + ); const dst = { href: 'https://example.com/page-2', title: 'Example page', diff --git a/src/json-hash/__tests__/assertStructHash.ts b/src/json-hash/__tests__/assertStructHash.ts index c095032d4f..6a16b01dfc 100644 --- a/src/json-hash/__tests__/assertStructHash.ts +++ b/src/json-hash/__tests__/assertStructHash.ts @@ -2,6 +2,7 @@ import {structHash as structHash_} from '../structHash'; import {structHashCrdt} from '../structHashCrdt'; import {Model} from '../../json-crdt'; +// biome-ignore lint: \x00 character const isASCII = (str: string) => /^[\x00-\x7F]*$/.test(str); export const assertStructHash = (json: unknown): string => { diff --git a/src/json-hash/__tests__/structHash.spec.ts b/src/json-hash/__tests__/structHash.spec.ts index d0985fcd1c..9a91074578 100644 --- a/src/json-hash/__tests__/structHash.spec.ts +++ b/src/json-hash/__tests__/structHash.spec.ts @@ -2,6 +2,7 @@ import {clone} from '@jsonjoy.com/util/lib/json-clone'; import {structHash as structHash_} from '../structHash'; import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; +// biome-ignore lint: \x00 character const isASCII = (str: string) => /^[\x00-\x7F]*$/.test(str); const structHash = (json: unknown): string => { diff --git a/src/json-hash/structHash.ts b/src/json-hash/structHash.ts index 4ae61fe896..afe73bdffc 100644 --- a/src/json-hash/structHash.ts +++ b/src/json-hash/structHash.ts @@ -1,5 +1,5 @@ import {sort} from '@jsonjoy.com/util/lib/sort/insertion'; -import {hash} from "./hash"; +import {hash} from './hash'; /** * Produces a *structural hash* of a JSON value. @@ -9,12 +9,13 @@ import {hash} from "./hash"; * * The hash is guaranteed to contain only printable ASCII characters, excluding * the newline character. - * + * * @param val A JSON value to hash. */ export const structHash = (val: unknown): string => { switch (typeof val) { - case 'string': return hash(val).toString(36); + case 'string': + return hash(val).toString(36); case 'number': case 'bigint': return val.toString(36); diff --git a/src/json-hash/structHashCrdt.ts b/src/json-hash/structHashCrdt.ts index d90c1b1d6b..1300f3d6d0 100644 --- a/src/json-hash/structHashCrdt.ts +++ b/src/json-hash/structHashCrdt.ts @@ -1,14 +1,14 @@ import {sort} from '@jsonjoy.com/util/lib/sort/insertion'; -import {ArrNode, BinNode, ConNode, JsonNode, ObjNode, StrNode, ValNode, VecNode} from "../json-crdt"; -import {hash} from "./hash"; -import {structHash} from "./structHash"; +import {ArrNode, BinNode, ConNode, type JsonNode, ObjNode, StrNode, ValNode, VecNode} from '../json-crdt'; +import {hash} from './hash'; +import {structHash} from './structHash'; /** * Constructs a structural hash of the view of the node. * * Produces a *structural hash* of a JSON CRDT node. Works the same as * `structHash, but uses the `JsonNode` interface instead of a generic value. - * + * * @todo PERF: instead of constructing a "str" and "bin" view, iterate over * the RGA chunks and hash them directly. */ diff --git a/src/json-patch-diff/JsonPatchDiff.ts b/src/json-patch-diff/JsonPatchDiff.ts index b600a557df..1d4d818a58 100644 --- a/src/json-patch-diff/JsonPatchDiff.ts +++ b/src/json-patch-diff/JsonPatchDiff.ts @@ -9,13 +9,15 @@ export class JsonPatchDiff { protected diffVal(path: string, src: unknown, dst: unknown): void { if (deepEqual(src, dst)) return; - this.patch.push({op: 'replace', path, value: dst}) + this.patch.push({op: 'replace', path, value: dst}); } protected diffStr(path: string, src: string, dst: string): void { if (src === dst) return; const patch = this.patch; - str.apply(str.diff(src, dst), src.length, + str.apply( + str.diff(src, dst), + src.length, (pos, str) => patch.push({op: 'str_ins', path, pos, str}), (pos, len, str) => patch.push({op: 'str_del', path, pos, len, str}), ); @@ -59,11 +61,12 @@ export class JsonPatchDiff { switch (type) { case line.LINE_PATCH_OP_TYPE.EQL: break; - case line.LINE_PATCH_OP_TYPE.MIX: + case line.LINE_PATCH_OP_TYPE.MIX: { const srcValue = src[srcIdx]; const dstValue = dst[dstIdx]; this.diff(pfx + srcIdx, srcValue, dstValue); break; + } case line.LINE_PATCH_OP_TYPE.INS: patch.push({op: 'add', path: pfx + (srcIdx + 1), value: dst[dstIdx]}); break; @@ -77,7 +80,7 @@ export class JsonPatchDiff { public diffAny(path: string, src: unknown, dst: unknown): void { switch (typeof src) { case 'string': { - if (typeof dst == 'string') this.diffStr(path, src, dst); + if (typeof dst === 'string') this.diffStr(path, src, dst); else this.diffVal(path, src, dst); break; } @@ -88,7 +91,10 @@ export class JsonPatchDiff { break; } case 'object': { - if (!src || !dst || typeof dst !== 'object') return this.diffVal(path, src, dst); + if (!src || !dst || typeof dst !== 'object') { + this.diffVal(path, src, dst); + return; + } if (Array.isArray(src)) { if (Array.isArray(dst)) this.diffArr(path, src, dst); else this.diffVal(path, src, dst); diff --git a/src/json-patch-diff/__tests__/JsonPatchDiff.spec.ts b/src/json-patch-diff/__tests__/JsonPatchDiff.spec.ts index a2be7bbd84..0784af74ab 100644 --- a/src/json-patch-diff/__tests__/JsonPatchDiff.spec.ts +++ b/src/json-patch-diff/__tests__/JsonPatchDiff.spec.ts @@ -92,7 +92,7 @@ describe('obj', () => { nested: { id: 2, name: 'world', - description: 'blablabla' + description: 'blablabla', }, }; const dst = { @@ -100,7 +100,7 @@ describe('obj', () => { name: 'hello!', nested: { id: 2, - description: 'Please dont use "blablabla"' + description: 'Please dont use "blablabla"', }, }; assertDiff(src, dst); @@ -192,14 +192,8 @@ describe('arr', () => { }); test('fuzzer - 1', () => { - const src: unknown[] = [ - 11, 10, 4, 6, - 3, 1, 5 - ]; - const dst: unknown[] = [ - 7, 3, 13, 7, 9, - 9, 9, 4, 9 - ]; + const src: unknown[] = [11, 10, 4, 6, 3, 1, 5]; + const dst: unknown[] = [7, 3, 13, 7, 9, 9, 9, 4, 9]; assertDiff(src, dst); }); }); @@ -220,7 +214,7 @@ test('array of objects diff', () => { id: 'xxxx', name: 'Music', description: 'I love music', - } + }, ]; const dst = [ { @@ -259,7 +253,7 @@ test('complex case', () => { id: 'xxxx', name: 'Music', description: 'I love music', - } + }, ], address: { city: 'New York', @@ -267,8 +261,8 @@ test('complex case', () => { zip: '10001', location: { lat: 40.7128, - lng: -74.0060, - } + lng: -74.006, + }, }, }; const dst = { @@ -296,7 +290,7 @@ test('complex case', () => { location: { lat: 40.7128, lng: 123.4567, - } + }, }, }; assertDiff(src, dst); diff --git a/src/json-patch-diff/__tests__/util.ts b/src/json-patch-diff/__tests__/util.ts index 3eeb67b70e..306fcca028 100644 --- a/src/json-patch-diff/__tests__/util.ts +++ b/src/json-patch-diff/__tests__/util.ts @@ -10,7 +10,7 @@ export const assertDiff = (src: unknown, dst: unknown) => { const {doc: res} = applyPatch(srcNested, patch1, {mutate: false}); // console.log(res); expect(res).toEqual({src: dst}); - const patch2 = new JsonPatchDiff().diff('/src', (res as any)['src'], dst); + const patch2 = new JsonPatchDiff().diff('/src', (res as any).src, dst); // console.log(patch2); expect(patch2.length).toBe(0); }; diff --git a/src/util/diff/__tests__/bin-fuzz.spec.ts b/src/util/diff/__tests__/bin-fuzz.spec.ts index 4a7f586d19..49b8aa7caf 100644 --- a/src/util/diff/__tests__/bin-fuzz.spec.ts +++ b/src/util/diff/__tests__/bin-fuzz.spec.ts @@ -3,9 +3,8 @@ import {toBuf} from '@jsonjoy.com/util/lib/buffers/toBuf'; import {assertPatch} from './util'; import * as bin from '../bin'; -const str = () => Math.random() > .7 - ? RandomJson.genString(Math.ceil(Math.random() * 200)) - : Math.random().toString(36).slice(2); +const str = () => + Math.random() > 0.7 ? RandomJson.genString(Math.ceil(Math.random() * 200)) : Math.random().toString(36).slice(2); const iterations = 100; test('fuzzing diff()', () => { diff --git a/src/util/diff/__tests__/line.spec.ts b/src/util/diff/__tests__/line.spec.ts index b60c24472c..3572e9e585 100644 --- a/src/util/diff/__tests__/line.spec.ts +++ b/src/util/diff/__tests__/line.spec.ts @@ -1,8 +1,8 @@ -import * as line from "../line"; -import { assertDiff } from "./line"; +import * as line from '../line'; +import {assertDiff} from './line'; -describe("diff", () => { - test("delete all lines", () => { +describe('diff', () => { + test('delete all lines', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -14,17 +14,12 @@ describe("diff", () => { expect(patch).toEqual([ [-1, 0, -1, [[-1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], [-1, 1, -1, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], - [ - -1, - 2, - -1, - [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], - ], + [-1, 2, -1, [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], [-1, 3, -1, [[-1, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test("delete all but first line", () => { + test('delete all but first line', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -36,27 +31,19 @@ describe("diff", () => { expect(patch).toEqual([ [0, 0, 0, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], [-1, 1, 0, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], - [ - -1, - 2, - 0, - [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], - ], + [-1, 2, 0, [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], [-1, 3, 0, [[-1, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test("delete all but middle lines line", () => { + test('delete all but middle lines line', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', '{"id": "abc", "name": "Merry Jane"}', ]; - const dst = [ - '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', - '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', - ]; + const dst = ['{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']; const patch = line.diff(src, dst); expect(patch).toEqual([ [-1, 0, -1, [[-1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], @@ -66,7 +53,7 @@ describe("diff", () => { ]); }); - test("delete all but the last line", () => { + test('delete all but the last line', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -78,52 +65,36 @@ describe("diff", () => { expect(patch).toEqual([ [-1, 0, -1, [[-1, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], [-1, 1, -1, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], - [ - -1, - 2, - -1, - [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], - ], + [-1, 2, -1, [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], [0, 3, 0, [[0, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test("normalize line beginnings (delete two middle ones)", () => { + test('normalize line beginnings (delete two middle ones)', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', '{"id": "abc", "name": "Merry Jane"}', ]; - const dst = [ - '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', - '{"id": "abc", "name": "Merry Jane"}', - ]; + const dst = ['{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "abc", "name": "Merry Jane"}']; const patch = line.diff(src, dst); expect(patch).toEqual([ [0, 0, 0, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], [-1, 1, 0, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], - [ - -1, - 2, - 0, - [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], - ], + [-1, 2, 0, [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], [0, 3, 1, [[0, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test("normalize line endings", () => { + test('normalize line endings', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "hello world!"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}', '{"id": "abc", "name": "Merry Jane"}', ]; - const dst = [ - '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', - '{"id": "abc", "name": "Merry Jane!"}', - ]; + const dst = ['{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "abc", "name": "Merry Jane!"}']; const patch = line.diff(src, dst); expect(patch).toEqual([ [ @@ -132,36 +103,31 @@ describe("diff", () => { 0, [ [0, '{"id": "xxx-xxxxxxx", "name": "'], - [-1, "h"], - [1, "H"], - [0, "ello"], - [1, ","], - [0, " world"], - [-1, "!"], + [-1, 'h'], + [1, 'H'], + [0, 'ello'], + [1, ','], + [0, ' world'], + [-1, '!'], [0, '"}'], ], ], [-1, 1, 0, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], - [ - -1, - 2, - 0, - [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], - ], + [-1, 2, 0, [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], [ 2, 3, 1, [ [0, '{"id": "abc", "name": "Merry Jane'], - [1, "!"], + [1, '!'], [0, '"}'], ], ], ]); }); - test("move first line to the end", () => { + test('move first line to the end', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -184,7 +150,7 @@ describe("diff", () => { ]); }); - test("move second line to the end", () => { + test('move second line to the end', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -207,7 +173,7 @@ describe("diff", () => { ]); }); - test("swap third and fourth lines", () => { + test('swap third and fourth lines', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -230,7 +196,7 @@ describe("diff", () => { ]); }); - test("move last line to the beginning", () => { + test('move last line to the beginning', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -253,7 +219,7 @@ describe("diff", () => { ]); }); - test("move second to last line to the beginning", () => { + test('move second to last line to the beginning', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -268,25 +234,15 @@ describe("diff", () => { ]; const patch = line.diff(src, dst); expect(patch).toEqual([ - [ - 1, - -1, - 0, - [[1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], - ], + [1, -1, 0, [[1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], [0, 0, 1, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world"}']]], [0, 1, 2, [[0, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], - [ - -1, - 2, - 2, - [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']], - ], + [-1, 2, 2, [[-1, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], [0, 3, 3, [[0, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test("swap first and second lines", () => { + test('swap first and second lines', () => { const src = [ '{"id": "xxx-xxxxxxx", "name": "Hello, world!!!!!!!!!!!!!!!!!!!!!!!!!"}', '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}', @@ -302,33 +258,20 @@ describe("diff", () => { const patch = line.diff(src, dst); expect(patch).toEqual([ [1, -1, 0, [[1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], - [ - 0, - 0, - 1, - [ - [ - 0, - '{"id": "xxx-xxxxxxx", "name": "Hello, world!!!!!!!!!!!!!!!!!!!!!!!!!"}', - ], - ], - ], + [0, 0, 1, [[0, '{"id": "xxx-xxxxxxx", "name": "Hello, world!!!!!!!!!!!!!!!!!!!!!!!!!"}']]], [-1, 1, 1, [[-1, '{"id": "xxx-yyyyyyy", "name": "Joe Doe"}']]], [0, 2, 2, [[0, '{"id": "lkasdjflkasjdf", "name": "Winston Churchill"}']]], [0, 3, 3, [[0, '{"id": "abc", "name": "Merry Jane"}']]], ]); }); - test("fuze two elements into one", () => { + test('fuze two elements into one', () => { const src = [ '{"asdfasdfasdf": 2398239234, "aaaa": "aaaaaaa"}', '{"bbbb": "bbbbbbbbbbbbbbb", "cccc": "ccccccccccccccccc"}', '{"this": "is a test", "number": 1234567890}', ]; - const dst = [ - '{"aaaa": "aaaaaaa", "bbbb": "bbbbbbbbbbbbbbb"}', - '{"this": "is a test", "number": 1234567890}', - ]; + const dst = ['{"aaaa": "aaaaaaa", "bbbb": "bbbbbbbbbbbbbbb"}', '{"this": "is a test", "number": 1234567890}']; const patch = line.diff(src, dst); expect(patch).toEqual([ [ @@ -339,7 +282,7 @@ describe("diff", () => { [0, '{"a'], [-1, 'sdfasdfasdf": 2398239234, "a'], [0, 'aaa": "aaaaaaa"'], - [-1, "}"], + [-1, '}'], ], ], [ @@ -347,8 +290,8 @@ describe("diff", () => { 1, 0, [ - [-1, "{"], - [1, ", "], + [-1, '{'], + [1, ', '], [0, '"bbbb": "bbbbbbbbbbbbbbb'], [-1, '", "cccc": "ccccccccccccccccc'], [0, '"}'], @@ -358,11 +301,8 @@ describe("diff", () => { ]); }); - test("split two elements into one", () => { - const src = [ - '{"aaaa": "aaaaaaa", "bbbb": "bbbbbbbbbbbbbbb"}', - '{"this": "is a test", "number": 1234567890}', - ]; + test('split two elements into one', () => { + const src = ['{"aaaa": "aaaaaaa", "bbbb": "bbbbbbbbbbbbbbb"}', '{"this": "is a test", "number": 1234567890}']; const dst = [ '{"asdfasdfasdf": 2398239234, "aaaa": "aaaaaaa"}', '{"bbbb": "bbbbbbbbbbbbbbb", "cccc": "ccccccccccccccccc"}', @@ -378,8 +318,8 @@ describe("diff", () => { [0, '{"a'], [1, 'sdfasdfasdf": 2398239234, "a'], [0, 'aaa": "aaaaaaa"'], - [-1, ", "], - [1, "}"], + [-1, ', '], + [1, '}'], ], ], [ @@ -387,7 +327,7 @@ describe("diff", () => { 0, 1, [ - [1, "{"], + [1, '{'], [0, '"bbbb": "bbbbbbbbbbbbbbb'], [1, '", "cccc": "ccccccccccccccccc'], [0, '"}'], @@ -397,58 +337,49 @@ describe("diff", () => { ]); }); - test("various examples", () => { - assertDiff( - ["0", "1", "3", "x", "y", "4", "5"], - ["1", "2", "3", "4", "a", "b", "c", "5"] - ); - assertDiff(["a", "x"], ["b", "c", "d"]); + test('various examples', () => { + assertDiff(['0', '1', '3', 'x', 'y', '4', '5'], ['1', '2', '3', '4', 'a', 'b', 'c', '5']); + assertDiff(['a', 'x'], ['b', 'c', 'd']); assertDiff([], []); - assertDiff(["1"], []); - assertDiff([], ["1"]); - assertDiff(["1"], ["1"]); - assertDiff(["1", "2"], ["1", "2"]); - assertDiff(["1", "2"], ["1", "3", "2"]); - assertDiff(["1", "3", "2"], ["1", "2"]); - assertDiff( - ["1", "2", "3", "4", "5", "6", "7"], - ["0", "1", "2", "5", "x", "y", "z", "a", "b", "7", "8"] - ); - assertDiff([], ["1"]); + assertDiff(['1'], []); + assertDiff([], ['1']); + assertDiff(['1'], ['1']); + assertDiff(['1', '2'], ['1', '2']); + assertDiff(['1', '2'], ['1', '3', '2']); + assertDiff(['1', '3', '2'], ['1', '2']); + assertDiff(['1', '2', '3', '4', '5', '6', '7'], ['0', '1', '2', '5', 'x', 'y', 'z', 'a', 'b', '7', '8']); + assertDiff([], ['1']); assertDiff([], []); - assertDiff(["1"], ["1"]); - assertDiff(["1", "1"], ["1", "1"]); - assertDiff(["1", "1", "2"], ["1", "1", "2"]); - assertDiff(["1", "1", "2"], ["1", "1"]); - assertDiff(["1", "2", "3"], ["1", "3"]); - assertDiff(["1", "2", "3"], ["2", "3"]); - assertDiff(["b", "a"], ["7", "3", "d", "7", "9", "9", "9"]); - assertDiff(["1"], []); - assertDiff(["1", "{}"], []); - assertDiff(["1", "2", "3", "4", "5", "6"], ["3"]); - assertDiff(["1", "2", "3"], ["2", "3"]); - assertDiff(["1", "2", "3"], ["1", "3"]); - assertDiff(["1", "2", "3"], ["1", "2"]); - assertDiff(["1", "2", "3", "4"], ["3", "4"]); - assertDiff(["1", "2"], ["1"]); - assertDiff(["1", "2"], ["2"]); - assertDiff( - ["1", "2", "3", "3", "5", "{a:4}", "5", '"6"'], - ["1", "2", "3", "5", "{a:4}", "5", '"6"', "6"] - ); - assertDiff(["0", "1"], ["xyz"]); + assertDiff(['1'], ['1']); + assertDiff(['1', '1'], ['1', '1']); + assertDiff(['1', '1', '2'], ['1', '1', '2']); + assertDiff(['1', '1', '2'], ['1', '1']); + assertDiff(['1', '2', '3'], ['1', '3']); + assertDiff(['1', '2', '3'], ['2', '3']); + assertDiff(['b', 'a'], ['7', '3', 'd', '7', '9', '9', '9']); + assertDiff(['1'], []); + assertDiff(['1', '{}'], []); + assertDiff(['1', '2', '3', '4', '5', '6'], ['3']); + assertDiff(['1', '2', '3'], ['2', '3']); + assertDiff(['1', '2', '3'], ['1', '3']); + assertDiff(['1', '2', '3'], ['1', '2']); + assertDiff(['1', '2', '3', '4'], ['3', '4']); + assertDiff(['1', '2'], ['1']); + assertDiff(['1', '2'], ['2']); + assertDiff(['1', '2', '3', '3', '5', '{a:4}', '5', '"6"'], ['1', '2', '3', '5', '{a:4}', '5', '"6"', '6']); + assertDiff(['0', '1'], ['xyz']); - assertDiff(["[]"], ["[1]"]); - assertDiff(["1", "[]"], ["1", "[1]"]); - assertDiff(["1", "2", "3"], ["1", "[2]", "3"]); - assertDiff(["1", "[1,2,3,4]", "3"], ["1", "[1,3,455]", "3"]); - assertDiff(["1", "[1,2,3,4]", "3"], ["1", "[1,3,455]", "[3]"]); - assertDiff(["1", "[1,2,3,4]", "3"], ["1", "[1,2,3,5]", "3"]); - assertDiff(["1", "[1,2,3,4]", "3"], ["1", "[1,4,3,5]", "3"]); - assertDiff(["[2]"], ["1", "2", "3"]); + assertDiff(['[]'], ['[1]']); + assertDiff(['1', '[]'], ['1', '[1]']); + assertDiff(['1', '2', '3'], ['1', '[2]', '3']); + assertDiff(['1', '[1,2,3,4]', '3'], ['1', '[1,3,455]', '3']); + assertDiff(['1', '[1,2,3,4]', '3'], ['1', '[1,3,455]', '[3]']); + assertDiff(['1', '[1,2,3,4]', '3'], ['1', '[1,2,3,5]', '3']); + assertDiff(['1', '[1,2,3,4]', '3'], ['1', '[1,4,3,5]', '3']); + assertDiff(['[2]'], ['1', '2', '3']); }); - test("fuzzer - 1", () => { + test('fuzzer - 1', () => { const src = [ '{"KW*V":"Wj6/Y1mgmm6n","uP1`NNND":{")zR8r|^KR":{}},"YYyO7.+>#.6AQ?U":"1%EA(q+S!}*","b\\nyc*o.":487228790.90332836}', '{"CO:_":238498277.2025599,"Gu4":{"pv`6^#.%9ka1*":true},"(x@cpBcAWb!_\\"{":963865518.3697702,"/Pda+3}:s(/sG{":"fj`({"}', @@ -461,34 +392,20 @@ describe("diff", () => { expect(patch).toEqual([ [-1, 0, -1, expect.any(Array)], [2, 1, 0, expect.any(Array)], - [ - -1, - 2, - 0, - [ - [ - -1, - '{".yk_":201,"KV1C":"yq#Af","b+Cö.EOa":["DDDDDDDDDDDDDDDD"],"%":[]}', - ], - ], - ], + [-1, 2, 0, [[-1, '{".yk_":201,"KV1C":"yq#Af","b+Cö.EOa":["DDDDDDDDDDDDDDDD"],"%":[]}']]], ]); }); - test("fuzzer - 2 (simplified)", () => { + test('fuzzer - 2 (simplified)', () => { const src = [ - "{asdfasdfasdf}", - "{12341234123412341234}", - `{zzzzzzzzzzzzzzzzzz}`, - "{12341234123412341234}", - "{00000000000000000000}", - "{12341234123412341234}", - ]; - const dst = [ - "{asdfasdfasdf}", - `{zzzzzzzzzzzzzzzzzz}`, - "{00000000000000000000}", - ]; + '{asdfasdfasdf}', + '{12341234123412341234}', + '{zzzzzzzzzzzzzzzzzz}', + '{12341234123412341234}', + '{00000000000000000000}', + '{12341234123412341234}', + ]; + const dst = ['{asdfasdfasdf}', '{zzzzzzzzzzzzzzzzzz}', '{00000000000000000000}']; const patch = line.diff(src, dst); expect(patch).toEqual([ [0, 0, 0, expect.any(Array)], @@ -500,7 +417,7 @@ describe("diff", () => { ]); }); - test("fuzzer - 2", () => { + test('fuzzer - 2', () => { const src = [ '{"qED5 { ]); }); - test("fuzzer - 3", () => { + test('fuzzer - 3', () => { const src = [ - "{aaaaaaaaaaa}", - "{bbbbbbbbbbb}", + '{aaaaaaaaaaa}', + '{bbbbbbbbbbb}', '{"75":259538477846144,"dadqM`0I":322795818.54331195,"<":"f*ßlwäm&=_y@w\\n","53aghXOyD%lC2":373122194.60806453,"\\\\9=M!\\"\\\\Tl-":"r.VdPY`mOQ"}', - "{11111111111111111111}", + '{11111111111111111111}', ]; const dst = [ '{"\\\\ 3[9}0dz+FaW\\"M":"rX?","P.Ed-s-VgiQDuNk":"18","}56zyy3FnC":[" [x[0], x[1], x[2]]); expect(patch).toEqual([ @@ -546,7 +463,7 @@ describe("diff", () => { ]); }); - test("fuzzer - 4", () => { + test('fuzzer - 4', () => { const src = [ '{"fE#vTih,M!q+TTR":-8702114011119315,"`F\\"M9":true,"]9+FC9f{48NnX":{"+\\\\]IQ7":"a;br-^_m"},"s&":"%n18QdrUewc8Nh8<"}', '{"<\\"R}d\\"HY65":[53195032.194879085,710289417.4711887],"WH]":"qqqqqqqqqq","W&0fQhOd8":96664625.24402197}', @@ -569,7 +486,7 @@ describe("diff", () => { ]); }); - test("fuzzer - 5", () => { + test('fuzzer - 5', () => { const src = [ '{"1111":[true,true],"111111111111111":-34785,"YRb#H`%Q`9yQ;":"S@>/8#"}', '{"$?":145566270.31451553,"&;\\\\V":729010872.7196132,"B4Xm[[X4":"WLFBc>*popRot]Y",") 8a%d@":811080332.6947087,"LnRab_vKhgz":"%"}', diff --git a/src/util/diff/__tests__/line.ts b/src/util/diff/__tests__/line.ts index bde03bc658..89962bbc7a 100644 --- a/src/util/diff/__tests__/line.ts +++ b/src/util/diff/__tests__/line.ts @@ -7,7 +7,7 @@ export const assertDiff = (src: string[], dst: string[]) => { // console.log(diff); const res: string[] = []; if (diff.length) { - for (let [type, srcIdx, dstIdx, patch] of diff) { + for (const [type, srcIdx, dstIdx, patch] of diff) { if (type === line.LINE_PATCH_OP_TYPE.DEL) { } else if (type === line.LINE_PATCH_OP_TYPE.INS) { res.push(dst[dstIdx]); diff --git a/src/util/diff/__tests__/str-fuzz.spec.ts b/src/util/diff/__tests__/str-fuzz.spec.ts index 807ca0f034..2f1ae6ef0d 100644 --- a/src/util/diff/__tests__/str-fuzz.spec.ts +++ b/src/util/diff/__tests__/str-fuzz.spec.ts @@ -3,9 +3,8 @@ import {assertPatch} from './util'; import {diff, diffEdit} from '../str'; const fastDiff = require('fast-diff') as typeof diff; -const str = () => Math.random() > .7 - ? RandomJson.genString(Math.ceil(Math.random() * 200)) - : Math.random().toString(36).slice(2); +const str = () => + Math.random() > 0.7 ? RandomJson.genString(Math.ceil(Math.random() * 200)) : Math.random().toString(36).slice(2); const iterations = 100; test('fuzzing diff()', () => { diff --git a/src/util/diff/__tests__/str.spec.ts b/src/util/diff/__tests__/str.spec.ts index 605e6b580e..3895055d65 100644 --- a/src/util/diff/__tests__/str.spec.ts +++ b/src/util/diff/__tests__/str.spec.ts @@ -1,4 +1,4 @@ -import {PATCH_OP_TYPE, Patch, diff, diffEdit} from '../str'; +import {PATCH_OP_TYPE, type Patch, diff, diffEdit} from '../str'; import {assertPatch} from './util'; describe('diff()', () => { @@ -220,4 +220,4 @@ describe('diffEdit()', () => { assertDiffEdit('aaa', 'bbb', 'ccc'); assertDiffEdit('1', '2', '3'); }); -}); \ No newline at end of file +}); diff --git a/src/util/diff/__tests__/util.ts b/src/util/diff/__tests__/util.ts index 5aab090eba..aa0019ecd0 100644 --- a/src/util/diff/__tests__/util.ts +++ b/src/util/diff/__tests__/util.ts @@ -1,23 +1,33 @@ -import * as diff from "../str"; +import * as diff from '../str'; export const assertPatch = (src: string, dst: string, patch: diff.Patch = diff.diff(src, dst)) => { const src1 = diff.src(patch); const dst1 = diff.dst(patch); let dst2 = src; - diff.apply(patch, dst2.length, (pos, str) => { - dst2 = dst2.slice(0, pos) + str + dst2.slice(pos); - }, (pos, len) => { - dst2 = dst2.slice(0, pos) + dst2.slice(pos + len); - }); + diff.apply( + patch, + dst2.length, + (pos, str) => { + dst2 = dst2.slice(0, pos) + str + dst2.slice(pos); + }, + (pos, len) => { + dst2 = dst2.slice(0, pos) + dst2.slice(pos + len); + }, + ); const inverted = diff.invert(patch); const src2 = diff.dst(inverted); const dst3 = diff.src(inverted); let src3 = dst; - diff.apply(inverted, src3.length, (pos, str) => { - src3 = src3.slice(0, pos) + str + src3.slice(pos); - }, (pos, len) => { - src3 = src3.slice(0, pos) + src3.slice(pos + len); - }); + diff.apply( + inverted, + src3.length, + (pos, str) => { + src3 = src3.slice(0, pos) + str + src3.slice(pos); + }, + (pos, len) => { + src3 = src3.slice(0, pos) + src3.slice(pos + len); + }, + ); expect(src1).toBe(src); expect(src2).toBe(src); expect(src3).toBe(src); diff --git a/src/util/diff/bin.ts b/src/util/diff/bin.ts index 87b71b0605..723f892044 100644 --- a/src/util/diff/bin.ts +++ b/src/util/diff/bin.ts @@ -1,4 +1,4 @@ -import * as str from "./str"; +import * as str from './str'; export const toStr = (buf: Uint8Array): string => { let hex = ''; @@ -10,7 +10,7 @@ export const toStr = (buf: Uint8Array): string => { export const toBin = (hex: string): Uint8Array => { const length = hex.length; const buf = new Uint8Array(length); - for (let i = 0; i < length; i ++) buf[i] = hex.charCodeAt(i); + for (let i = 0; i < length; i++) buf[i] = hex.charCodeAt(i); return buf; }; @@ -20,8 +20,12 @@ export const diff = (src: Uint8Array, dst: Uint8Array): str.Patch => { return str.diff(txtSrc, txtDst); }; -export const apply = (patch: str.Patch, srcLen: number, onInsert: (pos: number, str: Uint8Array) => void, onDelete: (pos: number, len: number) => void) => - str.apply(patch, srcLen, (pos, str) => onInsert(pos, toBin(str)), onDelete); +export const apply = ( + patch: str.Patch, + srcLen: number, + onInsert: (pos: number, str: Uint8Array) => void, + onDelete: (pos: number, len: number) => void, +) => str.apply(patch, srcLen, (pos, str) => onInsert(pos, toBin(str)), onDelete); export const src = (patch: str.Patch): Uint8Array => toBin(str.src(patch)); export const dst = (patch: str.Patch): Uint8Array => toBin(str.dst(patch)); diff --git a/src/util/diff/line.ts b/src/util/diff/line.ts index 8a056d3481..2b18660f5a 100644 --- a/src/util/diff/line.ts +++ b/src/util/diff/line.ts @@ -1,4 +1,4 @@ -import * as str from "./str"; +import * as str from './str'; export const enum LINE_PATCH_OP_TYPE { /** @@ -28,21 +28,18 @@ export const enum LINE_PATCH_OP_TYPE { export type LinePatchOp = [ type: LINE_PATCH_OP_TYPE, - /** * Assignment of this operation to the line in the `src` array. */ src: number, - /** * Assignment of this operation to the line in the `dst` array. */ dst: number, - /** * Character-level patch. */ - patch: str.Patch + patch: str.Patch, ]; export type LinePatch = LinePatchOp[]; @@ -75,7 +72,7 @@ export const agg = (patch: str.Patch): str.Patch[] => { const op = patch[i]; const type = op[0]; const str = op[1]; - const index = str.indexOf("\n"); + const index = str.indexOf('\n'); if (index < 0) { push(type, str); continue LINES; @@ -87,7 +84,7 @@ export const agg = (patch: str.Patch): str.Patch[] => { let prevIndex = index; const strLen = str.length; LINE: while (prevIndex < strLen) { - let nextIndex = str.indexOf("\n", prevIndex + 1); + const nextIndex = str.indexOf('\n', prevIndex + 1); if (nextIndex < 0) { push(type, str.slice(prevIndex + 1)); break LINE; @@ -98,7 +95,7 @@ export const agg = (patch: str.Patch): str.Patch[] => { } if (line.length) lines.push(line); // console.log("LINES", lines); - NORMALIZE_LINE_ENDINGS: { + { const length = lines.length; for (let i = 0; i < length; i++) { const line = lines[i]; @@ -110,17 +107,15 @@ export const agg = (patch: str.Patch): str.Patch[] => { const secondOpType = secondOp[0]; if ( firstOp[0] === str.PATCH_OP_TYPE.EQL && - (secondOpType === str.PATCH_OP_TYPE.DEL || - secondOpType === str.PATCH_OP_TYPE.INS) + (secondOpType === str.PATCH_OP_TYPE.DEL || secondOpType === str.PATCH_OP_TYPE.INS) ) { - for (let j = 2; j < lineLength; j++) - if (line[j][0] !== secondOpType) break NORMALIZE_LINE_START; + for (let j = 2; j < lineLength; j++) if (line[j][0] !== secondOpType) break NORMALIZE_LINE_START; for (let j = i + 1; j < length; j++) { const targetLine = lines[j]; const targetLineLength = targetLine.length; const pfx = firstOp[1]; - let targetLineFirstOp; - let targetLineSecondOp; + let targetLineFirstOp: str.PatchOperation; + let targetLineSecondOp: str.PatchOperation; if ( targetLine.length > 1 && (targetLineFirstOp = targetLine[0])[0] === secondOpType && @@ -150,18 +145,15 @@ export const agg = (patch: str.Patch): str.Patch[] => { const targetLine = lines[j]; const targetLineLength = targetLine.length; if (targetLineLength <= 1) { - if (targetLine[0][0] !== str.PATCH_OP_TYPE.DEL) - break NORMALIZE_LINE_END; + if (targetLine[0][0] !== str.PATCH_OP_TYPE.DEL) break NORMALIZE_LINE_END; } else { const targetLineLastOp = targetLine[targetLine.length - 1]; - if (targetLineLastOp[0] !== str.PATCH_OP_TYPE.EQL) - break NORMALIZE_LINE_END; + if (targetLineLastOp[0] !== str.PATCH_OP_TYPE.EQL) break NORMALIZE_LINE_END; for (let k = 0; k < targetLine.length - 1; k++) - if (targetLine[k][0] !== str.PATCH_OP_TYPE.DEL) - break NORMALIZE_LINE_END; + if (targetLine[k][0] !== str.PATCH_OP_TYPE.DEL) break NORMALIZE_LINE_END; let keepStr = targetLineLastOp[1]; - const keepStrEndsWithNl = keepStr.endsWith("\n"); - if (!keepStrEndsWithNl) keepStr += "\n"; + const keepStrEndsWithNl = keepStr.endsWith('\n'); + if (!keepStrEndsWithNl) keepStr += '\n'; if (keepStr.length > lastOpStr.length) break NORMALIZE_LINE_END; if (!lastOpStr.endsWith(keepStr)) break NORMALIZE_LINE_END; const index = lastOpStr.length - keepStr.length; @@ -184,13 +176,10 @@ export const agg = (patch: str.Patch): str.Patch[] => { } const targetLineSecondLastOp = targetLine[targetLine.length - 2]; if (targetLineSecondLastOp[0] === str.PATCH_OP_TYPE.DEL) { - targetLineSecondLastOp[1] += keepStrEndsWithNl - ? keepStr - : keepStr.slice(0, -1); + targetLineSecondLastOp[1] += keepStrEndsWithNl ? keepStr : keepStr.slice(0, -1); targetLine.splice(targetLineLength - 1, 1); } else { - (targetLineLastOp[0] as str.PATCH_OP_TYPE) = - str.PATCH_OP_TYPE.DEL; + (targetLineLastOp[0] as str.PATCH_OP_TYPE) = str.PATCH_OP_TYPE.DEL; } } } @@ -203,8 +192,8 @@ export const agg = (patch: str.Patch): str.Patch[] => { }; export const diff = (src: string[], dst: string[]): LinePatch => { - const srcTxt = src.join("\n") + '\n'; - const dstTxt = dst.join("\n") + '\n'; + const srcTxt = src.join('\n') + '\n'; + const dstTxt = dst.join('\n') + '\n'; if (srcTxt === dstTxt) return []; const strPatch = str.diff(srcTxt, dstTxt); const lines = agg(strPatch); @@ -221,10 +210,10 @@ export const diff = (src: string[], dst: string[]): LinePatch => { const lastOp = line[lineLength - 1]; const lastOpType = lastOp[0]; const txt = lastOp[1]; - if (txt === "\n") line.splice(lineLength - 1, 1); + if (txt === '\n') line.splice(lineLength - 1, 1); else { const strLength = txt.length; - if (txt[strLength - 1] === "\n") { + if (txt[strLength - 1] === '\n') { if (strLength === 1) line.splice(lineLength - 1, 1); else lastOp[1] = txt.slice(0, strLength - 1); } @@ -234,8 +223,8 @@ export const diff = (src: string[], dst: string[]): LinePatch => { if (i + 1 === length) { if (srcIdx + 1 < srcLength) { if (dstIdx + 1 < dstLength) { - lineType = lineLength === 1 && line[0][0] === str.PATCH_OP_TYPE.EQL - ? LINE_PATCH_OP_TYPE.EQL : LINE_PATCH_OP_TYPE.MIX; + lineType = + lineLength === 1 && line[0][0] === str.PATCH_OP_TYPE.EQL ? LINE_PATCH_OP_TYPE.EQL : LINE_PATCH_OP_TYPE.MIX; srcIdx++; dstIdx++; } else { diff --git a/src/util/diff/str.ts b/src/util/diff/str.ts index 69e8a53d6d..de0b2499eb 100644 --- a/src/util/diff/str.ts +++ b/src/util/diff/str.ts @@ -5,10 +5,7 @@ export const enum PATCH_OP_TYPE { } export type Patch = PatchOperation[]; -export type PatchOperation = - | PatchOperationDelete - | PatchOperationEqual - | PatchOperationInsert; +export type PatchOperation = PatchOperationDelete | PatchOperationEqual | PatchOperationInsert; 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]; @@ -31,12 +28,12 @@ const endsWithPairStart = (str: string): boolean => { * @param fixUnicode Whether to normalize to a unicode-correct diff */ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { - diff.push([PATCH_OP_TYPE.EQL, ""]); + diff.push([PATCH_OP_TYPE.EQL, '']); let pointer = 0; let delCnt = 0; let insCnt = 0; - let delTxt = ""; - let insTxt = ""; + let delTxt = ''; + let insTxt = ''; let commonLength: number = 0; while (pointer < diff.length) { if (pointer < diff.length - 1 && !diff[pointer][1]) { @@ -123,10 +120,7 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { if (prevEq >= 0) { diff[prevEq][1] += insTxt.slice(0, commonLength); } else { - diff.splice(0, 0, [ - PATCH_OP_TYPE.EQL, - insTxt.slice(0, commonLength), - ]); + diff.splice(0, 0, [PATCH_OP_TYPE.EQL, insTxt.slice(0, commonLength)]); pointer++; } insTxt = insTxt.slice(commonLength); @@ -135,8 +129,7 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { // Factor out any common suffixes. commonLength = sfx(insTxt, delTxt); if (commonLength !== 0) { - diff[pointer][1] = - insTxt.slice(insTxt.length - commonLength) + diff[pointer][1]; + diff[pointer][1] = insTxt.slice(insTxt.length - commonLength) + diff[pointer][1]; insTxt = insTxt.slice(0, insTxt.length - commonLength); delTxt = delTxt.slice(0, delTxt.length - commonLength); } @@ -155,12 +148,7 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { diff.splice(pointer - n, n, [PATCH_OP_TYPE.DEL, delTxt]); pointer = pointer - n + 1; } else { - diff.splice( - pointer - n, - n, - [PATCH_OP_TYPE.DEL, delTxt], - [PATCH_OP_TYPE.INS, insTxt] - ); + diff.splice(pointer - n, n, [PATCH_OP_TYPE.DEL, delTxt], [PATCH_OP_TYPE.INS, insTxt]); pointer = pointer - n + 2; } } @@ -172,13 +160,13 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { } else pointer++; insCnt = 0; delCnt = 0; - delTxt = ""; - insTxt = ""; + delTxt = ''; + insTxt = ''; break; } } } - if (diff[diff.length - 1][1] === "") diff.pop(); // Remove the dummy entry at the end. + if (diff[diff.length - 1][1] === '') diff.pop(); // Remove the dummy entry at the end. // Second pass: look for single edits surrounded on both sides by equalities // which can be shifted sideways to eliminate an equality. @@ -225,12 +213,7 @@ const cleanupMerge = (diff: Patch, fixUnicode: boolean) => { * @param y Index of split point in text2. * @return Array of diff tuples. */ -const bisectSplit = ( - text1: string, - text2: string, - x: number, - y: number -): Patch => { +const bisectSplit = (text1: string, text2: string, x: number, y: number): Patch => { const diffsA = diff_(text1.slice(0, x), text2.slice(0, y), false); const diffsB = diff_(text1.slice(x), text2.slice(y), false); return diffsA.concat(diffsB); @@ -282,11 +265,7 @@ const bisect = (text1: string, text2: string): Patch => { if (k1 === -d || (k1 !== d && v10 < v11)) x1 = v11; else x1 = v10 + 1; let y1 = x1 - k1; - while ( - x1 < text1Length && - y1 < text2Length && - text1.charAt(x1) === text2.charAt(y1) - ) { + while (x1 < text1Length && y1 < text2Length && text1.charAt(x1) === text2.charAt(y1)) { x1++; y1++; } @@ -297,8 +276,7 @@ const bisect = (text1: string, text2: string): Patch => { const k2Offset = vOffset + delta - k1; const v2Offset = v2[k2Offset]; if (k2Offset >= 0 && k2Offset < vLength && v2Offset !== -1) { - if (x1 >= text1Length - v2Offset) - return bisectSplit(text1, text2, x1, y1); + if (x1 >= text1Length - v2Offset) return bisectSplit(text1, text2, x1, y1); } } } @@ -306,15 +284,12 @@ const bisect = (text1: string, text2: string): Patch => { for (let k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { const k2_offset = vOffset + k2; let x2 = - k2 === -d || (k2 !== d && v2[k2_offset - 1] < v2[k2_offset + 1]) - ? v2[k2_offset + 1] - : v2[k2_offset - 1] + 1; + k2 === -d || (k2 !== d && v2[k2_offset - 1] < v2[k2_offset + 1]) ? v2[k2_offset + 1] : v2[k2_offset - 1] + 1; let y2 = x2 - k2; while ( x2 < text1Length && y2 < text2Length && - text1.charAt(text1Length - x2 - 1) === - text2.charAt(text2Length - y2 - 1) + text1.charAt(text1Length - x2 - 1) === text2.charAt(text2Length - y2 - 1) ) { x2++; y2++; @@ -419,10 +394,7 @@ export const sfx = (txt1: string, txt2: string): number => { let mid = max; let end = 0; while (min < mid) { - if ( - txt1.slice(txt1.length - mid, txt1.length - end) === - txt2.slice(txt2.length - mid, txt2.length - end) - ) { + if (txt1.slice(txt1.length - mid, txt1.length - end) === txt2.slice(txt2.length - mid, txt2.length - end)) { min = mid; end = min; } else max = mid; @@ -531,7 +503,7 @@ export const diffEdit = (src: string, dst: string, caret: number) => { }; export const src = (patch: Patch): string => { - let txt = ""; + let txt = ''; const length = patch.length; for (let i = 0; i < length; i++) { const op = patch[i]; @@ -541,7 +513,7 @@ export const src = (patch: Patch): string => { }; export const dst = (patch: Patch): string => { - let txt = ""; + let txt = ''; const length = patch.length; for (let i = 0; i < length; i++) { const op = patch[i]; @@ -555,8 +527,8 @@ const invertOp = (op: PatchOperation): PatchOperation => { return type === PATCH_OP_TYPE.EQL ? op : type === PATCH_OP_TYPE.INS - ? [PATCH_OP_TYPE.DEL, op[1]] - : [PATCH_OP_TYPE.INS, op[1]]; + ? [PATCH_OP_TYPE.DEL, op[1]] + : [PATCH_OP_TYPE.INS, op[1]]; }; /** @@ -578,7 +550,7 @@ export const apply = ( patch: Patch, srcLen: number, onInsert: (pos: number, str: string) => void, - onDelete: (pos: number, len: number, str: string) => void + onDelete: (pos: number, len: number, str: string) => void, ) => { const length = patch.length; let pos = srcLen;