Files
regex-tools/src/regex/comparison/compare-results.ts

667 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T>(
values: readonly T[],
used: Set<number>,
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<number>();
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<number>();
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";
}