151 lines
4.2 KiB
TypeScript
151 lines
4.2 KiB
TypeScript
import { computeLineHash } from "./hash-computation"
|
|
import { HASHLINE_REF_PATTERN, HASHLINE_LEGACY_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}|[0-9]+:[0-9a-fA-F]{2,})/
|
|
|
|
function normalizeLineRef(ref: string): string {
|
|
const trimmed = ref.trim()
|
|
if (HASHLINE_REF_PATTERN.test(trimmed)) {
|
|
return trimmed
|
|
}
|
|
if (HASHLINE_LEGACY_REF_PATTERN.test(trimmed)) {
|
|
return trimmed
|
|
}
|
|
|
|
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
|
|
if (extracted) {
|
|
return extracted[1]
|
|
}
|
|
|
|
return trimmed
|
|
}
|
|
|
|
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 legacyMatch = normalized.match(HASHLINE_LEGACY_REF_PATTERN)
|
|
if (legacyMatch) {
|
|
return {
|
|
line: Number.parseInt(legacyMatch[1], 10),
|
|
hash: legacyMatch[2],
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Invalid line reference format: "${ref}". Expected format: "LINE#ID" (e.g., "42#VK")`
|
|
)
|
|
}
|
|
|
|
export function validateLineRef(lines: string[], ref: string): void {
|
|
const { line, hash } = parseLineRef(ref)
|
|
|
|
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]
|
|
const currentHash = computeLineHash(line, content)
|
|
|
|
if (currentHash !== 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#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")
|
|
}
|
|
}
|
|
|
|
export function validateLineRefs(lines: string[], refs: string[]): void {
|
|
const mismatches: HashMismatch[] = []
|
|
|
|
for (const ref of refs) {
|
|
const { line, hash } = parseLineRef(ref)
|
|
|
|
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]
|
|
const currentHash = computeLineHash(line, content)
|
|
if (currentHash !== hash) {
|
|
mismatches.push({ line, expected: hash })
|
|
}
|
|
}
|
|
|
|
if (mismatches.length > 0) {
|
|
throw new HashlineMismatchError(mismatches, lines)
|
|
}
|
|
}
|