mirror of https://github.com/mongodb/mongo
SERVER-115264: Implement string diffing logic for JS assertions (#45122)
GitOrigin-RevId: d521d5b368b827b9d50c46afd635ec184858b165
This commit is contained in:
parent
4bb54ca1cd
commit
99c997b601
|
|
@ -0,0 +1,203 @@
|
|||
import {describe, it} from "jstests/libs/mochalite.js";
|
||||
import {stringdiff} from "src/mongo/shell/stringdiff.js";
|
||||
|
||||
describe("diff strings", () => {
|
||||
function difftest(oldStr, newStr, expectedDiff) {
|
||||
let diff = stringdiff(oldStr, newStr);
|
||||
assert.eq(diff, expectedDiff);
|
||||
}
|
||||
|
||||
it("no diff", () => {
|
||||
const oldStr = "aaa\nbbb\nccc";
|
||||
const newStr = "aaa\nbbb\nccc";
|
||||
const expectedDiff = "";
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
|
||||
it("middle diff", () => {
|
||||
const oldStr = "aaa\nbbb\nccc";
|
||||
const newStr = "aaa\nxxx\nccc";
|
||||
const expectedDiff = `\
|
||||
aaa
|
||||
-bbb
|
||||
+xxx
|
||||
ccc`;
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
|
||||
it("prefix diff", () => {
|
||||
const oldStr = "aaa\nbbb\nccc";
|
||||
const newStr = "xxx\nbbb\nccc";
|
||||
const expectedDiff = `\
|
||||
-aaa
|
||||
+xxx
|
||||
bbb
|
||||
ccc`;
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
|
||||
it("suffix diff", () => {
|
||||
const oldStr = "aaa\nbbb\nccc";
|
||||
const newStr = "aaa\nbbb\nxxx";
|
||||
const expectedDiff = `\
|
||||
aaa
|
||||
bbb
|
||||
-ccc
|
||||
+xxx`;
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
|
||||
it("oneliner diff", () => {
|
||||
const oldStr = "aaa";
|
||||
const newStr = "axa";
|
||||
// don't do character by character diffing, just line by line
|
||||
const expectedDiff = `\
|
||||
-aaa
|
||||
+axa`;
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
|
||||
it("completely different", () => {
|
||||
const oldStr = "aaa\nbbb\nccc";
|
||||
const newStr = "xxx\nyyy\nzzz";
|
||||
const expectedDiff = `\
|
||||
-aaa
|
||||
-bbb
|
||||
-ccc
|
||||
+xxx
|
||||
+yyy
|
||||
+zzz`;
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
|
||||
it("context window", () => {
|
||||
const oldStr = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\nq\nr\ns\nt\nu\nv\nw\nx\ny\nz";
|
||||
const newStr = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nX\nn\no\np\nq\nr\ns\nt\nu\nv\nw\nx\ny\nz";
|
||||
const expectedDiff = `\
|
||||
i
|
||||
j
|
||||
k
|
||||
l
|
||||
-m
|
||||
+X
|
||||
n
|
||||
o
|
||||
p
|
||||
q`;
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
|
||||
it("overlapping context window", () => {
|
||||
const oldStr = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\nq\nr\ns\nt\nu\nv\nw\nx\ny\nz";
|
||||
const newStr = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nX\nl\nm\nn\nY\np\nq\nr\ns\nt\nu\nv\nw\nx\ny\nz";
|
||||
const expectedDiff = `\
|
||||
g
|
||||
h
|
||||
i
|
||||
j
|
||||
-k
|
||||
+X
|
||||
l
|
||||
m
|
||||
n
|
||||
-o
|
||||
+Y
|
||||
p
|
||||
q
|
||||
r
|
||||
s`;
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
|
||||
it("separate chunks", () => {
|
||||
const oldStr = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\nq\nr\ns\nt\nu\nv\nw\nx\ny\nz";
|
||||
const newStr = "a\nb\nX\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\nq\nr\ns\nt\nu\nv\nw\nY\ny\nz";
|
||||
const expectedDiff = `\
|
||||
a
|
||||
b
|
||||
-c
|
||||
+X
|
||||
d
|
||||
e
|
||||
f
|
||||
g
|
||||
---
|
||||
t
|
||||
u
|
||||
v
|
||||
w
|
||||
-x
|
||||
+Y
|
||||
y
|
||||
z`;
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
|
||||
it("compound diff", () => {
|
||||
// https://www.nathaniel.ai/myers-diff/
|
||||
const oldStr = `\
|
||||
Empty Bottles - Colin Morton (1981)
|
||||
---
|
||||
line up all the empty bottles
|
||||
the long-necked beer bottles from the antique stores
|
||||
the wine bottles and pop bottles left on beaches
|
||||
steam off the labels and line the bottles up the green ones with
|
||||
the brown black yellow and clear ones
|
||||
line up
|
||||
the beer bottles whose labels have been torn off by
|
||||
neurotic fingers
|
||||
and the bottles sent back by the breweries because they have
|
||||
cockroaches or dead mice at the bottom
|
||||
line up
|
||||
the bottles afloat on all the seas those with messages in
|
||||
them and those without any
|
||||
and the bottles with methyl hydrate-soaked cotton in them
|
||||
used by schoolkids for killing insects
|
||||
line up the bottle that killed Malcolm Lowry with the bottle that...`;
|
||||
|
||||
const newStr = `\
|
||||
Monkey Stops Whistling - David Morgan (2011)
|
||||
---
|
||||
Stand to attention all the empty bottles
|
||||
the long-necked beer bottles from the antique stores
|
||||
the wine bottles and pop bottles left on beaches
|
||||
steam off the labels and line the bottles up the green ones with
|
||||
the brown black yellow and clear ones
|
||||
Stand to attention all the empty bottles
|
||||
the beer bottles whose labels have been torn off by
|
||||
neurotic fingers
|
||||
and the bottles sent back by the breweries because they have
|
||||
cockroaches or dead bluebottles at the bottom
|
||||
Stand to attention all the empty bottles
|
||||
the bottles afloat on all the seas those with messages in
|
||||
them and those without any
|
||||
line up the bottle that killed Malcolm Lowry with the bottle that...`;
|
||||
|
||||
const expectedDiff = `\
|
||||
-Empty Bottles - Colin Morton (1981)
|
||||
+Monkey Stops Whistling - David Morgan (2011)
|
||||
---
|
||||
-line up all the empty bottles
|
||||
+Stand to attention all the empty bottles
|
||||
the long-necked beer bottles from the antique stores
|
||||
the wine bottles and pop bottles left on beaches
|
||||
steam off the labels and line the bottles up the green ones with
|
||||
the brown black yellow and clear ones
|
||||
-line up
|
||||
+Stand to attention all the empty bottles
|
||||
the beer bottles whose labels have been torn off by
|
||||
neurotic fingers
|
||||
and the bottles sent back by the breweries because they have
|
||||
-cockroaches or dead mice at the bottom
|
||||
-line up
|
||||
+cockroaches or dead bluebottles at the bottom
|
||||
+Stand to attention all the empty bottles
|
||||
the bottles afloat on all the seas those with messages in
|
||||
them and those without any
|
||||
-and the bottles with methyl hydrate-soaked cotton in them
|
||||
-used by schoolkids for killing insects
|
||||
line up the bottle that killed Malcolm Lowry with the bottle that...`;
|
||||
|
||||
difftest(oldStr, newStr, expectedDiff);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Compares two strings and returns their differences in patch format.
|
||||
*
|
||||
* This function uses Myers diff algorithm to compute the differences between two strings,
|
||||
* then converts the result into a patch format that can be applied to transform the old
|
||||
* string into the new string.
|
||||
*
|
||||
* @param {string} oldStr - The original string to compare from
|
||||
* @param {string} newStr - The new string to compare to
|
||||
* @returns {*} A patch representation of the differences between oldStr and newStr
|
||||
* @throws {AssertionError} If oldStr is not a string
|
||||
* @throws {AssertionError} If newStr is not a string
|
||||
*
|
||||
* @example
|
||||
* const diff = stringdiff("hello world", "hello javascript");
|
||||
* // Returns a patch showing the transformation from "hello world" to "hello javascript"
|
||||
*/
|
||||
export function stringdiff(oldStr, newStr) {
|
||||
assert(typeof oldStr === "string");
|
||||
assert(typeof newStr === "string");
|
||||
|
||||
return patchdiff(myersdiff(oldStr, newStr));
|
||||
}
|
||||
|
||||
const INS = "+";
|
||||
const DEL = "-";
|
||||
const PAD = " "; // matching lines
|
||||
|
||||
/**
|
||||
* Converts a full diff output into a patch format with context windows.
|
||||
*
|
||||
* This function processes a diff string (with lines prefixed by '+', '-', or ' ')
|
||||
* and returns a condensed version showing only the changed lines plus a configurable
|
||||
* number of surrounding context lines. Separate chunks of changes are delimited with '---'.
|
||||
*
|
||||
* @param {string} fulldiff - The complete diff string with each line prefixed by '+' (insertion),
|
||||
* '-' (deletion), or ' ' (unchanged)
|
||||
* @returns {string} A condensed patch showing only changed lines with 4 lines of context
|
||||
* before and after each change. Separate change chunks are separated by '---'.
|
||||
*
|
||||
* @example
|
||||
* const fulldiff = " line1\n line2\n-line3\n+line3a\n line4\n line5";
|
||||
* const patch = patchdiff(fulldiff);
|
||||
* // Returns: " line1\n line2\n-line3\n+line3a\n line4\n line5"
|
||||
*
|
||||
* @example
|
||||
* // With large gaps between changes, chunks are separated
|
||||
* const fulldiff = "-a\n+b\n c\n d\n e\n f\n g\n h\n i\n j\n-k\n+l";
|
||||
* const patch = patchdiff(fulldiff);
|
||||
* // Returns: "-a\n+b\n c\n d\n e\n f\n---\n g\n h\n i\n j\n-k\n+l"
|
||||
*/
|
||||
function patchdiff(fulldiff) {
|
||||
let lines = fulldiff.split("\n");
|
||||
|
||||
const context = 4; // surround with 4 lines for context before/after diff
|
||||
|
||||
let keep = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith(DEL) || lines[i].startsWith(INS)) {
|
||||
let start = Math.max(0, i - context);
|
||||
let end = Math.min(lines.length, i + context + 1);
|
||||
for (let j = start; j < end; j++) {
|
||||
keep[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (keep[i]) {
|
||||
if (i > 0 && !keep[i - 1] && result.length > 0) {
|
||||
result.push("---");
|
||||
}
|
||||
result.push(lines[i]);
|
||||
}
|
||||
}
|
||||
result = result.join("\n");
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Implements Myers diff algorithm to compute the difference between two strings.
|
||||
*
|
||||
* This function uses Myers' O(ND) difference algorithm to find the shortest edit script
|
||||
* that transforms string `a` into string `b`. The algorithm splits both strings into lines
|
||||
* and computes insertions, deletions, and unchanged sections.
|
||||
*
|
||||
* The result is a multi-line string where each line is prefixed with:
|
||||
* - ' ' (space) for unchanged lines
|
||||
* - '-' for lines deleted from the original string
|
||||
* - '+' for lines added in the new string
|
||||
*
|
||||
* @param {string} a - The original string to compare from
|
||||
* @param {string} b - The new string to compare to
|
||||
* @returns {string} A diff string with each line prefixed by ' ', '-', or '+' indicating
|
||||
* unchanged, deleted, or inserted lines respectively. Lines are separated
|
||||
* by newline characters.
|
||||
*
|
||||
* @example
|
||||
* const diff = myersdiff("hello\nworld", "hello\njavascript");
|
||||
* // Returns: " hello\n-world\n+javascript"
|
||||
*
|
||||
* @see {@link https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/|Myers Diff Algorithm}
|
||||
*/
|
||||
function myersdiff(a, b) {
|
||||
const aLines = a.split("\n");
|
||||
const bLines = b.split("\n");
|
||||
|
||||
const N = aLines.length;
|
||||
const M = bLines.length;
|
||||
const MAX = N + M;
|
||||
|
||||
const v = [];
|
||||
const trace = [];
|
||||
v[1] = 0;
|
||||
|
||||
for (let d = 0; d <= MAX; d++) {
|
||||
trace.push({...v});
|
||||
|
||||
for (let k = -d; k <= d; k += 2) {
|
||||
let x;
|
||||
if (k === -d || (k !== d && v[k - 1] < v[k + 1])) {
|
||||
x = v[k + 1];
|
||||
} else {
|
||||
x = v[k - 1] + 1;
|
||||
}
|
||||
|
||||
let y = x - k;
|
||||
|
||||
// Follow diagonal
|
||||
while (x < N && y < M && aLines[x] === bLines[y]) {
|
||||
x++;
|
||||
y++;
|
||||
}
|
||||
|
||||
v[k] = x;
|
||||
if (x >= N && y >= M) {
|
||||
// Found the solution, now backtrack to build the diff
|
||||
return backtrack(aLines, bLines, trace, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function backtrack(aLines, bLines, trace, d) {
|
||||
let x = aLines.length;
|
||||
let y = bLines.length;
|
||||
const diff = [];
|
||||
|
||||
for (let depth = d; depth >= 0; depth--) {
|
||||
const v = trace[depth];
|
||||
const k = x - y;
|
||||
|
||||
let prevK = k;
|
||||
if (k === -depth || (k !== depth && v[k - 1] < v[k + 1])) {
|
||||
prevK++;
|
||||
} else {
|
||||
prevK--;
|
||||
}
|
||||
|
||||
const prevX = v[prevK];
|
||||
const prevY = prevX - prevK;
|
||||
|
||||
// Add diagonal (unchanged) lines
|
||||
while (x > prevX && y > prevY) {
|
||||
x--;
|
||||
y--;
|
||||
diff.unshift(PAD + aLines[x]);
|
||||
}
|
||||
|
||||
// Add deletion or insertion
|
||||
if (depth > 0) {
|
||||
if (x === prevX) {
|
||||
// Insertion
|
||||
y--;
|
||||
diff.unshift(INS + bLines[y]);
|
||||
} else {
|
||||
// Deletion
|
||||
x--;
|
||||
diff.unshift(DEL + aLines[x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff.join("\n");
|
||||
}
|
||||
Loading…
Reference in New Issue