Prettier errors

This commit is contained in:
Yura Dupyn 2026-02-07 01:24:12 +01:00
parent e389e46852
commit 3d1cd89067
6 changed files with 313 additions and 48 deletions

View file

@ -1,63 +1,214 @@
import * as readline from 'readline';
import { parse } from '../parser/parser';
import * as fs from 'fs';
import { parse, ParseError } from '../parser/parser';
import { SourceText, renderSpan, sourceText } from '../parser/source_text';
import { exprToString } from '../debug/expr_show';
import { valueToString } from '../debug/value_show';
import { eval_start, Program } from '../value';
import { Result } from '../result';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: '> '
});
// ANSI Color Codes
const C = {
Reset: "\x1b[0m",
Red: "\x1b[31m",
Green: "\x1b[32m",
Yellow: "\x1b[33m",
Blue: "\x1b[34m",
Gray: "\x1b[90m",
Bold: "\x1b[1m",
};
// We create one persistent program context
const program = Program.makeEmpty();
console.log("=== Evaluator REPL ===");
console.log("Input -> Parse -> Eval -> Value");
console.log("Ctrl+C to exit.\n");
rl.prompt();
rl.on('line', (lineInput) => {
const trimmed = lineInput.trim();
if (!trimmed) {
rl.prompt();
return;
}
function runSource(inputRaw: string, isRepl: boolean): boolean {
const input = inputRaw.trim();
if (!input) return true; // Empty lines are fine
try {
// === 1. PARSE ===
const parseResult = parse(trimmed);
// Wrap in SourceText
const text = sourceText(input);
// === Parse ===
const parseResult = parse(text);
if (parseResult.tag === "error") {
const err = parseResult.error;
console.log(`\n❌ [Parse Error]:`, err);
// (Optional: Reuse your line/col logic here)
rl.prompt();
return;
printPrettyError(text, parseResult.error);
return false; // Failed
}
const ast = parseResult.value;
console.log(`\nAST: ${exprToString(ast)}`);
console.log(`${C.Green}AST:${C.Reset} ${exprToString(ast)}`);
// === 2. EVALUATE ===
// === Eval ===
const evalResult = eval_start(program, ast);
if (evalResult.tag === "ok") {
console.log(`VAL: ${valueToString(evalResult.value)}`);
// Always print the result value
console.log(`${C.Blue}VAL:${C.Reset} ${valueToString(evalResult.value)}`);
return true;
} else {
const err = evalResult.error;
console.log(`\n🔥 [Runtime Error]:`, err);
// If your Runtime Errors have spans, use printPrettyError(text, err) here too!
return false;
}
} catch (e) {
console.log(`\n💥 [System Crash]:`);
console.log(e);
return false;
}
}
// === 3. Entry Point Logic ===
// Check for command line arguments (ignoring 'node' and 'script.ts')
const args = process.argv.slice(2);
if (args.length > 0) {
// === FILE MODE ===
const filePath = args[0];
if (!fs.existsSync(filePath)) {
console.error(`${C.Red}Error: File not found '${filePath}'${C.Reset}`);
process.exit(1);
}
console.log("");
rl.prompt();
});
const fileContent = fs.readFileSync(filePath, 'utf-8');
// Run the file
const success = runSource(fileContent, false);
// Exit with appropriate code
process.exit(success ? 0 : 1);
} else {
// === REPL MODE ===
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: '> '
});
console.log("=== Evaluator REPL ===");
console.log("Input -> Parse -> Eval -> Value");
console.log("Ctrl+C to exit.\n");
rl.prompt();
rl.on('line', (line) => {
runSource(line, true);
console.log(""); // Empty line for spacing
rl.prompt();
});
}
// === Formatting ===
// Helper to safely print code points (handling special chars like \n)
function formatChar(cp: number | undefined): string {
// Handle EOF (undefined) or invalid numbers safely
if (cp === undefined || Number.isNaN(cp)) {
return "EOF";
}
const s = String.fromCodePoint(cp);
if (s === '\n') return "\\n";
if (s === '\r') return "\\r";
if (s === '\t') return "\\t";
return `'${s}'`;
}
function getErrorMessage(err: ParseError): string {
switch (err.tag) {
case "UnexpectedToken":
return `Unexpected token. Expected: ${err.expected}`;
case "UnexpectedTokenWhileParsingSequence":
return `Unexpected token in sequence. Expected delimiter ${formatChar(err.expectedDelimiter)} or terminator ${formatChar(err.expectedTerminator)}, but found ${formatChar(err.received)}.`;
case "UnexpectedCharacter":
return `Unexpected character: ${formatChar(err.char)}`;
case "UnexpectedEOF":
return "Unexpected end of file.";
case "ExpectedNumber":
return "Expected a number here.";
case "InvalidNumber":
return err.reason === "NotFinite"
? "Number is too large or invalid."
: "Invalid number format (missing fractional digits?).";
case "InvalidIdentifier":
// Handle nested reasons if needed, e.g. "Keyword 'let' cannot be used as an identifier"
return `Invalid identifier '${err.text}': ${err.reason.tag}`;
case "InvalidEscape":
switch (err.reason.tag) {
case "UnknownEscapeSequence": return `Unknown escape sequence: \\${formatChar(err.reason.char)}`;
case "UnicodeMissingBrace": return "Unicode escape missing opening brace '{'.";
case "UnicodeNoDigits": return "Unicode escape missing hex digits.";
case "UnicodeUnclosed": return "Unicode escape missing closing brace '}'.";
case "UnicodeOverflow": return `Unicode code point ${err.reason.value.toString(16)} is out of bounds.`;
}
return "Invalid escape sequence.";
// Context specific errors
case "ExpectedExpression": return "Expected an expression here.";
case "ExpectedFieldAssignmentSymbol": return "Expected '=' for field assignment.";
case "ExpectedPatternAssignmentSymbol": return "Expected '=' for pattern assignment.";
case "ExpectedPatternBindingSymbol": return "Expected '.' for pattern binding.";
case "ExpectedFunctionCallStart": return "Expected '(' to start function call.";
case "ExpectedRecordOpen": return "Expected '(' to start record.";
case "ExpectedLetBlockOpen": return "Expected '{' to start let-block.";
case "ExpectedLetBlockClose": return "Expected '}' to close let-block.";
case "ExpectedMatchBlockOpen": return "Expected '{' to start match-block.";
case "ExpectedMatchBlockClose": return "Expected '}' to close match-block.";
case "ExpectedLambdaBlockOpen": return "Expected '{' to start lambda body.";
case "ExpectedLambdaBlockClose": return "Expected '}' to close lambda body.";
case "ExpectedApplyStart": return "Expected '(' after 'apply'.";
case "ExpectedApplySeparator": return "Expected '!' inside 'apply'.";
case "UnexpectedTagPattern": return "Unexpected tag pattern (expected product pattern).";
case "ExpectedPattern": return "Expected a pattern here.";
case "ExpectedRecordPatternOpen": return "Expected '(' for record pattern.";
case "ExpectedRecordField": return "Expected a field name in record pattern.";
default:
return `Unknown error: ${(err as any).tag}`;
}
}
function printPrettyError(text: SourceText, err: ParseError) {
const msg = getErrorMessage(err);
console.log(`\n${C.Red}${C.Bold}Parse Error:${C.Reset} ${C.Bold}${msg}${C.Reset}`);
// Use your new renderSpan function
// We request 1 line of context before/after
const views = renderSpan(text, err.span, 3);
for (const view of views) {
// Format the gutter (line number)
// e.g. " 10 | "
const gutter = `${C.Blue}${view.gutterPad}${view.lineNo} | ${C.Reset}`;
// Reconstruct the line with highlighting
// prefix (gray) + highlight (red/bold) + suffix (gray)
const code =
`${C.Gray}${view.prefix}${C.Reset}` +
`${C.Red}${C.Bold}${view.highlight}${C.Reset}` +
`${C.Gray}${view.suffix}${C.Reset}`;
console.log(`${gutter}${code}`);
// Render the underline if this line contains the error
if (view.underline.trim().length > 0) {
const emptyGutter = " ".repeat(view.gutterPad.length + String(view.lineNo).length + 3); // match " N | "
console.log(`${emptyGutter}${C.Red}${C.Bold}${view.underline}${C.Reset}`);
}
}
}

View file

@ -70,6 +70,7 @@ export class Cursor {
return this.index;
}
// TODO: unicode-index ~> string-offset, make that into a separate function.
currentOffset(): StringIndex {
return this.text.chars[this.index]?.offset ?? this.text.source.length;
}
@ -79,15 +80,9 @@ export class Cursor {
}
makeSpan(start: SourceLocation): Span {
const startOffset =
this.text.chars[start.index]?.offset ?? this.text.source.length;
const endOffset = this.currentOffset();
return {
start: startOffset,
end: endOffset,
line: start.line,
column: start.column,
start,
end: this.currentLocation(),
};
}

View file

@ -444,8 +444,7 @@ function recordPatternField(cursor: Cursor): FieldPattern {
}
export function parse(input: string): Result<Expr, ParseError> {
const source = new SourceText(input);
export function parse(source: SourceText): Result<Expr, ParseError> {
const cursor = new Cursor(source);
try {

View file

@ -93,6 +93,21 @@ export class SourceText {
// TODO: Consider removing \r or \n from the end if they exist.
return this.sliceByCp(startCp, endCp);
}
getLineRange(line: number): { start: CodePointIndex, end: CodePointIndex } {
const lineIndex = line - 1;
if (lineIndex < 0 || lineIndex >= this.lineStarts.length) {
// TODO: This is a bit suspicious. Maybe return undefined?
return { start: 0, end: 0 };
}
const start = this.lineStarts[lineIndex];
const end = (lineIndex + 1 < this.lineStarts.length)
? this.lineStarts[lineIndex + 1]
: this.chars.length;
return { start, end };
}
}
export function sourceText(s: string) {
@ -100,17 +115,15 @@ export function sourceText(s: string) {
}
export type Span = {
start: StringIndex,
end: StringIndex,
line: number,
column: number,
start: SourceLocation;
end: SourceLocation;
}
export type SourceLocation = {
index: CodePointIndex;
line: number; // 1-based
column: number; // 1-based
};
}
// Whitespace
export const NEW_LINE: CodePoint = char('\n');
@ -130,3 +143,97 @@ export const UPPERCASE_A: CodePoint = char('A');
export const LOWERCASE_f: CodePoint = char('f');
export const UPPERCASE_F: CodePoint = char('F');
// === Rendering Utilities ===
export type LineView = {
lineNo: number;
sourceLine: string; // The full raw text of the line
// These split the line into 3 parts for coloring:
// prefix | highlight | suffix
prefix: string;
highlight: string;
suffix: string;
// Helpers for underlines (e.g., " ^^^^^")
gutterPad: string; // Padding to align line numbers
underline: string; // The literal "^^^" string for CLI usage
};
export function renderSpan(text: SourceText, span: Span, contextLines = 1): LineView[] {
const views: LineView[] = [];
// Determine range of lines to show (including context)
const startLine = Math.max(1, span.start.line - contextLines);
const endLine = Math.min(text.lineStarts.length, span.end.line + contextLines);
// Calculate the max width of line numbers for nice padding (e.g. " 9 |" vs " 10 |")
const maxLineNoWidth = endLine.toString().length;
for (let lineNo = startLine; lineNo <= endLine; lineNo++) {
const lineRange = text.getLineRange(lineNo);
// We strip the trailing newline for display purposes
let lineRaw = text.sliceByCp(lineRange.start, lineRange.end);
if (lineRaw.endsWith('\n') || lineRaw.endsWith('\r')) {
lineRaw = lineRaw.trimEnd();
}
// Determine the intersection of the Span with this specific Line
// 1. Where does the highlight start on this line?
// If this is the start line, use span.column. Otherwise start at 0 (beginning of line)
// We subtract 1 because columns are 1-based, string indices are 0-based.
const highlightStartCol = (lineNo === span.start.line)
? span.start.column - 1
: 0;
// 2. Where does the highlight end on this line?
// If this is the end line, use span.column. Otherwise end at the string length.
const highlightEndCol = (lineNo === span.end.line)
? span.end.column - 1
: lineRaw.length;
// Logic to distinguish context lines from error lines
const isErrorLine = lineNo >= span.start.line && lineNo <= span.end.line;
let prefix = "", highlight = "", suffix = "";
if (isErrorLine) {
// Clamp indices to bounds (safety)
const safeStart = Math.max(0, Math.min(highlightStartCol, lineRaw.length));
const safeEnd = Math.max(0, Math.min(highlightEndCol, lineRaw.length));
prefix = lineRaw.substring(0, safeStart);
highlight = lineRaw.substring(safeStart, safeEnd);
suffix = lineRaw.substring(safeEnd);
} else {
// Pure context line
prefix = lineRaw;
}
// Build the "underline" string (e.g., " ^^^^")
// Note: This naive approach assumes monospaced fonts and no fancy unicode widths,
// which usually holds for code.
let underline = "";
if (isErrorLine) {
// Spaces for prefix
underline += " ".repeat(prefix.length);
// Carets for highlight (ensure at least 1 if it's a zero-width cursor position)
const hlLen = Math.max(1, highlight.length);
underline += "^".repeat(hlLen);
}
views.push({
lineNo,
sourceLine: lineRaw,
prefix,
highlight,
suffix,
gutterPad: " ".repeat(maxLineNoWidth - lineNo.toString().length),
underline
});
}
return views;
}

11
tmp_repl/test.flux Normal file
View file

@ -0,0 +1,11 @@
let {
foo = "some important message" .
fn { x, y .
x // this should be an error
}
}

View file

@ -17,3 +17,5 @@ npx ts-node src/parser/cursor.test.ts
npx ts-node src/debug/repl.ts
npx ts-node src/debug/repl.ts tmp_repl/test.flux