From 3d1cd8906773464488450b425e95864098742dbf Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Sat, 7 Feb 2026 01:24:12 +0100 Subject: [PATCH] Prettier errors --- src/debug/repl.ts | 217 ++++++++++++++++++++++++++++++++------ src/parser/cursor.ts | 11 +- src/parser/parser.ts | 3 +- src/parser/source_text.ts | 117 +++++++++++++++++++- tmp_repl/test.flux | 11 ++ tmp_repl/tmp_repl.md | 2 + 6 files changed, 313 insertions(+), 48 deletions(-) create mode 100644 tmp_repl/test.flux diff --git a/src/debug/repl.ts b/src/debug/repl.ts index 7bc968d..3980e10 100644 --- a/src/debug/repl.ts +++ b/src/debug/repl.ts @@ -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}`); + } + } +} diff --git a/src/parser/cursor.ts b/src/parser/cursor.ts index 7a01cc4..a5a5e5f 100644 --- a/src/parser/cursor.ts +++ b/src/parser/cursor.ts @@ -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(), }; } diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 96f1f36..fec1a14 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -444,8 +444,7 @@ function recordPatternField(cursor: Cursor): FieldPattern { } -export function parse(input: string): Result { - const source = new SourceText(input); +export function parse(source: SourceText): Result { const cursor = new Cursor(source); try { diff --git a/src/parser/source_text.ts b/src/parser/source_text.ts index 09efa7e..bea2545 100644 --- a/src/parser/source_text.ts +++ b/src/parser/source_text.ts @@ -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; +} diff --git a/tmp_repl/test.flux b/tmp_repl/test.flux new file mode 100644 index 0000000..d1286fd --- /dev/null +++ b/tmp_repl/test.flux @@ -0,0 +1,11 @@ + + + +let { + foo = "some important message" . + + fn { x, y . + x // this should be an error + } + +} diff --git a/tmp_repl/tmp_repl.md b/tmp_repl/tmp_repl.md index ed9fdf3..f4d86eb 100644 --- a/tmp_repl/tmp_repl.md +++ b/tmp_repl/tmp_repl.md @@ -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 +