import { findFencedCodeBlocks } from "./preservation.ts"; export interface ValidationResult { valid: boolean; errors: string[]; warnings: string[]; fallbackApplied: boolean; } function requireExactPresence( label: string, originalItems: string[], compressed: string, errors: string[] ) { for (const item of originalItems) { if (!compressed.includes(item)) { const preview = collapseWhitespaceForPreview(item).slice(0, 80); errors.push(`${label} changed or missing: ${preview}`); } } } function isPreviewWhitespace(char: string): boolean { return ( char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f" || char === "\v" ); } function isHorizontalWhitespace(char: string): boolean { return char === " " || char === "\t"; } function isAsciiDigit(char: string | undefined): boolean { return !!char && char >= "0" && char <= "9"; } function isAsciiUpper(char: string | undefined): boolean { return !!char && char >= "A" && char <= "Z"; } function isAsciiLetter(char: string | undefined): boolean { return !!char && ((char >= "A" && char <= "Z") || (char >= "a" && char <= "z")); } function isAsciiAlphaNumeric(char: string | undefined): boolean { return !!char && (isAsciiLetter(char) || isAsciiDigit(char)); } function isWordChar(char: string | undefined): boolean { return !!char && (isAsciiAlphaNumeric(char) || char === "_"); } function isUrlTerminator(char: string): boolean { return ( isPreviewWhitespace(char) || char === ")" || char === "]" || char === '"' || char === "'" || char === ">" ); } function collapseWhitespaceForPreview(text: string): string { let output = ""; let previousWasWhitespace = false; let changed = false; for (const char of text) { if (isPreviewWhitespace(char)) { if (!previousWasWhitespace) { output += " "; } else { changed = true; } if (char !== " ") changed = true; previousWasWhitespace = true; continue; } output += char; previousWasWhitespace = false; } return changed ? output : text; } export function validateCompression(original: string, compressed: string): ValidationResult { const errors: string[] = []; const warnings: string[] = []; if (typeof original !== "string" || typeof compressed !== "string") { return { valid: false, errors: ["validation received non-string input"], warnings, fallbackApplied: true, }; } if (original.length > 0 && compressed.trim().length === 0) { errors.push("compressed text is empty"); } requireExactPresence("fenced code block", findFencedCodeBlocks(original), compressed, errors); requireExactPresence("inline code", collectInlineCode(original), compressed, errors); requireExactPresence("URL", collectUrls(original), compressed, errors); requireExactPresence("markdown link", collectMarkdownLinks(original), compressed, errors); requireExactPresence("frontmatter", collectFrontmatter(original), compressed, errors); requireExactPresence("heading", collectHeadings(original), compressed, errors); requireExactPresence("table row", collectTableRows(original), compressed, errors); requireExactPresence("math block", collectMathBlocks(original), compressed, errors); requireExactPresence("inline math", collectInlineMath(original), compressed, errors); requireExactPresence("LaTeX block", collectLatexBlocks(original), compressed, errors); requireExactPresence("version", collectVersions(original), compressed, errors); requireExactPresence("CONST_CASE", collectConstCase(original), compressed, errors); const originalFenceCount = findFencedCodeBlocks(original).length; const compressedFenceCount = findFencedCodeBlocks(compressed).length; if (compressedFenceCount < originalFenceCount) { errors.push( `fenced code block count dropped: ${originalFenceCount} -> ${compressedFenceCount}` ); } if (compressed.length > original.length) { warnings.push("compressed text is longer than original"); } return { valid: errors.length === 0, errors, warnings, fallbackApplied: errors.length > 0, }; } function collectInlineCode(text: string): string[] { const matches: string[] = []; for (let index = 0; index < text.length; index++) { if (text[index] !== "`") continue; const end = text.indexOf("`", index + 1); if (end === -1) break; const content = text.slice(index + 1, end); if (content.length > 0 && !content.includes("\n")) { matches.push(text.slice(index, end + 1)); index = end; } } return matches; } function collectUrls(text: string): string[] { const matches: string[] = []; for (let index = 0; index < text.length; index++) { const startsWithHttp = text.startsWith("http://", index); const startsWithHttps = text.startsWith("https://", index); if (!startsWithHttp && !startsWithHttps) continue; if (index > 0 && isWordChar(text[index - 1])) continue; let end = index + (startsWithHttps ? "https://".length : "http://".length); while (end < text.length && !isUrlTerminator(text[end])) { end++; } if (end > index) { matches.push(text.slice(index, end)); index = end - 1; } } return matches; } function collectMarkdownLinks(text: string): string[] { const matches: string[] = []; for (let index = 0; index < text.length; index++) { if (text[index] !== "[") continue; const labelEnd = text.indexOf("]", index + 1); if (labelEnd === -1 || labelEnd - index - 1 > 1000) continue; if (text.slice(index + 1, labelEnd).includes("\n")) continue; if (text[labelEnd + 1] !== "(") continue; const targetStart = labelEnd + 2; const maxTargetEnd = Math.min(text.length, targetStart + 2000); let targetEnd = -1; for (let cursor = targetStart; cursor < maxTargetEnd; cursor++) { if (text[cursor] === "\n") break; if (text[cursor] === ")") { targetEnd = cursor; break; } } if (targetEnd !== -1) { matches.push(text.slice(index, targetEnd + 1)); index = targetEnd; } } return matches; } function collectFrontmatter(text: string): string[] { if (!text.startsWith("---\n")) return []; const close = text.indexOf("\n---", 4); if (close === -1) return []; const closeEnd = text.indexOf("\n", close + 4); const end = closeEnd === -1 ? text.length : closeEnd + 1; return [text.slice(0, end)]; } function collectHeadings(text: string): string[] { return collectLines(text).filter((line) => { let markerCount = 0; while (markerCount < line.length && line[markerCount] === "#") { markerCount++; } if (markerCount < 1 || markerCount > 6) return false; if (!isPreviewWhitespace(line[markerCount] ?? "")) return false; for (let index = markerCount + 1; index < line.length; index++) { if (!isPreviewWhitespace(line[index])) return true; } return false; }); } function collectTableRows(text: string): string[] { return collectLines(text).filter((line) => { let index = 0; while (index < line.length && isHorizontalWhitespace(line[index])) { index++; } if (line[index] !== "|") return false; let pipeCount = 0; for (; index < line.length; index++) { if (line[index] === "|") pipeCount++; } return pipeCount >= 2; }); } function collectLines(text: string): string[] { const lines: string[] = []; let start = 0; for (let index = 0; index < text.length; index++) { if (text[index] !== "\n") continue; lines.push(text.slice(start, index)); start = index + 1; } lines.push(text.slice(start)); return lines; } function collectMathBlocks(text: string): string[] { const matches: string[] = []; let index = 0; while (index < text.length) { const start = text.indexOf("$$", index); if (start === -1) break; const end = text.indexOf("$$", start + 2); if (end === -1) break; if (end - start - 2 <= 10000) { matches.push(text.slice(start, end + 2)); index = end + 2; } else { index = start + 2; } } return matches; } function collectInlineMath(text: string): string[] { const matches: string[] = []; for (let index = 0; index < text.length; index++) { if (text[index] !== "$") continue; if (text[index - 1] === "$") continue; const next = text[index + 1]; if (!next || isPreviewWhitespace(next) || next === "$" || isAsciiDigit(next)) continue; const maxEnd = Math.min(text.length, index + 1 + 160); for (let cursor = index + 1; cursor < maxEnd; cursor++) { const char = text[cursor]; if (char === "\n") break; if (char === "\\") { cursor++; continue; } if (char !== "$") continue; if (isPreviewWhitespace(text[cursor - 1] ?? "")) continue; if (text[cursor + 1] === "$") continue; matches.push(text.slice(index, cursor + 1)); index = cursor; break; } } return matches; } function collectLatexBlocks(text: string): string[] { const matches: string[] = []; let index = 0; while (index < text.length) { const start = text.indexOf("\\begin{", index); if (start === -1) break; const envStart = start + "\\begin{".length; const envEnd = text.indexOf("}", envStart); if (envEnd === -1 || envEnd - envStart < 1 || envEnd - envStart > 50) { index = envStart; continue; } const env = text.slice(envStart, envEnd); if (![...env].every((char) => isAsciiLetter(char) || char === "*")) { index = envStart; continue; } const closeToken = `\\end{${env}}`; const closeStart = text.indexOf(closeToken, envEnd + 1); if (closeStart === -1) { index = envEnd + 1; continue; } if (closeStart - envEnd - 1 <= 10000) { const end = closeStart + closeToken.length; matches.push(text.slice(start, end)); index = end; } else { index = envEnd + 1; } } return matches; } function collectVersions(text: string): string[] { const matches: string[] = []; for (let index = 0; index < text.length; index++) { if (!isAsciiDigit(text[index])) continue; if (index > 0 && isWordChar(text[index - 1])) continue; let cursor = readDigits(text, index); let dotGroups = 0; while (dotGroups < 3 && text[cursor] === "." && isAsciiDigit(text[cursor + 1])) { cursor = readDigits(text, cursor + 1); dotGroups++; } if (dotGroups < 1) continue; if ((text[cursor] === "-" || text[cursor] === "+") && isVersionSuffixChar(text[cursor + 1])) { cursor++; while (cursor < text.length && isVersionSuffixChar(text[cursor])) { cursor++; } } if (isWordChar(text[cursor])) continue; matches.push(text.slice(index, cursor)); index = cursor - 1; } return matches; } function readDigits(text: string, start: number): number { let cursor = start; while (cursor < text.length && isAsciiDigit(text[cursor])) { cursor++; } return cursor; } function isVersionSuffixChar(char: string | undefined): boolean { return !!char && (isAsciiAlphaNumeric(char) || char === "." || char === "-"); } function collectConstCase(text: string): string[] { const matches: string[] = []; for (let index = 0; index < text.length; index++) { if (!isAsciiUpper(text[index])) continue; if (index > 0 && isWordChar(text[index - 1])) continue; let cursor = index + 1; let hasUnderscore = false; while (cursor < text.length) { const char = text[cursor]; if (char === "_") { if (!isAsciiUpper(text[cursor + 1]) && !isAsciiDigit(text[cursor + 1])) break; hasUnderscore = true; cursor++; continue; } if (!isAsciiUpper(char) && !isAsciiDigit(char)) break; cursor++; } if (!hasUnderscore || isWordChar(text[cursor])) continue; matches.push(text.slice(index, cursor)); index = cursor - 1; } return matches; }