From 182307a81f57f371c0d273cf8528fcb4a9b2603f Mon Sep 17 00:00:00 2001
From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com>
Date: Fri, 13 Feb 2026 18:59:43 +0100
Subject: [PATCH] Basic REPL in the web UI
---
src/ui/App.tsx | 26 +++++++++
src/ui/Expr.tsx | 108 +++++++++++++++++++++++++++++++++++++
src/ui/LineView.tsx | 35 ++++++++++++
src/ui/ParseError.tsx | 101 ++++++++++++++++++++++++++++++++++
src/ui/ProgramProvider.tsx | 21 ++++++++
src/ui/REPL.tsx | 107 ++++++++++++++++++++++++++++++++++++
src/ui/Value.tsx | 58 ++++++++++++++++++++
src/ui/index.tsx | 14 +++--
8 files changed, 467 insertions(+), 3 deletions(-)
create mode 100644 src/ui/App.tsx
create mode 100644 src/ui/Expr.tsx
create mode 100644 src/ui/LineView.tsx
create mode 100644 src/ui/ParseError.tsx
create mode 100644 src/ui/ProgramProvider.tsx
create mode 100644 src/ui/REPL.tsx
create mode 100644 src/ui/Value.tsx
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
new file mode 100644
index 0000000..4b88998
--- /dev/null
+++ b/src/ui/App.tsx
@@ -0,0 +1,26 @@
+import { createSignal } from 'solid-js';
+import { ExprREPL } from './REPL';
+
+function Hello() {
+ const [ count, setCount ] = createSignal(0);
+
+ return (
+
+ { count() > 5 ?
too damn high :
{ count() } }
+
+
+
+
+
+ );
+}
+
+export default function App() {
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/ui/Expr.tsx b/src/ui/Expr.tsx
new file mode 100644
index 0000000..427234f
--- /dev/null
+++ b/src/ui/Expr.tsx
@@ -0,0 +1,108 @@
+import { Expr, FieldAssignment, FieldPattern, Literal, Pattern, ProductPattern } from "../lang/expr";
+
+export function Expression(prop: { expr: Expr }) {
+ return (
+ { exprToString(prop.expr) }
+ );
+}
+
+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)}`;
+}
diff --git a/src/ui/LineView.tsx b/src/ui/LineView.tsx
new file mode 100644
index 0000000..bc8f052
--- /dev/null
+++ b/src/ui/LineView.tsx
@@ -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 (
+
+
{ prop.view.gutterPad }{ prop.view.lineNo }
+
+
+ { prop.view.prefix }
+ { prop.view.highlight }
+ { prop.view.suffix }
+
+
+
0}>
+
+ {" ".repeat(prop.view.gutterPad.length + String(prop.view.lineNo).length + 3)}
+ {prop.view.underline}
+
+
+
+
+ );
+}
+
+export function DisplayLineViews(prop: { views: LineView[] }) {
+ return (
+
+ {(view) => (
+
+ )}
+
+ );
+}
+
diff --git a/src/ui/ParseError.tsx b/src/ui/ParseError.tsx
new file mode 100644
index 0000000..db8accc
--- /dev/null
+++ b/src/ui/ParseError.tsx
@@ -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 (
+
+
+ Parse Error:
+ {msg()}
+
+
+
+
+ );
+}
+
diff --git a/src/ui/ProgramProvider.tsx b/src/ui/ProgramProvider.tsx
new file mode 100644
index 0000000..63f53de
--- /dev/null
+++ b/src/ui/ProgramProvider.tsx
@@ -0,0 +1,21 @@
+import { Program } from 'src/lang/program';
+import { createContext, useContext } from "solid-js";
+
+const ProgramContext = createContext();
+
+export function ProgramProvider(props: { program: Program; children: any }) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function useProgram() {
+ const context = useContext(ProgramContext);
+ if (!context) {
+ throw new Error("useProgram must be used within a ProgramProvider");
+ }
+ return context;
+}
+
diff --git a/src/ui/REPL.tsx b/src/ui/REPL.tsx
new file mode 100644
index 0000000..24ea9f2
--- /dev/null
+++ b/src/ui/REPL.tsx
@@ -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({ 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 (
+
+ );
+}
diff --git a/src/ui/Value.tsx b/src/ui/Value.tsx
new file mode 100644
index 0000000..e41b350
--- /dev/null
+++ b/src/ui/Value.tsx
@@ -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 (
+ { valueToString(prop.value) }
+ );
+}
+
+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(", ")} }`;
+}
diff --git a/src/ui/index.tsx b/src/ui/index.tsx
index 8fa8af2..92ae21b 100644
--- a/src/ui/index.tsx
+++ b/src/ui/index.tsx
@@ -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(() => (
-
+
+
+
), root!);
}
+