Basic REPL in the web UI
This commit is contained in:
parent
e2354fb9ce
commit
182307a81f
8 changed files with 467 additions and 3 deletions
26
src/ui/App.tsx
Normal file
26
src/ui/App.tsx
Normal 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
108
src/ui/Expr.tsx
Normal 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
35
src/ui/LineView.tsx
Normal 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
101
src/ui/ParseError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/ui/ProgramProvider.tsx
Normal file
21
src/ui/ProgramProvider.tsx
Normal 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
107
src/ui/REPL.tsx
Normal 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
58
src/ui/Value.tsx
Normal 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(", ")} }`;
|
||||
}
|
||||
|
|
@ -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!);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue