refactor(packages): extract hashline-core package

Move the hash-anchored edit core (hash computation, validation, edit operations, text normalization, chunk formatter, diff utilities, and a runtime-aware xxHash32 binding) into a new @oh-my-opencode/hashline-core workspace package.

The src/tools/hashline-edit/ surface becomes a set of thin re-export shims, so existing import paths in the plugin keep working while the pure logic lives behind a stable package boundary that has no opencode runtime dependencies.

Tests: bun test packages/hashline-core src/tools/hashline-edit
This commit is contained in:
YeonGyu-Kim
2026-05-21 16:02:09 +09:00
parent f29411a689
commit 4ea76365cd
42 changed files with 2561 additions and 1260 deletions
+10
View File
@@ -29,6 +29,7 @@
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
"@oh-my-opencode/boulder-state": "workspace:*",
"@oh-my-opencode/comment-checker-core": "workspace:*",
"@oh-my-opencode/hashline-core": "workspace:*",
"@oh-my-opencode/model-core": "workspace:*",
"@oh-my-opencode/rules-engine": "workspace:*",
"@oh-my-opencode/utils": "workspace:*",
@@ -93,6 +94,13 @@
"name": "@oh-my-opencode/comment-checker-core",
"version": "0.1.0",
},
"packages/hashline-core": {
"name": "@oh-my-opencode/hashline-core",
"version": "0.1.0",
"dependencies": {
"diff": "^9.0.0",
},
},
"packages/model-core": {
"name": "@oh-my-opencode/model-core",
"version": "0.1.0",
@@ -197,6 +205,8 @@
"@oh-my-opencode/comment-checker-core": ["@oh-my-opencode/comment-checker-core@workspace:packages/comment-checker-core"],
"@oh-my-opencode/hashline-core": ["@oh-my-opencode/hashline-core@workspace:packages/hashline-core"],
"@oh-my-opencode/model-core": ["@oh-my-opencode/model-core@workspace:packages/model-core"],
"@oh-my-opencode/rules-engine": ["@oh-my-opencode/rules-engine@workspace:packages/rules-engine"],
+3 -1
View File
@@ -12,6 +12,7 @@
"packages/utils",
"packages/model-core",
"packages/comment-checker-core",
"packages/hashline-core",
"packages/boulder-state",
"packages/agents-md-core"
],
@@ -48,7 +49,7 @@
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json",
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json",
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
"test": "bun test",
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
@@ -97,6 +98,7 @@
"@oh-my-opencode/agents-md-core": "workspace:*",
"@oh-my-opencode/boulder-state": "workspace:*",
"@oh-my-opencode/comment-checker-core": "workspace:*",
"@oh-my-opencode/hashline-core": "workspace:*",
"@oh-my-opencode/model-core": "workspace:*",
"@oh-my-opencode/rules-engine": "workspace:*",
"@oh-my-opencode/utils": "workspace:*",
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@oh-my-opencode/hashline-core",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Pure TypeScript hashline core logic for hash-anchored edits.",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./src/index.ts"
}
},
"types": "./index.d.ts",
"scripts": {
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "bun test src/*.test.ts"
},
"dependencies": {
"diff": "^9.0.0"
}
}
@@ -0,0 +1,179 @@
function normalizeTokens(text: string): string {
return text.replace(/\s+/g, "")
}
function stripAllWhitespace(text: string): string {
return normalizeTokens(text)
}
export function stripTrailingContinuationTokens(text: string): string {
return text.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, "")
}
export function stripMergeOperatorChars(text: string): string {
return text.replace(/[|&?]/g, "")
}
function leadingWhitespace(text: string): string {
if (!text) return ""
const match = text.match(/^\s*/)
return match ? match[0] : ""
}
export function restoreOldWrappedLines(originalLines: string[], replacementLines: string[]): string[] {
if (originalLines.length === 0 || replacementLines.length < 2) return replacementLines
const canonicalToOriginal = new Map<string, { line: string; count: number }>()
for (const line of originalLines) {
const canonical = stripAllWhitespace(line)
const existing = canonicalToOriginal.get(canonical)
if (existing) {
existing.count += 1
} else {
canonicalToOriginal.set(canonical, { line, count: 1 })
}
}
const candidates: { start: number; len: number; replacement: string; canonical: string }[] = []
for (let start = 0; start < replacementLines.length; start += 1) {
for (let len = 2; len <= 10 && start + len <= replacementLines.length; len += 1) {
const span = replacementLines.slice(start, start + len)
if (span.some((line) => line.trim().length === 0)) continue
const canonicalSpan = stripAllWhitespace(span.join(""))
const original = canonicalToOriginal.get(canonicalSpan)
if (original && original.count === 1 && canonicalSpan.length >= 6) {
candidates.push({ start, len, replacement: original.line, canonical: canonicalSpan })
}
}
}
if (candidates.length === 0) return replacementLines
const canonicalCounts = new Map<string, number>()
for (const candidate of candidates) {
canonicalCounts.set(candidate.canonical, (canonicalCounts.get(candidate.canonical) ?? 0) + 1)
}
const uniqueCandidates = candidates.filter((candidate) => (canonicalCounts.get(candidate.canonical) ?? 0) === 1)
if (uniqueCandidates.length === 0) return replacementLines
uniqueCandidates.sort((a, b) => b.start - a.start)
const correctedLines = [...replacementLines]
for (const candidate of uniqueCandidates) {
correctedLines.splice(candidate.start, candidate.len, candidate.replacement)
}
return correctedLines
}
export function maybeExpandSingleLineMerge(
originalLines: string[],
replacementLines: string[]
): string[] {
if (replacementLines.length !== 1 || originalLines.length <= 1) {
return replacementLines
}
const merged = replacementLines[0]
const parts = originalLines.map((line) => line.trim()).filter((line) => line.length > 0)
if (parts.length !== originalLines.length) return replacementLines
const indices: number[] = []
let offset = 0
let orderedMatch = true
for (const part of parts) {
let idx = merged.indexOf(part, offset)
let matchedLen = part.length
if (idx === -1) {
const stripped = stripTrailingContinuationTokens(part)
if (stripped !== part) {
idx = merged.indexOf(stripped, offset)
if (idx !== -1) matchedLen = stripped.length
}
}
if (idx === -1) {
const segment = merged.slice(offset)
const segmentStripped = stripMergeOperatorChars(segment)
const partStripped = stripMergeOperatorChars(part)
const fuzzyIdx = segmentStripped.indexOf(partStripped)
if (fuzzyIdx !== -1) {
let strippedPos = 0
let originalPos = 0
while (strippedPos < fuzzyIdx && originalPos < segment.length) {
if (!/[|&?]/.test(segment[originalPos])) strippedPos += 1
originalPos += 1
}
idx = offset + originalPos
matchedLen = part.length
}
}
if (idx === -1) {
orderedMatch = false
break
}
indices.push(idx)
offset = idx + matchedLen
}
const expanded: string[] = []
if (orderedMatch) {
for (let i = 0; i < indices.length; i += 1) {
const start = indices[i]
const end = i + 1 < indices.length ? indices[i + 1] : merged.length
const candidate = merged.slice(start, end).trim()
if (candidate.length === 0) {
orderedMatch = false
break
}
expanded.push(candidate)
}
}
if (orderedMatch && expanded.length === originalLines.length) {
return expanded
}
const semicolonSplit = merged
.split(/;\s+/)
.map((line, idx, arr) => {
if (idx < arr.length - 1 && !line.endsWith(";")) {
return `${line};`
}
return line
})
.map((line) => line.trim())
.filter((line) => line.length > 0)
if (semicolonSplit.length === originalLines.length) {
return semicolonSplit
}
return replacementLines
}
export function restoreIndentForPairedReplacement(
originalLines: string[],
replacementLines: string[]
): string[] {
if (originalLines.length !== replacementLines.length) {
return replacementLines
}
return replacementLines.map((line, idx) => {
if (line.length === 0) return line
if (leadingWhitespace(line).length > 0) return line
const indent = leadingWhitespace(originalLines[idx])
if (indent.length === 0) return line
if (originalLines[idx].trim() === line.trim()) return line
return `${indent}${line}`
})
}
export function autocorrectReplacementLines(
originalLines: string[],
replacementLines: string[]
): string[] {
let next = replacementLines
next = maybeExpandSingleLineMerge(originalLines, next)
next = restoreOldWrappedLines(originalLines, next)
next = restoreIndentForPairedReplacement(originalLines, next)
return next
}
+10
View File
@@ -0,0 +1,10 @@
export const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"
export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
const high = i >>> 4
const low = i & 0x0f
return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}`
})
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/
@@ -0,0 +1,149 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { parsePatch } from "diff"
import { generateUnifiedDiff } from "./diff-utils"
function createNumberedLines(totalLineCount: number): string {
return Array.from({ length: totalLineCount }, (_, index) => `line ${index + 1}`).join("\n")
}
describe("generateUnifiedDiff", () => {
describe("#given OpenCode compatibility format", () => {
it("#then includes the Index header emitted by diff library", () => {
//#given
const oldContent = "a\n"
const newContent = "b\n"
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "test.ts")
//#then
expect(diff).toContain("Index: test.ts")
})
it("#then includes unified --- and +++ file headers", () => {
//#given
const oldContent = "a\n"
const newContent = "b\n"
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "test.ts")
//#then
expect(diff).toContain("--- test.ts")
expect(diff).toContain("+++ test.ts")
})
it("#then remains parseable by OpenCode parsePatch flow", () => {
//#given
const oldContent = "line1\nline2\n"
const newContent = "line1\nline2-updated\n"
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "test.ts")
const patches = parsePatch(diff)
//#then
expect(patches).toHaveLength(1)
expect(patches[0]?.oldFileName).toBe("test.ts")
expect(patches[0]?.newFileName).toBe("test.ts")
expect(patches[0]?.hunks).toHaveLength(1)
})
})
describe("#given content without trailing newline", () => {
it("#then keeps no-newline markers parseable", () => {
//#given
const oldContent = "a"
const newContent = "b"
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "test.ts")
const patches = parsePatch(diff)
const hunkLines = patches[0]?.hunks[0]?.lines ?? []
//#then
expect(diff).toContain("\\ No newline at end of file")
expect(hunkLines).toEqual(["-a", "\\ No newline at end of file", "+b", "\\ No newline at end of file"])
})
})
it("creates separate hunks for distant changes", () => {
//#given
const oldContent = createNumberedLines(60)
const newLines = oldContent.split("\n")
newLines[4] = "line 5 updated"
newLines[49] = "line 50 updated"
const newContent = newLines.join("\n")
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "sample.txt")
//#then
const hunkHeaders = diff.match(/^@@/gm) ?? []
expect(hunkHeaders.length).toBe(2)
})
it("creates a single hunk for adjacent changes", () => {
//#given
const oldContent = createNumberedLines(20)
const newLines = oldContent.split("\n")
newLines[9] = "line 10 updated"
newLines[10] = "line 11 updated"
const newContent = newLines.join("\n")
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "sample.txt")
//#then
const hunkHeaders = diff.match(/^@@/gm) ?? []
expect(hunkHeaders.length).toBe(1)
expect(diff).toContain(" line 8")
expect(diff).toContain(" line 13")
})
it("limits each hunk to three context lines", () => {
//#given
const oldContent = createNumberedLines(20)
const newLines = oldContent.split("\n")
newLines[9] = "line 10 updated"
const newContent = newLines.join("\n")
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "sample.txt")
//#then
expect(diff).toContain(" line 7")
expect(diff).toContain(" line 13")
expect(diff).not.toContain(" line 6")
expect(diff).not.toContain(" line 14")
})
it("returns a diff string for identical content", () => {
//#given
const oldContent = "alpha\nbeta\ngamma"
const newContent = "alpha\nbeta\ngamma"
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "sample.txt")
//#then
expect(typeof diff).toBe("string")
expect(diff).toContain("--- sample.txt")
expect(diff).toContain("+++ sample.txt")
})
it("returns a valid diff when old content is empty", () => {
//#given
const oldContent = ""
const newContent = "first line\nsecond line"
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "sample.txt")
//#then
expect(diff).toContain("--- sample.txt")
expect(diff).toContain("+++ sample.txt")
expect(diff).toContain("+first line")
})
})
+53
View File
@@ -0,0 +1,53 @@
import { createTwoFilesPatch } from "diff"
import { computeLineHash } from "./hash-computation"
export function toHashlineContent(content: string): string {
if (!content) return content
const lines = content.split("\n")
const lastLine = lines[lines.length - 1]
const hasTrailingNewline = lastLine === ""
const contentLines = hasTrailingNewline ? lines.slice(0, -1) : lines
const hashlined = contentLines.map((line, i) => {
const lineNum = i + 1
const hash = computeLineHash(lineNum, line)
return `${lineNum}#${hash}|${line}`
})
return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n")
}
export function generateUnifiedDiff(oldContent: string, newContent: string, filePath: string): string {
return createTwoFilesPatch(filePath, filePath, oldContent, newContent, undefined, undefined, { context: 3 })
}
export function countLineDiffs(oldContent: string, newContent: string): { additions: number; deletions: number } {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
const oldSet = new Map<string, number>()
for (const line of oldLines) {
oldSet.set(line, (oldSet.get(line) ?? 0) + 1)
}
const newSet = new Map<string, number>()
for (const line of newLines) {
newSet.set(line, (newSet.get(line) ?? 0) + 1)
}
let deletions = 0
for (const [line, count] of oldSet) {
const newCount = newSet.get(line) ?? 0
if (count > newCount) {
deletions += count - newCount
}
}
let additions = 0
for (const [line, count] of newSet) {
const oldCount = oldSet.get(line) ?? 0
if (count > oldCount) {
additions += count - oldCount
}
}
return { additions, deletions }
}
@@ -0,0 +1,43 @@
import type { HashlineEdit } from "./types"
import { toNewLines } from "./edit-text-normalization"
import { normalizeLineRef } from "./validation"
function normalizeEditPayload(payload: string | string[]): string {
return toNewLines(payload).join("\n")
}
function canonicalAnchor(anchor: string | undefined): string {
if (!anchor) return ""
return normalizeLineRef(anchor)
}
function buildDedupeKey(edit: HashlineEdit): string {
switch (edit.op) {
case "replace":
return `replace|${canonicalAnchor(edit.pos)}|${edit.end ? canonicalAnchor(edit.end) : ""}|${normalizeEditPayload(edit.lines)}`
case "append":
return `append|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
case "prepend":
return `prepend|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
default:
return JSON.stringify(edit)
}
}
export function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
const seen = new Set<string>()
const deduped: HashlineEdit[] = []
let deduplicatedEdits = 0
for (const edit of edits) {
const key = buildDedupeKey(edit)
if (seen.has(key)) {
deduplicatedEdits += 1
continue
}
seen.add(key)
deduped.push(edit)
}
return { edits: deduped, deduplicatedEdits }
}
@@ -0,0 +1,126 @@
import { autocorrectReplacementLines } from "./autocorrect-replacement-lines"
import {
restoreLeadingIndent,
stripInsertAnchorEcho,
stripInsertBeforeEcho,
stripInsertBoundaryEcho,
stripRangeBoundaryEcho,
toNewLines,
} from "./edit-text-normalization"
import { parseLineRef, validateLineRef } from "./validation"
interface EditApplyOptions {
skipValidation?: boolean
}
function shouldValidate(options?: EditApplyOptions): boolean {
return options?.skipValidation !== true
}
export function applySetLine(
lines: string[],
anchor: string,
newText: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const originalLine = lines[line - 1] ?? ""
const corrected = autocorrectReplacementLines([originalLine], toNewLines(newText))
const replacement = corrected.map((entry, idx) => {
if (idx !== 0) return entry
return restoreLeadingIndent(originalLine, entry)
})
result.splice(line - 1, 1, ...replacement)
return result
}
export function applyReplaceLines(
lines: string[],
startAnchor: string,
endAnchor: string,
newText: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) {
validateLineRef(lines, startAnchor)
validateLineRef(lines, endAnchor)
}
const { line: startLine } = parseLineRef(startAnchor)
const { line: endLine } = parseLineRef(endAnchor)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const result = [...lines]
const originalRange = lines.slice(startLine - 1, endLine)
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
const corrected = autocorrectReplacementLines(originalRange, stripped)
const restored = corrected.map((entry, idx) => {
if (idx !== 0) return entry
return restoreLeadingIndent(lines[startLine - 1] ?? "", entry)
})
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
return result
}
export function applyInsertAfter(
lines: string[],
anchor: string,
text: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`append (anchored) requires non-empty text for ${anchor}`)
}
result.splice(line, 0, ...newLines)
return result
}
export function applyInsertBefore(
lines: string[],
anchor: string,
text: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`prepend (anchored) requires non-empty text for ${anchor}`)
}
result.splice(line - 1, 0, ...newLines)
return result
}
export function applyAppend(lines: string[], text: string | string[]): string[] {
const normalized = toNewLines(text)
if (normalized.length === 0) {
throw new Error("append requires non-empty text")
}
if (lines.length === 1 && lines[0] === "") {
return [...normalized]
}
return [...lines, ...normalized]
}
export function applyPrepend(lines: string[], text: string | string[]): string[] {
const normalized = toNewLines(text)
if (normalized.length === 0) {
throw new Error("prepend requires non-empty text")
}
if (lines.length === 1 && lines[0] === "") {
return [...normalized]
}
return [...normalized, ...lines]
}
@@ -0,0 +1,411 @@
import { describe, expect, it } from "bun:test"
import { applyHashlineEdits, applyHashlineEditsWithReport } from "./edit-operations"
import { applyAppend, applyInsertAfter, applyPrepend, applyReplaceLines, applySetLine } from "./edit-operation-primitives"
import { computeLineHash } from "./hash-computation"
import type { HashlineEdit } from "./types"
function anchorFor(lines: string[], line: number): string {
return `${line}#${computeLineHash(line, lines[line - 1])}`
}
describe("hashline edit operations", () => {
it("applies set_line with LINE#ID anchor", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "new line 2")
//#then
expect(result).toEqual(["line 1", "new line 2", "line 3"])
})
it("applies replace_lines with LINE#ID anchors", () => {
//#given
const lines = ["line 1", "line 2", "line 3", "line 4"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 2), anchorFor(lines, 3), "replaced")
//#then
expect(result).toEqual(["line 1", "replaced", "line 4"])
})
it("applies insert_after with LINE#ID anchor", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applyInsertAfter(lines, anchorFor(lines, 2), "inserted")
//#then
expect(result).toEqual(["line 1", "line 2", "inserted", "line 3"])
})
it("applies insert_before with LINE#ID anchor", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applyHashlineEdits(
lines.join("\n"),
[{ op: "prepend", pos: anchorFor(lines, 2), lines: "before 2" }]
)
//#then
expect(result).toEqual("line 1\nbefore 2\nline 2\nline 3")
})
it("throws when insert_after receives empty text array", () => {
//#given
const lines = ["line 1", "line 2"]
//#when / #then
expect(() => applyInsertAfter(lines, anchorFor(lines, 1), [])).toThrow(/non-empty/i)
})
it("throws when insert_before receives empty text array", () => {
//#given
const lines = ["line 1", "line 2"]
//#when / #then
expect(() =>
applyHashlineEdits(lines.join("\n"), [{ op: "prepend", pos: anchorFor(lines, 1), lines: [] }])
).toThrow(/non-empty/i)
})
it("applies mixed edits in one pass", () => {
//#given
const content = "line 1\nline 2\nline 3"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
{ op: "replace", pos: anchorFor(lines, 3), lines: "modified" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\ninserted\nline 2\nmodified")
})
it("applies replace before prepend when both target same line", () => {
//#given
const content = "line 1\nline 2\nline 3"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "prepend", pos: anchorFor(lines, 2), lines: "before line 2" },
{ op: "replace", pos: anchorFor(lines, 2), lines: "modified line 2" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\nbefore line 2\nmodified line 2\nline 3")
})
it("deduplicates identical insert edits in one pass", () => {
//#given
const content = "line 1\nline 2"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\ninserted\nline 2")
})
it("keeps literal backslash-n in plain string text", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "join(\\n)")
//#then
expect(result).toEqual(["line 1", "join(\\n)", "line 3"])
})
it("strips copied hashline prefixes from multiline text", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "1#VK|first\n2#NP|second")
//#then
expect(result).toEqual(["line 1", "first", "second", "line 3"])
})
it("autocorrects anchor echo for insert_after payload", () => {
//#given
const lines = ["line 1", "line 2"]
//#when
const result = applyInsertAfter(lines, anchorFor(lines, 1), ["line 1", "inserted"])
//#then
expect(result).toEqual(["line 1", "inserted", "line 2"])
})
it("throws when insert_after payload only repeats anchor line", () => {
//#given
const lines = ["line 1", "line 2"]
//#when / #then
expect(() => applyInsertAfter(lines, anchorFor(lines, 1), ["line 1"])).toThrow(/non-empty/i)
})
it("restores indentation for paired single-line replacement", () => {
//#given
const lines = ["if (x) {", " return 1", "}"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "return 2")
//#then
expect(result).toEqual(["if (x) {", " return 2", "}"])
})
it("preserves intentional indentation removal (tab to no-tab)", () => {
//#given
const lines = ["# Title", "\t1절", "content"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "1절")
//#then
expect(result).toEqual(["# Title", "1절", "content"])
})
it("preserves intentional indentation removal (spaces to no-spaces)", () => {
//#given
const lines = ["function foo() {", " indented", "}"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "indented")
//#then
expect(result).toEqual(["function foo() {", "indented", "}"])
})
it("strips boundary echo around replace_lines content", () => {
//#given
const lines = ["before", "old 1", "old 2", "after"]
//#when
const result = applyReplaceLines(
lines,
anchorFor(lines, 2),
anchorFor(lines, 3),
["before", "new 1", "new 2", "after"]
)
//#then
expect(result).toEqual(["before", "new 1", "new 2", "after"])
})
it("restores indentation for first replace_lines entry", () => {
//#given
const lines = ["if (x) {", " return 1", " return 2", "}"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 2), anchorFor(lines, 3), ["return 3", "return 4"])
//#then
expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"])
})
it("preserves blank lines and indentation in range replace (no false unwrap)", () => {
//#given, reproduces the 애국가 bug where blank+indented lines collapse
const lines = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
//#when, replace the range with indented version (blank lines preserved)
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
anchorFor(lines, 7),
["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""]
)
//#then, all 7 lines preserved with indentation, not collapsed to 3
expect(result).toEqual(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
})
it("collapses wrapped replacement span back to unique original single line", () => {
//#given
const lines = [
"const request = buildRequest({ method: \"GET\", retries: 3 })",
"const done = true",
]
//#when
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
anchorFor(lines, 1),
["const request = buildRequest({", "method: \"GET\", retries: 3 })"]
)
//#then
expect(result).toEqual([
"const request = buildRequest({ method: \"GET\", retries: 3 })",
"const done = true",
])
})
it("keeps wrapped replacement when canonical match is not unique in original lines", () => {
//#given
const lines = ["const query = a + b", "const query = a+b", "const done = true"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["const query = a +", "b"])
//#then
expect(result).toEqual(["const query = a +", "b", "const done = true"])
})
it("keeps wrapped replacement when same canonical candidate appears multiple times", () => {
//#given
const lines = ["const expression = alpha + beta + gamma", "const done = true"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 1), anchorFor(lines, 1), [
"const expression = alpha +",
"beta + gamma",
"const expression = alpha +",
"beta + gamma",
])
//#then
expect(result).toEqual([
"const expression = alpha +",
"beta + gamma",
"const expression = alpha +",
"beta + gamma",
"const done = true",
])
})
it("keeps wrapped replacement when canonical match is shorter than threshold", () => {
//#given
const lines = ["a + b", "const done = true"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 1), anchorFor(lines, 1), ["a +", "b"])
//#then
expect(result).toEqual(["a +", "b", "const done = true"])
})
it("applies append and prepend operations", () => {
//#given
const content = "line 1\nline 2"
//#when
const result = applyHashlineEdits(content, [
{ op: "append", lines: ["line 3"] },
{ op: "prepend", lines: ["line 0"] },
])
//#then
expect(result).toEqual("line 0\nline 1\nline 2\nline 3")
})
it("appends to empty file without extra blank line", () => {
//#given
const lines = [""]
//#when
const result = applyAppend(lines, ["line1"])
//#then
expect(result).toEqual(["line1"])
})
it("prepends to empty file without extra blank line", () => {
//#given
const lines = [""]
//#when
const result = applyPrepend(lines, ["line1"])
//#then
expect(result).toEqual(["line1"])
})
it("autocorrects single-line merged replacement into original line count", () => {
//#given
const lines = ["const a = 1;", "const b = 2;"]
//#when
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
anchorFor(lines, 2),
"const a = 10; const b = 20;"
)
//#then
expect(result).toEqual(["const a = 10;", "const b = 20;"])
})
it("throws on overlapping range edits", () => {
//#given
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 3), lines: "replaced A" },
{ op: "replace", pos: anchorFor(lines, 2), end: anchorFor(lines, 4), lines: "replaced B" },
]
//#when / #then
expect(() => applyHashlineEdits(content, edits)).toThrow(/overlapping/i)
})
it("allows non-overlapping range edits", () => {
//#given
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 2), lines: "replaced A" },
{ op: "replace", pos: anchorFor(lines, 4), end: anchorFor(lines, 5), lines: "replaced B" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("replaced A\nline 3\nreplaced B")
})
})
describe("dedupe anchor canonicalization", () => {
it("deduplicates edits with whitespace-variant anchors", () => {
//#given
const content = "line 1\nline 2"
const lines = content.split("\n")
const canonical = `1#${computeLineHash(1, lines[0])}`
const spaced = ` 1 # ${computeLineHash(1, lines[0])} `
//#when
const report = applyHashlineEditsWithReport(content, [
{ op: "append", pos: canonical, lines: ["inserted"] },
{ op: "append", pos: spaced, lines: ["inserted"] },
])
//#then
expect(report.deduplicatedEdits).toBe(1)
expect(report.content).toBe("line 1\ninserted\nline 2")
})
})
@@ -0,0 +1,103 @@
import { dedupeEdits } from "./edit-deduplication"
import { collectLineRefs, detectOverlappingRanges, getEditLineNumber } from "./edit-ordering"
import type { HashlineEdit } from "./types"
import {
applyAppend,
applyInsertAfter,
applyInsertBefore,
applyPrepend,
applyReplaceLines,
applySetLine,
} from "./edit-operation-primitives"
import { validateLineRefs } from "./validation"
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
export interface HashlineApplyReport {
content: string
noopEdits: number
deduplicatedEdits: number
}
export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport {
if (edits.length === 0) {
return {
content,
noopEdits: 0,
deduplicatedEdits: 0,
}
}
const dedupeResult = dedupeEdits(edits)
const EDIT_PRECEDENCE: Record<string, number> = { replace: 0, append: 1, prepend: 2 }
const sortedEdits = [...dedupeResult.edits].sort((a, b) => {
const lineA = getEditLineNumber(a)
const lineB = getEditLineNumber(b)
if (lineB !== lineA) return lineB - lineA
return (EDIT_PRECEDENCE[a.op] ?? 3) - (EDIT_PRECEDENCE[b.op] ?? 3)
})
let noopEdits = 0
let lines = content.length === 0 ? [] : content.split("\n")
const refs = collectLineRefs(sortedEdits)
validateLineRefs(lines, refs)
const overlapError = detectOverlappingRanges(sortedEdits)
if (overlapError) throw new Error(overlapError)
for (const edit of sortedEdits) {
switch (edit.op) {
case "replace": {
const next = edit.end
? applyReplaceLines(lines, edit.pos, edit.end, edit.lines, { skipValidation: true })
: applySetLine(lines, edit.pos, edit.lines, { skipValidation: true })
if (arraysEqual(next, lines)) {
noopEdits += 1
break
}
lines = next
break
}
case "append": {
const next = edit.pos
? applyInsertAfter(lines, edit.pos, edit.lines, { skipValidation: true })
: applyAppend(lines, edit.lines)
if (arraysEqual(next, lines)) {
noopEdits += 1
break
}
lines = next
break
}
case "prepend": {
const next = edit.pos
? applyInsertBefore(lines, edit.pos, edit.lines, { skipValidation: true })
: applyPrepend(lines, edit.lines)
if (arraysEqual(next, lines)) {
noopEdits += 1
break
}
lines = next
break
}
}
}
return {
content: lines.join("\n"),
noopEdits,
deduplicatedEdits: dedupeResult.deduplicatedEdits,
}
}
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
return applyHashlineEditsWithReport(content, edits).content
}
@@ -0,0 +1,56 @@
import { parseLineRef } from "./validation"
import type { HashlineEdit } from "./types"
export function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.op) {
case "replace":
return parseLineRef(edit.end ?? edit.pos).line
case "append":
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
case "prepend":
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
}
export function collectLineRefs(edits: HashlineEdit[]): string[] {
return edits.flatMap((edit) => {
switch (edit.op) {
case "replace":
return edit.end ? [edit.pos, edit.end] : [edit.pos]
case "append":
case "prepend":
return edit.pos ? [edit.pos] : []
default:
return []
}
})
}
export function detectOverlappingRanges(edits: HashlineEdit[]): string | null {
const ranges: { start: number; end: number; idx: number }[] = []
for (let i = 0; i < edits.length; i++) {
const edit = edits[i]
if (edit.op !== "replace" || !edit.end) continue
const start = parseLineRef(edit.pos).line
const end = parseLineRef(edit.end).line
ranges.push({ start, end, idx: i })
}
if (ranges.length < 2) return null
ranges.sort((a, b) => a.start - b.start || a.end - b.end)
for (let i = 1; i < ranges.length; i++) {
const prev = ranges[i - 1]
const curr = ranges[i]
if (curr.start <= prev.end) {
return (
`Overlapping range edits detected: ` +
`edit ${prev.idx + 1} (lines ${prev.start}-${prev.end}) overlaps with ` +
`edit ${curr.idx + 1} (lines ${curr.start}-${curr.end}). ` +
`Use pos-only replace for single-line edits.`
)
}
}
return null
}
@@ -0,0 +1,111 @@
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\|/
const DIFF_PLUS_RE = /^[+](?![+])/
function equalsIgnoringWhitespace(a: string, b: string): boolean {
if (a === b) return true
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "")
}
function leadingWhitespace(text: string): string {
if (!text) return ""
const match = text.match(/^\s*/)
return match ? match[0] : ""
}
export function stripLinePrefixes(lines: string[]): string[] {
let hashPrefixCount = 0
let diffPlusCount = 0
let nonEmpty = 0
for (const line of lines) {
if (line.length === 0) continue
nonEmpty += 1
if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount += 1
if (DIFF_PLUS_RE.test(line)) diffPlusCount += 1
}
if (nonEmpty === 0) {
return lines
}
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5
if (!stripHash && !stripPlus) {
return lines
}
return lines.map((line) => {
if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "")
if (stripPlus) return line.replace(DIFF_PLUS_RE, "")
return line
})
}
export function toNewLines(input: string | string[]): string[] {
if (Array.isArray(input)) {
return stripLinePrefixes(input)
}
return stripLinePrefixes(input.split("\n"))
}
export function restoreLeadingIndent(templateLine: string, line: string): string {
if (line.length === 0) return line
const templateIndent = leadingWhitespace(templateLine)
if (templateIndent.length === 0) return line
if (leadingWhitespace(line).length > 0) return line
if (templateLine.trim() === line.trim()) return line
return `${templateIndent}${line}`
}
export function stripInsertAnchorEcho(anchorLine: string, newLines: string[]): string[] {
if (newLines.length === 0) return newLines
if (equalsIgnoringWhitespace(newLines[0], anchorLine)) {
return newLines.slice(1)
}
return newLines
}
export function stripInsertBeforeEcho(anchorLine: string, newLines: string[]): string[] {
if (newLines.length <= 1) return newLines
if (equalsIgnoringWhitespace(newLines[newLines.length - 1], anchorLine)) {
return newLines.slice(0, -1)
}
return newLines
}
export function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, newLines: string[]): string[] {
let out = newLines
if (out.length > 0 && equalsIgnoringWhitespace(out[0], afterLine)) {
out = out.slice(1)
}
if (out.length > 0 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
out = out.slice(0, -1)
}
return out
}
export function stripRangeBoundaryEcho(
lines: string[],
startLine: number,
endLine: number,
newLines: string[]
): string[] {
const replacedCount = endLine - startLine + 1
if (newLines.length <= 1 || newLines.length <= replacedCount) {
return newLines
}
let out = newLines
const beforeIdx = startLine - 2
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], lines[beforeIdx])) {
out = out.slice(1)
}
const afterIdx = endLine
if (afterIdx < lines.length && out.length > 0 && equalsIgnoringWhitespace(out[out.length - 1], lines[afterIdx])) {
out = out.slice(0, -1)
}
return out
}
@@ -0,0 +1,44 @@
export interface FileTextEnvelope {
content: string
hadBom: boolean
lineEnding: "\n" | "\r\n"
}
function detectLineEnding(content: string): "\n" | "\r\n" {
const crlfIndex = content.indexOf("\r\n")
const lfIndex = content.indexOf("\n")
if (lfIndex === -1) return "\n"
if (crlfIndex === -1) return "\n"
return crlfIndex < lfIndex ? "\r\n" : "\n"
}
function stripBom(content: string): { content: string; hadBom: boolean } {
if (!content.startsWith("\uFEFF")) {
return { content, hadBom: false }
}
return { content: content.slice(1), hadBom: true }
}
function normalizeToLf(content: string): string {
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}
function restoreLineEndings(content: string, lineEnding: "\n" | "\r\n"): string {
if (lineEnding === "\n") return content
return content.replace(/\n/g, "\r\n")
}
export function canonicalizeFileText(content: string): FileTextEnvelope {
const stripped = stripBom(content)
return {
content: normalizeToLf(stripped.content),
hadBom: stripped.hadBom,
lineEnding: detectLineEnding(stripped.content),
}
}
export function restoreFileText(content: string, envelope: FileTextEnvelope): string {
const withLineEnding = restoreLineEndings(content, envelope.lineEnding)
if (!envelope.hadBom) return withLineEnding
return `\uFEFF${withLineEnding}`
}
@@ -0,0 +1,206 @@
import { describe, it, expect } from "bun:test"
import {
computeLineHash,
computeLegacyLineHash,
formatHashLine,
formatHashLines,
streamHashLinesFromLines,
streamHashLinesFromUtf8,
} from "./hash-computation"
describe("computeLineHash", () => {
it("returns deterministic 2-char CID hash per line", () => {
//#given
const content = "function hello() {"
//#when
const hash1 = computeLineHash(1, content)
const hash2 = computeLineHash(1, content)
//#then
expect(hash1).toBe(hash2)
expect(hash1).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/)
})
it("produces same hashes for significant content on different lines", () => {
//#given
const content = "function hello() {"
//#when
const hash1 = computeLineHash(1, content)
const hash2 = computeLineHash(2, content)
//#then
expect(hash1).toBe(hash2)
})
it("mixes line number for non-significant lines", () => {
//#given
const punctuationOnly = "{}"
//#when
const hash1 = computeLineHash(1, punctuationOnly)
const hash2 = computeLineHash(2, punctuationOnly)
//#then
expect(hash1).not.toBe(hash2)
})
it("produces different hashes for different leading indentation", () => {
//#given
const content1 = "function hello() {"
const content2 = " function hello() {"
//#when
const hash1 = computeLineHash(1, content1)
const hash2 = computeLineHash(1, content2)
//#then
expect(hash1).not.toBe(hash2)
})
it("preserves legacy hashes for leading indentation variants", () => {
//#given
const content1 = "function hello() {"
const content2 = " function hello() {"
//#when
const hash1 = computeLegacyLineHash(1, content1)
const hash2 = computeLegacyLineHash(1, content2)
//#then
expect(hash1).toBe(hash2)
})
it("preserves legacy hashes for internal whitespace variants", () => {
//#given
const content1 = "if (a && b) {"
const content2 = "if(a&&b){"
//#when
const hash1 = computeLegacyLineHash(1, content1)
const hash2 = computeLegacyLineHash(1, content2)
//#then
expect(hash1).toBe(hash2)
})
it("ignores trailing whitespace differences", () => {
//#given
const content1 = "function hello() {"
const content2 = "function hello() { "
//#when
const hash1 = computeLineHash(1, content1)
const hash2 = computeLineHash(1, content2)
//#then
expect(hash1).toBe(hash2)
})
it("produces same hash for CRLF and LF line endings", () => {
//#given
const content1 = "function hello() {"
const content2 = "function hello() {\r"
//#when
const hash1 = computeLineHash(1, content1)
const hash2 = computeLineHash(1, content2)
//#then
expect(hash1).toBe(hash2)
})
})
describe("formatHashLine", () => {
it("formats single line as LINE#ID|content", () => {
//#given
const lineNumber = 42
const content = "const x = 42"
//#when
const result = formatHashLine(lineNumber, content)
//#then
expect(result).toMatch(/^42#[ZPMQVRWSNKTXJBYH]{2}\|const x = 42$/)
})
})
describe("formatHashLines", () => {
it("formats all lines as LINE#ID|content", () => {
//#given
const content = "a\nb\nc"
//#when
const result = formatHashLines(content)
//#then
const lines = result.split("\n")
expect(lines).toHaveLength(3)
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|a$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|b$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|c$/)
})
})
describe("streamHashLinesFrom*", () => {
async function collectStream(stream: AsyncIterable<string>): Promise<string> {
const chunks: string[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks.join("\n")
}
async function* utf8Chunks(text: string, chunkSize: number): AsyncGenerator<Uint8Array> {
const encoded = new TextEncoder().encode(text)
for (let i = 0; i < encoded.length; i += chunkSize) {
yield encoded.slice(i, i + chunkSize)
}
}
it("matches formatHashLines for utf8 stream input", async () => {
//#given
const content = "a\nb\nc"
//#when
const result = await collectStream(streamHashLinesFromUtf8(utf8Chunks(content, 1), { maxChunkLines: 1 }))
//#then
expect(result).toBe(formatHashLines(content))
})
it("matches formatHashLines for line iterable input", async () => {
//#given
const content = "x\ny\n"
const lines = ["x", "y", ""]
//#when
const result = await collectStream(streamHashLinesFromLines(lines, { maxChunkLines: 2 }))
//#then
expect(result).toBe(formatHashLines(content))
})
it("matches formatHashLines for empty utf8 stream input", async () => {
//#given
const content = ""
//#when
const result = await collectStream(streamHashLinesFromUtf8(utf8Chunks(content, 1), { maxChunkLines: 1 }))
//#then
expect(result).toBe(formatHashLines(content))
})
it("matches formatHashLines for empty line iterable input", async () => {
//#given
const content = ""
//#when
const result = await collectStream(streamHashLinesFromLines([], { maxChunkLines: 1 }))
//#then
expect(result).toBe(formatHashLines(content))
})
})
@@ -0,0 +1,155 @@
import { HASHLINE_DICT } from "./constants"
import { createHashlineChunkFormatter } from "./hashline-chunk-formatter"
import { hashXxh32 } from "./xxhash32"
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
const stripped = normalizedContent
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
const hash = hashXxh32(stripped, seed)
const index = hash % 256
return HASHLINE_DICT[index]
}
export function computeLineHash(lineNumber: number, content: string): string {
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd())
}
export function computeLegacyLineHash(lineNumber: number, content: string): string {
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, ""))
}
export function formatHashLine(lineNumber: number, content: string): string {
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}|${content}`
}
export function formatHashLines(content: string): string {
if (!content) return ""
const lines = content.split("\n")
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
}
export interface HashlineStreamOptions {
startLine?: number
maxChunkLines?: number
maxChunkBytes?: number
}
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
typeof value === "object" &&
value !== null &&
"getReader" in value &&
typeof (value as { getReader?: unknown }).getReader === "function"
)
}
async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
const reader = stream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) return
if (value) yield value
}
} finally {
reader.releaseLock()
}
}
export async function* streamHashLinesFromUtf8(
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
options: HashlineStreamOptions = {}
): AsyncGenerator<string> {
const startLine = options.startLine ?? 1
const maxChunkLines = options.maxChunkLines ?? 200
const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024
const decoder = new TextDecoder("utf-8")
const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source
let lineNumber = startLine
let pending = ""
let sawAnyText = false
let endedWithNewline = false
const chunkFormatter = createHashlineChunkFormatter({ maxChunkLines, maxChunkBytes })
const pushLine = (line: string): string[] => {
const formatted = formatHashLine(lineNumber, line)
lineNumber += 1
return chunkFormatter.push(formatted)
}
const consumeText = (text: string): string[] => {
if (text.length === 0) return []
sawAnyText = true
pending += text
const chunksToYield: string[] = []
let lastIdx = 0
while (true) {
const idx = pending.indexOf("\n", lastIdx)
if (idx === -1) break
const line = pending.slice(lastIdx, idx)
lastIdx = idx + 1
endedWithNewline = true
chunksToYield.push(...pushLine(line))
}
pending = pending.slice(lastIdx)
if (pending.length > 0) endedWithNewline = false
return chunksToYield
}
for await (const chunk of chunks) {
for (const out of consumeText(decoder.decode(chunk, { stream: true }))) {
yield out
}
}
for (const out of consumeText(decoder.decode())) {
yield out
}
if (sawAnyText && (pending.length > 0 || endedWithNewline)) {
for (const out of pushLine(pending)) {
yield out
}
}
const finalChunk = chunkFormatter.flush()
if (finalChunk) yield finalChunk
}
export async function* streamHashLinesFromLines(
lines: Iterable<string> | AsyncIterable<string>,
options: HashlineStreamOptions = {}
): AsyncGenerator<string> {
const startLine = options.startLine ?? 1
const maxChunkLines = options.maxChunkLines ?? 200
const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024
let lineNumber = startLine
const chunkFormatter = createHashlineChunkFormatter({ maxChunkLines, maxChunkBytes })
const pushLine = (line: string): string[] => {
const formatted = formatHashLine(lineNumber, line)
lineNumber += 1
return chunkFormatter.push(formatted)
}
const asyncIterator = (lines as AsyncIterable<string>)[Symbol.asyncIterator]
if (typeof asyncIterator === "function") {
for await (const line of lines as AsyncIterable<string>) {
for (const out of pushLine(line)) yield out
}
} else {
for (const line of lines as Iterable<string>) {
for (const out of pushLine(line)) yield out
}
}
const finalChunk = chunkFormatter.flush()
if (finalChunk) yield finalChunk
}
@@ -0,0 +1,52 @@
export interface HashlineChunkFormatter {
push(formattedLine: string): string[]
flush(): string | undefined
}
interface HashlineChunkFormatterOptions {
maxChunkLines: number
maxChunkBytes: number
}
export function createHashlineChunkFormatter(options: HashlineChunkFormatterOptions): HashlineChunkFormatter {
const { maxChunkLines, maxChunkBytes } = options
let outputLines: string[] = []
let outputBytes = 0
const flush = (): string | undefined => {
if (outputLines.length === 0) return undefined
const chunk = outputLines.join("\n")
outputLines = []
outputBytes = 0
return chunk
}
const push = (formattedLine: string): string[] => {
const chunksToYield: string[] = []
const separatorBytes = outputLines.length === 0 ? 0 : 1
const lineBytes = Buffer.byteLength(formattedLine, "utf-8")
if (
outputLines.length > 0 &&
(outputLines.length >= maxChunkLines || outputBytes + separatorBytes + lineBytes > maxChunkBytes)
) {
const flushed = flush()
if (flushed) chunksToYield.push(flushed)
}
outputLines.push(formattedLine)
outputBytes += (outputLines.length === 1 ? 0 : 1) + lineBytes
if (outputLines.length >= maxChunkLines || outputBytes >= maxChunkBytes) {
const flushed = flush()
if (flushed) chunksToYield.push(flushed)
}
return chunksToYield
}
return {
push,
flush,
}
}
@@ -0,0 +1,31 @@
import { computeLineHash } from "./hash-computation"
export function generateHashlineDiff(oldContent: string, newContent: string, filePath: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
const parts: string[] = [`--- ${filePath}\n+++ ${filePath}\n`]
const maxLines = Math.max(oldLines.length, newLines.length)
for (let i = 0; i < maxLines; i += 1) {
const oldLine = oldLines[i] ?? ""
const newLine = newLines[i] ?? ""
const lineNum = i + 1
const hash = computeLineHash(lineNum, newLine)
if (i >= oldLines.length) {
parts.push(`+ ${lineNum}#${hash}|${newLine}\n`)
continue
}
if (i >= newLines.length) {
parts.push(`- ${lineNum}# |${oldLine}\n`)
continue
}
if (oldLine !== newLine) {
parts.push(`- ${lineNum}# |${oldLine}\n`)
parts.push(`+ ${lineNum}#${hash}|${newLine}\n`)
}
}
return parts.join("")
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Hashline core public API.
*
* Hash dependency choice: Option 2.
* This package embeds a runtime-aware xxHash32 implementation (`xxhash32.ts`)
* that prefers the host runtime's native xxHash32 binding when available and
* falls back to a pure-JS implementation otherwise. No package-level dependency
* on any specific runtime; the binding is detected via globalThis at call time.
*/
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
export type { ReplaceEdit, AppendEdit, PrependEdit, HashlineEdit } from "./types"
export {
computeLineHash,
computeLegacyLineHash,
formatHashLine,
formatHashLines,
streamHashLinesFromUtf8,
streamHashLinesFromLines,
} from "./hash-computation"
export { parseLineRef, validateLineRef, validateLineRefs, HashlineMismatchError, normalizeLineRef } from "./validation"
export type { LineRef } from "./validation"
export { applyHashlineEdits, applyHashlineEditsWithReport } from "./edit-operations"
export type { HashlineApplyReport } from "./edit-operations"
export {
applySetLine,
applyReplaceLines,
applyInsertAfter,
applyInsertBefore,
applyAppend,
applyPrepend,
} from "./edit-operation-primitives"
export { getEditLineNumber, collectLineRefs, detectOverlappingRanges } from "./edit-ordering"
export { dedupeEdits } from "./edit-deduplication"
export {
stripLinePrefixes,
toNewLines,
restoreLeadingIndent,
stripInsertAnchorEcho,
stripInsertBeforeEcho,
stripInsertBoundaryEcho,
stripRangeBoundaryEcho,
} from "./edit-text-normalization"
export { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
export type { FileTextEnvelope } from "./file-text-canonicalization"
export {
stripTrailingContinuationTokens,
stripMergeOperatorChars,
restoreOldWrappedLines,
maybeExpandSingleLineMerge,
restoreIndentForPairedReplacement,
autocorrectReplacementLines,
} from "./autocorrect-replacement-lines"
export { normalizeHashlineEdits } from "./normalize-edits"
export type { RawHashlineEdit } from "./normalize-edits"
export { createHashlineChunkFormatter } from "./hashline-chunk-formatter"
export type { HashlineChunkFormatter } from "./hashline-chunk-formatter"
export type { HashlineStreamOptions } from "./hash-computation"
export { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils"
export { generateHashlineDiff } from "./hashline-edit-diff"
@@ -0,0 +1,62 @@
import { describe, expect, it } from "bun:test"
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("normalizeHashlineEdits", () => {
it("maps replace with pos to replace", () => {
//#given
const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", lines: "updated" }]
//#when
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ op: "replace", pos: "2#VK", lines: "updated" }])
})
it("maps replace with pos and end to replace", () => {
//#given
const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", end: "4#MB", lines: ["a", "b"] }]
//#when
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ op: "replace", pos: "2#VK", end: "4#MB", lines: ["a", "b"] }])
})
it("maps anchored append and prepend preserving op", () => {
//#given
const input: RawHashlineEdit[] = [
{ op: "append", pos: "2#VK", lines: ["after"] },
{ op: "prepend", pos: "4#MB", lines: ["before"] },
]
//#when
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ op: "append", pos: "2#VK", lines: ["after"] }, { op: "prepend", pos: "4#MB", lines: ["before"] }])
})
it("prefers pos over end for prepend anchors", () => {
//#given
const input: RawHashlineEdit[] = [{ op: "prepend", pos: "3#AA", end: "7#BB", lines: ["before"] }]
//#when
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ op: "prepend", pos: "3#AA", lines: ["before"] }])
})
it("rejects legacy payload without op", () => {
//#given
const input = unsafeTestValue<Parameters<
typeof normalizeHashlineEdits
>[0]>([{ type: "set_line", line: "2#VK", text: "updated" }])
//#when / #then
expect(() => normalizeHashlineEdits(input)).toThrow(/legacy format was removed/i)
})
})
@@ -0,0 +1,95 @@
import type { AppendEdit, HashlineEdit, PrependEdit, ReplaceEdit } from "./types"
type HashlineToolOp = "replace" | "append" | "prepend"
export interface RawHashlineEdit {
op?: HashlineToolOp
pos?: string
end?: string
lines?: string | string[] | null
}
function normalizeAnchor(value: string | undefined): string | undefined {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
return trimmed === "" ? undefined : trimmed
}
function requireLines(edit: RawHashlineEdit, index: number): string | string[] {
if (edit.lines === undefined) {
throw new Error(`Edit ${index}: lines is required for ${edit.op ?? "unknown"}`)
}
if (edit.lines === null) {
return []
}
return edit.lines
}
function requireLine(anchor: string | undefined, index: number, op: HashlineToolOp): string {
if (!anchor) {
throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference (pos or end)`)
}
return anchor
}
function normalizeReplaceEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = requireLine(pos ?? end, index, "replace")
const lines = requireLines(edit, index)
const normalized: ReplaceEdit = {
op: "replace",
pos: anchor,
lines,
}
if (end) normalized.end = end
return normalized
}
function normalizeAppendEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = pos ?? end
const lines = requireLines(edit, index)
const normalized: AppendEdit = {
op: "append",
lines,
}
if (anchor) normalized.pos = anchor
return normalized
}
function normalizePrependEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = pos ?? end
const lines = requireLines(edit, index)
const normalized: PrependEdit = {
op: "prepend",
lines,
}
if (anchor) normalized.pos = anchor
return normalized
}
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] {
return rawEdits.map((rawEdit, index) => {
const edit = rawEdit ?? {}
switch (edit.op) {
case "replace":
return normalizeReplaceEdit(edit, index)
case "append":
return normalizeAppendEdit(edit, index)
case "prepend":
return normalizePrependEdit(edit, index)
default:
throw new Error(
`Edit ${index}: unsupported op "${String(edit.op)}". Legacy format was removed; use op/pos/end/lines.`
)
}
})
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from "bun:test"
import {
HASHLINE_DICT,
HASHLINE_OUTPUT_PATTERN,
HASHLINE_REF_PATTERN,
NIBBLE_STR,
} from "./constants"
import { autocorrectReplacementLines } from "./autocorrect-replacement-lines"
import { dedupeEdits } from "./edit-deduplication"
import { collectLineRefs, detectOverlappingRanges } from "./edit-ordering"
import { toNewLines } from "./edit-text-normalization"
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
import { createHashlineChunkFormatter } from "./hashline-chunk-formatter"
import { generateHashlineDiff } from "./hashline-edit-diff"
describe("smoke coverage for moved modules without direct legacy tests", () => {
it("exposes constants and patterns", () => {
expect(NIBBLE_STR).toHaveLength(16)
expect(HASHLINE_DICT).toHaveLength(256)
expect(HASHLINE_REF_PATTERN.test("1#ZZ")).toBe(true)
expect(HASHLINE_OUTPUT_PATTERN.test("1#ZZ|line")).toBe(true)
})
it("runs representative helpers", () => {
const normalized = toNewLines("1#ZZ|alpha\n2#PM|beta")
expect(normalized).toEqual(["alpha", "beta"])
const corrected = autocorrectReplacementLines([" return 1"], ["return 2"])
expect(corrected).toEqual([" return 2"])
const deduped = dedupeEdits([
{ op: "append", pos: "1#ZZ", lines: "x" },
{ op: "append", pos: "1#ZZ", lines: "x" },
])
expect(deduped.deduplicatedEdits).toBe(1)
const refs = collectLineRefs([{ op: "replace", pos: "1#ZZ", lines: "x" }])
expect(refs).toEqual(["1#ZZ"])
expect(
detectOverlappingRanges([
{ op: "replace", pos: "1#ZZ", end: "2#PM", lines: "x" },
{ op: "replace", pos: "2#PM", end: "3#QV", lines: "y" },
])
).toContain("Overlapping range edits")
const envelope = canonicalizeFileText("\uFEFFa\r\nb\r\n")
expect(restoreFileText(envelope.content, envelope)).toBe("\uFEFFa\r\nb\r\n")
const formatter = createHashlineChunkFormatter({ maxChunkLines: 1, maxChunkBytes: 1024 })
expect(formatter.push("1#ZZ|a")).toEqual(["1#ZZ|a"])
const diff = generateHashlineDiff("a", "b", "x.ts")
expect(diff).toContain("+++ x.ts")
})
})
+20
View File
@@ -0,0 +1,20 @@
export interface ReplaceEdit {
op: "replace"
pos: string
end?: string
lines: string | string[]
}
export interface AppendEdit {
op: "append"
pos?: string
lines: string | string[]
}
export interface PrependEdit {
op: "prepend"
pos?: string
lines: string | string[]
}
export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit
@@ -0,0 +1,154 @@
import { describe, it, expect } from "bun:test"
import { computeLineHash, computeLegacyLineHash } from "./hash-computation"
import { parseLineRef, validateLineRef, validateLineRefs } from "./validation"
describe("parseLineRef", () => {
it("parses valid LINE#ID reference", () => {
//#given
const ref = "42#VK"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 42, hash: "VK" })
})
it("throws on invalid format", () => {
//#given
const ref = "42:VK"
//#when / #then
expect(() => parseLineRef(ref)).toThrow("{line_number}#{hash_id}")
})
it("gives specific hint when literal text is used instead of line number", () => {
//#given, model sends "LINE#HK" instead of "1#HK"
const ref = "LINE#HK"
//#when / #then, error should mention that LINE is not a valid number
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
it("gives specific hint for other non-numeric prefixes like POS#VK", () => {
//#given
const ref = "POS#VK"
//#when / #then
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
it("extracts valid line number from mixed prefix like LINE42 without throwing", () => {
//#given, normalizeLineRef extracts 42#VK from LINE42#VK
const ref = "LINE42#VK"
//#when / #then, should parse successfully as line 42
const result = parseLineRef(ref)
expect(result.line).toBe(42)
expect(result.hash).toBe("VK")
})
it("gives specific hint when hyphenated prefix like line-ref is used", () => {
//#given
const ref = "line-ref#VK"
//#when / #then
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
it("gives specific hint when prefix contains a period like line.ref", () => {
//#given
const ref = "line.ref#VK"
//#when / #then
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
it("accepts refs copied with markers and trailing content", () => {
//#given
const ref = ">>> 42#VK|const value = 1"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 42, hash: "VK" })
})
it("accepts refs copied with >>> marker only", () => {
//#given
const ref = ">>> 42#VK"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 42, hash: "VK" })
})
it("accepts refs with spaces around hash separator", () => {
//#given
const ref = "42 # VK"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 42, hash: "VK" })
})
})
describe("validateLineRef", () => {
it("accepts matching reference", () => {
//#given
const lines = ["function hello() {", " return 42", "}"]
const hash = computeLineHash(1, lines[0])
//#when / #then
expect(() => validateLineRef(lines, `1#${hash}`)).not.toThrow()
})
it("throws on mismatch and includes current hash", () => {
//#given
const lines = ["function hello() {"]
//#when / #then
expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/)
})
it("accepts legacy hashes for indented lines", () => {
//#given
const lines = [" function hello() {", " return 42", " }"]
const legacyHash = computeLegacyLineHash(1, lines[0])
//#when / #then
expect(() => validateLineRef(lines, `1#${legacyHash}`)).not.toThrow()
})
it("accepts legacy hashes for internal whitespace variants", () => {
//#given
const lines = ["if (a && b) {"]
const legacyHash = computeLegacyLineHash(1, "if(a&&b){")
//#when / #then
expect(() => validateLineRef(lines, `1#${legacyHash}`)).not.toThrow()
})
it("shows >>> mismatch context in batched validation", () => {
//#given
const lines = ["one", "two", "three", "four"]
//#when / #then
expect(() => validateLineRefs(lines, ["2#ZZ"]))
.toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}\|two/)
})
it("suggests correct line number when hash matches a file line", () => {
//#given, model sends LINE#XX where XX is the actual hash for line 1
const lines = ["function hello() {", " return 42", "}"]
const hash = computeLineHash(1, lines[0])
//#when / #then, error should suggest the correct reference
expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`))
})
})
+181
View File
@@ -0,0 +1,181 @@
import { computeLegacyLineHash, computeLineHash } from "./hash-computation"
import { HASHLINE_REF_PATTERN } from "./constants"
export interface LineRef {
line: number
hash: string
}
interface HashMismatch {
line: number
expected: string
}
const MISMATCH_CONTEXT = 2
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
function isCompatibleLineHash(line: number, content: string, hash: string): boolean {
return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash
}
export function normalizeLineRef(ref: string): string {
const originalTrimmed = ref.trim()
let trimmed = originalTrimmed
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
trimmed = trimmed.replace(/\s*#\s*/, "#")
trimmed = trimmed.replace(/\|.*$/, "")
trimmed = trimmed.trim()
if (HASHLINE_REF_PATTERN.test(trimmed)) {
return trimmed
}
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
if (extracted) {
return extracted[1]
}
return originalTrimmed
}
export function parseLineRef(ref: string): LineRef {
const normalized = normalizeLineRef(ref)
const match = normalized.match(HASHLINE_REF_PATTERN)
if (match) {
return {
line: Number.parseInt(match[1], 10),
hash: match[2],
}
}
const hashIdx = normalized.indexOf('#')
if (hashIdx > 0) {
const prefix = normalized.slice(0, hashIdx)
const suffix = normalized.slice(hashIdx + 1)
if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) {
throw new Error(
`Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
`Use the actual line number from the read output.`
)
}
}
throw new Error(
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
)
}
export function validateLineRef(lines: string[], ref: string): void {
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(
`Line number ${line} out of bounds. File has ${lines.length} lines.`
)
}
const content = lines[line - 1]
if (!isCompatibleLineHash(line, content, hash)) {
throw new HashlineMismatchError([{ line, expected: hash }], lines)
}
}
export class HashlineMismatchError extends Error {
readonly remaps: ReadonlyMap<string, string>
constructor(
private readonly mismatches: HashMismatch[],
private readonly fileLines: string[]
) {
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
this.name = "HashlineMismatchError"
const remaps = new Map<string, string>()
for (const mismatch of mismatches) {
const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "")
remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`)
}
this.remaps = remaps
}
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
const mismatchByLine = new Map<number, HashMismatch>()
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
const displayLines = new Set<number>()
for (const mismatch of mismatches) {
const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT)
const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT)
for (let line = low; line <= high; line++) displayLines.add(line)
}
const sortedLines = [...displayLines].sort((a, b) => a - b)
const output: string[] = []
output.push(
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
)
output.push("")
let previousLine = -1
for (const line of sortedLines) {
if (previousLine !== -1 && line > previousLine + 1) {
output.push(" ...")
}
previousLine = line
const content = fileLines[line - 1] ?? ""
const hash = computeLineHash(line, content)
const prefix = `${line}#${hash}|${content}`
if (mismatchByLine.has(line)) {
output.push(`>>> ${prefix}`)
} else {
output.push(` ${prefix}`)
}
}
return output.join("\n")
}
}
function suggestLineForHash(ref: string, lines: string[]): string | null {
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
if (!hashMatch) return null
const hash = hashMatch[1]
for (let i = 0; i < lines.length; i++) {
if (isCompatibleLineHash(i + 1, lines[i], hash)) {
return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i])}"?`
}
}
return null
}
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
try {
return parseLineRef(ref)
} catch (parseError) {
const hint = suggestLineForHash(ref, lines)
if (hint && parseError instanceof Error) {
throw new Error(`${parseError.message} ${hint}`)
}
throw parseError
}
}
export function validateLineRefs(lines: string[], refs: string[]): void {
const mismatches: HashMismatch[] = []
for (const ref of refs) {
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
}
const content = lines[line - 1]
if (!isCompatibleLineHash(line, content, hash)) {
mismatches.push({ line, expected: hash })
}
}
if (mismatches.length > 0) {
throw new HashlineMismatchError(mismatches, lines)
}
}
+90
View File
@@ -0,0 +1,90 @@
type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } }
const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime }
const encoder = new TextEncoder()
const PRIME32_1 = 0x9e3779b1
const PRIME32_2 = 0x85ebca77
const PRIME32_3 = 0xc2b2ae3d
const PRIME32_4 = 0x27d4eb2f
const PRIME32_5 = 0x165667b1
function rotateLeft32(value: number, bits: number): number {
return ((value << bits) | (value >>> (32 - bits))) >>> 0
}
function readUint32LittleEndian(input: Uint8Array, offset: number): number {
return (
((input[offset] ?? 0) |
((input[offset + 1] ?? 0) << 8) |
((input[offset + 2] ?? 0) << 16) |
((input[offset + 3] ?? 0) << 24)) >>>
0
)
}
function round32(accumulator: number, value: number): number {
const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0
return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0
}
function xxHash32Js(input: Uint8Array, seed: number): number {
let offset = 0
const length = input.length
let hash: number
if (length >= 16) {
const limit = length - 16
let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0
let value2 = (seed + PRIME32_2) >>> 0
let value3 = seed >>> 0
let value4 = (seed - PRIME32_1) >>> 0
while (offset <= limit) {
value1 = round32(value1, readUint32LittleEndian(input, offset))
offset += 4
value2 = round32(value2, readUint32LittleEndian(input, offset))
offset += 4
value3 = round32(value3, readUint32LittleEndian(input, offset))
offset += 4
value4 = round32(value4, readUint32LittleEndian(input, offset))
offset += 4
}
hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0
hash = (hash + rotateLeft32(value3, 12)) >>> 0
hash = (hash + rotateLeft32(value4, 18)) >>> 0
} else {
hash = (seed + PRIME32_5) >>> 0
}
hash = (hash + length) >>> 0
while (offset + 4 <= length) {
hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0
hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0
offset += 4
}
while (offset < length) {
hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0
hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0
offset += 1
}
hash = (hash ^ (hash >>> 15)) >>> 0
hash = Math.imul(hash, PRIME32_2) >>> 0
hash = (hash ^ (hash >>> 13)) >>> 0
hash = Math.imul(hash, PRIME32_3) >>> 0
return (hash ^ (hash >>> 16)) >>> 0
}
export function hashXxh32(input: string, seed: number): number {
const bun = runtime.Bun
if (bun !== undefined) {
return bun.hash.xxHash32(input, seed)
}
return xxHash32Js(encoder.encode(input), seed >>> 0)
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ESNext"],
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
@@ -1,179 +1,8 @@
function normalizeTokens(text: string): string {
return text.replace(/\s+/g, "")
}
function stripAllWhitespace(text: string): string {
return normalizeTokens(text)
}
export function stripTrailingContinuationTokens(text: string): string {
return text.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, "")
}
export function stripMergeOperatorChars(text: string): string {
return text.replace(/[|&?]/g, "")
}
function leadingWhitespace(text: string): string {
if (!text) return ""
const match = text.match(/^\s*/)
return match ? match[0] : ""
}
export function restoreOldWrappedLines(originalLines: string[], replacementLines: string[]): string[] {
if (originalLines.length === 0 || replacementLines.length < 2) return replacementLines
const canonicalToOriginal = new Map<string, { line: string; count: number }>()
for (const line of originalLines) {
const canonical = stripAllWhitespace(line)
const existing = canonicalToOriginal.get(canonical)
if (existing) {
existing.count += 1
} else {
canonicalToOriginal.set(canonical, { line, count: 1 })
}
}
const candidates: { start: number; len: number; replacement: string; canonical: string }[] = []
for (let start = 0; start < replacementLines.length; start += 1) {
for (let len = 2; len <= 10 && start + len <= replacementLines.length; len += 1) {
const span = replacementLines.slice(start, start + len)
if (span.some((line) => line.trim().length === 0)) continue
const canonicalSpan = stripAllWhitespace(span.join(""))
const original = canonicalToOriginal.get(canonicalSpan)
if (original && original.count === 1 && canonicalSpan.length >= 6) {
candidates.push({ start, len, replacement: original.line, canonical: canonicalSpan })
}
}
}
if (candidates.length === 0) return replacementLines
const canonicalCounts = new Map<string, number>()
for (const candidate of candidates) {
canonicalCounts.set(candidate.canonical, (canonicalCounts.get(candidate.canonical) ?? 0) + 1)
}
const uniqueCandidates = candidates.filter((candidate) => (canonicalCounts.get(candidate.canonical) ?? 0) === 1)
if (uniqueCandidates.length === 0) return replacementLines
uniqueCandidates.sort((a, b) => b.start - a.start)
const correctedLines = [...replacementLines]
for (const candidate of uniqueCandidates) {
correctedLines.splice(candidate.start, candidate.len, candidate.replacement)
}
return correctedLines
}
export function maybeExpandSingleLineMerge(
originalLines: string[],
replacementLines: string[]
): string[] {
if (replacementLines.length !== 1 || originalLines.length <= 1) {
return replacementLines
}
const merged = replacementLines[0]
const parts = originalLines.map((line) => line.trim()).filter((line) => line.length > 0)
if (parts.length !== originalLines.length) return replacementLines
const indices: number[] = []
let offset = 0
let orderedMatch = true
for (const part of parts) {
let idx = merged.indexOf(part, offset)
let matchedLen = part.length
if (idx === -1) {
const stripped = stripTrailingContinuationTokens(part)
if (stripped !== part) {
idx = merged.indexOf(stripped, offset)
if (idx !== -1) matchedLen = stripped.length
}
}
if (idx === -1) {
const segment = merged.slice(offset)
const segmentStripped = stripMergeOperatorChars(segment)
const partStripped = stripMergeOperatorChars(part)
const fuzzyIdx = segmentStripped.indexOf(partStripped)
if (fuzzyIdx !== -1) {
let strippedPos = 0
let originalPos = 0
while (strippedPos < fuzzyIdx && originalPos < segment.length) {
if (!/[|&?]/.test(segment[originalPos])) strippedPos += 1
originalPos += 1
}
idx = offset + originalPos
matchedLen = part.length
}
}
if (idx === -1) {
orderedMatch = false
break
}
indices.push(idx)
offset = idx + matchedLen
}
const expanded: string[] = []
if (orderedMatch) {
for (let i = 0; i < indices.length; i += 1) {
const start = indices[i]
const end = i + 1 < indices.length ? indices[i + 1] : merged.length
const candidate = merged.slice(start, end).trim()
if (candidate.length === 0) {
orderedMatch = false
break
}
expanded.push(candidate)
}
}
if (orderedMatch && expanded.length === originalLines.length) {
return expanded
}
const semicolonSplit = merged
.split(/;\s+/)
.map((line, idx, arr) => {
if (idx < arr.length - 1 && !line.endsWith(";")) {
return `${line};`
}
return line
})
.map((line) => line.trim())
.filter((line) => line.length > 0)
if (semicolonSplit.length === originalLines.length) {
return semicolonSplit
}
return replacementLines
}
export function restoreIndentForPairedReplacement(
originalLines: string[],
replacementLines: string[]
): string[] {
if (originalLines.length !== replacementLines.length) {
return replacementLines
}
return replacementLines.map((line, idx) => {
if (line.length === 0) return line
if (leadingWhitespace(line).length > 0) return line
const indent = leadingWhitespace(originalLines[idx])
if (indent.length === 0) return line
if (originalLines[idx].trim() === line.trim()) return line
return `${indent}${line}`
})
}
export function autocorrectReplacementLines(
originalLines: string[],
replacementLines: string[]
): string[] {
let next = replacementLines
next = maybeExpandSingleLineMerge(originalLines, next)
next = restoreOldWrappedLines(originalLines, next)
next = restoreIndentForPairedReplacement(originalLines, next)
return next
}
export {
stripTrailingContinuationTokens,
stripMergeOperatorChars,
restoreOldWrappedLines,
maybeExpandSingleLineMerge,
restoreIndentForPairedReplacement,
autocorrectReplacementLines,
} from "@oh-my-opencode/hashline-core"
+6 -10
View File
@@ -1,10 +1,6 @@
export const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"
export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
const high = i >>> 4
const low = i & 0x0f
return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}`
})
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/
export {
NIBBLE_STR,
HASHLINE_DICT,
HASHLINE_REF_PATTERN,
HASHLINE_OUTPUT_PATTERN,
} from "@oh-my-opencode/hashline-core"
+5 -53
View File
@@ -1,53 +1,5 @@
import { createTwoFilesPatch } from "diff"
import { computeLineHash } from "./hash-computation"
export function toHashlineContent(content: string): string {
if (!content) return content
const lines = content.split("\n")
const lastLine = lines[lines.length - 1]
const hasTrailingNewline = lastLine === ""
const contentLines = hasTrailingNewline ? lines.slice(0, -1) : lines
const hashlined = contentLines.map((line, i) => {
const lineNum = i + 1
const hash = computeLineHash(lineNum, line)
return `${lineNum}#${hash}|${line}`
})
return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n")
}
export function generateUnifiedDiff(oldContent: string, newContent: string, filePath: string): string {
return createTwoFilesPatch(filePath, filePath, oldContent, newContent, undefined, undefined, { context: 3 })
}
export function countLineDiffs(oldContent: string, newContent: string): { additions: number; deletions: number } {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
const oldSet = new Map<string, number>()
for (const line of oldLines) {
oldSet.set(line, (oldSet.get(line) ?? 0) + 1)
}
const newSet = new Map<string, number>()
for (const line of newLines) {
newSet.set(line, (newSet.get(line) ?? 0) + 1)
}
let deletions = 0
for (const [line, count] of oldSet) {
const newCount = newSet.get(line) ?? 0
if (count > newCount) {
deletions += count - newCount
}
}
let additions = 0
for (const [line, count] of newSet) {
const oldCount = oldSet.get(line) ?? 0
if (count > oldCount) {
additions += count - oldCount
}
}
return { additions, deletions }
}
export {
toHashlineContent,
generateUnifiedDiff,
countLineDiffs,
} from "@oh-my-opencode/hashline-core"
+1 -43
View File
@@ -1,43 +1 @@
import type { HashlineEdit } from "./types"
import { toNewLines } from "./edit-text-normalization"
import { normalizeLineRef } from "./validation"
function normalizeEditPayload(payload: string | string[]): string {
return toNewLines(payload).join("\n")
}
function canonicalAnchor(anchor: string | undefined): string {
if (!anchor) return ""
return normalizeLineRef(anchor)
}
function buildDedupeKey(edit: HashlineEdit): string {
switch (edit.op) {
case "replace":
return `replace|${canonicalAnchor(edit.pos)}|${edit.end ? canonicalAnchor(edit.end) : ""}|${normalizeEditPayload(edit.lines)}`
case "append":
return `append|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
case "prepend":
return `prepend|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
default:
return JSON.stringify(edit)
}
}
export function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
const seen = new Set<string>()
const deduped: HashlineEdit[] = []
let deduplicatedEdits = 0
for (const edit of edits) {
const key = buildDedupeKey(edit)
if (seen.has(key)) {
deduplicatedEdits += 1
continue
}
seen.add(key)
deduped.push(edit)
}
return { edits: deduped, deduplicatedEdits }
}
export { dedupeEdits } from "@oh-my-opencode/hashline-core"
@@ -1,126 +1,8 @@
import { autocorrectReplacementLines } from "./autocorrect-replacement-lines"
import {
restoreLeadingIndent,
stripInsertAnchorEcho,
stripInsertBeforeEcho,
stripInsertBoundaryEcho,
stripRangeBoundaryEcho,
toNewLines,
} from "./edit-text-normalization"
import { parseLineRef, validateLineRef } from "./validation"
interface EditApplyOptions {
skipValidation?: boolean
}
function shouldValidate(options?: EditApplyOptions): boolean {
return options?.skipValidation !== true
}
export function applySetLine(
lines: string[],
anchor: string,
newText: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const originalLine = lines[line - 1] ?? ""
const corrected = autocorrectReplacementLines([originalLine], toNewLines(newText))
const replacement = corrected.map((entry, idx) => {
if (idx !== 0) return entry
return restoreLeadingIndent(originalLine, entry)
})
result.splice(line - 1, 1, ...replacement)
return result
}
export function applyReplaceLines(
lines: string[],
startAnchor: string,
endAnchor: string,
newText: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) {
validateLineRef(lines, startAnchor)
validateLineRef(lines, endAnchor)
}
const { line: startLine } = parseLineRef(startAnchor)
const { line: endLine } = parseLineRef(endAnchor)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const result = [...lines]
const originalRange = lines.slice(startLine - 1, endLine)
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
const corrected = autocorrectReplacementLines(originalRange, stripped)
const restored = corrected.map((entry, idx) => {
if (idx !== 0) return entry
return restoreLeadingIndent(lines[startLine - 1] ?? "", entry)
})
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
return result
}
export function applyInsertAfter(
lines: string[],
anchor: string,
text: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`append (anchored) requires non-empty text for ${anchor}`)
}
result.splice(line, 0, ...newLines)
return result
}
export function applyInsertBefore(
lines: string[],
anchor: string,
text: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`prepend (anchored) requires non-empty text for ${anchor}`)
}
result.splice(line - 1, 0, ...newLines)
return result
}
export function applyAppend(lines: string[], text: string | string[]): string[] {
const normalized = toNewLines(text)
if (normalized.length === 0) {
throw new Error("append requires non-empty text")
}
if (lines.length === 1 && lines[0] === "") {
return [...normalized]
}
return [...lines, ...normalized]
}
export function applyPrepend(lines: string[], text: string | string[]): string[] {
const normalized = toNewLines(text)
if (normalized.length === 0) {
throw new Error("prepend requires non-empty text")
}
if (lines.length === 1 && lines[0] === "") {
return [...normalized]
}
return [...normalized, ...lines]
}
export {
applySetLine,
applyReplaceLines,
applyInsertAfter,
applyInsertBefore,
applyAppend,
applyPrepend,
} from "@oh-my-opencode/hashline-core"
+5 -103
View File
@@ -1,103 +1,5 @@
import { dedupeEdits } from "./edit-deduplication"
import { collectLineRefs, detectOverlappingRanges, getEditLineNumber } from "./edit-ordering"
import type { HashlineEdit } from "./types"
import {
applyAppend,
applyInsertAfter,
applyInsertBefore,
applyPrepend,
applyReplaceLines,
applySetLine,
} from "./edit-operation-primitives"
import { validateLineRefs } from "./validation"
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
export interface HashlineApplyReport {
content: string
noopEdits: number
deduplicatedEdits: number
}
export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport {
if (edits.length === 0) {
return {
content,
noopEdits: 0,
deduplicatedEdits: 0,
}
}
const dedupeResult = dedupeEdits(edits)
const EDIT_PRECEDENCE: Record<string, number> = { replace: 0, append: 1, prepend: 2 }
const sortedEdits = [...dedupeResult.edits].sort((a, b) => {
const lineA = getEditLineNumber(a)
const lineB = getEditLineNumber(b)
if (lineB !== lineA) return lineB - lineA
return (EDIT_PRECEDENCE[a.op] ?? 3) - (EDIT_PRECEDENCE[b.op] ?? 3)
})
let noopEdits = 0
let lines = content.length === 0 ? [] : content.split("\n")
const refs = collectLineRefs(sortedEdits)
validateLineRefs(lines, refs)
const overlapError = detectOverlappingRanges(sortedEdits)
if (overlapError) throw new Error(overlapError)
for (const edit of sortedEdits) {
switch (edit.op) {
case "replace": {
const next = edit.end
? applyReplaceLines(lines, edit.pos, edit.end, edit.lines, { skipValidation: true })
: applySetLine(lines, edit.pos, edit.lines, { skipValidation: true })
if (arraysEqual(next, lines)) {
noopEdits += 1
break
}
lines = next
break
}
case "append": {
const next = edit.pos
? applyInsertAfter(lines, edit.pos, edit.lines, { skipValidation: true })
: applyAppend(lines, edit.lines)
if (arraysEqual(next, lines)) {
noopEdits += 1
break
}
lines = next
break
}
case "prepend": {
const next = edit.pos
? applyInsertBefore(lines, edit.pos, edit.lines, { skipValidation: true })
: applyPrepend(lines, edit.lines)
if (arraysEqual(next, lines)) {
noopEdits += 1
break
}
lines = next
break
}
}
}
return {
content: lines.join("\n"),
noopEdits,
deduplicatedEdits: dedupeResult.deduplicatedEdits,
}
}
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
return applyHashlineEditsWithReport(content, edits).content
}
export {
applyHashlineEdits,
applyHashlineEditsWithReport,
} from "@oh-my-opencode/hashline-core"
export type { HashlineApplyReport } from "@oh-my-opencode/hashline-core"
+1 -56
View File
@@ -1,56 +1 @@
import { parseLineRef } from "./validation"
import type { HashlineEdit } from "./types"
export function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.op) {
case "replace":
return parseLineRef(edit.end ?? edit.pos).line
case "append":
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
case "prepend":
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
}
export function collectLineRefs(edits: HashlineEdit[]): string[] {
return edits.flatMap((edit) => {
switch (edit.op) {
case "replace":
return edit.end ? [edit.pos, edit.end] : [edit.pos]
case "append":
case "prepend":
return edit.pos ? [edit.pos] : []
default:
return []
}
})
}
export function detectOverlappingRanges(edits: HashlineEdit[]): string | null {
const ranges: { start: number; end: number; idx: number }[] = []
for (let i = 0; i < edits.length; i++) {
const edit = edits[i]
if (edit.op !== "replace" || !edit.end) continue
const start = parseLineRef(edit.pos).line
const end = parseLineRef(edit.end).line
ranges.push({ start, end, idx: i })
}
if (ranges.length < 2) return null
ranges.sort((a, b) => a.start - b.start || a.end - b.end)
for (let i = 1; i < ranges.length; i++) {
const prev = ranges[i - 1]
const curr = ranges[i]
if (curr.start <= prev.end) {
return (
`Overlapping range edits detected: ` +
`edit ${prev.idx + 1} (lines ${prev.start}-${prev.end}) overlaps with ` +
`edit ${curr.idx + 1} (lines ${curr.start}-${curr.end}). ` +
`Use pos-only replace for single-line edits.`
)
}
}
return null
}
export { getEditLineNumber, collectLineRefs, detectOverlappingRanges } from "@oh-my-opencode/hashline-core"
@@ -1,111 +1,9 @@
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\|/
const DIFF_PLUS_RE = /^[+](?![+])/
function equalsIgnoringWhitespace(a: string, b: string): boolean {
if (a === b) return true
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "")
}
function leadingWhitespace(text: string): string {
if (!text) return ""
const match = text.match(/^\s*/)
return match ? match[0] : ""
}
export function stripLinePrefixes(lines: string[]): string[] {
let hashPrefixCount = 0
let diffPlusCount = 0
let nonEmpty = 0
for (const line of lines) {
if (line.length === 0) continue
nonEmpty += 1
if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount += 1
if (DIFF_PLUS_RE.test(line)) diffPlusCount += 1
}
if (nonEmpty === 0) {
return lines
}
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5
if (!stripHash && !stripPlus) {
return lines
}
return lines.map((line) => {
if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "")
if (stripPlus) return line.replace(DIFF_PLUS_RE, "")
return line
})
}
export function toNewLines(input: string | string[]): string[] {
if (Array.isArray(input)) {
return stripLinePrefixes(input)
}
return stripLinePrefixes(input.split("\n"))
}
export function restoreLeadingIndent(templateLine: string, line: string): string {
if (line.length === 0) return line
const templateIndent = leadingWhitespace(templateLine)
if (templateIndent.length === 0) return line
if (leadingWhitespace(line).length > 0) return line
if (templateLine.trim() === line.trim()) return line
return `${templateIndent}${line}`
}
export function stripInsertAnchorEcho(anchorLine: string, newLines: string[]): string[] {
if (newLines.length === 0) return newLines
if (equalsIgnoringWhitespace(newLines[0], anchorLine)) {
return newLines.slice(1)
}
return newLines
}
export function stripInsertBeforeEcho(anchorLine: string, newLines: string[]): string[] {
if (newLines.length <= 1) return newLines
if (equalsIgnoringWhitespace(newLines[newLines.length - 1], anchorLine)) {
return newLines.slice(0, -1)
}
return newLines
}
export function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, newLines: string[]): string[] {
let out = newLines
if (out.length > 0 && equalsIgnoringWhitespace(out[0], afterLine)) {
out = out.slice(1)
}
if (out.length > 0 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
out = out.slice(0, -1)
}
return out
}
export function stripRangeBoundaryEcho(
lines: string[],
startLine: number,
endLine: number,
newLines: string[]
): string[] {
const replacedCount = endLine - startLine + 1
if (newLines.length <= 1 || newLines.length <= replacedCount) {
return newLines
}
let out = newLines
const beforeIdx = startLine - 2
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], lines[beforeIdx])) {
out = out.slice(1)
}
const afterIdx = endLine
if (afterIdx < lines.length && out.length > 0 && equalsIgnoringWhitespace(out[out.length - 1], lines[afterIdx])) {
out = out.slice(0, -1)
}
return out
}
export {
stripLinePrefixes,
toNewLines,
restoreLeadingIndent,
stripInsertAnchorEcho,
stripInsertBeforeEcho,
stripInsertBoundaryEcho,
stripRangeBoundaryEcho,
} from "@oh-my-opencode/hashline-core"
@@ -1,44 +1,2 @@
export interface FileTextEnvelope {
content: string
hadBom: boolean
lineEnding: "\n" | "\r\n"
}
function detectLineEnding(content: string): "\n" | "\r\n" {
const crlfIndex = content.indexOf("\r\n")
const lfIndex = content.indexOf("\n")
if (lfIndex === -1) return "\n"
if (crlfIndex === -1) return "\n"
return crlfIndex < lfIndex ? "\r\n" : "\n"
}
function stripBom(content: string): { content: string; hadBom: boolean } {
if (!content.startsWith("\uFEFF")) {
return { content, hadBom: false }
}
return { content: content.slice(1), hadBom: true }
}
function normalizeToLf(content: string): string {
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}
function restoreLineEndings(content: string, lineEnding: "\n" | "\r\n"): string {
if (lineEnding === "\n") return content
return content.replace(/\n/g, "\r\n")
}
export function canonicalizeFileText(content: string): FileTextEnvelope {
const stripped = stripBom(content)
return {
content: normalizeToLf(stripped.content),
hadBom: stripped.hadBom,
lineEnding: detectLineEnding(stripped.content),
}
}
export function restoreFileText(content: string, envelope: FileTextEnvelope): string {
const withLineEnding = restoreLineEndings(content, envelope.lineEnding)
if (!envelope.hadBom) return withLineEnding
return `\uFEFF${withLineEnding}`
}
export { canonicalizeFileText, restoreFileText } from "@oh-my-opencode/hashline-core"
export type { FileTextEnvelope } from "@oh-my-opencode/hashline-core"
+9 -155
View File
@@ -1,155 +1,9 @@
import { HASHLINE_DICT } from "./constants"
import { createHashlineChunkFormatter } from "./hashline-chunk-formatter"
import { bunHashXxh32 } from "../../shared/bun-hash-shim"
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
const stripped = normalizedContent
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
const hash = bunHashXxh32(stripped, seed)
const index = hash % 256
return HASHLINE_DICT[index]
}
export function computeLineHash(lineNumber: number, content: string): string {
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd())
}
export function computeLegacyLineHash(lineNumber: number, content: string): string {
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, ""))
}
export function formatHashLine(lineNumber: number, content: string): string {
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}|${content}`
}
export function formatHashLines(content: string): string {
if (!content) return ""
const lines = content.split("\n")
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
}
export interface HashlineStreamOptions {
startLine?: number
maxChunkLines?: number
maxChunkBytes?: number
}
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
typeof value === "object" &&
value !== null &&
"getReader" in value &&
typeof (value as { getReader?: unknown }).getReader === "function"
)
}
async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
const reader = stream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) return
if (value) yield value
}
} finally {
reader.releaseLock()
}
}
export async function* streamHashLinesFromUtf8(
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
options: HashlineStreamOptions = {}
): AsyncGenerator<string> {
const startLine = options.startLine ?? 1
const maxChunkLines = options.maxChunkLines ?? 200
const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024
const decoder = new TextDecoder("utf-8")
const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source
let lineNumber = startLine
let pending = ""
let sawAnyText = false
let endedWithNewline = false
const chunkFormatter = createHashlineChunkFormatter({ maxChunkLines, maxChunkBytes })
const pushLine = (line: string): string[] => {
const formatted = formatHashLine(lineNumber, line)
lineNumber += 1
return chunkFormatter.push(formatted)
}
const consumeText = (text: string): string[] => {
if (text.length === 0) return []
sawAnyText = true
pending += text
const chunksToYield: string[] = []
let lastIdx = 0
while (true) {
const idx = pending.indexOf("\n", lastIdx)
if (idx === -1) break
const line = pending.slice(lastIdx, idx)
lastIdx = idx + 1
endedWithNewline = true
chunksToYield.push(...pushLine(line))
}
pending = pending.slice(lastIdx)
if (pending.length > 0) endedWithNewline = false
return chunksToYield
}
for await (const chunk of chunks) {
for (const out of consumeText(decoder.decode(chunk, { stream: true }))) {
yield out
}
}
for (const out of consumeText(decoder.decode())) {
yield out
}
if (sawAnyText && (pending.length > 0 || endedWithNewline)) {
for (const out of pushLine(pending)) {
yield out
}
}
const finalChunk = chunkFormatter.flush()
if (finalChunk) yield finalChunk
}
export async function* streamHashLinesFromLines(
lines: Iterable<string> | AsyncIterable<string>,
options: HashlineStreamOptions = {}
): AsyncGenerator<string> {
const startLine = options.startLine ?? 1
const maxChunkLines = options.maxChunkLines ?? 200
const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024
let lineNumber = startLine
const chunkFormatter = createHashlineChunkFormatter({ maxChunkLines, maxChunkBytes })
const pushLine = (line: string): string[] => {
const formatted = formatHashLine(lineNumber, line)
lineNumber += 1
return chunkFormatter.push(formatted)
}
const asyncIterator = (lines as AsyncIterable<string>)[Symbol.asyncIterator]
if (typeof asyncIterator === "function") {
for await (const line of lines as AsyncIterable<string>) {
for (const out of pushLine(line)) yield out
}
} else {
for (const line of lines as Iterable<string>) {
for (const out of pushLine(line)) yield out
}
}
const finalChunk = chunkFormatter.flush()
if (finalChunk) yield finalChunk
}
export {
computeLineHash,
computeLegacyLineHash,
formatHashLine,
formatHashLines,
streamHashLinesFromUtf8,
streamHashLinesFromLines,
} from "@oh-my-opencode/hashline-core"
export type { HashlineStreamOptions } from "@oh-my-opencode/hashline-core"
@@ -1,52 +1,2 @@
export interface HashlineChunkFormatter {
push(formattedLine: string): string[]
flush(): string | undefined
}
interface HashlineChunkFormatterOptions {
maxChunkLines: number
maxChunkBytes: number
}
export function createHashlineChunkFormatter(options: HashlineChunkFormatterOptions): HashlineChunkFormatter {
const { maxChunkLines, maxChunkBytes } = options
let outputLines: string[] = []
let outputBytes = 0
const flush = (): string | undefined => {
if (outputLines.length === 0) return undefined
const chunk = outputLines.join("\n")
outputLines = []
outputBytes = 0
return chunk
}
const push = (formattedLine: string): string[] => {
const chunksToYield: string[] = []
const separatorBytes = outputLines.length === 0 ? 0 : 1
const lineBytes = Buffer.byteLength(formattedLine, "utf-8")
if (
outputLines.length > 0 &&
(outputLines.length >= maxChunkLines || outputBytes + separatorBytes + lineBytes > maxChunkBytes)
) {
const flushed = flush()
if (flushed) chunksToYield.push(flushed)
}
outputLines.push(formattedLine)
outputBytes += (outputLines.length === 1 ? 0 : 1) + lineBytes
if (outputLines.length >= maxChunkLines || outputBytes >= maxChunkBytes) {
const flushed = flush()
if (flushed) chunksToYield.push(flushed)
}
return chunksToYield
}
return {
push,
flush,
}
}
export { createHashlineChunkFormatter } from "@oh-my-opencode/hashline-core"
export type { HashlineChunkFormatter } from "@oh-my-opencode/hashline-core"
+1 -31
View File
@@ -1,31 +1 @@
import { computeLineHash } from "./hash-computation"
export function generateHashlineDiff(oldContent: string, newContent: string, filePath: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
const parts: string[] = [`--- ${filePath}\n+++ ${filePath}\n`]
const maxLines = Math.max(oldLines.length, newLines.length)
for (let i = 0; i < maxLines; i += 1) {
const oldLine = oldLines[i] ?? ""
const newLine = newLines[i] ?? ""
const lineNum = i + 1
const hash = computeLineHash(lineNum, newLine)
if (i >= oldLines.length) {
parts.push(`+ ${lineNum}#${hash}|${newLine}\n`)
continue
}
if (i >= newLines.length) {
parts.push(`- ${lineNum}# |${oldLine}\n`)
continue
}
if (oldLine !== newLine) {
parts.push(`- ${lineNum}# |${oldLine}\n`)
parts.push(`+ ${lineNum}#${hash}|${newLine}\n`)
}
}
return parts.join("")
}
export { generateHashlineDiff } from "@oh-my-opencode/hashline-core"
+2 -95
View File
@@ -1,95 +1,2 @@
import type { AppendEdit, HashlineEdit, PrependEdit, ReplaceEdit } from "./types"
type HashlineToolOp = "replace" | "append" | "prepend"
export interface RawHashlineEdit {
op?: HashlineToolOp
pos?: string
end?: string
lines?: string | string[] | null
}
function normalizeAnchor(value: string | undefined): string | undefined {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
return trimmed === "" ? undefined : trimmed
}
function requireLines(edit: RawHashlineEdit, index: number): string | string[] {
if (edit.lines === undefined) {
throw new Error(`Edit ${index}: lines is required for ${edit.op ?? "unknown"}`)
}
if (edit.lines === null) {
return []
}
return edit.lines
}
function requireLine(anchor: string | undefined, index: number, op: HashlineToolOp): string {
if (!anchor) {
throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference (pos or end)`)
}
return anchor
}
function normalizeReplaceEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = requireLine(pos ?? end, index, "replace")
const lines = requireLines(edit, index)
const normalized: ReplaceEdit = {
op: "replace",
pos: anchor,
lines,
}
if (end) normalized.end = end
return normalized
}
function normalizeAppendEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = pos ?? end
const lines = requireLines(edit, index)
const normalized: AppendEdit = {
op: "append",
lines,
}
if (anchor) normalized.pos = anchor
return normalized
}
function normalizePrependEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = pos ?? end
const lines = requireLines(edit, index)
const normalized: PrependEdit = {
op: "prepend",
lines,
}
if (anchor) normalized.pos = anchor
return normalized
}
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] {
return rawEdits.map((rawEdit, index) => {
const edit = rawEdit ?? {}
switch (edit.op) {
case "replace":
return normalizeReplaceEdit(edit, index)
case "append":
return normalizeAppendEdit(edit, index)
case "prepend":
return normalizePrependEdit(edit, index)
default:
throw new Error(
`Edit ${index}: unsupported op "${String(edit.op)}". Legacy format was removed; use op/pos/end/lines.`
)
}
})
}
export { normalizeHashlineEdits } from "@oh-my-opencode/hashline-core"
export type { RawHashlineEdit } from "@oh-my-opencode/hashline-core"
+1 -20
View File
@@ -1,20 +1 @@
export interface ReplaceEdit {
op: "replace"
pos: string
end?: string
lines: string | string[]
}
export interface AppendEdit {
op: "append"
pos?: string
lines: string | string[]
}
export interface PrependEdit {
op: "prepend"
pos?: string
lines: string | string[]
}
export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit
export type { ReplaceEdit, AppendEdit, PrependEdit, HashlineEdit } from "@oh-my-opencode/hashline-core"
+8 -181
View File
@@ -1,181 +1,8 @@
import { computeLegacyLineHash, computeLineHash } from "./hash-computation"
import { HASHLINE_REF_PATTERN } from "./constants"
export interface LineRef {
line: number
hash: string
}
interface HashMismatch {
line: number
expected: string
}
const MISMATCH_CONTEXT = 2
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
function isCompatibleLineHash(line: number, content: string, hash: string): boolean {
return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash
}
export function normalizeLineRef(ref: string): string {
const originalTrimmed = ref.trim()
let trimmed = originalTrimmed
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
trimmed = trimmed.replace(/\s*#\s*/, "#")
trimmed = trimmed.replace(/\|.*$/, "")
trimmed = trimmed.trim()
if (HASHLINE_REF_PATTERN.test(trimmed)) {
return trimmed
}
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
if (extracted) {
return extracted[1]
}
return originalTrimmed
}
export function parseLineRef(ref: string): LineRef {
const normalized = normalizeLineRef(ref)
const match = normalized.match(HASHLINE_REF_PATTERN)
if (match) {
return {
line: Number.parseInt(match[1], 10),
hash: match[2],
}
}
const hashIdx = normalized.indexOf('#')
if (hashIdx > 0) {
const prefix = normalized.slice(0, hashIdx)
const suffix = normalized.slice(hashIdx + 1)
if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) {
throw new Error(
`Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
`Use the actual line number from the read output.`
)
}
}
throw new Error(
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
)
}
export function validateLineRef(lines: string[], ref: string): void {
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(
`Line number ${line} out of bounds. File has ${lines.length} lines.`
)
}
const content = lines[line - 1]
if (!isCompatibleLineHash(line, content, hash)) {
throw new HashlineMismatchError([{ line, expected: hash }], lines)
}
}
export class HashlineMismatchError extends Error {
readonly remaps: ReadonlyMap<string, string>
constructor(
private readonly mismatches: HashMismatch[],
private readonly fileLines: string[]
) {
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
this.name = "HashlineMismatchError"
const remaps = new Map<string, string>()
for (const mismatch of mismatches) {
const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "")
remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`)
}
this.remaps = remaps
}
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
const mismatchByLine = new Map<number, HashMismatch>()
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
const displayLines = new Set<number>()
for (const mismatch of mismatches) {
const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT)
const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT)
for (let line = low; line <= high; line++) displayLines.add(line)
}
const sortedLines = [...displayLines].sort((a, b) => a - b)
const output: string[] = []
output.push(
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
)
output.push("")
let previousLine = -1
for (const line of sortedLines) {
if (previousLine !== -1 && line > previousLine + 1) {
output.push(" ...")
}
previousLine = line
const content = fileLines[line - 1] ?? ""
const hash = computeLineHash(line, content)
const prefix = `${line}#${hash}|${content}`
if (mismatchByLine.has(line)) {
output.push(`>>> ${prefix}`)
} else {
output.push(` ${prefix}`)
}
}
return output.join("\n")
}
}
function suggestLineForHash(ref: string, lines: string[]): string | null {
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
if (!hashMatch) return null
const hash = hashMatch[1]
for (let i = 0; i < lines.length; i++) {
if (isCompatibleLineHash(i + 1, lines[i], hash)) {
return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i])}"?`
}
}
return null
}
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
try {
return parseLineRef(ref)
} catch (parseError) {
const hint = suggestLineForHash(ref, lines)
if (hint && parseError instanceof Error) {
throw new Error(`${parseError.message} ${hint}`)
}
throw parseError
}
}
export function validateLineRefs(lines: string[], refs: string[]): void {
const mismatches: HashMismatch[] = []
for (const ref of refs) {
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
}
const content = lines[line - 1]
if (!isCompatibleLineHash(line, content, hash)) {
mismatches.push({ line, expected: hash })
}
}
if (mismatches.length > 0) {
throw new HashlineMismatchError(mismatches, lines)
}
}
export {
parseLineRef,
validateLineRef,
validateLineRefs,
HashlineMismatchError,
normalizeLineRef,
} from "@oh-my-opencode/hashline-core"
export type { LineRef } from "@oh-my-opencode/hashline-core"