-
-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f05ea30
commit 4c78153
Showing
52 changed files
with
640 additions
and
115 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
// Imports | ||
import { tokenize } from "./diff.ts" | ||
|
||
/** Hunk header regular expression */ | ||
const HUNK_HEADER = /^@@ -(?<ai>\d+)(?:,(?<aj>\d+))? \+(?<bi>\d+)(?:,(?<bj>\d+))? @@/ | ||
|
||
/** | ||
* ANSI patterns | ||
* https://github.com/chalk/ansi-regex/blob/02fa893d619d3da85411acc8fd4e2eea0e95a9d9/index.js | ||
*/ | ||
const ANSI_PATTERN = new RegExp( | ||
[ | ||
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", | ||
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TXZcf-nq-uy=><~]))", | ||
].join("|"), | ||
"g", | ||
) | ||
|
||
/** | ||
* Apply back an unified patch to a string. | ||
* | ||
* ```ts | ||
* import { apply } from "./apply.ts" | ||
* apply("foo\n", `--- a | ||
* +++ b | ||
* @@ -1 +1 @@ | ||
* -foo | ||
* +foo | ||
* \\ No newline at end of file`) | ||
* ``` | ||
* | ||
* @author Simon Lecoq (lowlighter) | ||
* @license MIT | ||
*/ | ||
export function apply(a: string, patch: string): string { | ||
const patchable = patch.trim() ? tokenize(patch.replace(ANSI_PATTERN, "")) : [] | ||
const b = tokenize(a) | ||
if (b.at(-1) === "\n") { | ||
b.pop() | ||
} | ||
let offset = 0 | ||
let k = 0 | ||
let newline = (patchable.length > 0) || (a.endsWith("\n")) | ||
const errors = [] | ||
for (let i = 0; i < patchable.length; i++) { | ||
// Parse hunk header | ||
const header = patchable[i].match(HUNK_HEADER) | ||
if (!header) { | ||
continue | ||
} | ||
const { ai, aj, bi, bj } = Object.fromEntries(Object.entries(header.groups!).map(([k, v]) => [k, Number(v ?? 1)])) | ||
// Apply hunk | ||
try { | ||
let j = ai - 1 + offset | ||
const count = { aj: 0, bj: 0, context: 0 } | ||
k++ | ||
while ((++i < patchable.length) && (!HUNK_HEADER.test(patchable[i]))) { | ||
const diff = patchable[i] | ||
switch (true) { | ||
case diff.startsWith("-"): { | ||
let [removed] = b.splice(j, 1) | ||
count.aj++ | ||
if (removed !== diff.slice(1)) { | ||
removed ??= "" | ||
throw new SyntaxError(`Patch ${k}: line ${j} mismatch (expected "${diff.slice(1).trim()}", actual "${removed.trim()}")`) | ||
} | ||
break | ||
} | ||
case diff.startsWith("+"): | ||
b.splice(j, 0, patchable[i].slice(1)) | ||
j++ | ||
count.bj++ | ||
break | ||
case diff.startsWith(" "): | ||
if (b[j] !== diff.slice(1)) { | ||
b[j] ??= "" | ||
throw new SyntaxError(`Patch ${k}: line ${j} mismatch (expected "${diff.slice(1).trim()}", actual "${b[j].trim()}")`) | ||
} | ||
j++ | ||
count.context++ | ||
break | ||
case diff === "\n": | ||
j++ | ||
count.context++ | ||
break | ||
case (i + 1 === patchable.length) && (diff === "\\ No newline at end of file\n"): | ||
newline = false | ||
break | ||
} | ||
} | ||
i-- | ||
offset = j - (bi + bj) | ||
// Check hunk header counts | ||
count.bj += count.context | ||
count.aj += count.context | ||
if (count.bj !== bj) { | ||
throw new SyntaxError(`Patch ${k}: hunk header text b count mismatch (expected ${bj}, actual ${count.bj})`) | ||
} | ||
if (count.aj !== aj) { | ||
throw new SyntaxError(`Patch ${k}: hunk header text a count mismatch (expected ${aj}, actual ${count.aj})`) | ||
} | ||
} catch (error) { | ||
errors.push(error) | ||
} | ||
} | ||
// Return patched string | ||
if (!b.length) { | ||
newline = false | ||
} | ||
let result = b.join("") | ||
if ((result.endsWith("\n")) && (!newline)) { | ||
result = result.slice(0, -1) | ||
} else if (!result.endsWith("\n") && newline) { | ||
result += "\n" | ||
} | ||
if (errors.length) { | ||
throw new AggregateError(errors, result) | ||
} | ||
return result | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
// deno-lint-ignore-file no-external-import | ||
import { apply } from "./apply.ts" | ||
import { expect, test, throws } from "@libs/testing" | ||
import { readFile } from "node:fs/promises" | ||
|
||
async function read(test: string) { | ||
const a = await readFile(new URL(`testing/${test}/a`, import.meta.url), "utf-8") | ||
const b = await readFile(new URL(`testing/${test}/b`, import.meta.url), "utf-8") | ||
const c = await readFile(new URL(`testing/${test}/c`, import.meta.url), "utf-8") | ||
return { a: a.replaceAll("\r\n", "\n"), b: b.replaceAll("\r\n", "\n"), c: c.replaceAll("\r\n", "\n") } | ||
} | ||
|
||
test("`apply()` handles empty texts", async () => { | ||
const { a, b, c } = await read("empty") | ||
expect(apply(a, c)).toStrictEqual(b) | ||
}, { permissions: { read: true } }) | ||
|
||
test("`apply()` handles identical texts", async () => { | ||
const { a, b, c } = await read("identical") | ||
expect(apply(a, c)).toStrictEqual(b) | ||
}, { permissions: { read: true } }) | ||
|
||
for (const newline of ["both", "none", "none_diff", "none_diff_alt", "atob", "atob_diff", "btoa", "btoa_diff"]) { | ||
test(`\`apply()\` handles final newline in texts (${newline})`, async () => { | ||
const { a, b, c } = await read(`newline/${newline}`) | ||
expect(apply(a, c)).toStrictEqual(b) | ||
}, { permissions: { read: true } }) | ||
} | ||
|
||
for (const operation of ["addition", "deletion", "edition"]) { | ||
test(`\`apply()\` handles single ${operation}`, async () => { | ||
const { a, b, c } = await read(`single/${operation}`) | ||
expect(apply(a, c)).toStrictEqual(b) | ||
}, { permissions: { read: true } }) | ||
} | ||
|
||
for (const operation of ["addition", "deletion", "edition"]) { | ||
test(`\`apply()\` handles ${operation}s`, async () => { | ||
const { a, b, c } = await read(`${operation}`) | ||
expect(apply(a, c)).toStrictEqual(b) | ||
}, { permissions: { read: true } }) | ||
} | ||
|
||
test("`apply()` handles moved lines", async () => { | ||
const { a, b, c } = await read("moved") | ||
expect(apply(a, c)).toStrictEqual(b) | ||
}, { permissions: { read: true } }) | ||
|
||
for ( | ||
const { operation } of [ | ||
{ operation: "separate" }, | ||
{ operation: "merged", context: 100 }, | ||
{ operation: "oneline", context: 0 }, | ||
{ operation: "twolines", context: 2 }, | ||
] | ||
) { | ||
test(`\`apply()\` handles ${operation} hunks`, async () => { | ||
const { a, b, c } = await read(`hunks/${operation}`) | ||
expect(apply(a, c)).toStrictEqual(b) | ||
}, { permissions: { read: true } }) | ||
} | ||
|
||
test("`apply()` handles complex texts", async () => { | ||
const { a, b, c } = await read("lorem") | ||
expect(apply(a, c)).toStrictEqual(b) | ||
}, { permissions: { read: true } }) | ||
|
||
test("`apply()` validates hunk header for text b lines", async () => { | ||
const error = await new Promise<AggregateError>((resolve, reject) => { | ||
try { | ||
resolve(apply( | ||
"", | ||
` | ||
--- a | ||
+++ b | ||
@@ -0,0 +1,999 @@ | ||
+Lorem ipsum dolor sit amet | ||
`.trim(), | ||
) as unknown as AggregateError) | ||
} catch (error) { | ||
reject(error) | ||
} | ||
}).catch((error) => error) | ||
expect(error).toBeInstanceOf(AggregateError) | ||
expect(error.errors).toHaveLength(1) | ||
expect(() => throws(error.errors[0])).toThrow(SyntaxError, "Patch 1: hunk header text b count mismatch (expected 999, actual 1)") | ||
}, { permissions: { read: true } }) | ||
|
||
test("`apply()` validates hunk header for text a lines", async () => { | ||
const error = await new Promise<AggregateError>((resolve, reject) => { | ||
try { | ||
resolve(apply( | ||
"Lorem ipsum dolor sit amet", | ||
` | ||
--- a | ||
+++ b | ||
@@ -1,999 +0,0 @@ | ||
-Lorem ipsum dolor sit amet | ||
`.trim(), | ||
) as unknown as AggregateError) | ||
} catch (error) { | ||
reject(error) | ||
} | ||
}).catch((error) => error) | ||
expect(error).toBeInstanceOf(AggregateError) | ||
expect(error.errors).toHaveLength(1) | ||
expect(() => throws(error.errors[0])).toThrow(SyntaxError, "Patch 1: hunk header text a count mismatch (expected 999, actual 1)") | ||
}, { permissions: { read: true } }) | ||
|
||
test("`apply()` validates deleted lines", async () => { | ||
const error = await new Promise<AggregateError>((resolve, reject) => { | ||
try { | ||
resolve(apply( | ||
"Lorem ipsum dolor sit amet", | ||
` | ||
--- a | ||
+++ b | ||
@@ -1 +0,0 @@ | ||
-Consectetur adipiscing elit | ||
`.trim(), | ||
) as unknown as AggregateError) | ||
} catch (error) { | ||
reject(error) | ||
} | ||
}).catch((error) => error) | ||
expect(error).toBeInstanceOf(AggregateError) | ||
expect(error.errors).toHaveLength(1) | ||
expect(() => throws(error.errors[0])).toThrow(SyntaxError, `Patch 1: line 0 mismatch (expected "Consectetur adipiscing elit", actual "Lorem ipsum dolor sit amet")`) | ||
}, { permissions: { read: true } }) | ||
|
||
test("`apply()` validates context lines", async () => { | ||
const error = await new Promise<AggregateError>((resolve, reject) => { | ||
try { | ||
resolve(apply( | ||
"Lorem ipsum dolor sit amet", | ||
` | ||
--- a | ||
+++ b | ||
@@ -0,0 +0,0 @@ | ||
Consectetur adipiscing elit | ||
`.trim(), | ||
) as unknown as AggregateError) | ||
} catch (error) { | ||
reject(error) | ||
} | ||
}).catch((error) => error) | ||
expect(error).toBeInstanceOf(AggregateError) | ||
expect(error.errors).toHaveLength(1) | ||
expect(() => throws(error.errors[0])).toThrow(SyntaxError, `Patch 1: line -1 mismatch (expected "Consectetur adipiscing elit", actual "")`) | ||
}, { permissions: { read: true } }) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.