Skip to content

Commit

Permalink
feat(diff)!: add support for apply and normalize diff output (#92)
Browse files Browse the repository at this point in the history
  • Loading branch information
lowlighter authored Dec 15, 2024
1 parent f05ea30 commit 4c78153
Show file tree
Hide file tree
Showing 52 changed files with 640 additions and 115 deletions.
120 changes: 120 additions & 0 deletions diff/apply.ts
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
}
150 changes: 150 additions & 0 deletions diff/apply_test.ts
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 } })
6 changes: 5 additions & 1 deletion diff/deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"playground": "https://libs.lecoq.io/diff",
"exports": {
".": "./mod.ts",
"./diff": "./diff.ts"
"./diff": "./diff.ts",
"./apply": "./apply.ts"
},
"test:permissions": {
"read": true
Expand All @@ -16,5 +17,8 @@
"bun": true,
"cloudflare-workers": true,
"browsers": true
},
"fmt": {
"exclude": ["**/testing/**"]
}
}
Loading

0 comments on commit 4c78153

Please sign in to comment.