Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/lang/expression/expression.lang.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MatchKind } from "../../mod.ts";
import { MatchKind, visualizeMatchFailure } from "../../mod.ts";
import { expr } from "./expression.lang.ts";
import { assertEquals } from "@std/assert";

Expand Down Expand Up @@ -45,3 +45,37 @@ Deno.test(
});
},
);

// Integration test for invalid expression parsing with visualization
// This test verifies that:
// 1. The expression "(add 1 #)" fails to parse (# is not a valid expression)
// 2. The visualizeMatchFailure function shows the hierarchical pattern structure
// 3. Pipeline steps are clearly denoted (Tokenizer -> Expression)
// 4. The rightmost failure points to the # character
Deno.test(
{
name: "lang.expression.invalid",
ignore: p.state !== "granted",
},
async (t) => {
await t.step({
name:
"EXPR_LANG_INVALID_00 - should fail with invalid expression (add 1 #)",
fn: async () => {
const m = await expr("(add 1 #)");
// Expecting the match to fail because "#" is not a valid expression
assertEquals(m.kind, MatchKind.Fail);

// Visualize the failure for debugging
// Expected output should show:
// - Module URL
// - Parse failure position (pointing to # character)
// - Pipeline with Tokenizer (success) and Expression (failure) steps
// - Hierarchical pattern tree showing where parsing failed
// - Input context showing the # character
console.log("\nVisualized Match Failure:");
console.log(visualizeMatchFailure(m));
},
});
},
);
1 change: 1 addition & 0 deletions src/lang/expression/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const Expression: ModuleDeclaration = {
{
kind: ImportDeclarationKind.Module,
moduleUrl: "../tokenizer/mod.ts",
names: ["Tokenizer"],
},
],
exports: [],
Expand Down
4 changes: 2 additions & 2 deletions src/lang/expression/primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
TerminalExpression,
} from "../../runtime/expressions/expression.ts";

export const Terminal: ModuleDeclaration = {
export const Primary: ModuleDeclaration = {
imports: [
{
kind: ImportDeclarationKind.Module,
Expand Down Expand Up @@ -71,4 +71,4 @@ export const Terminal: ModuleDeclaration = {
],
};

export default Terminal;
export default Primary;
2 changes: 1 addition & 1 deletion src/match.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assertEquals } from "@std/assert";
import { fail, getRightmostFailure, MatchKind, MatchOk } from "./match.ts";
import { fail, getRightmostFailure, MatchKind, type MatchOk } from "./match.ts";
import { Path } from "./path.ts";
import { Scope } from "./runtime/scope.ts";
import { Input } from "./input.ts";
Expand Down
256 changes: 256 additions & 0 deletions src/match.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { Pattern } from "./runtime/patterns/pattern.ts";
import { PatternKind } from "./runtime/patterns/pattern.kind.ts";
import type { Scope } from "./runtime/scope.ts";
import { type Span, spanFrom } from "./span.ts";
import { Type, type } from "@justinmchase/type";
import { StackFrameKind } from "./runtime/stack/stackFrameKind.ts";
import type { Module, Rule } from "./runtime/modules/mod.ts";
import type { Path } from "./path.ts";

export enum MatchErrorCode {
UnknownReference = "E_UNKNOWN_REFERENCE",
Expand Down Expand Up @@ -146,3 +151,254 @@ export function getRightmostFailure(match: MatchFail): MatchFail {

return rightmost;
}

/**
* Visualizes a match failure in a human-readable format for debugging.
* Displays hierarchical pattern structure, values matched/not matched,
* pipeline steps, and source location information.
*
* @param match The Match to visualize (typically a MatchFail)
* @returns A formatted string representation of the failure
*
* @example
* ```ts
* const result = match(pattern, scope);
* if (result.kind === MatchKind.Fail) {
* console.log(visualizeMatchFailure(result));
* }
* ```
*/
export function visualizeMatchFailure(match: Match): string {
const output: string[] = [];
const visited = new WeakSet<Match>();

// Get module and file information if available
if (match.scope) {
const moduleUrl = match.scope.module?.moduleUrl;
if (moduleUrl) {
output.push(`Module: ${moduleUrl}\n`);
}
}

// Find and highlight the rightmost failure
let rightmostFailure: MatchFail | undefined;
if (match.kind === MatchKind.Fail) {
rightmostFailure = getRightmostFailure(match);
const pos = rightmostFailure.span.start.toString();
const scope = rightmostFailure.scope;

// Get the actual value at the failure position
let valueStr = "<no value>";
if (scope.stream) {
const next = scope.stream.next();
if (next.done) {
valueStr = "<end of input>";
} else {
valueStr = formatValue(next.value);
}
}

output.push(`\n🔴 Parse failed at position: ${pos}\n`);
output.push(` Found: ${valueStr}\n`);
}

output.push("\n--- Match Stack ---\n");
let module: Module | undefined;
let rule: Rule | undefined;
for (const m of rightmostFailure?.scope.stack ?? []) {
if (m.kind === StackFrameKind.Module) {
module = m.module;
} else if (m.kind === StackFrameKind.Rule) {
rule = m.rule;
output.push(
` at ${rule.name}@${module?.moduleUrl ?? "[unknown module]"}\n`,
);
}
}

output.push("\n--- Match Tree ---\n");

function visualizeMatch(
m: Match,
indent = 0,
rule: Rule | undefined = undefined,
): Path {
// Get pattern name/kind
const prefix = " ".repeat(indent);
const patternName = getPatternName(m.pattern);
let deepest = m.scope.stream.path;

// Prevent infinite loops from circular references
if (visited.has(m)) {
output.push(`${prefix}➰ ${patternName} [circular reference]\n`);
return deepest;
}
visited.add(m);

switch (m.kind) {
case MatchKind.Ok: {
const valueStr = formatValue(m.value);
output.push(
`${prefix}✓ ${patternName} → ${valueStr}\n`,
);
break;
}
case MatchKind.Fail: {
// Rules can be left recursive, don't visualize that here
if (m.pattern === m.scope.rule?.pattern && m.scope.rule !== rule) {
output.push(
`${prefix}✗ ${m.scope.rule.name}\n`,
);

for (const child of m.matches) {
if (child.scope.stream.path.compareTo(deepest) >= 0) {
deepest = visualizeMatch(child, indent + 1, m.scope.rule);
}
}
} else if (m === rightmostFailure) {
// Show what value was found at the failure point
let foundValue = "";
const next = m.scope.stream.next();
if (!next.done) {
foundValue = ` (found: ${formatValue(next.value)})`;
} else {
foundValue = " (found: <end of input>)";
}
output.push(
`${prefix.slice(0, -3)}👉 ✗ ${patternName}${foundValue}\n`,
);
} else {
for (const child of m.matches) {
if (child.scope.stream.path.compareTo(deepest) >= 0) {
deepest = visualizeMatch(child, indent, m.scope.rule);
}
}
}
break;
}
case MatchKind.Error: {
output.push(
`${prefix}⚠ ${patternName}: ${m.code} - ${m.message}\n`,
);
break;
}
case MatchKind.LR: {
output.push(
`${prefix}↻ ${patternName} (left recursion)\n`,
);
break;
}
}

return deepest;
}

visualizeMatch(match);

return output.join("");
}

function getPatternName(pattern: Pattern): string {
switch (pattern.kind) {
case PatternKind.Reference:
return pattern.name;
case PatternKind.Pipeline:
return `Pipeline`;
case PatternKind.Then:
return "Then";
case PatternKind.Or:
return "Or";
case PatternKind.And:
return "And";
case PatternKind.Maybe:
return "Maybe";
case PatternKind.Slice:
return "Slice";
case PatternKind.Equal:
return `Equal(${formatValue(pattern.value)})`;
case PatternKind.Includes:
return "Includes";
case PatternKind.Range:
return "Range";
case PatternKind.RegExp:
return `RegExp(/${pattern.pattern}/)`;
case PatternKind.Type:
return `Type(${pattern.type})`;
case PatternKind.Variable:
return `Variable(${pattern.name}, ${getPatternName(pattern.pattern)})`;
case PatternKind.Run:
return pattern.name ? `Run(${pattern.name})` : "Run";
case PatternKind.Character:
return `Character(${pattern.characterClass})`;
case PatternKind.Special:
return `Special(${pattern.name})`;
case PatternKind.Into:
return "Into";
case PatternKind.Over:
return "Over";
case PatternKind.Not:
return "Not";
case PatternKind.Any:
return "Any";
case PatternKind.Ok:
return "Ok";
case PatternKind.Fail:
return "Fail";
case PatternKind.End:
return "End";
default:
return "Unknown";
}
}

function formatValue(value: unknown): string {
if (value === undefined) return "<undefined>";
if (value === null) return "<null>";

const [t, v] = type(value);
switch (t) {
case Type.Null:
return "<null>";
case Type.Undefined:
return "<undefined>";
case Type.Function:
return `<function ${v.name ?? "anonymous"}>`;
case Type.Error:
return `<error ${v.message}>`;
case Type.Map:
return `<map>`;
case Type.Set:
return `<set>`;
case Type.String:
return JSON.stringify(v.length > 50 ? v.substring(0, 50) + "..." : v);
case Type.Number:
case Type.Boolean:
case Type.BigInt:
return String(value);
case Type.Symbol:
return (value as symbol).toString();
case Type.Date:
return (value as Date).toISOString();
case Type.Array: {
const arr = value as unknown[];
if (arr.length === 0) return "[]";
if (arr.length > 3) {
return `[${arr.slice(0, 5).map(formatValue).join(", ")}, ... (${
arr.length - 5
} items)]`;
}
return `[${arr.map(formatValue).join(", ")}]`;
}
case Type.Object: {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) return "{}";
if (keys.length > 3) {
return `{${keys.slice(0, 10).join(", ")}, ... (${keys.length} keys)}`;
}
return JSON.stringify(value);
}
default:
return String(value);
}
}
4 changes: 4 additions & 0 deletions src/runtime/patterns/pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,7 @@ export type VariablePattern = {
name: string;
pattern: Pattern;
};

export function isPipeline(pattern: Pattern): pattern is PipelinePattern {
return pattern.kind === PatternKind.Pipeline;
}
8 changes: 4 additions & 4 deletions src/runtime/patterns/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function pipeline(pattern: PipelinePattern, scope: Scope) {
let next = scope;
const matches: Match[] = [];
for (let i = 0; i < steps.length; i++) {
const pattern = steps[i];
const p = steps[i];

// The first pipeline step should operate on the original scope and stream
// Its ok if it doesn't completely consume the entire stream, there may be more
Expand All @@ -20,15 +20,15 @@ export function pipeline(pattern: PipelinePattern, scope: Scope) {
// However steps beyond the first in the pipeline will operate like an entire pattern
// match operation which will require the entire stream to be read.

next = next.pushPipeline(pattern);
const m = match(pattern, next);
next = next.pushPipeline(p);
const m = match(p, next);
matches.push(m);
switch (m.kind) {
case MatchKind.LR:
case MatchKind.Error:
return m;
case MatchKind.Fail:
return fail(scope, pattern, matches);
return fail(scope, p, matches);
case MatchKind.Ok:
last = m;
break;
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function rule(
case MatchKind.Error:
return m;
case MatchKind.Fail:
return fail(scope, rule.pattern, [m]);
return fail(subScope, rule.pattern, [m]);
case MatchKind.Ok: {
const value = expression ? exec(expression, m) : m.value;
return ok(
Expand Down
Loading
Loading