import type { CaptureResult, RegexExecutionResult, RegexMatchResult, } from "../model/match"; import type { CaptureAlignment, ComparisonDifference, ComparisonDifferenceKind, ComparisonFlavour, ComparisonNotComparableReason, ComparisonSideOutcome, MatchAlignment, RegexComparisonRequest, RegexComparisonResult, } from "./comparison.types"; const MAXIMUM_RETAINED_DIFFERENCES = 5_000; const MAXIMUM_RETAINED_MATCH_ALIGNMENTS = 2_000; const MAXIMUM_DIFFERENCE_VALUE_UTF16 = 1_024; function differenceValue(value: string | undefined): string | undefined { if (value === undefined || value.length <= MAXIMUM_DIFFERENCE_VALUE_UTF16) { return value; } return `${value.slice(0, MAXIMUM_DIFFERENCE_VALUE_UTF16)}… (${value.length.toLocaleString()} UTF-16 units; complete value retained in its side result)`; } class DifferenceCollector { readonly retained: ComparisonDifference[] = []; total = 0; semanticTotal = 0; add( kind: ComparisonDifferenceKind, summary: string, left?: string, right?: string, ): void { this.total += 1; if (kind !== "effective-flags" && kind !== "offset-model") { this.semanticTotal += 1; } if (this.retained.length >= MAXIMUM_RETAINED_DIFFERENCES) return; this.retained.push({ kind, summary, ...(left === undefined ? {} : { left }), ...(right === undefined ? {} : { right }), }); } } function rangeKey( value: | { readonly range?: { readonly startUtf16: number; readonly endUtf16: number; }; } | undefined, ): string | undefined { const range = value?.range; return range ? `${range.startUtf16}:${range.endUtf16}` : undefined; } function displayRange( value: | { readonly range?: { readonly startUtf16: number; readonly endUtf16: number; }; } | undefined, ): string { const range = value?.range; return range ? `${range.startUtf16}–${range.endUtf16}` : "unavailable"; } function captureIdentity(capture: CaptureResult): string { return capture.groupName ? `name:${capture.groupName}` : `number:${capture.groupNumber}`; } function takeMatching( values: readonly T[], used: Set, predicate: (value: T) => boolean, ): { readonly value: T; readonly index: number } | undefined { for (let index = 0; index < values.length; index += 1) { if (used.has(index)) continue; const value = values[index]; if (value !== undefined && predicate(value)) return { value, index }; } return undefined; } function captureEqual(left: CaptureResult, right: CaptureResult): boolean { return ( left.groupNumber === right.groupNumber && left.groupName === right.groupName && left.status === right.status && rangeKey(left) === rangeKey(right) && left.value === right.value ); } function compareCapture( left: CaptureResult | undefined, right: CaptureResult | undefined, alignment: CaptureAlignment["alignment"], differences: DifferenceCollector, ): CaptureAlignment { if (!left || !right) { differences.add( "capture-presence", "A capture is present on only one side.", left ? captureIdentity(left) : "absent", right ? captureIdentity(right) : "absent", ); return { alignment, ...(left ? { left } : {}), ...(right ? { right } : {}), equal: false, }; } if ( left.groupNumber !== right.groupNumber || left.groupName !== right.groupName ) { differences.add( "capture-identity", "Aligned captures have different group identities.", captureIdentity(left), captureIdentity(right), ); } if (left.status !== right.status) { differences.add( "capture-status", "Aligned captures have different participation states.", left.status, right.status, ); } if (rangeKey(left) !== rangeKey(right)) { differences.add( "capture-range", "Aligned captures have different normalized UTF-16 ranges.", displayRange(left), displayRange(right), ); } if (left.value !== right.value) { differences.add( "capture-value", "Aligned captures have different retained values.", differenceValue(left.value) ?? "unavailable", differenceValue(right.value) ?? "unavailable", ); } return { alignment, left, right, equal: captureEqual(left, right), }; } function alignCaptures( left: readonly CaptureResult[], right: readonly CaptureResult[], differences: DifferenceCollector, ): readonly CaptureAlignment[] { const alignments: CaptureAlignment[] = []; const usedRight = new Set(); const deferredLeft: CaptureResult[] = []; for (const capture of left) { const key = rangeKey(capture); const sameRange = key ? (takeMatching( right, usedRight, (candidate) => rangeKey(candidate) === key && captureIdentity(candidate) === captureIdentity(capture), ) ?? takeMatching( right, usedRight, (candidate) => rangeKey(candidate) === key, )) : undefined; if (!sameRange) { deferredLeft.push(capture); continue; } usedRight.add(sameRange.index); alignments.push( compareCapture(capture, sameRange.value, "normalized-range", differences), ); } const stillDeferred: CaptureResult[] = []; for (const capture of deferredLeft) { const sameIdentity = takeMatching( right, usedRight, (candidate) => captureIdentity(candidate) === captureIdentity(capture), ); if (!sameIdentity) { stillDeferred.push(capture); continue; } usedRight.add(sameIdentity.index); alignments.push( compareCapture( capture, sameIdentity.value, "capture-identity", differences, ), ); } const remainingRight = right .map((value, index) => ({ value, index })) .filter(({ index }) => !usedRight.has(index)); const paired = Math.min(stillDeferred.length, remainingRight.length); for (let index = 0; index < paired; index += 1) { const leftCapture = stillDeferred[index]; const rightCapture = remainingRight[index]; if (!leftCapture || !rightCapture) continue; usedRight.add(rightCapture.index); alignments.push( compareCapture( leftCapture, rightCapture.value, "ordinal-fallback", differences, ), ); } for (const capture of stillDeferred.slice(paired)) { alignments.push( compareCapture(capture, undefined, "left-only", differences), ); } for (const { value, index } of remainingRight.slice(paired)) { usedRight.add(index); alignments.push( compareCapture(undefined, value, "right-only", differences), ); } return alignments; } function matchEqual( left: RegexMatchResult, right: RegexMatchResult, captures: readonly CaptureAlignment[], ): boolean { return ( rangeKey(left) === rangeKey(right) && left.value === right.value && left.valueStatus === right.valueStatus && captures.every((capture) => capture.equal) ); } function compareMatch( left: RegexMatchResult | undefined, right: RegexMatchResult | undefined, alignment: MatchAlignment["alignment"], differences: DifferenceCollector, ): MatchAlignment { if (!left || !right) { differences.add( "match-presence", "A match is present on only one side.", left ? `match ${left.matchNumber} at ${displayRange(left)}` : "absent", right ? `match ${right.matchNumber} at ${displayRange(right)}` : "absent", ); return { alignment, ...(left ? { left } : {}), ...(right ? { right } : {}), captures: [], equal: false, }; } if (rangeKey(left) !== rangeKey(right)) { differences.add( "match-range", "Aligned matches have different normalized UTF-16 ranges.", displayRange(left), displayRange(right), ); } if (left.value !== right.value || left.valueStatus !== right.valueStatus) { differences.add( "match-value", "Aligned matches have different retained values.", differenceValue(left.value), differenceValue(right.value), ); } const captures = alignCaptures(left.captures, right.captures, differences); return { alignment, left, right, captures, equal: matchEqual(left, right, captures), }; } function alignMatches( left: readonly RegexMatchResult[], right: readonly RegexMatchResult[], differences: DifferenceCollector, ): readonly MatchAlignment[] { const alignments: MatchAlignment[] = []; const usedRight = new Set(); const deferredLeft: RegexMatchResult[] = []; for (const match of left) { const key = rangeKey(match); const sameRange = takeMatching( right, usedRight, (candidate) => rangeKey(candidate) === key, ); if (!sameRange) { deferredLeft.push(match); continue; } usedRight.add(sameRange.index); alignments.push( compareMatch(match, sameRange.value, "normalized-range", differences), ); } const remainingRight = right .map((value, index) => ({ value, index })) .filter(({ index }) => !usedRight.has(index)); const paired = Math.min(deferredLeft.length, remainingRight.length); for (let index = 0; index < paired; index += 1) { const leftMatch = deferredLeft[index]; const rightMatch = remainingRight[index]; if (!leftMatch || !rightMatch) continue; usedRight.add(rightMatch.index); alignments.push( compareMatch( leftMatch, rightMatch.value, "ordinal-fallback", differences, ), ); } for (const match of deferredLeft.slice(paired)) { alignments.push(compareMatch(match, undefined, "left-only", differences)); } for (const { value, index } of remainingRight.slice(paired)) { usedRight.add(index); alignments.push(compareMatch(undefined, value, "right-only", differences)); } return alignments; } function executionFor( side: ComparisonSideOutcome, ): RegexExecutionResult | undefined { return side.runtime.replacement?.execution ?? side.runtime.execution; } function addReason( values: ComparisonNotComparableReason[], reason: ComparisonNotComparableReason, ): void { if ( values.some( (candidate) => candidate.code === reason.code && candidate.flavour === reason.flavour && candidate.message === reason.message, ) ) { return; } values.push(reason); } function taskReasons( side: ComparisonSideOutcome, reasons: ComparisonNotComparableReason[], ): void { const flavour = side.input.flavour; if (side.syntax.status === "timeout") { addReason(reasons, { code: "syntax-timeout", flavour, message: `${flavour} syntax parsing timed out.`, }); } else if (side.syntax.status !== "complete") { addReason(reasons, { code: "syntax-worker-failed", flavour, message: `${flavour} syntax parsing did not complete: ${side.syntax.error ?? side.syntax.status}.`, }); } else if ( side.syntax.pattern?.accepted === false || side.syntax.replacement?.accepted === false ) { addReason(reasons, { code: "syntax-rejected", flavour, message: `${flavour} syntax provider rejected the exact pattern or replacement snapshot.`, }); } if (side.runtime.status === "timeout") { addReason(reasons, { code: "execution-timeout", flavour, message: `${flavour} execution timed out; timeout is not a no-match result.`, }); return; } if (side.runtime.status === "cancelled") { addReason(reasons, { code: "execution-cancelled", flavour, message: `${flavour} execution was cancelled and has no semantic result.`, }); return; } if (side.runtime.status !== "complete") { addReason(reasons, { code: "execution-worker-failed", flavour, message: `${flavour} worker did not complete: ${side.runtime.error ?? side.runtime.status}.`, }); return; } const execution = executionFor(side); if (!execution?.accepted) { addReason(reasons, { code: "compile-rejected", flavour, message: `${flavour} engine rejected the exact pattern snapshot.`, }); return; } if (execution.truncated) { addReason(reasons, { code: "result-truncated", flavour, message: `${flavour} results reached a configured match or capture limit.`, }); } if ( execution.matches.some( (match) => match.valueStatus === "truncated" || match.captures.some((capture) => capture.status === "truncated"), ) ) { addReason(reasons, { code: "value-truncated", flavour, message: `${flavour} retained value previews are incomplete.`, }); } if (side.runtime.replacement?.truncated) { addReason(reasons, { code: "replacement-truncated", flavour, message: `${flavour} replacement output or its match collection is incomplete.`, }); } } function compareMetadata( left: ComparisonSideOutcome, right: ComparisonSideOutcome, differences: DifferenceCollector, ): void { const leftSyntax = left.syntax.pattern; const rightSyntax = right.syntax.pattern; if ( leftSyntax && rightSyntax && leftSyntax.accepted !== rightSyntax.accepted ) { differences.add( "syntax-acceptance", "The syntax providers disagree on acceptance.", leftSyntax.accepted ? "accepted" : "rejected", rightSyntax.accepted ? "accepted" : "rejected", ); } const leftExecution = executionFor(left); const rightExecution = executionFor(right); if ( leftExecution && rightExecution && leftExecution.accepted !== rightExecution.accepted ) { differences.add( "compile-acceptance", "The execution engines disagree on compilation.", leftExecution.accepted ? "accepted" : "rejected", rightExecution.accepted ? "accepted" : "rejected", ); } if ( leftExecution && rightExecution && leftExecution.flags.effectiveFlags !== rightExecution.flags.effectiveFlags ) { differences.add( "effective-flags", "The exact effective engine flags differ.", leftExecution.flags.effectiveFlags || "none", rightExecution.flags.effectiveFlags || "none", ); } if ( leftExecution && rightExecution && leftExecution.engine.offsetUnit !== rightExecution.engine.offsetUnit ) { differences.add( "offset-model", "Native offset units differ; alignments use normalized editor UTF-16 ranges.", leftExecution.engine.offsetUnit, rightExecution.engine.offsetUnit, ); } } export function compareCompletedResults( request: RegexComparisonRequest, startedAt: string, elapsedMs: number, sides: readonly [ComparisonSideOutcome, ComparisonSideOutcome], ): RegexComparisonResult { const [left, right] = sides; const differences = new DifferenceCollector(); const notComparable: ComparisonNotComparableReason[] = []; taskReasons(left, notComparable); taskReasons(right, notComparable); compareMetadata(left, right, differences); const leftExecution = executionFor(left); const rightExecution = executionFor(right); let allAlignments: readonly MatchAlignment[] = []; if (leftExecution?.accepted && rightExecution?.accepted) { if (leftExecution.matches.length !== rightExecution.matches.length) { differences.add( "match-count", "The engines returned different match counts.", leftExecution.matches.length.toLocaleString(), rightExecution.matches.length.toLocaleString(), ); } allAlignments = alignMatches( leftExecution.matches, rightExecution.matches, differences, ); const leftReplacement = left.runtime.replacement; const rightReplacement = right.runtime.replacement; if ( leftReplacement && rightReplacement && leftReplacement.output !== rightReplacement.output ) { differences.add( "replacement-output", "The engine-native replacement outputs differ.", differenceValue(leftReplacement.output), differenceValue(rightReplacement.output), ); } } const syntaxAcceptanceDiffers = left.syntax.pattern !== undefined && right.syntax.pattern !== undefined && left.syntax.pattern.accepted !== right.syntax.pattern.accepted; const compileAcceptanceDiffers = leftExecution !== undefined && rightExecution !== undefined && leftExecution.accepted !== rightExecution.accepted; if (syntaxAcceptanceDiffers || compileAcceptanceDiffers) { addReason(notComparable, { code: "flavour-specific-syntax", message: "At least one exact snapshot is accepted on only one side; no runtime equivalence claim is possible.", }); } const status = notComparable.length > 0 ? "not-comparable" : differences.semanticTotal === 0 ? "same-for-current-input" : "different-for-current-input"; const notices = status === "same-for-current-input" ? [ { confidence: "likely-equivalent-for-input" as const, message: "The completed, untruncated results agree for this subject only. This is test evidence, not a proof that the patterns are equivalent.", }, ] : status === "different-for-current-input" ? [ { confidence: "requires-manual-review" as const, message: "The exact engine results differ for this subject. Review syntax, flags, ranges and captures before porting.", }, ] : [ { confidence: compileAcceptanceDiffers || syntaxAcceptanceDiffers ? ("no-direct-equivalent" as const) : ("requires-manual-review" as const), message: "The run is not comparable because one or more authoritative results are incomplete or rejected.", }, ]; return { schemaVersion: 1, request, startedAt, elapsedMs, sides, status, notComparable, differences: differences.retained, totalDifferences: differences.total, differencesTruncated: differences.retained.length < differences.total, matchAlignments: allAlignments.slice(0, MAXIMUM_RETAINED_MATCH_ALIGNMENTS), totalMatchAlignments: allAlignments.length, matchAlignmentsTruncated: allAlignments.length > MAXIMUM_RETAINED_MATCH_ALIGNMENTS, notices, }; } export function comparisonSideLabel(flavour: ComparisonFlavour): string { return flavour === "ecmascript" ? "ECMAScript" : "PCRE2"; }