Basic REPL in the web UI

This commit is contained in:
Yura Dupyn 2026-02-13 18:59:43 +01:00
parent e2354fb9ce
commit 182307a81f
8 changed files with 467 additions and 3 deletions

26
src/ui/App.tsx Normal file
View file

@ -0,0 +1,26 @@
import { createSignal } from 'solid-js';
import { ExprREPL } from './REPL';
function Hello() {
const [ count, setCount ] = createSignal(0);
return (
<div>
{ count() > 5 ? <span style={{ color: "red" }}>too damn high</span> : <span>{ count() }</span> }
<div>
<button onClick={ () => setCount((x: number) => x + 1) }>+</button>
<button onClick={ () => setCount((x: number) => Math.max(x - 1, 0)) }>-</button>
</div>
</div>
);
}
export default function App() {
return (
<div>
<Hello />
<ExprREPL />
</div>
);
}

108
src/ui/Expr.tsx Normal file
View file

@ -0,0 +1,108 @@
import { Expr, FieldAssignment, FieldPattern, Literal, Pattern, ProductPattern } from "../lang/expr";
export function Expression(prop: { expr: Expr }) {
return (
<code>{ exprToString(prop.expr) }</code>
);
}
export function exprToString(expr: Expr): string {
switch (expr.tag) {
case "literal":
return literalToString(expr.literal);
case "var_use":
return `\$${expr.name}`;
case "call":
return `${expr.name}(${expr.args.map(exprToString).join(", ")})`;
case "tuple":
return `(${expr.exprs.map(exprToString).join(", ")})`;
case "record": {
const fields = expr.fields.map(fieldAssignmentToString).join(", ");
return `{ ${fields} }`;
}
case "tag":
return `#${expr.tag_name}`;
case "tagged": {
const payload = exprToString(expr.expr);
return `#${expr.tag_name} ${payload}`;
}
case "let": {
const bindings = expr.bindings
.map(b => `${productPatternToString(b.pattern)} = ${exprToString(b.expr)}`)
.join(", ");
return `let { ${bindings} . ${exprToString(expr.body)} }`;
}
case "lambda": {
const params = expr.parameters.map(productPatternToString).join(", ");
return `fn { ${params} . ${exprToString(expr.body)} }`;
}
case "apply": {
const args = expr.args.map(exprToString).join(", ");
return `apply(${exprToString(expr.callee)} ! ${args})`;
}
case "match": {
const branches = expr.branches
.map(b => `${patternToString(b.pattern)} . ${exprToString(b.body)}`)
.join(" | ");
return `match ${exprToString(expr.arg)} { ${branches} }`;
}
}
}
// === Helpers ===
function literalToString(lit: Literal): string {
switch (lit.tag) {
case "number": return lit.value.toString();
case "string": return `"${lit.value}"`; // TODO: simplistic string escaping
}
}
function fieldAssignmentToString(f: FieldAssignment): string {
return `${f.name} = ${exprToString(f.expr)}`;
}
// === Pattern Printers ===
export function patternToString(pat: Pattern): string {
switch (pat.tag) {
case "tag":
return `#${pat.tag_name}`;
case "tagged":
return `#${pat.tag_name} ${patternToString(pat.pattern)}`;
// If it's a product pattern (any, tuple, record)
default:
return productPatternToString(pat);
}
}
export function productPatternToString(pat: ProductPattern): string {
switch (pat.tag) {
case "any":
return pat.name;
case "tuple":
return `(${pat.patterns.map(productPatternToString).join(", ")})`;
case "record":
return `{ ${pat.fields.map(fieldPatternToString).join(", ")} }`;
}
}
function fieldPatternToString(f: FieldPattern): string {
// Check for punning: if pattern is "any" and name matches fieldName
if (f.pattern.tag === "any" && f.pattern.name === f.fieldName) {
return f.fieldName;
}
return `${f.fieldName} = ${productPatternToString(f.pattern)}`;
}

35
src/ui/LineView.tsx Normal file
View file

@ -0,0 +1,35 @@
import { LineView } from "src/lang/parser/source_text";
import { For, Show } from "solid-js";
export function DisplayLineView(prop: { view: LineView }) {
return (
<div style={{ "white-space": "pre" }}>
<span style={{ color: "lightblue" }}>{ prop.view.gutterPad }{ prop.view.lineNo }</span>
<span>
<span style={{ color: "#808080" }}>{ prop.view.prefix }</span>
<span style={{ color: "#ff5555", "font-weight": "bold", "text-decoration": "underline" }}>{ prop.view.highlight }</span>
<span style={{ color: "#808080" }}>{ prop.view.suffix }</span>
</span>
<Show when={prop.view.underline.trim().length > 0}>
<div>
<span>{" ".repeat(prop.view.gutterPad.length + String(prop.view.lineNo).length + 3)}</span>
<span style={{ color: "#ff5555", "font-weight": "bold" }}>{prop.view.underline}</span>
</div>
</Show>
</div>
);
}
export function DisplayLineViews(prop: { views: LineView[] }) {
return (
<For each={prop.views}>
{(view) => (
<DisplayLineView view={ view } />
)}
</For>
);
}

101
src/ui/ParseError.tsx Normal file
View file

@ -0,0 +1,101 @@
import { ParseError } from "src/lang/parser/parser";
import { renderSpan, SourceText } from "src/lang/parser/source_text";
import { DisplayLineViews } from "./LineView";
export function formatErrorMesage(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.`;
}
// 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}`;
}
}
// 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}'`;
}
export function ShowParseError(props: { text: SourceText, err: ParseError }) {
const msg = () => formatErrorMesage(props.err);
const views = () => renderSpan(props.text, props.err.span, 3);
// Parse Error: Expected '(' to start function call.
// 1 | +(23, x)
// ^
return (
<div>
<div>
<span style={{ color: "#ff5555", "font-weight": "bold" }}>Parse Error: </span>
<span style={{ "font-weight": "bold" }}>{msg()}</span>
</div>
<DisplayLineViews views={views()} />
</div>
);
}

View file

@ -0,0 +1,21 @@
import { Program } from 'src/lang/program';
import { createContext, useContext } from "solid-js";
const ProgramContext = createContext<Program>();
export function ProgramProvider(props: { program: Program; children: any }) {
return (
<ProgramContext.Provider value={props.program}>
{props.children}
</ProgramContext.Provider>
);
}
export function useProgram() {
const context = useContext(ProgramContext);
if (!context) {
throw new Error("useProgram must be used within a ProgramProvider");
}
return context;
}

107
src/ui/REPL.tsx Normal file
View file

@ -0,0 +1,107 @@
import { createSignal, Match, Switch } from 'solid-js';
import { useProgram } from './ProgramProvider';
import { eval_start } from 'src/lang/eval/evaluator';
import { Value } from 'src/lang/eval/value';
import { RuntimeError } from 'src/lang/eval/error';
import { SourceText, sourceText } from 'src/lang/parser/source_text';
import { ParseError, parseExpr } from 'src/lang/parser/parser';
import { ShowParseError } from './ParseError';
import { Val } from './Value';
namespace ReplResult {
export type Idle =
{ tag: "idle" }
export type Success =
{ tag: "success", value: Value }
export type Parse_Error =
{ tag: "parse_error", text: SourceText, err: ParseError }
export type Runtime_Error =
{ tag: "runtime_error", err: RuntimeError }
}
type ReplResult =
| ReplResult.Idle
| ReplResult.Success
| ReplResult.Parse_Error
| ReplResult.Runtime_Error
export function ExprREPL() {
const program = useProgram();
const [input, setInput] = createSignal("");
const [result, setResult] = createSignal<ReplResult>({ tag: "idle" });
function runExecution() {
const raw = input();
if (input().trim() === "") {
return;
}
const text = sourceText(raw);
const parseResult = parseExpr(text);
if (parseResult.tag === "error") {
setResult({ tag: "parse_error", text: text, err: parseResult.error });
} else {
const evalResult = eval_start(program, parseResult.value);
if (evalResult.tag === "ok") {
setResult({ tag: "success", value: evalResult.value });
} else {
setResult({ tag: "runtime_error", err: evalResult.error });
}
}
}
return (
<section>
<textarea
placeholder="+(3, 4)"
rows="5"
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => {
// Check for Enter + (Ctrl or Command for Mac)
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); // Stop the newline from being added
runExecution();
}
}}
/>
<button onClick={runExecution}>Run</button>
<hr />
<div>
<Switch>
<Match when={result().tag === "idle"}>
{ "" }
</Match>
<Match when={result().tag === "success" && result() as ReplResult.Success }>
{(res) => (
<article>
<header>Result</header>
<Val value={ res().value } />
</article>
)}
</Match>
<Match when={result().tag === "parse_error" && result() as ReplResult.Parse_Error }>
{(res) => (
<ShowParseError text={res().text} err={res().err} />
)}
</Match>
<Match when={result().tag === "runtime_error" && result() as ReplResult.Runtime_Error }>
{(res) => (
<article style={{ "border-color": "var(--pico-del-color)" }}>
<header style={{ color: "var(--pico-del-color)" }}>Runtime Error</header>
<pre>{JSON.stringify(res().err, null, 2)}</pre>
</article>
)}
</Match>
</Switch>
</div>
</section>
);
}

58
src/ui/Value.tsx Normal file
View file

@ -0,0 +1,58 @@
import { Closure, Value, Env, EnvFrame } from '../lang/eval/value';
import { exprToString, productPatternToString } from './Expr';
export function Val(prop: { value: Value }) {
return (
<code>{ valueToString(prop.value) }</code>
);
}
export function valueToString(val: Value): string {
switch (val.tag) {
case "number": return val.value.toString();
case "string": return `"${val.value}"`;
case "tag": return `#${val.tag_name}`;
case "tagged": return `#${val.tag_name} ${valueToString(val.value)}`;
case "tuple": return `(${val.values.map(valueToString).join(", ")})`;
case "record": {
const entries = Array.from(val.fields.entries())
.map(([k, v]) => `${k} = ${valueToString(v)}`)
.join(", ");
return `{ ${entries} }`;
}
case "closure": return closureToString(val.closure);
}
}
function closureToString(c: Closure): string {
const params = c.parameters.map(productPatternToString).join(", ");
const envStr = envToString(c.env);
// We represent the closure as the code + a summary of its captured scope
return `fn { ${params} . ${exprToString(c.body)} } [captured: ${envStr}]`;
}
function envToString(env: Env): string {
if (env.tag === "nil") return "∅";
const frames: string[] = [];
let current: Env = env;
while (current.tag === "frame") {
frames.push(frameToString(current.frame));
current = current.parent;
}
// Shows stack from inner-most to outer-most
return frames.join(" ⮕ ");
}
function frameToString(frame: EnvFrame): string {
const entries = Array.from(frame.entries());
if (entries.length === 0) return "{}";
const formattedEntries = entries.map(([name, val]) => {
return `${name} = ${valueToString(val)}`;
});
return `{ ${formattedEntries.join(", ")} }`;
}

View file

@ -1,15 +1,23 @@
import { render } from 'solid-js/web';
import './index.scss';
import App from './SolidHi';
import { render } from 'solid-js/web';
import { Program } from 'src/lang/program';
import { ProgramProvider } from './ProgramProvider';
// import App from './AppElm';
import App from './App';
export function startApp() {
const root = document.getElementById('app');
const program = Program.make();
if (root && !(root instanceof HTMLDivElement)) {
throw new Error('Root element not found');
}
render(() => (
<App />
<ProgramProvider program={ program }>
<App />
</ProgramProvider>
), root!);
}