From 4ea76365cd03f42dcc44ec99e3941940c9ca7c3a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 21 May 2026 16:02:09 +0900 Subject: [PATCH] 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 --- bun.lock | 10 + package.json | 4 +- packages/hashline-core/package.json | 21 + .../src/autocorrect-replacement-lines.ts | 179 ++++++++ packages/hashline-core/src/constants.ts | 10 + packages/hashline-core/src/diff-utils.test.ts | 149 +++++++ packages/hashline-core/src/diff-utils.ts | 53 +++ .../hashline-core/src/edit-deduplication.ts | 43 ++ .../src/edit-operation-primitives.ts | 126 ++++++ .../hashline-core/src/edit-operations.test.ts | 411 ++++++++++++++++++ packages/hashline-core/src/edit-operations.ts | 103 +++++ packages/hashline-core/src/edit-ordering.ts | 56 +++ .../src/edit-text-normalization.ts | 111 +++++ .../src/file-text-canonicalization.ts | 44 ++ .../src/hash-computation.test.ts | 206 +++++++++ .../hashline-core/src/hash-computation.ts | 155 +++++++ .../src/hashline-chunk-formatter.ts | 52 +++ .../hashline-core/src/hashline-edit-diff.ts | 31 ++ packages/hashline-core/src/index.ts | 59 +++ .../hashline-core/src/normalize-edits.test.ts | 62 +++ packages/hashline-core/src/normalize-edits.ts | 95 ++++ .../src/smoke-untested-modules.test.ts | 55 +++ packages/hashline-core/src/types.ts | 20 + packages/hashline-core/src/validation.test.ts | 154 +++++++ packages/hashline-core/src/validation.ts | 181 ++++++++ packages/hashline-core/src/xxhash32.ts | 90 ++++ packages/hashline-core/tsconfig.json | 14 + .../autocorrect-replacement-lines.ts | 187 +------- src/tools/hashline-edit/constants.ts | 16 +- src/tools/hashline-edit/diff-utils.ts | 58 +-- src/tools/hashline-edit/edit-deduplication.ts | 44 +- .../edit-operation-primitives.ts | 134 +----- src/tools/hashline-edit/edit-operations.ts | 108 +---- src/tools/hashline-edit/edit-ordering.ts | 57 +-- .../hashline-edit/edit-text-normalization.ts | 120 +---- .../file-text-canonicalization.ts | 46 +- src/tools/hashline-edit/hash-computation.ts | 164 +------ .../hashline-edit/hashline-chunk-formatter.ts | 54 +-- src/tools/hashline-edit/hashline-edit-diff.ts | 32 +- src/tools/hashline-edit/normalize-edits.ts | 97 +---- src/tools/hashline-edit/types.ts | 21 +- src/tools/hashline-edit/validation.ts | 189 +------- 42 files changed, 2561 insertions(+), 1260 deletions(-) create mode 100644 packages/hashline-core/package.json create mode 100644 packages/hashline-core/src/autocorrect-replacement-lines.ts create mode 100644 packages/hashline-core/src/constants.ts create mode 100644 packages/hashline-core/src/diff-utils.test.ts create mode 100644 packages/hashline-core/src/diff-utils.ts create mode 100644 packages/hashline-core/src/edit-deduplication.ts create mode 100644 packages/hashline-core/src/edit-operation-primitives.ts create mode 100644 packages/hashline-core/src/edit-operations.test.ts create mode 100644 packages/hashline-core/src/edit-operations.ts create mode 100644 packages/hashline-core/src/edit-ordering.ts create mode 100644 packages/hashline-core/src/edit-text-normalization.ts create mode 100644 packages/hashline-core/src/file-text-canonicalization.ts create mode 100644 packages/hashline-core/src/hash-computation.test.ts create mode 100644 packages/hashline-core/src/hash-computation.ts create mode 100644 packages/hashline-core/src/hashline-chunk-formatter.ts create mode 100644 packages/hashline-core/src/hashline-edit-diff.ts create mode 100644 packages/hashline-core/src/index.ts create mode 100644 packages/hashline-core/src/normalize-edits.test.ts create mode 100644 packages/hashline-core/src/normalize-edits.ts create mode 100644 packages/hashline-core/src/smoke-untested-modules.test.ts create mode 100644 packages/hashline-core/src/types.ts create mode 100644 packages/hashline-core/src/validation.test.ts create mode 100644 packages/hashline-core/src/validation.ts create mode 100644 packages/hashline-core/src/xxhash32.ts create mode 100644 packages/hashline-core/tsconfig.json diff --git a/bun.lock b/bun.lock index 61c8148c6..bc8a39d65 100644 --- a/bun.lock +++ b/bun.lock @@ -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"], diff --git a/package.json b/package.json index b4f17c812..790ebf419 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/packages/hashline-core/package.json b/packages/hashline-core/package.json new file mode 100644 index 000000000..dfd8f1602 --- /dev/null +++ b/packages/hashline-core/package.json @@ -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" + } +} diff --git a/packages/hashline-core/src/autocorrect-replacement-lines.ts b/packages/hashline-core/src/autocorrect-replacement-lines.ts new file mode 100644 index 000000000..fe0c5c721 --- /dev/null +++ b/packages/hashline-core/src/autocorrect-replacement-lines.ts @@ -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() + 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() + 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 +} diff --git a/packages/hashline-core/src/constants.ts b/packages/hashline-core/src/constants.ts new file mode 100644 index 000000000..2eac4d9c2 --- /dev/null +++ b/packages/hashline-core/src/constants.ts @@ -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})\|(.*)$/ diff --git a/packages/hashline-core/src/diff-utils.test.ts b/packages/hashline-core/src/diff-utils.test.ts new file mode 100644 index 000000000..4e751db22 --- /dev/null +++ b/packages/hashline-core/src/diff-utils.test.ts @@ -0,0 +1,149 @@ +/// +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") + }) +}) diff --git a/packages/hashline-core/src/diff-utils.ts b/packages/hashline-core/src/diff-utils.ts new file mode 100644 index 000000000..10c3dfc9d --- /dev/null +++ b/packages/hashline-core/src/diff-utils.ts @@ -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() + for (const line of oldLines) { + oldSet.set(line, (oldSet.get(line) ?? 0) + 1) + } + + const newSet = new Map() + 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 } +} diff --git a/packages/hashline-core/src/edit-deduplication.ts b/packages/hashline-core/src/edit-deduplication.ts new file mode 100644 index 000000000..8818b61ae --- /dev/null +++ b/packages/hashline-core/src/edit-deduplication.ts @@ -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() + 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 } +} diff --git a/packages/hashline-core/src/edit-operation-primitives.ts b/packages/hashline-core/src/edit-operation-primitives.ts new file mode 100644 index 000000000..cd93e923d --- /dev/null +++ b/packages/hashline-core/src/edit-operation-primitives.ts @@ -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] +} diff --git a/packages/hashline-core/src/edit-operations.test.ts b/packages/hashline-core/src/edit-operations.test.ts new file mode 100644 index 000000000..9be6dabae --- /dev/null +++ b/packages/hashline-core/src/edit-operations.test.ts @@ -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") + }) +}) diff --git a/packages/hashline-core/src/edit-operations.ts b/packages/hashline-core/src/edit-operations.ts new file mode 100644 index 000000000..13b010f95 --- /dev/null +++ b/packages/hashline-core/src/edit-operations.ts @@ -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 = { 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 +} diff --git a/packages/hashline-core/src/edit-ordering.ts b/packages/hashline-core/src/edit-ordering.ts new file mode 100644 index 000000000..3a9444fc0 --- /dev/null +++ b/packages/hashline-core/src/edit-ordering.ts @@ -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 +} diff --git a/packages/hashline-core/src/edit-text-normalization.ts b/packages/hashline-core/src/edit-text-normalization.ts new file mode 100644 index 000000000..7eaf1da24 --- /dev/null +++ b/packages/hashline-core/src/edit-text-normalization.ts @@ -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 +} diff --git a/packages/hashline-core/src/file-text-canonicalization.ts b/packages/hashline-core/src/file-text-canonicalization.ts new file mode 100644 index 000000000..6a94e613c --- /dev/null +++ b/packages/hashline-core/src/file-text-canonicalization.ts @@ -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}` +} diff --git a/packages/hashline-core/src/hash-computation.test.ts b/packages/hashline-core/src/hash-computation.test.ts new file mode 100644 index 000000000..15f19dfee --- /dev/null +++ b/packages/hashline-core/src/hash-computation.test.ts @@ -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): Promise { + const chunks: string[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + return chunks.join("\n") + } + + async function* utf8Chunks(text: string, chunkSize: number): AsyncGenerator { + 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)) + }) +}) diff --git a/packages/hashline-core/src/hash-computation.ts b/packages/hashline-core/src/hash-computation.ts new file mode 100644 index 000000000..bfda2d677 --- /dev/null +++ b/packages/hashline-core/src/hash-computation.ts @@ -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 { + return ( + typeof value === "object" && + value !== null && + "getReader" in value && + typeof (value as { getReader?: unknown }).getReader === "function" + ) +} + +async function* bytesFromReadableStream(stream: ReadableStream): AsyncGenerator { + 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 | AsyncIterable, + options: HashlineStreamOptions = {} +): AsyncGenerator { + 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 | AsyncIterable, + options: HashlineStreamOptions = {} +): AsyncGenerator { + 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)[Symbol.asyncIterator] + if (typeof asyncIterator === "function") { + for await (const line of lines as AsyncIterable) { + for (const out of pushLine(line)) yield out + } + } else { + for (const line of lines as Iterable) { + for (const out of pushLine(line)) yield out + } + } + + const finalChunk = chunkFormatter.flush() + if (finalChunk) yield finalChunk +} diff --git a/packages/hashline-core/src/hashline-chunk-formatter.ts b/packages/hashline-core/src/hashline-chunk-formatter.ts new file mode 100644 index 000000000..f92563ece --- /dev/null +++ b/packages/hashline-core/src/hashline-chunk-formatter.ts @@ -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, + } +} diff --git a/packages/hashline-core/src/hashline-edit-diff.ts b/packages/hashline-core/src/hashline-edit-diff.ts new file mode 100644 index 000000000..04f53506d --- /dev/null +++ b/packages/hashline-core/src/hashline-edit-diff.ts @@ -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("") +} diff --git a/packages/hashline-core/src/index.ts b/packages/hashline-core/src/index.ts new file mode 100644 index 000000000..b1ec9a5c2 --- /dev/null +++ b/packages/hashline-core/src/index.ts @@ -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" diff --git a/packages/hashline-core/src/normalize-edits.test.ts b/packages/hashline-core/src/normalize-edits.test.ts new file mode 100644 index 000000000..a76b9f8fb --- /dev/null +++ b/packages/hashline-core/src/normalize-edits.test.ts @@ -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[0]>([{ type: "set_line", line: "2#VK", text: "updated" }]) + + //#when / #then + expect(() => normalizeHashlineEdits(input)).toThrow(/legacy format was removed/i) + }) +}) diff --git a/packages/hashline-core/src/normalize-edits.ts b/packages/hashline-core/src/normalize-edits.ts new file mode 100644 index 000000000..03b28f7c4 --- /dev/null +++ b/packages/hashline-core/src/normalize-edits.ts @@ -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.` + ) + } + }) +} diff --git a/packages/hashline-core/src/smoke-untested-modules.test.ts b/packages/hashline-core/src/smoke-untested-modules.test.ts new file mode 100644 index 000000000..452cdc033 --- /dev/null +++ b/packages/hashline-core/src/smoke-untested-modules.test.ts @@ -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") + }) +}) diff --git a/packages/hashline-core/src/types.ts b/packages/hashline-core/src/types.ts new file mode 100644 index 000000000..e0fc8485d --- /dev/null +++ b/packages/hashline-core/src/types.ts @@ -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 diff --git a/packages/hashline-core/src/validation.test.ts b/packages/hashline-core/src/validation.test.ts new file mode 100644 index 000000000..c3c531bef --- /dev/null +++ b/packages/hashline-core/src/validation.test.ts @@ -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}`)) + }) +}) diff --git a/packages/hashline-core/src/validation.ts b/packages/hashline-core/src/validation.ts new file mode 100644 index 000000000..f09b8fb8b --- /dev/null +++ b/packages/hashline-core/src/validation.ts @@ -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 + + constructor( + private readonly mismatches: HashMismatch[], + private readonly fileLines: string[] + ) { + super(HashlineMismatchError.formatMessage(mismatches, fileLines)) + this.name = "HashlineMismatchError" + const remaps = new Map() + 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() + for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch) + + const displayLines = new Set() + 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) + } +} diff --git a/packages/hashline-core/src/xxhash32.ts b/packages/hashline-core/src/xxhash32.ts new file mode 100644 index 000000000..26603f964 --- /dev/null +++ b/packages/hashline-core/src/xxhash32.ts @@ -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) +} diff --git a/packages/hashline-core/tsconfig.json b/packages/hashline-core/tsconfig.json new file mode 100644 index 000000000..7a69d1222 --- /dev/null +++ b/packages/hashline-core/tsconfig.json @@ -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/**/*"] +} diff --git a/src/tools/hashline-edit/autocorrect-replacement-lines.ts b/src/tools/hashline-edit/autocorrect-replacement-lines.ts index fe0c5c721..434f97709 100644 --- a/src/tools/hashline-edit/autocorrect-replacement-lines.ts +++ b/src/tools/hashline-edit/autocorrect-replacement-lines.ts @@ -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() - 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() - 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" diff --git a/src/tools/hashline-edit/constants.ts b/src/tools/hashline-edit/constants.ts index 2eac4d9c2..88c9bc26c 100644 --- a/src/tools/hashline-edit/constants.ts +++ b/src/tools/hashline-edit/constants.ts @@ -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" diff --git a/src/tools/hashline-edit/diff-utils.ts b/src/tools/hashline-edit/diff-utils.ts index 10c3dfc9d..9eab43983 100644 --- a/src/tools/hashline-edit/diff-utils.ts +++ b/src/tools/hashline-edit/diff-utils.ts @@ -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() - for (const line of oldLines) { - oldSet.set(line, (oldSet.get(line) ?? 0) + 1) - } - - const newSet = new Map() - 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" diff --git a/src/tools/hashline-edit/edit-deduplication.ts b/src/tools/hashline-edit/edit-deduplication.ts index 8818b61ae..5c8173008 100644 --- a/src/tools/hashline-edit/edit-deduplication.ts +++ b/src/tools/hashline-edit/edit-deduplication.ts @@ -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() - 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" diff --git a/src/tools/hashline-edit/edit-operation-primitives.ts b/src/tools/hashline-edit/edit-operation-primitives.ts index cd93e923d..b69e95b4e 100644 --- a/src/tools/hashline-edit/edit-operation-primitives.ts +++ b/src/tools/hashline-edit/edit-operation-primitives.ts @@ -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" diff --git a/src/tools/hashline-edit/edit-operations.ts b/src/tools/hashline-edit/edit-operations.ts index 13b010f95..60489da3c 100644 --- a/src/tools/hashline-edit/edit-operations.ts +++ b/src/tools/hashline-edit/edit-operations.ts @@ -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 = { 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" diff --git a/src/tools/hashline-edit/edit-ordering.ts b/src/tools/hashline-edit/edit-ordering.ts index 3a9444fc0..71d2234e1 100644 --- a/src/tools/hashline-edit/edit-ordering.ts +++ b/src/tools/hashline-edit/edit-ordering.ts @@ -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" diff --git a/src/tools/hashline-edit/edit-text-normalization.ts b/src/tools/hashline-edit/edit-text-normalization.ts index 7eaf1da24..f077f7156 100644 --- a/src/tools/hashline-edit/edit-text-normalization.ts +++ b/src/tools/hashline-edit/edit-text-normalization.ts @@ -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" diff --git a/src/tools/hashline-edit/file-text-canonicalization.ts b/src/tools/hashline-edit/file-text-canonicalization.ts index 6a94e613c..b878dcac0 100644 --- a/src/tools/hashline-edit/file-text-canonicalization.ts +++ b/src/tools/hashline-edit/file-text-canonicalization.ts @@ -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" diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts index e5c31b67a..a39e3e22a 100644 --- a/src/tools/hashline-edit/hash-computation.ts +++ b/src/tools/hashline-edit/hash-computation.ts @@ -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 { - return ( - typeof value === "object" && - value !== null && - "getReader" in value && - typeof (value as { getReader?: unknown }).getReader === "function" - ) -} - -async function* bytesFromReadableStream(stream: ReadableStream): AsyncGenerator { - 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 | AsyncIterable, - options: HashlineStreamOptions = {} -): AsyncGenerator { - 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 | AsyncIterable, - options: HashlineStreamOptions = {} -): AsyncGenerator { - 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)[Symbol.asyncIterator] - if (typeof asyncIterator === "function") { - for await (const line of lines as AsyncIterable) { - for (const out of pushLine(line)) yield out - } - } else { - for (const line of lines as Iterable) { - 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" diff --git a/src/tools/hashline-edit/hashline-chunk-formatter.ts b/src/tools/hashline-edit/hashline-chunk-formatter.ts index f92563ece..6106ea06d 100644 --- a/src/tools/hashline-edit/hashline-chunk-formatter.ts +++ b/src/tools/hashline-edit/hashline-chunk-formatter.ts @@ -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" diff --git a/src/tools/hashline-edit/hashline-edit-diff.ts b/src/tools/hashline-edit/hashline-edit-diff.ts index 04f53506d..e0830f9fc 100644 --- a/src/tools/hashline-edit/hashline-edit-diff.ts +++ b/src/tools/hashline-edit/hashline-edit-diff.ts @@ -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" diff --git a/src/tools/hashline-edit/normalize-edits.ts b/src/tools/hashline-edit/normalize-edits.ts index 03b28f7c4..fdf12a783 100644 --- a/src/tools/hashline-edit/normalize-edits.ts +++ b/src/tools/hashline-edit/normalize-edits.ts @@ -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" diff --git a/src/tools/hashline-edit/types.ts b/src/tools/hashline-edit/types.ts index e0fc8485d..0b66a968f 100644 --- a/src/tools/hashline-edit/types.ts +++ b/src/tools/hashline-edit/types.ts @@ -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" diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index f09b8fb8b..8e9dfa65f 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -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 - - constructor( - private readonly mismatches: HashMismatch[], - private readonly fileLines: string[] - ) { - super(HashlineMismatchError.formatMessage(mismatches, fileLines)) - this.name = "HashlineMismatchError" - const remaps = new Map() - 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() - for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch) - - const displayLines = new Set() - 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"