Files
oh-my-openagent/src/tools/hashline-edit/edit-operations.ts
YeonGyu-Kim 07fa0560c2 fix(hashline-edit): restore leading indentation for first line in replace_lines
- BUG-21: Apply restoreLeadingIndent to first entry of replace_lines, matching set_line behavior
- Update test to verify indentation preservation
2026-02-21 02:41:21 +09:00

222 lines
6.3 KiB
TypeScript

import { parseLineRef, validateLineRef, validateLineRefs } from "./validation"
import type { HashlineEdit } from "./types"
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[A-Z]{2}:/
const DIFF_PLUS_RE = /^[+-](?![+-])/
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
})
}
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 {
const match = text.match(/^\s*/)
return match ? match[0] : ""
}
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
return `${templateIndent}${line}`
}
function stripInsertAnchorEcho(anchorLine: string, newLines: string[]): string[] {
if (newLines.length <= 1) return newLines
if (equalsIgnoringWhitespace(newLines[0], anchorLine)) {
return newLines.slice(1)
}
return newLines
}
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
}
function toNewLines(input: string | string[]): string[] {
if (Array.isArray(input)) {
return stripLinePrefixes(input)
}
return stripLinePrefixes(input.split("\n"))
}
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))
result.splice(line, 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 "replace":
return Number.NEGATIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
}
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
if (edits.length === 0) {
return content
}
const sortedEdits = [...edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
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 "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": {
lines = applyInsertAfter(lines, edit.line, edit.text)
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
result = result.replaceAll(edit.old_text, replacement)
lines = result.split("\n")
break
}
}
}
return lines.join("\n")
}