Create proper validation library. Rewrite new-function-draft component.

This commit is contained in:
Yura Dupyn 2026-02-14 15:59:49 +01:00
parent 0941756bf9
commit 8e4dcb5de7
8 changed files with 366 additions and 55 deletions

View file

@ -2,7 +2,7 @@ import { Cursor } from './cursor';
import { ExprScanError, exprStart, ExprStartToken, IdentifierKind, identifierScanner, isNextTokenExprStart, isNextTokenProductPatternStart, patternStart, PatternStartToken, signalExprStart, SignalExprStartToken, skipWhitespaceAndComments } from './scanner'; import { ExprScanError, exprStart, ExprStartToken, IdentifierKind, identifierScanner, isNextTokenExprStart, isNextTokenProductPatternStart, patternStart, PatternStartToken, signalExprStart, SignalExprStartToken, skipWhitespaceAndComments } from './scanner';
import { char, CodePoint, SourceText, Span } from './source_text'; import { char, CodePoint, SourceText, Span } from './source_text';
import { Result } from '../result'; import { Result } from '../result';
import { Expr, ExprBinding, FieldAssignment, FieldPattern, MatchBranch, Pattern, ProductPattern, SignalExpr } from '../expr'; import { Expr, ExprBinding, FieldAssignment, FieldPattern, FunctionName, MatchBranch, Pattern, ProductPattern, SignalExpr } from '../expr';
// CONVENTION: Every parser is responsible to consume whitespace/comments at the end. // CONVENTION: Every parser is responsible to consume whitespace/comments at the end.
// Every parser is not responsible for cleaning up whitespace/comments at the start - only the final `parse` that's exposed to the public. // Every parser is not responsible for cleaning up whitespace/comments at the start - only the final `parse` that's exposed to the public.
@ -492,8 +492,7 @@ function finishProductPattern(cursor: Cursor, token: PatternStartToken): Product
switch (token.kw) { switch (token.kw) {
case ":": { case ":": {
// :( a = p, b ) // :( a = p, b )
// TODO: parse open-paren if (!tryConsume(cursor, char('('))) {
if (!tryConsume(cursor, char('{'))) {
throw { throw {
tag: "ExpectedRecordPatternOpen", tag: "ExpectedRecordPatternOpen",
span: cursor.makeSpan(cursor.currentLocation()) span: cursor.makeSpan(cursor.currentLocation())
@ -598,3 +597,25 @@ export function parseFunctionParameters(source: SourceText): Result<ProductPatte
} }
} }
export function parseFunctionName(source: SourceText): Result<FunctionName, ParseError> {
const cursor = new Cursor(source);
try {
skipWhitespaceAndComments(cursor);
// TODO: We should introduce new `function_name`
const name = identifier(cursor, "function_call");
if (!cursor.eof()) {
return Result.error({
tag: "UnexpectedToken",
expected: "EndOfFile",
span: cursor.makeSpan(cursor.currentLocation())
} as ParseError);
}
return Result.ok(name.name);
} catch (e) {
// TODO: This is a bit sketchy. We maybe forced to have "checked" Exceptions for `ParseError` by wrapping it in something that has a proper tag.
return Result.error(e as ParseError);
}
}

View file

@ -84,7 +84,7 @@ type PrimitiveSignalDefinition = {
export namespace Program { export namespace Program {
type Error = export type Error =
| { tag: "DuplicateFunctionName", name: FunctionName } | { tag: "DuplicateFunctionName", name: FunctionName }
| { tag: "FunctionNotFound", name: FunctionName } | { tag: "FunctionNotFound", name: FunctionName }
| { tag: "CannotEditPrimitiveFunction", name: FunctionName } | { tag: "CannotEditPrimitiveFunction", name: FunctionName }
@ -98,7 +98,7 @@ export namespace Program {
| { tag: "CannotDeletePrimitiveSignal", name: SignalName } | { tag: "CannotDeletePrimitiveSignal", name: SignalName }
| { tag: "PrimitiveSignalAlreadyExists", name: SignalName } | { tag: "PrimitiveSignalAlreadyExists", name: SignalName }
type Result<T> = export type Result<T> =
| { tag: "ok", value: T } | { tag: "ok", value: T }
| { tag: "error", error: Error } | { tag: "error", error: Error }

View file

@ -1,49 +0,0 @@
import { createSignal } from "solid-js";
import { Digith } from "./Digith";
import { useProgram } from "./ProgramProvider";
export function NewFunctionDraftDigith(props: { draft: Digith.NewFunctionDraft }) {
const program = useProgram();
const [name, setName] = createSignal(props.draft.raw_name);
const [params, setParams] = createSignal(props.draft.raw_parameters);
const [body, setBody] = createSignal(props.draft.raw_body);
const handleCommit = () => {
// TODO: Add the new function to the draft
console.log(`Committing ${name()} to the Program...`);
};
// TODO: What about parsing errors?
return (
<article>
<header><strong>Fn Draft</strong></header>
<div>TODO: New Fn Draft under construction</div>
</article>
);
}
// TODO: What about renaming?
export function FunctionDigith(props: { function: Digith.Function }) {
const program = useProgram();
const [params, setParams] = createSignal(props.function.raw_parameters);
const [body, setBody] = createSignal(props.function.raw_body);
const handleCommit = () => {
// TODO: Update the old function with new code
console.log(`Committing ${props.function.name} to the Program...`);
};
return (
<article>
<header><strong>Fn</strong></header>
<div>TODO: Fn under construction</div>
</article>
);
}

View file

@ -0,0 +1,32 @@
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { Digith } from "../Digith";
import { useProgram } from "../ProgramProvider";
import { CodeEditor } from "../CodeEditor";
import { ParseError, parseExpr, parseFunctionName, parseFunctionParameters } from "src/lang/parser/parser";
import { sourceText, SourceText } from "src/lang/parser/source_text";
import { Expr, FunctionName, ProductPattern } from "src/lang/expr";
import { ShowParseError } from "../ParseError";
import { Program } from "src/lang/program";
import { V, Validation, letValidate } from "../validation";
// TODO: What about renaming?
export function FunctionDigith(props: { function: Digith.Function }) {
const program = useProgram();
const [params, setParams] = createSignal(props.function.raw_parameters);
const [body, setBody] = createSignal(props.function.raw_body);
const handleCommit = () => {
// TODO: Update the old function with new code
console.log(`Committing ${props.function.name} to the Program...`);
};
return (
<article>
<header><strong>Fn</strong></header>
<div>TODO: Fn under construction</div>
</article>
);
}

View file

@ -0,0 +1,51 @@
import { ParseError, parseExpr, parseFunctionName, parseFunctionParameters } from "src/lang/parser/parser";
import { sourceText } from "src/lang/parser/source_text";
import { Expr, FunctionName, ProductPattern } from "src/lang/expr";
import { Program } from "src/lang/program";
import { V } from "../validation";
// === Parser wrappers ===
export function validateNameRaw(input: string): V<FunctionName, ParseError> {
const src = sourceText(input);
const res = parseFunctionName(src);
return res.tag === "ok" ? V.ok(res.value) : V.errors([res.error]);
};
export function validateParamsRaw(input: string): V<ProductPattern[], ParseError> {
const src = sourceText(input);
const res = parseFunctionParameters(src);
return res.tag === "ok" ? V.ok(res.value) : V.errors([res.error]);
};
export function validateExprRaw(input: string): V<Expr, ParseError> {
const src = sourceText(input);
const res = parseExpr(src);
return res.tag === "ok" ? V.ok(res.value) : V.errors([res.error]);
};
// === Displaying Errors ===
// TODO: Move this into more appropriate place
export function ProgramErrorDisplay(props: { error: Program.Error }) {
const message = () => {
switch (props.error.tag) {
case "DuplicateFunctionName":
return `A function named '${props.error.name}' already exists.`;
case "PrimitiveFunctionAlreadyExists":
return `Cannot overwrite the primitive function '${props.error.name}'.`;
// TODO: handle other cases
default:
return `Runtime Error: ${props.error.tag}`;
}
};
return (
<article style={{ border: "1px solid var(--pico-del-color)", padding: "0.5rem 1rem" }}>
<small style={{ color: "var(--pico-del-color)", "font-weight": "bold" }}>
Registration Failed
</small>
<p style={{ margin: 0 }}>{message()}</p>
</article>
);
}

View file

@ -0,0 +1,155 @@
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { Digith } from "../Digith";
import { useProgram } from "../ProgramProvider";
import { CodeEditor } from "../CodeEditor";
import { ParseError } from "src/lang/parser/parser";
import { sourceText, SourceText } from "src/lang/parser/source_text";
import { ShowParseError } from "../ParseError";
import { Program } from "src/lang/program";
import { V, Validation, letValidate } from "../validation";
import { ProgramErrorDisplay, validateExprRaw, validateNameRaw, validateParamsRaw } from "./Helpers";
type NewFnError =
| { tag: "Parse", field: "name" | "params" | "body", err: ParseError, src: SourceText }
| { tag: "Program", err: Program.Error };
const fieldLabels: Record<string, string> = {
name: "Function Name",
params: "Parameters",
body: "Function Body"
};
export function SingleErrorDisplay(props: { error: NewFnError }) {
return (
<div style={{ "margin-bottom": "1rem" }}>
<Switch>
<Match
when={props.error.tag === "Parse" ? (props.error as Extract<NewFnError, { tag: "Parse" }>) : undefined}
>
{(err) => (
<article style={{ border: "1px solid var(--pico-del-color)", padding: "0.5rem 1rem" }}>
<header style={{ "margin-bottom": "0.5rem", color: "var(--pico-del-color)", "font-weight": "bold" }}>
{fieldLabels[err().field]} Error
</header>
<ShowParseError text={err().src} err={err().err} />
</article>
)}
</Match>
<Match
when={props.error.tag === "Program" ? (props.error as Extract<NewFnError, { tag: "Program" }>) : undefined}
>
{(err) => ( <ProgramErrorDisplay error={err().err} />)}
</Match>
</Switch>
</div>
);
}
function ErrorListDisplay(props: { errors: NewFnError[] }) {
return (
<div style={{ "margin-top": "2rem", "border-top": "1px solid var(--pico-muted-border-color)", "padding-top": "1rem" }}>
<For each={props.errors}>
{(error) => <SingleErrorDisplay error={error} />}
</For>
</div>
);
}
export function NewFunctionDraftDigith(props: { draft: Digith.NewFunctionDraft }) {
const program = useProgram();
const [name, setName] = createSignal(props.draft.raw_name);
const [params, setParams] = createSignal(props.draft.raw_parameters);
const [body, setBody] = createSignal(props.draft.raw_body);
const [validResult, setValidResult] = createSignal<V<void, NewFnError> | null>(null);
type Input = {
raw_name: string,
raw_params: string,
raw_body: string,
}
const validator: Validation<Input, void, NewFnError> = letValidate((input: Input) => ({
name: V.elseErr(validateNameRaw(input.raw_name), err => ({ tag: "Parse", field: "name", err, src: sourceText(input.raw_name) })),
parameters: V.elseErr(validateParamsRaw(input.raw_params), err => ({ tag: "Parse", field: "params", err, src: sourceText(input.raw_params) })),
body: V.elseErr(validateExprRaw(input.raw_body), err => ({ tag: "Parse", field: "body", err, src: sourceText(input.raw_body) })),
}),
(fields, input) => {
const createFunction: Program.CreateFunction = {
name: fields.name,
parameters: fields.parameters,
body: fields.body,
raw_parameters: input.raw_name,
raw_body: input.raw_body,
};
const regResult = Program.registerFunction(program, createFunction);
if (regResult.tag === "ok") {
// TODO: Side effects? Not sure about this. Ideally validator would be pure... but it is nice that we can return errors here.
// But then again... these are not really normal errors, right? or? But that's probably a misuse...
// For now we just return Ok
return V.ok(undefined);
} else {
return V.error({ tag: "Program", err: regResult.error });
}
});
// TODO: There's something wrong with this, it doesn't trigger when expected... WTF
function handleCommit() {
const result = validator({ raw_name: name(), raw_params: params(), raw_body: body() });
setValidResult(result);
if (result.tag === "ok") {
// Handle success closure here if needed
console.log("Function created successfully!");
}
};
return (
<article>
<header><strong>Fn Draft</strong></header>
<div class="grid">
<label>
Name
<input
type="text"
placeholder="my_func"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
</label>
<label>
Parameters (comma separated)
<input
type="text"
placeholder="x, y"
value={params()}
onInput={(e) => setParams(e.currentTarget.value)}
/>
</label>
</div>
<label>Body</label>
<CodeEditor
value={body()}
onUpdate={setBody}
onRun={handleCommit}
/>
<footer>
<button class="primary" onClick={handleCommit}>Commit</button>
</footer>
<Show when={validResult()?.tag === "errors"}>
<ErrorListDisplay
errors={(validResult() as { tag: "errors", errors: NewFnError[] }).errors}
/>
</Show>
</article>
);
}

View file

@ -1,7 +1,8 @@
import { For, Match, Switch } from "solid-js"; import { For, Match, Switch } from "solid-js";
import { ExprREPL } from "./REPL"; import { ExprREPL } from "./REPL";
import { scrowl } from "./scrowlStore"; import { scrowl } from "./scrowlStore";
import { FunctionDigith, NewFunctionDraftDigith } from "./Function"; import { NewFunctionDraftDigith } from "./Function/NewFunctionDraftDigith";
import { FunctionDigith } from "./Function/FunctionDigith";
import { Digith } from "./Digith"; import { Digith } from "./Digith";
// WTF are these names? // WTF are these names?

100
src/ui/validation.ts Normal file
View file

@ -0,0 +1,100 @@
// Applicative Validation library.
// Validation Result
// `letValidate` is the main construct. It takes an input, runs parallel validations on fields, and if all succeed, runs a body function.
// It came up as first trying to design a language from scratch where validation would be nice from the start:
// ```
// let-validate { { raw_username, raw_pwd, raw_pwd_check .
// username := lengthAtleast(5, raw_username) else { err . #username :( msg = "too-short", err ) },
// pwd := lengthAtleast(8, raw_pwd) else { err1 . #pwd :( msg = "too-short", err ) },
// check := lengthAtleast(8, raw_pwd_check) else { err1 . #pwd :( msg = "check too-short", err ) },
// .
// if pwd == check {
// ok :( username, pwd )
// } else {
// err #pwd :( msg = "pwd and check don't match" )
// }
// }
// ```
// and the below is the result of trying to port it to `ts`.
// Note that `else` became `V.mapError`
export type V<T, E> =
| { tag: "ok", value: T }
| { tag: "errors", errors: E[] }
export type Validation<A, B, E> = (input: A) => V<B, E>
export namespace V {
export function ok<T, E>(value: T): V<T, E> { return { tag: "ok", value } }
export function errors<T, E>(errors: E[]): V<T, E> { return { tag: "errors", errors } }
export function error<T, E>(error: E): V<T, E> { return { tag: "errors", errors: [error] } }
// basically just map-error
export function elseErr<A, E0, E1>(result: V<A, E0>, f: (e0: E0) => E1): V<A, E1> {
if (result.tag === "ok") {
return V.ok(result.value);
} else {
const errors1 = [];
for (const e0 of result.errors) {
errors1.push(f(e0));
}
return V.errors(errors1);
}
}
export function map<A, B, C, E>(
v: Validation<A, B, E>,
f: (val: B) => C
): Validation<A, C, E> {
return (input: A) => {
const res = v(input);
return res.tag === "ok" ? V.ok(f(res.value)) : res;
};
}
export function pure<A, B>(f: (x: A) => B): Validation<A, B, never>{
return (x: A) => { return V.ok(f(x)) };
}
}
// Example:
// letValidate(
// (input: LoginForm) => ({
// // Bindings: Run validators on input fields
// username: minLength(3)(input.u),
// password: minLength(8)(input.p),
// }),
// // Body: Runs only if bindings succeed. 'fields' has the validated types.
// (fields) => {
// return V.ok({
// username: fields.username.toLowerCase(),
// passwordHash: hash(fields.password)
// });
// }
// )
export function letValidate<A, T extends Record<string, any>, E, B>(
bindings: (input: A) => { [K in keyof T]: V<T[K], E> },
body: (fields: T, input: A) => V<B, E>
): Validation<A, B, E>{
return (input: A): V<B, E> => {
const results: Partial<T> = {};
const allErrors: E[] = [];
const args = bindings(input);
for (const key in args) {
const result = args[key];
if (result.tag === 'ok') {
results[key] = result.value;
} else {
allErrors.push(...result.errors);
}
}
if (allErrors.length > 0) {
const e = V.errors(allErrors) as V<B, E>;
return e;
} else {
const x = body(results as T, input);
return x;
}
};
}