import { hasConflictMarkers, splitLines } from "../git/merge3"; import type { ConflictHunk } from "../git/merge3"; import { parseConflictText } from "~/lib/conflict-blocks"; import { assembleResolution } from "./candidates"; /** A candidate may reorder and lightly edit both sides, but not balloon past them. */ const GROWTH_FACTOR = 2; const GROWTH_SLACK = 4; const MIN_SIGNIFICANT_CHARS = 4; const BRACE_LANGUAGES = /\.(go|ts|tsx|js|jsx|mjs|cjs|java|kt|kts|c|h|cc|cpp|hpp|cs|swift|scala|php|json|jsonc)$/i; function bare(line: string): string { return line.replace(/\r?\n$/, "").trim(); } /** Lines that carry enough content that repeating them elsewhere is not a coincidence. */ function significant(line: string): boolean { const text = bare(line); return text.length >= MIN_SIGNIFICANT_CHARS && /[\p{L}\p{N}]/u.test(text); } function countLines(text: string): number { return splitLines(text).length; } /** Text outside every conflict block, i.e. what the resolution is spliced into. */ function outsideLines(content: string): Set { const parsed = parseConflictText(content); if (!parsed) return new Set(); return new Set(parsed.segments.flatMap((segment) => ("text" in segment ? splitLines(segment.text).map(bare) : []))); } /** * Bracket balance with strings, runes, raw strings and comments skipped. Not a parser, just enough to spot * a candidate that pasted half a declaration into the hunk. */ export function bracketsBalanced(text: string): boolean { const stack: string[] = []; const closer: Record = { "(": ")", "[": "]", "{": "}" }; let index = 0; while (index < text.length) { const char = text[index]; const next = text[index + 1]; if (char === "/" && next === "/") { const end = text.indexOf("\n", index); index = end === -1 ? text.length : end; } else if (char === "/" && next === "*") { const end = text.indexOf("*/", index + 2); index = end === -1 ? text.length : end + 2; } else if (char === '"' || char === "'" || char === "`") { index++; while (index < text.length && text[index] !== char) { if (char !== "`" && text[index] === "\n") break; index += text[index] === "\\" && char !== "`" ? 2 : 1; } index++; } else { if (char in closer) stack.push(closer[char]); else if (char === ")" || char === "]" || char === "}") { if (stack.pop() !== char) return false; } index++; } } return stack.length === 0; } function goImports(text: string): string[] { const imports: string[] = []; const blocks = text.matchAll(/^import\s*\(([\s\S]*?)^\)/gm); for (const block of blocks) imports.push(...[...block[1].matchAll(/"([^"\n]+)"/g)].map((match) => match[1])); for (const single of text.matchAll(/^import\s+(?:[\w.]+\s+)?"([^"\n]+)"/gm)) imports.push(single[1]); return imports; } /** Go structure the head side already satisfies and a resolution must not break. */ function goProblem(text: string): string | null { if ((text.match(/^package\s+\w+/gm) ?? []).length > 1) return "declares the package more than once"; const imports = goImports(text); if (new Set(imports).size !== imports.length) return "imports the same package twice"; return null; } function fileProblem(path: string, file: string, head: string | null): string | null { if (BRACE_LANGUAGES.test(path) && (head === null || bracketsBalanced(head)) && !bracketsBalanced(file)) { return "leaves brackets unbalanced"; } if (/\.go$/i.test(path) && (head === null || goProblem(head) === null)) return goProblem(file); return null; } export type ValidationInput = { path: string; content: string; hunks: ConflictHunk[]; index: number }; /** * Why a candidate for hunk `index` must not be offered, or null when it passes. It is checked in place: the * other hunks keep the head side, so the assembled file isolates what this candidate changes. */ export function candidateProblem(text: string, input: ValidationInput): string | null { const hunk = input.hunks[input.index]; if (!hunk) return "has no matching conflict"; if (hasConflictMarkers(text)) return "contains conflict markers"; if (countLines(text) > GROWTH_FACTOR * (countLines(hunk.ours) + countLines(hunk.theirs)) + GROWTH_SLACK) { return "is far larger than both sides together"; } const own = new Set([hunk.base, hunk.ours, hunk.theirs].flatMap((side) => splitLines(side).map(bare))); const outside = outsideLines(input.content); const copied = splitLines(text).filter( (line) => significant(line) && !own.has(bare(line)) && outside.has(bare(line)), ); if (copied.length > 0) return "repeats lines from outside the conflict"; const heads = input.hunks.map((entry) => entry.ours); const file = assembleResolution(input.content, input.hunks, heads.with(input.index, text)); if (file === null) return "does not assemble into a clean file"; return fileProblem(input.path, file, assembleResolution(input.content, input.hunks, heads)); }