667 lines
18 KiB
TypeScript
667 lines
18 KiB
TypeScript
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";
|
||
}
|