refactor(packages): extract hashline-core package
Move the hash-anchored edit core (hash computation, validation, edit operations, text normalization, chunk formatter, diff utilities, and a runtime-aware xxHash32 binding) into a new @oh-my-opencode/hashline-core workspace package. The src/tools/hashline-edit/ surface becomes a set of thin re-export shims, so existing import paths in the plugin keep working while the pure logic lives behind a stable package boundary that has no opencode runtime dependencies. Tests: bun test packages/hashline-core src/tools/hashline-edit
This commit is contained in:
@@ -1,179 +1,8 @@
|
||||
function normalizeTokens(text: string): string {
|
||||
return text.replace(/\s+/g, "")
|
||||
}
|
||||
|
||||
function stripAllWhitespace(text: string): string {
|
||||
return normalizeTokens(text)
|
||||
}
|
||||
|
||||
export function stripTrailingContinuationTokens(text: string): string {
|
||||
return text.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, "")
|
||||
}
|
||||
|
||||
export function stripMergeOperatorChars(text: string): string {
|
||||
return text.replace(/[|&?]/g, "")
|
||||
}
|
||||
|
||||
function leadingWhitespace(text: string): string {
|
||||
if (!text) return ""
|
||||
const match = text.match(/^\s*/)
|
||||
return match ? match[0] : ""
|
||||
}
|
||||
|
||||
export function restoreOldWrappedLines(originalLines: string[], replacementLines: string[]): string[] {
|
||||
if (originalLines.length === 0 || replacementLines.length < 2) return replacementLines
|
||||
|
||||
const canonicalToOriginal = new Map<string, { line: string; count: number }>()
|
||||
for (const line of originalLines) {
|
||||
const canonical = stripAllWhitespace(line)
|
||||
const existing = canonicalToOriginal.get(canonical)
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
} else {
|
||||
canonicalToOriginal.set(canonical, { line, count: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: { start: number; len: number; replacement: string; canonical: string }[] = []
|
||||
for (let start = 0; start < replacementLines.length; start += 1) {
|
||||
for (let len = 2; len <= 10 && start + len <= replacementLines.length; len += 1) {
|
||||
const span = replacementLines.slice(start, start + len)
|
||||
if (span.some((line) => line.trim().length === 0)) continue
|
||||
const canonicalSpan = stripAllWhitespace(span.join(""))
|
||||
const original = canonicalToOriginal.get(canonicalSpan)
|
||||
if (original && original.count === 1 && canonicalSpan.length >= 6) {
|
||||
candidates.push({ start, len, replacement: original.line, canonical: canonicalSpan })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return replacementLines
|
||||
|
||||
const canonicalCounts = new Map<string, number>()
|
||||
for (const candidate of candidates) {
|
||||
canonicalCounts.set(candidate.canonical, (canonicalCounts.get(candidate.canonical) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const uniqueCandidates = candidates.filter((candidate) => (canonicalCounts.get(candidate.canonical) ?? 0) === 1)
|
||||
if (uniqueCandidates.length === 0) return replacementLines
|
||||
|
||||
uniqueCandidates.sort((a, b) => b.start - a.start)
|
||||
const correctedLines = [...replacementLines]
|
||||
for (const candidate of uniqueCandidates) {
|
||||
correctedLines.splice(candidate.start, candidate.len, candidate.replacement)
|
||||
}
|
||||
return correctedLines
|
||||
}
|
||||
|
||||
export function maybeExpandSingleLineMerge(
|
||||
originalLines: string[],
|
||||
replacementLines: string[]
|
||||
): string[] {
|
||||
if (replacementLines.length !== 1 || originalLines.length <= 1) {
|
||||
return replacementLines
|
||||
}
|
||||
|
||||
const merged = replacementLines[0]
|
||||
const parts = originalLines.map((line) => line.trim()).filter((line) => line.length > 0)
|
||||
if (parts.length !== originalLines.length) return replacementLines
|
||||
|
||||
const indices: number[] = []
|
||||
let offset = 0
|
||||
let orderedMatch = true
|
||||
for (const part of parts) {
|
||||
let idx = merged.indexOf(part, offset)
|
||||
let matchedLen = part.length
|
||||
if (idx === -1) {
|
||||
const stripped = stripTrailingContinuationTokens(part)
|
||||
if (stripped !== part) {
|
||||
idx = merged.indexOf(stripped, offset)
|
||||
if (idx !== -1) matchedLen = stripped.length
|
||||
}
|
||||
}
|
||||
if (idx === -1) {
|
||||
const segment = merged.slice(offset)
|
||||
const segmentStripped = stripMergeOperatorChars(segment)
|
||||
const partStripped = stripMergeOperatorChars(part)
|
||||
const fuzzyIdx = segmentStripped.indexOf(partStripped)
|
||||
if (fuzzyIdx !== -1) {
|
||||
let strippedPos = 0
|
||||
let originalPos = 0
|
||||
while (strippedPos < fuzzyIdx && originalPos < segment.length) {
|
||||
if (!/[|&?]/.test(segment[originalPos])) strippedPos += 1
|
||||
originalPos += 1
|
||||
}
|
||||
idx = offset + originalPos
|
||||
matchedLen = part.length
|
||||
}
|
||||
}
|
||||
if (idx === -1) {
|
||||
orderedMatch = false
|
||||
break
|
||||
}
|
||||
indices.push(idx)
|
||||
offset = idx + matchedLen
|
||||
}
|
||||
|
||||
const expanded: string[] = []
|
||||
if (orderedMatch) {
|
||||
for (let i = 0; i < indices.length; i += 1) {
|
||||
const start = indices[i]
|
||||
const end = i + 1 < indices.length ? indices[i + 1] : merged.length
|
||||
const candidate = merged.slice(start, end).trim()
|
||||
if (candidate.length === 0) {
|
||||
orderedMatch = false
|
||||
break
|
||||
}
|
||||
expanded.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
if (orderedMatch && expanded.length === originalLines.length) {
|
||||
return expanded
|
||||
}
|
||||
|
||||
const semicolonSplit = merged
|
||||
.split(/;\s+/)
|
||||
.map((line, idx, arr) => {
|
||||
if (idx < arr.length - 1 && !line.endsWith(";")) {
|
||||
return `${line};`
|
||||
}
|
||||
return line
|
||||
})
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
if (semicolonSplit.length === originalLines.length) {
|
||||
return semicolonSplit
|
||||
}
|
||||
|
||||
return replacementLines
|
||||
}
|
||||
|
||||
export function restoreIndentForPairedReplacement(
|
||||
originalLines: string[],
|
||||
replacementLines: string[]
|
||||
): string[] {
|
||||
if (originalLines.length !== replacementLines.length) {
|
||||
return replacementLines
|
||||
}
|
||||
|
||||
return replacementLines.map((line, idx) => {
|
||||
if (line.length === 0) return line
|
||||
if (leadingWhitespace(line).length > 0) return line
|
||||
const indent = leadingWhitespace(originalLines[idx])
|
||||
if (indent.length === 0) return line
|
||||
if (originalLines[idx].trim() === line.trim()) return line
|
||||
return `${indent}${line}`
|
||||
})
|
||||
}
|
||||
|
||||
export function autocorrectReplacementLines(
|
||||
originalLines: string[],
|
||||
replacementLines: string[]
|
||||
): string[] {
|
||||
let next = replacementLines
|
||||
next = maybeExpandSingleLineMerge(originalLines, next)
|
||||
next = restoreOldWrappedLines(originalLines, next)
|
||||
next = restoreIndentForPairedReplacement(originalLines, next)
|
||||
return next
|
||||
}
|
||||
export {
|
||||
stripTrailingContinuationTokens,
|
||||
stripMergeOperatorChars,
|
||||
restoreOldWrappedLines,
|
||||
maybeExpandSingleLineMerge,
|
||||
restoreIndentForPairedReplacement,
|
||||
autocorrectReplacementLines,
|
||||
} from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,53 +1,5 @@
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
|
||||
export function toHashlineContent(content: string): string {
|
||||
if (!content) return content
|
||||
const lines = content.split("\n")
|
||||
const lastLine = lines[lines.length - 1]
|
||||
const hasTrailingNewline = lastLine === ""
|
||||
const contentLines = hasTrailingNewline ? lines.slice(0, -1) : lines
|
||||
const hashlined = contentLines.map((line, i) => {
|
||||
const lineNum = i + 1
|
||||
const hash = computeLineHash(lineNum, line)
|
||||
return `${lineNum}#${hash}|${line}`
|
||||
})
|
||||
return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n")
|
||||
}
|
||||
|
||||
export function generateUnifiedDiff(oldContent: string, newContent: string, filePath: string): string {
|
||||
return createTwoFilesPatch(filePath, filePath, oldContent, newContent, undefined, undefined, { context: 3 })
|
||||
}
|
||||
|
||||
export function countLineDiffs(oldContent: string, newContent: string): { additions: number; deletions: number } {
|
||||
const oldLines = oldContent.split("\n")
|
||||
const newLines = newContent.split("\n")
|
||||
|
||||
const oldSet = new Map<string, number>()
|
||||
for (const line of oldLines) {
|
||||
oldSet.set(line, (oldSet.get(line) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const newSet = new Map<string, number>()
|
||||
for (const line of newLines) {
|
||||
newSet.set(line, (newSet.get(line) ?? 0) + 1)
|
||||
}
|
||||
|
||||
let deletions = 0
|
||||
for (const [line, count] of oldSet) {
|
||||
const newCount = newSet.get(line) ?? 0
|
||||
if (count > newCount) {
|
||||
deletions += count - newCount
|
||||
}
|
||||
}
|
||||
|
||||
let additions = 0
|
||||
for (const [line, count] of newSet) {
|
||||
const oldCount = oldSet.get(line) ?? 0
|
||||
if (count > oldCount) {
|
||||
additions += count - oldCount
|
||||
}
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
}
|
||||
export {
|
||||
toHashlineContent,
|
||||
generateUnifiedDiff,
|
||||
countLineDiffs,
|
||||
} from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,43 +1 @@
|
||||
import type { HashlineEdit } from "./types"
|
||||
import { toNewLines } from "./edit-text-normalization"
|
||||
import { normalizeLineRef } from "./validation"
|
||||
|
||||
function normalizeEditPayload(payload: string | string[]): string {
|
||||
return toNewLines(payload).join("\n")
|
||||
}
|
||||
|
||||
function canonicalAnchor(anchor: string | undefined): string {
|
||||
if (!anchor) return ""
|
||||
return normalizeLineRef(anchor)
|
||||
}
|
||||
|
||||
function buildDedupeKey(edit: HashlineEdit): string {
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
return `replace|${canonicalAnchor(edit.pos)}|${edit.end ? canonicalAnchor(edit.end) : ""}|${normalizeEditPayload(edit.lines)}`
|
||||
case "append":
|
||||
return `append|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
|
||||
case "prepend":
|
||||
return `prepend|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
|
||||
default:
|
||||
return JSON.stringify(edit)
|
||||
}
|
||||
}
|
||||
|
||||
export function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
|
||||
const seen = new Set<string>()
|
||||
const deduped: HashlineEdit[] = []
|
||||
let deduplicatedEdits = 0
|
||||
|
||||
for (const edit of edits) {
|
||||
const key = buildDedupeKey(edit)
|
||||
if (seen.has(key)) {
|
||||
deduplicatedEdits += 1
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
deduped.push(edit)
|
||||
}
|
||||
|
||||
return { edits: deduped, deduplicatedEdits }
|
||||
}
|
||||
export { dedupeEdits } from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,126 +1,8 @@
|
||||
import { autocorrectReplacementLines } from "./autocorrect-replacement-lines"
|
||||
import {
|
||||
restoreLeadingIndent,
|
||||
stripInsertAnchorEcho,
|
||||
stripInsertBeforeEcho,
|
||||
stripInsertBoundaryEcho,
|
||||
stripRangeBoundaryEcho,
|
||||
toNewLines,
|
||||
} from "./edit-text-normalization"
|
||||
import { parseLineRef, validateLineRef } from "./validation"
|
||||
|
||||
interface EditApplyOptions {
|
||||
skipValidation?: boolean
|
||||
}
|
||||
|
||||
function shouldValidate(options?: EditApplyOptions): boolean {
|
||||
return options?.skipValidation !== true
|
||||
}
|
||||
|
||||
export function applySetLine(
|
||||
lines: string[],
|
||||
anchor: string,
|
||||
newText: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const originalLine = lines[line - 1] ?? ""
|
||||
const corrected = autocorrectReplacementLines([originalLine], toNewLines(newText))
|
||||
const replacement = corrected.map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(originalLine, entry)
|
||||
})
|
||||
result.splice(line - 1, 1, ...replacement)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyReplaceLines(
|
||||
lines: string[],
|
||||
startAnchor: string,
|
||||
endAnchor: string,
|
||||
newText: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) {
|
||||
validateLineRef(lines, startAnchor)
|
||||
validateLineRef(lines, endAnchor)
|
||||
}
|
||||
|
||||
const { line: startLine } = parseLineRef(startAnchor)
|
||||
const { line: endLine } = parseLineRef(endAnchor)
|
||||
|
||||
if (startLine > endLine) {
|
||||
throw new Error(
|
||||
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = [...lines]
|
||||
const originalRange = lines.slice(startLine - 1, endLine)
|
||||
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
|
||||
const corrected = autocorrectReplacementLines(originalRange, stripped)
|
||||
const restored = corrected.map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(lines[startLine - 1] ?? "", entry)
|
||||
})
|
||||
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertAfter(
|
||||
lines: string[],
|
||||
anchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`append (anchored) requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertBefore(
|
||||
lines: string[],
|
||||
anchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`prepend (anchored) requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyAppend(lines: string[], text: string | string[]): string[] {
|
||||
const normalized = toNewLines(text)
|
||||
if (normalized.length === 0) {
|
||||
throw new Error("append requires non-empty text")
|
||||
}
|
||||
if (lines.length === 1 && lines[0] === "") {
|
||||
return [...normalized]
|
||||
}
|
||||
return [...lines, ...normalized]
|
||||
}
|
||||
|
||||
export function applyPrepend(lines: string[], text: string | string[]): string[] {
|
||||
const normalized = toNewLines(text)
|
||||
if (normalized.length === 0) {
|
||||
throw new Error("prepend requires non-empty text")
|
||||
}
|
||||
if (lines.length === 1 && lines[0] === "") {
|
||||
return [...normalized]
|
||||
}
|
||||
return [...normalized, ...lines]
|
||||
}
|
||||
export {
|
||||
applySetLine,
|
||||
applyReplaceLines,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
applyAppend,
|
||||
applyPrepend,
|
||||
} from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,103 +1,5 @@
|
||||
import { dedupeEdits } from "./edit-deduplication"
|
||||
import { collectLineRefs, detectOverlappingRanges, getEditLineNumber } from "./edit-ordering"
|
||||
import type { HashlineEdit } from "./types"
|
||||
import {
|
||||
applyAppend,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
applyPrepend,
|
||||
applyReplaceLines,
|
||||
applySetLine,
|
||||
} from "./edit-operation-primitives"
|
||||
import { validateLineRefs } from "./validation"
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export interface HashlineApplyReport {
|
||||
content: string
|
||||
noopEdits: number
|
||||
deduplicatedEdits: number
|
||||
}
|
||||
|
||||
export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport {
|
||||
if (edits.length === 0) {
|
||||
return {
|
||||
content,
|
||||
noopEdits: 0,
|
||||
deduplicatedEdits: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const dedupeResult = dedupeEdits(edits)
|
||||
const EDIT_PRECEDENCE: Record<string, number> = { replace: 0, append: 1, prepend: 2 }
|
||||
const sortedEdits = [...dedupeResult.edits].sort((a, b) => {
|
||||
const lineA = getEditLineNumber(a)
|
||||
const lineB = getEditLineNumber(b)
|
||||
if (lineB !== lineA) return lineB - lineA
|
||||
return (EDIT_PRECEDENCE[a.op] ?? 3) - (EDIT_PRECEDENCE[b.op] ?? 3)
|
||||
})
|
||||
|
||||
let noopEdits = 0
|
||||
|
||||
let lines = content.length === 0 ? [] : content.split("\n")
|
||||
|
||||
const refs = collectLineRefs(sortedEdits)
|
||||
validateLineRefs(lines, refs)
|
||||
|
||||
const overlapError = detectOverlappingRanges(sortedEdits)
|
||||
if (overlapError) throw new Error(overlapError)
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
switch (edit.op) {
|
||||
case "replace": {
|
||||
const next = edit.end
|
||||
? applyReplaceLines(lines, edit.pos, edit.end, edit.lines, { skipValidation: true })
|
||||
: applySetLine(lines, edit.pos, edit.lines, { skipValidation: true })
|
||||
if (arraysEqual(next, lines)) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
lines = next
|
||||
break
|
||||
}
|
||||
case "append": {
|
||||
const next = edit.pos
|
||||
? applyInsertAfter(lines, edit.pos, edit.lines, { skipValidation: true })
|
||||
: applyAppend(lines, edit.lines)
|
||||
if (arraysEqual(next, lines)) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
lines = next
|
||||
break
|
||||
}
|
||||
case "prepend": {
|
||||
const next = edit.pos
|
||||
? applyInsertBefore(lines, edit.pos, edit.lines, { skipValidation: true })
|
||||
: applyPrepend(lines, edit.lines)
|
||||
if (arraysEqual(next, lines)) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
lines = next
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: lines.join("\n"),
|
||||
noopEdits,
|
||||
deduplicatedEdits: dedupeResult.deduplicatedEdits,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
|
||||
return applyHashlineEditsWithReport(content, edits).content
|
||||
}
|
||||
export {
|
||||
applyHashlineEdits,
|
||||
applyHashlineEditsWithReport,
|
||||
} from "@oh-my-opencode/hashline-core"
|
||||
export type { HashlineApplyReport } from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,56 +1 @@
|
||||
import { parseLineRef } from "./validation"
|
||||
import type { HashlineEdit } from "./types"
|
||||
|
||||
export function getEditLineNumber(edit: HashlineEdit): number {
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
return parseLineRef(edit.end ?? edit.pos).line
|
||||
case "append":
|
||||
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
|
||||
case "prepend":
|
||||
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
|
||||
default:
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
export function collectLineRefs(edits: HashlineEdit[]): string[] {
|
||||
return edits.flatMap((edit) => {
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
return edit.end ? [edit.pos, edit.end] : [edit.pos]
|
||||
case "append":
|
||||
case "prepend":
|
||||
return edit.pos ? [edit.pos] : []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function detectOverlappingRanges(edits: HashlineEdit[]): string | null {
|
||||
const ranges: { start: number; end: number; idx: number }[] = []
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
const edit = edits[i]
|
||||
if (edit.op !== "replace" || !edit.end) continue
|
||||
const start = parseLineRef(edit.pos).line
|
||||
const end = parseLineRef(edit.end).line
|
||||
ranges.push({ start, end, idx: i })
|
||||
}
|
||||
if (ranges.length < 2) return null
|
||||
|
||||
ranges.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||
for (let i = 1; i < ranges.length; i++) {
|
||||
const prev = ranges[i - 1]
|
||||
const curr = ranges[i]
|
||||
if (curr.start <= prev.end) {
|
||||
return (
|
||||
`Overlapping range edits detected: ` +
|
||||
`edit ${prev.idx + 1} (lines ${prev.start}-${prev.end}) overlaps with ` +
|
||||
`edit ${curr.idx + 1} (lines ${curr.start}-${curr.end}). ` +
|
||||
`Use pos-only replace for single-line edits.`
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
export { getEditLineNumber, collectLineRefs, detectOverlappingRanges } from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,111 +1,9 @@
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\|/
|
||||
const DIFF_PLUS_RE = /^[+](?![+])/
|
||||
|
||||
function equalsIgnoringWhitespace(a: string, b: string): boolean {
|
||||
if (a === b) return true
|
||||
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "")
|
||||
}
|
||||
|
||||
function leadingWhitespace(text: string): string {
|
||||
if (!text) return ""
|
||||
const match = text.match(/^\s*/)
|
||||
return match ? match[0] : ""
|
||||
}
|
||||
|
||||
export function stripLinePrefixes(lines: string[]): string[] {
|
||||
let hashPrefixCount = 0
|
||||
let diffPlusCount = 0
|
||||
let nonEmpty = 0
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) continue
|
||||
nonEmpty += 1
|
||||
if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount += 1
|
||||
if (DIFF_PLUS_RE.test(line)) diffPlusCount += 1
|
||||
}
|
||||
|
||||
if (nonEmpty === 0) {
|
||||
return lines
|
||||
}
|
||||
|
||||
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5
|
||||
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5
|
||||
|
||||
if (!stripHash && !stripPlus) {
|
||||
return lines
|
||||
}
|
||||
|
||||
return lines.map((line) => {
|
||||
if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "")
|
||||
if (stripPlus) return line.replace(DIFF_PLUS_RE, "")
|
||||
return line
|
||||
})
|
||||
}
|
||||
|
||||
export function toNewLines(input: string | string[]): string[] {
|
||||
if (Array.isArray(input)) {
|
||||
return stripLinePrefixes(input)
|
||||
}
|
||||
return stripLinePrefixes(input.split("\n"))
|
||||
}
|
||||
|
||||
export function restoreLeadingIndent(templateLine: string, line: string): string {
|
||||
if (line.length === 0) return line
|
||||
const templateIndent = leadingWhitespace(templateLine)
|
||||
if (templateIndent.length === 0) return line
|
||||
if (leadingWhitespace(line).length > 0) return line
|
||||
if (templateLine.trim() === line.trim()) return line
|
||||
return `${templateIndent}${line}`
|
||||
}
|
||||
|
||||
export function stripInsertAnchorEcho(anchorLine: string, newLines: string[]): string[] {
|
||||
if (newLines.length === 0) return newLines
|
||||
if (equalsIgnoringWhitespace(newLines[0], anchorLine)) {
|
||||
return newLines.slice(1)
|
||||
}
|
||||
return newLines
|
||||
}
|
||||
|
||||
export function stripInsertBeforeEcho(anchorLine: string, newLines: string[]): string[] {
|
||||
if (newLines.length <= 1) return newLines
|
||||
if (equalsIgnoringWhitespace(newLines[newLines.length - 1], anchorLine)) {
|
||||
return newLines.slice(0, -1)
|
||||
}
|
||||
return newLines
|
||||
}
|
||||
|
||||
export function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, newLines: string[]): string[] {
|
||||
let out = newLines
|
||||
if (out.length > 0 && equalsIgnoringWhitespace(out[0], afterLine)) {
|
||||
out = out.slice(1)
|
||||
}
|
||||
if (out.length > 0 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
|
||||
out = out.slice(0, -1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function stripRangeBoundaryEcho(
|
||||
lines: string[],
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
newLines: string[]
|
||||
): string[] {
|
||||
const replacedCount = endLine - startLine + 1
|
||||
if (newLines.length <= 1 || newLines.length <= replacedCount) {
|
||||
return newLines
|
||||
}
|
||||
|
||||
let out = newLines
|
||||
const beforeIdx = startLine - 2
|
||||
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], lines[beforeIdx])) {
|
||||
out = out.slice(1)
|
||||
}
|
||||
|
||||
const afterIdx = endLine
|
||||
if (afterIdx < lines.length && out.length > 0 && equalsIgnoringWhitespace(out[out.length - 1], lines[afterIdx])) {
|
||||
out = out.slice(0, -1)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
export {
|
||||
stripLinePrefixes,
|
||||
toNewLines,
|
||||
restoreLeadingIndent,
|
||||
stripInsertAnchorEcho,
|
||||
stripInsertBeforeEcho,
|
||||
stripInsertBoundaryEcho,
|
||||
stripRangeBoundaryEcho,
|
||||
} from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,44 +1,2 @@
|
||||
export interface FileTextEnvelope {
|
||||
content: string
|
||||
hadBom: boolean
|
||||
lineEnding: "\n" | "\r\n"
|
||||
}
|
||||
|
||||
function detectLineEnding(content: string): "\n" | "\r\n" {
|
||||
const crlfIndex = content.indexOf("\r\n")
|
||||
const lfIndex = content.indexOf("\n")
|
||||
if (lfIndex === -1) return "\n"
|
||||
if (crlfIndex === -1) return "\n"
|
||||
return crlfIndex < lfIndex ? "\r\n" : "\n"
|
||||
}
|
||||
|
||||
function stripBom(content: string): { content: string; hadBom: boolean } {
|
||||
if (!content.startsWith("\uFEFF")) {
|
||||
return { content, hadBom: false }
|
||||
}
|
||||
return { content: content.slice(1), hadBom: true }
|
||||
}
|
||||
|
||||
function normalizeToLf(content: string): string {
|
||||
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
function restoreLineEndings(content: string, lineEnding: "\n" | "\r\n"): string {
|
||||
if (lineEnding === "\n") return content
|
||||
return content.replace(/\n/g, "\r\n")
|
||||
}
|
||||
|
||||
export function canonicalizeFileText(content: string): FileTextEnvelope {
|
||||
const stripped = stripBom(content)
|
||||
return {
|
||||
content: normalizeToLf(stripped.content),
|
||||
hadBom: stripped.hadBom,
|
||||
lineEnding: detectLineEnding(stripped.content),
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreFileText(content: string, envelope: FileTextEnvelope): string {
|
||||
const withLineEnding = restoreLineEndings(content, envelope.lineEnding)
|
||||
if (!envelope.hadBom) return withLineEnding
|
||||
return `\uFEFF${withLineEnding}`
|
||||
}
|
||||
export { canonicalizeFileText, restoreFileText } from "@oh-my-opencode/hashline-core"
|
||||
export type { FileTextEnvelope } from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,155 +1,9 @@
|
||||
import { HASHLINE_DICT } from "./constants"
|
||||
import { createHashlineChunkFormatter } from "./hashline-chunk-formatter"
|
||||
import { bunHashXxh32 } from "../../shared/bun-hash-shim"
|
||||
|
||||
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
|
||||
|
||||
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
|
||||
const stripped = normalizedContent
|
||||
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
|
||||
const hash = bunHashXxh32(stripped, seed)
|
||||
const index = hash % 256
|
||||
return HASHLINE_DICT[index]
|
||||
}
|
||||
|
||||
export function computeLineHash(lineNumber: number, content: string): string {
|
||||
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd())
|
||||
}
|
||||
|
||||
export function computeLegacyLineHash(lineNumber: number, content: string): string {
|
||||
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, ""))
|
||||
}
|
||||
|
||||
export function formatHashLine(lineNumber: number, content: string): string {
|
||||
const hash = computeLineHash(lineNumber, content)
|
||||
return `${lineNumber}#${hash}|${content}`
|
||||
}
|
||||
|
||||
export function formatHashLines(content: string): string {
|
||||
if (!content) return ""
|
||||
const lines = content.split("\n")
|
||||
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
|
||||
}
|
||||
|
||||
export interface HashlineStreamOptions {
|
||||
startLine?: number
|
||||
maxChunkLines?: number
|
||||
maxChunkBytes?: number
|
||||
}
|
||||
|
||||
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"getReader" in value &&
|
||||
typeof (value as { getReader?: unknown }).getReader === "function"
|
||||
)
|
||||
}
|
||||
|
||||
async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
|
||||
const reader = stream.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) return
|
||||
if (value) yield value
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamHashLinesFromUtf8(
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
options: HashlineStreamOptions = {}
|
||||
): AsyncGenerator<string> {
|
||||
const startLine = options.startLine ?? 1
|
||||
const maxChunkLines = options.maxChunkLines ?? 200
|
||||
const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024
|
||||
const decoder = new TextDecoder("utf-8")
|
||||
const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source
|
||||
|
||||
let lineNumber = startLine
|
||||
let pending = ""
|
||||
let sawAnyText = false
|
||||
let endedWithNewline = false
|
||||
const chunkFormatter = createHashlineChunkFormatter({ maxChunkLines, maxChunkBytes })
|
||||
|
||||
const pushLine = (line: string): string[] => {
|
||||
const formatted = formatHashLine(lineNumber, line)
|
||||
lineNumber += 1
|
||||
return chunkFormatter.push(formatted)
|
||||
}
|
||||
|
||||
const consumeText = (text: string): string[] => {
|
||||
if (text.length === 0) return []
|
||||
sawAnyText = true
|
||||
pending += text
|
||||
const chunksToYield: string[] = []
|
||||
|
||||
let lastIdx = 0
|
||||
while (true) {
|
||||
const idx = pending.indexOf("\n", lastIdx)
|
||||
if (idx === -1) break
|
||||
const line = pending.slice(lastIdx, idx)
|
||||
lastIdx = idx + 1
|
||||
endedWithNewline = true
|
||||
chunksToYield.push(...pushLine(line))
|
||||
}
|
||||
|
||||
pending = pending.slice(lastIdx)
|
||||
if (pending.length > 0) endedWithNewline = false
|
||||
return chunksToYield
|
||||
}
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
for (const out of consumeText(decoder.decode(chunk, { stream: true }))) {
|
||||
yield out
|
||||
}
|
||||
}
|
||||
|
||||
for (const out of consumeText(decoder.decode())) {
|
||||
yield out
|
||||
}
|
||||
|
||||
if (sawAnyText && (pending.length > 0 || endedWithNewline)) {
|
||||
for (const out of pushLine(pending)) {
|
||||
yield out
|
||||
}
|
||||
}
|
||||
|
||||
const finalChunk = chunkFormatter.flush()
|
||||
if (finalChunk) yield finalChunk
|
||||
}
|
||||
|
||||
export async function* streamHashLinesFromLines(
|
||||
lines: Iterable<string> | AsyncIterable<string>,
|
||||
options: HashlineStreamOptions = {}
|
||||
): AsyncGenerator<string> {
|
||||
const startLine = options.startLine ?? 1
|
||||
const maxChunkLines = options.maxChunkLines ?? 200
|
||||
const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024
|
||||
|
||||
let lineNumber = startLine
|
||||
const chunkFormatter = createHashlineChunkFormatter({ maxChunkLines, maxChunkBytes })
|
||||
|
||||
const pushLine = (line: string): string[] => {
|
||||
const formatted = formatHashLine(lineNumber, line)
|
||||
lineNumber += 1
|
||||
return chunkFormatter.push(formatted)
|
||||
}
|
||||
|
||||
const asyncIterator = (lines as AsyncIterable<string>)[Symbol.asyncIterator]
|
||||
if (typeof asyncIterator === "function") {
|
||||
for await (const line of lines as AsyncIterable<string>) {
|
||||
for (const out of pushLine(line)) yield out
|
||||
}
|
||||
} else {
|
||||
for (const line of lines as Iterable<string>) {
|
||||
for (const out of pushLine(line)) yield out
|
||||
}
|
||||
}
|
||||
|
||||
const finalChunk = chunkFormatter.flush()
|
||||
if (finalChunk) yield finalChunk
|
||||
}
|
||||
export {
|
||||
computeLineHash,
|
||||
computeLegacyLineHash,
|
||||
formatHashLine,
|
||||
formatHashLines,
|
||||
streamHashLinesFromUtf8,
|
||||
streamHashLinesFromLines,
|
||||
} from "@oh-my-opencode/hashline-core"
|
||||
export type { HashlineStreamOptions } from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,52 +1,2 @@
|
||||
export interface HashlineChunkFormatter {
|
||||
push(formattedLine: string): string[]
|
||||
flush(): string | undefined
|
||||
}
|
||||
|
||||
interface HashlineChunkFormatterOptions {
|
||||
maxChunkLines: number
|
||||
maxChunkBytes: number
|
||||
}
|
||||
|
||||
export function createHashlineChunkFormatter(options: HashlineChunkFormatterOptions): HashlineChunkFormatter {
|
||||
const { maxChunkLines, maxChunkBytes } = options
|
||||
let outputLines: string[] = []
|
||||
let outputBytes = 0
|
||||
|
||||
const flush = (): string | undefined => {
|
||||
if (outputLines.length === 0) return undefined
|
||||
const chunk = outputLines.join("\n")
|
||||
outputLines = []
|
||||
outputBytes = 0
|
||||
return chunk
|
||||
}
|
||||
|
||||
const push = (formattedLine: string): string[] => {
|
||||
const chunksToYield: string[] = []
|
||||
const separatorBytes = outputLines.length === 0 ? 0 : 1
|
||||
const lineBytes = Buffer.byteLength(formattedLine, "utf-8")
|
||||
|
||||
if (
|
||||
outputLines.length > 0 &&
|
||||
(outputLines.length >= maxChunkLines || outputBytes + separatorBytes + lineBytes > maxChunkBytes)
|
||||
) {
|
||||
const flushed = flush()
|
||||
if (flushed) chunksToYield.push(flushed)
|
||||
}
|
||||
|
||||
outputLines.push(formattedLine)
|
||||
outputBytes += (outputLines.length === 1 ? 0 : 1) + lineBytes
|
||||
|
||||
if (outputLines.length >= maxChunkLines || outputBytes >= maxChunkBytes) {
|
||||
const flushed = flush()
|
||||
if (flushed) chunksToYield.push(flushed)
|
||||
}
|
||||
|
||||
return chunksToYield
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
flush,
|
||||
}
|
||||
}
|
||||
export { createHashlineChunkFormatter } from "@oh-my-opencode/hashline-core"
|
||||
export type { HashlineChunkFormatter } from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,31 +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"
|
||||
|
||||
@@ -1,95 +1,2 @@
|
||||
import type { AppendEdit, HashlineEdit, PrependEdit, ReplaceEdit } from "./types"
|
||||
|
||||
type HashlineToolOp = "replace" | "append" | "prepend"
|
||||
|
||||
export interface RawHashlineEdit {
|
||||
op?: HashlineToolOp
|
||||
pos?: string
|
||||
end?: string
|
||||
lines?: string | string[] | null
|
||||
}
|
||||
|
||||
function normalizeAnchor(value: string | undefined): string | undefined {
|
||||
if (typeof value !== "string") return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed === "" ? undefined : trimmed
|
||||
}
|
||||
|
||||
function requireLines(edit: RawHashlineEdit, index: number): string | string[] {
|
||||
if (edit.lines === undefined) {
|
||||
throw new Error(`Edit ${index}: lines is required for ${edit.op ?? "unknown"}`)
|
||||
}
|
||||
if (edit.lines === null) {
|
||||
return []
|
||||
}
|
||||
return edit.lines
|
||||
}
|
||||
|
||||
function requireLine(anchor: string | undefined, index: number, op: HashlineToolOp): string {
|
||||
if (!anchor) {
|
||||
throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference (pos or end)`)
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
function normalizeReplaceEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
|
||||
const pos = normalizeAnchor(edit.pos)
|
||||
const end = normalizeAnchor(edit.end)
|
||||
const anchor = requireLine(pos ?? end, index, "replace")
|
||||
const lines = requireLines(edit, index)
|
||||
|
||||
const normalized: ReplaceEdit = {
|
||||
op: "replace",
|
||||
pos: anchor,
|
||||
lines,
|
||||
}
|
||||
if (end) normalized.end = end
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeAppendEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
|
||||
const pos = normalizeAnchor(edit.pos)
|
||||
const end = normalizeAnchor(edit.end)
|
||||
const anchor = pos ?? end
|
||||
const lines = requireLines(edit, index)
|
||||
|
||||
const normalized: AppendEdit = {
|
||||
op: "append",
|
||||
lines,
|
||||
}
|
||||
if (anchor) normalized.pos = anchor
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizePrependEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
|
||||
const pos = normalizeAnchor(edit.pos)
|
||||
const end = normalizeAnchor(edit.end)
|
||||
const anchor = pos ?? end
|
||||
const lines = requireLines(edit, index)
|
||||
|
||||
const normalized: PrependEdit = {
|
||||
op: "prepend",
|
||||
lines,
|
||||
}
|
||||
if (anchor) normalized.pos = anchor
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] {
|
||||
return rawEdits.map((rawEdit, index) => {
|
||||
const edit = rawEdit ?? {}
|
||||
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
return normalizeReplaceEdit(edit, index)
|
||||
case "append":
|
||||
return normalizeAppendEdit(edit, index)
|
||||
case "prepend":
|
||||
return normalizePrependEdit(edit, index)
|
||||
default:
|
||||
throw new Error(
|
||||
`Edit ${index}: unsupported op "${String(edit.op)}". Legacy format was removed; use op/pos/end/lines.`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
export { normalizeHashlineEdits } from "@oh-my-opencode/hashline-core"
|
||||
export type { RawHashlineEdit } from "@oh-my-opencode/hashline-core"
|
||||
|
||||
@@ -1,20 +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"
|
||||
|
||||
@@ -1,181 +1,8 @@
|
||||
import { computeLegacyLineHash, computeLineHash } from "./hash-computation"
|
||||
import { HASHLINE_REF_PATTERN } from "./constants"
|
||||
|
||||
export interface LineRef {
|
||||
line: number
|
||||
hash: string
|
||||
}
|
||||
|
||||
interface HashMismatch {
|
||||
line: number
|
||||
expected: string
|
||||
}
|
||||
|
||||
const MISMATCH_CONTEXT = 2
|
||||
|
||||
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
|
||||
|
||||
function isCompatibleLineHash(line: number, content: string, hash: string): boolean {
|
||||
return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash
|
||||
}
|
||||
|
||||
export function normalizeLineRef(ref: string): string {
|
||||
const originalTrimmed = ref.trim()
|
||||
let trimmed = originalTrimmed
|
||||
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
|
||||
trimmed = trimmed.replace(/\s*#\s*/, "#")
|
||||
trimmed = trimmed.replace(/\|.*$/, "")
|
||||
trimmed = trimmed.trim()
|
||||
|
||||
if (HASHLINE_REF_PATTERN.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
|
||||
if (extracted) {
|
||||
return extracted[1]
|
||||
}
|
||||
|
||||
return originalTrimmed
|
||||
}
|
||||
|
||||
export function parseLineRef(ref: string): LineRef {
|
||||
const normalized = normalizeLineRef(ref)
|
||||
const match = normalized.match(HASHLINE_REF_PATTERN)
|
||||
if (match) {
|
||||
return {
|
||||
line: Number.parseInt(match[1], 10),
|
||||
hash: match[2],
|
||||
}
|
||||
}
|
||||
const hashIdx = normalized.indexOf('#')
|
||||
if (hashIdx > 0) {
|
||||
const prefix = normalized.slice(0, hashIdx)
|
||||
const suffix = normalized.slice(hashIdx + 1)
|
||||
if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) {
|
||||
throw new Error(
|
||||
`Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
|
||||
`Use the actual line number from the read output.`
|
||||
)
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
|
||||
)
|
||||
}
|
||||
|
||||
export function validateLineRef(lines: string[], ref: string): void {
|
||||
const { line, hash } = parseLineRefWithHint(ref, lines)
|
||||
|
||||
if (line < 1 || line > lines.length) {
|
||||
throw new Error(
|
||||
`Line number ${line} out of bounds. File has ${lines.length} lines.`
|
||||
)
|
||||
}
|
||||
|
||||
const content = lines[line - 1]
|
||||
if (!isCompatibleLineHash(line, content, hash)) {
|
||||
throw new HashlineMismatchError([{ line, expected: hash }], lines)
|
||||
}
|
||||
}
|
||||
|
||||
export class HashlineMismatchError extends Error {
|
||||
readonly remaps: ReadonlyMap<string, string>
|
||||
|
||||
constructor(
|
||||
private readonly mismatches: HashMismatch[],
|
||||
private readonly fileLines: string[]
|
||||
) {
|
||||
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
|
||||
this.name = "HashlineMismatchError"
|
||||
const remaps = new Map<string, string>()
|
||||
for (const mismatch of mismatches) {
|
||||
const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "")
|
||||
remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`)
|
||||
}
|
||||
this.remaps = remaps
|
||||
}
|
||||
|
||||
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
||||
const mismatchByLine = new Map<number, HashMismatch>()
|
||||
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
|
||||
|
||||
const displayLines = new Set<number>()
|
||||
for (const mismatch of mismatches) {
|
||||
const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT)
|
||||
const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT)
|
||||
for (let line = low; line <= high; line++) displayLines.add(line)
|
||||
}
|
||||
|
||||
const sortedLines = [...displayLines].sort((a, b) => a - b)
|
||||
const output: string[] = []
|
||||
output.push(
|
||||
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
|
||||
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
|
||||
)
|
||||
output.push("")
|
||||
|
||||
let previousLine = -1
|
||||
for (const line of sortedLines) {
|
||||
if (previousLine !== -1 && line > previousLine + 1) {
|
||||
output.push(" ...")
|
||||
}
|
||||
previousLine = line
|
||||
|
||||
const content = fileLines[line - 1] ?? ""
|
||||
const hash = computeLineHash(line, content)
|
||||
const prefix = `${line}#${hash}|${content}`
|
||||
if (mismatchByLine.has(line)) {
|
||||
output.push(`>>> ${prefix}`)
|
||||
} else {
|
||||
output.push(` ${prefix}`)
|
||||
}
|
||||
}
|
||||
|
||||
return output.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
function suggestLineForHash(ref: string, lines: string[]): string | null {
|
||||
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
|
||||
if (!hashMatch) return null
|
||||
const hash = hashMatch[1]
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (isCompatibleLineHash(i + 1, lines[i], hash)) {
|
||||
return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i])}"?`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
|
||||
try {
|
||||
return parseLineRef(ref)
|
||||
} catch (parseError) {
|
||||
const hint = suggestLineForHash(ref, lines)
|
||||
if (hint && parseError instanceof Error) {
|
||||
throw new Error(`${parseError.message} ${hint}`)
|
||||
}
|
||||
throw parseError
|
||||
}
|
||||
}
|
||||
|
||||
export function validateLineRefs(lines: string[], refs: string[]): void {
|
||||
const mismatches: HashMismatch[] = []
|
||||
|
||||
for (const ref of refs) {
|
||||
const { line, hash } = parseLineRefWithHint(ref, lines)
|
||||
|
||||
if (line < 1 || line > lines.length) {
|
||||
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
|
||||
}
|
||||
|
||||
const content = lines[line - 1]
|
||||
if (!isCompatibleLineHash(line, content, hash)) {
|
||||
mismatches.push({ line, expected: hash })
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
throw new HashlineMismatchError(mismatches, lines)
|
||||
}
|
||||
}
|
||||
export {
|
||||
parseLineRef,
|
||||
validateLineRef,
|
||||
validateLineRefs,
|
||||
HashlineMismatchError,
|
||||
normalizeLineRef,
|
||||
} from "@oh-my-opencode/hashline-core"
|
||||
export type { LineRef } from "@oh-my-opencode/hashline-core"
|
||||
|
||||
Reference in New Issue
Block a user