Files
oh-my-opencode/src/tools/hashline-edit/edit-operations.ts
T

272 lines
8.1 KiB
TypeScript

import { parseLineRef, validateLineRef, validateLineRefs } from "./validation"
import type { HashlineEdit } from "./types"
import {
restoreLeadingIndent,
stripInsertAnchorEcho,
stripInsertBeforeEcho,
stripInsertBoundaryEcho,
stripRangeBoundaryEcho,
toNewLines,
} from "./edit-text-normalization"
export interface HashlineApplyReport {
content: string
noopEdits: number
deduplicatedEdits: number
}
export function applySetLine(lines: string[], anchor: string, newText: string | string[]): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const replacement = toNewLines(newText).map((entry, idx) => {
if (idx !== 0) return entry
return restoreLeadingIndent(lines[line - 1], entry)
})
result.splice(line - 1, 1, ...replacement)
return result
}
export function applyReplaceLines(
lines: string[],
startAnchor: string,
endAnchor: string,
newText: string | string[]
): string[] {
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 stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
const restored = stripped.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[]): string[] {
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(`insert_after requires non-empty text for ${anchor}`)
}
result.splice(line, 0, ...newLines)
return result
}
export function applyInsertBefore(lines: string[], anchor: string, text: string | string[]): string[] {
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(`insert_before requires non-empty text for ${anchor}`)
}
result.splice(line - 1, 0, ...newLines)
return result
}
export function applyInsertBetween(
lines: string[],
afterAnchor: string,
beforeAnchor: string,
text: string | string[]
): string[] {
validateLineRef(lines, afterAnchor)
validateLineRef(lines, beforeAnchor)
const { line: afterLine } = parseLineRef(afterAnchor)
const { line: beforeLine } = parseLineRef(beforeAnchor)
if (beforeLine <= afterLine) {
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
}
const result = [...lines]
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
}
result.splice(beforeLine - 1, 0, ...newLines)
return result
}
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
if (!content.includes(oldText)) {
throw new Error(`Text not found: "${oldText}"`)
}
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
return content.replaceAll(oldText, replacement)
}
function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.type) {
case "set_line":
return parseLineRef(edit.line).line
case "replace_lines":
return parseLineRef(edit.end_line).line
case "insert_after":
return parseLineRef(edit.line).line
case "insert_before":
return parseLineRef(edit.line).line
case "insert_between":
return parseLineRef(edit.before_line).line
case "replace":
return Number.NEGATIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
}
function normalizeEditPayload(payload: string | string[]): string {
return toNewLines(payload).join("\n")
}
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 = (() => {
switch (edit.type) {
case "set_line":
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
case "replace_lines":
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
case "insert_after":
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_before":
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_between":
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
case "replace":
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}`
}
})()
if (seen.has(key)) {
deduplicatedEdits += 1
continue
}
seen.add(key)
deduped.push(edit)
}
return { edits: deduped, deduplicatedEdits }
}
export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport {
if (edits.length === 0) {
return {
content,
noopEdits: 0,
deduplicatedEdits: 0,
}
}
const dedupeResult = dedupeEdits(edits)
const sortedEdits = [...dedupeResult.edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
let noopEdits = 0
let result = content
let lines = result.split("\n")
const refs = sortedEdits.flatMap((edit) => {
switch (edit.type) {
case "set_line":
return [edit.line]
case "replace_lines":
return [edit.start_line, edit.end_line]
case "insert_after":
return [edit.line]
case "insert_before":
return [edit.line]
case "insert_between":
return [edit.after_line, edit.before_line]
case "replace":
return []
default:
return []
}
})
validateLineRefs(lines, refs)
for (const edit of sortedEdits) {
switch (edit.type) {
case "set_line": {
lines = applySetLine(lines, edit.line, edit.text)
break
}
case "replace_lines": {
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text)
break
}
case "insert_after": {
const next = applyInsertAfter(lines, edit.line, edit.text)
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
}
lines = next
break
}
case "insert_before": {
const next = applyInsertBefore(lines, edit.line, edit.text)
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
}
lines = next
break
}
case "insert_between": {
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text)
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
}
lines = next
break
}
case "replace": {
result = lines.join("\n")
if (!result.includes(edit.old_text)) {
throw new Error(`Text not found: "${edit.old_text}"`)
}
const replacement = Array.isArray(edit.new_text) ? edit.new_text.join("\n") : edit.new_text
const replaced = result.replaceAll(edit.old_text, replacement)
if (replaced === result) {
noopEdits += 1
break
}
result = replaced
lines = result.split("\n")
break
}
}
}
return {
content: lines.join("\n"),
noopEdits,
deduplicatedEdits: dedupeResult.deduplicatedEdits,
}
}
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
return applyHashlineEditsWithReport(content, edits).content
}