From c0198d419f03ba887717edaf132e8bd2aca60f2f Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:11:57 +0100 Subject: [PATCH] Make basic Signal Digith work --- src/lang/expr.ts | 1 + src/lang/parser/parser.ts | 71 ++++---- src/lang/parser/scanner.ts | 15 +- src/lang/program.ts | 14 +- src/ui/Component/ParseError.tsx | 74 +++++++-- src/ui/Controls/index.tsx | 4 +- src/ui/Digith/Function/FunctionDigith.tsx | 5 +- .../Function/NewFunctionDraftDigith.tsx | 4 +- src/ui/Digith/Signal/NewSignalDraftDigith.tsx | 107 ++++++++++++ src/ui/Digith/Signal/SignalDigith.tsx | 154 ++++++++++++++++++ src/ui/Digith/index.tsx | 18 +- src/ui/Scrowl/index.tsx | 10 ++ src/ui/Scrowl/scrowlStore.ts | 48 +++++- .../Helpers.tsx => validation/helpers.ts} | 12 +- src/ui/{validation.ts => validation/index.ts} | 0 15 files changed, 464 insertions(+), 73 deletions(-) create mode 100644 src/ui/Digith/Signal/NewSignalDraftDigith.tsx create mode 100644 src/ui/Digith/Signal/SignalDigith.tsx rename src/ui/{Digith/Function/Helpers.tsx => validation/helpers.ts} (67%) rename src/ui/{validation.ts => validation/index.ts} (100%) diff --git a/src/lang/expr.ts b/src/lang/expr.ts index f7cbe05..21ebbcd 100644 --- a/src/lang/expr.ts +++ b/src/lang/expr.ts @@ -88,6 +88,7 @@ export namespace Expr { export namespace SignalExpr { export const read = (name: SignalName, span: Span): SignalExpr => ({ tag: "read", name, span }); export const signalBinding = (pattern: ProductPattern, expr: SignalExpr, span: Span): SignalExprBinding => ({ pattern, expr, span }); + export const let_ = (bindings: SignalExprBinding[], body: Expr, span: Span): SignalExpr => ({ tag: "let", bindings, body, span }); } export namespace ProductPattern { diff --git a/src/lang/parser/parser.ts b/src/lang/parser/parser.ts index 194bd11..f88003d 100644 --- a/src/lang/parser/parser.ts +++ b/src/lang/parser/parser.ts @@ -2,7 +2,7 @@ import { Cursor } from './cursor'; import { ExprScanError, exprStart, ExprStartToken, IdentifierKind, identifierScanner, isNextTokenExprStart, isNextTokenProductPatternStart, patternStart, PatternStartToken, signalExprStart, SignalExprStartToken, skipWhitespaceAndComments } from './scanner'; import { char, CodePoint, SourceText, Span } from './source_text'; import { Result } from '../result'; -import { Expr, ExprBinding, FieldAssignment, FieldPattern, FunctionName, MatchBranch, Pattern, ProductPattern, SignalExpr } from '../expr'; +import { Expr, ExprBinding, FieldAssignment, FieldPattern, FunctionName, MatchBranch, Pattern, ProductPattern, SignalExpr, SignalExprBinding } from '../expr'; // 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. @@ -29,6 +29,8 @@ export type ParseError = | { tag: "ExpectedRecordOpen", span: Span } // Expected '(' after ':' | { tag: "ExpectedLetBlockOpen", span: Span } // Expected '{' after 'let' | { tag: "ExpectedLetBlockClose", span: Span } // Expected '}' at end of 'let' expression + | { tag: "ExpectedLetSignalBlockOpen", span: Span } // Expected '{' after `let-signal` + | { tag: "ExpectedLetSignalBlockClose", span: Span } // Expected '}' at end of 'let-signal' expression | { tag: "ExpectedMatchBlockOpen", span: Span } // Expected '{' after 'match' | { tag: "ExpectedMatchBlockClose", span: Span } // Expected '}' at end of 'match' expression | { tag: "ExpectedLambdaBlockOpen", span: Span } // Expected '{' after `fn` @@ -37,7 +39,7 @@ export type ParseError = | { tag: "ExpectedApplySeparator", span: Span } // Expected '!' inside 'apply' | { tag: "UnexpectedTagPattern", span: Span } // Found #tag where product pattern expected | { tag: "ExpectedPattern", span: Span } // EOF or invalid start of pattern - | { tag: "ExpectedRecordPatternOpen", span: Span } // Expected '(' at start of record pattern + | { tag: "ExpectedRecordPatternOpen", span: Span } // Expected ':(' at start of record pattern | { tag: "ExpectedRecordField", span: Span }; // Expected identifier in record pattern // TODO: Delete? @@ -50,6 +52,8 @@ export type Expectation = | "ExpectedRecordOpen" | "ExpectedLetBlockOpen" | "ExpectedLetBlockClose" + | "ExpectedLetSignalBlockOpen" + | "ExpectedLetSignalBlockClose" | "ExpectedMatchBlockOpen" | "ExpectedMatchBlockClose" | "ExpectedApplyStart" @@ -314,6 +318,7 @@ function expr(cursor: Cursor): Expr { function signalExpr(cursor: Cursor): SignalExpr { const start = cursor.currentLocation(); const token = signalExprStartToken(cursor); + switch (token.tag) { case "EOF": throw { @@ -323,41 +328,27 @@ function signalExpr(cursor: Cursor): SignalExpr { } as ParseError; case "signal_read": return SignalExpr.read(token.name, token.span); - // case "function_name": - // TODO: when components are ready - // // e.g. my_func(arg1, arg2) - // // parse a `,` delimiter sequence of expr - // // need to consume ) - // if (!tryConsume(cursor, char('('))) { - // throw { - // tag: "ExpectedFunctionCallStart", - // span: cursor.makeSpan(cursor.currentLocation()) - // } as ParseError; - // } - // const args = delimitedTerminalSequence(cursor, DELIMITER_COMMA, TERMINATOR_CLOSE_PAREN, expr); - // return Expr.call(token.name, args, cursor.makeSpan(start)); case "keyword": switch (token.kw) { case "let-signal": - // TODO: - // // let { p0 = e0, p1 = e2 . body } - // if (!tryConsume(cursor, char('{'))) { - // throw { - // tag: "ExpectedLetBlockOpen", - // span: cursor.makeSpan(cursor.currentLocation()) - // } as ParseError; - // } - // const bindings = delimitedTerminalSequence(cursor, DELIMITER_COMMA, TERMINATOR_DOT, productPatternBinding); - // const body = expr(cursor); + // let { x := sig-expr, y := sig-expr . normal-expr } + // TODO: Decide if to introduce new keyword `:=` or just reuse `=`? + if (!tryConsume(cursor, char('{'))) { + throw { + tag: "ExpectedLetSignalBlockOpen", + span: cursor.makeSpan(cursor.currentLocation()) + } as ParseError; + } + const bindings = delimitedTerminalSequence(cursor, DELIMITER_COMMA, TERMINATOR_DOT, productPatternSignalBinding); + const body = expr(cursor); - // if (!tryConsume(cursor, TERMINATOR_CLOSE_BRACE)) { - // throw { - // tag: "ExpectedLetBlockClose", - // span: cursor.makeSpan(cursor.currentLocation()) - // } as ParseError; - // } - // return Expr.let_(bindings, body, cursor.makeSpan(start)); - return 0 as any; + if (!tryConsume(cursor, TERMINATOR_CLOSE_BRACE)) { + throw { + tag: "ExpectedLetSignalBlockClose", + span: cursor.makeSpan(cursor.currentLocation()) + } as ParseError; + } + return SignalExpr.let_(bindings, body, cursor.makeSpan(start)); case "let": case "fn": case "match": @@ -415,6 +406,20 @@ function productPatternBinding(cursor: Cursor): ExprBinding { return Expr.exprBinding(pattern, e, cursor.makeSpan(start)); } +function productPatternSignalBinding(cursor: Cursor): SignalExprBinding { + const start = cursor.currentLocation(); + const pattern = productPattern(cursor); + + if (!tryConsume(cursor, char('='))) { + throw { + tag: "ExpectedPatternBindingSymbol", + span: cursor.makeSpan(cursor.currentLocation()) + } as ParseError; + } + const e = signalExpr(cursor); + return SignalExpr.signalBinding(pattern, e, cursor.makeSpan(start)); +} + function fieldAssignment(cursor: Cursor): FieldAssignment { const start = cursor.currentLocation(); // `f = e` diff --git a/src/lang/parser/scanner.ts b/src/lang/parser/scanner.ts index 2f6a372..6ad1154 100644 --- a/src/lang/parser/scanner.ts +++ b/src/lang/parser/scanner.ts @@ -55,6 +55,7 @@ export type ExprScanError = | NumberError | StringError | { tag: "InvalidIdentifier", text: string, kind: IdentifierKind, reason: IdentifierErrorReason, span: Span } + | { tag: "UnexpectedIdentifier", identifier: string, span: Span } // What kind of identifier were we trying to parse? export type IdentifierKind = @@ -268,9 +269,11 @@ export function signalExprStart(cursor: Cursor): SignalExprStartToken { case "keyword": return result; case "identifier": - // TODO: when we have parametrized signal-expressions - // return { tag: "function_name", name: result.name, span: result.span }; - return 0 as any; + throw ({ + tag: "UnexpectedIdentifier", + identifier: result.name, + span: result.span, + } as ExprScanError); } } @@ -349,9 +352,6 @@ export function isNextTokenExprStart(cursor: Cursor): boolean { case "EOF": return false; - - default: - return false; } } catch (e) { @@ -386,7 +386,8 @@ export function isNextTokenProductPatternStart(cursor: Cursor): boolean { case "!": return false; } - default: + case "tag": + case "EOF": return false; } } catch (e) { diff --git a/src/lang/program.ts b/src/lang/program.ts index 524dc2d..49ee449 100644 --- a/src/lang/program.ts +++ b/src/lang/program.ts @@ -377,6 +377,14 @@ export namespace Program { } // === Signals === + export function getSignal(program: Program, name: FunctionName): Result { + const sigDef = program.signal_definitions.get(name); + if (sigDef === undefined) { + return Result.error({ tag: "SignalNotFound", name }); + } + return Result.ok(sigDef); + } + export type CreateSignal = { name: SignalName, body: SignalExpr, @@ -386,7 +394,7 @@ export namespace Program { export function registerSignal( program: Program, { name, body, raw_body }: CreateSignal - ): Result { + ): Result { if (program.signal_definitions.has(name)) { return Result.error({ tag: "DuplicateSignalName", name }); } @@ -410,7 +418,7 @@ export namespace Program { // TODO: Note that this doesn't actually evaluate the signal and doesn't insert it into signal-runtime. // For that we will use `get_or_create_signal` - return Result.ok(undefined); + return Result.ok(name); } export type UpdateSignal = { @@ -547,7 +555,7 @@ export function updateSignal( // TODO: MAY THROW RuntimeError. Should probably switch to `eval_start` - and extend the `Program.Error` with runtime errors. const newValue = eval_expr(program, Env.nil(), body); - // 2. Find the existing runtime signal + // Find the existing runtime signal if (def.signalId === undefined) { // This should theoretically not happen for cells since we initialize them eagerly, // but good to be safe. diff --git a/src/ui/Component/ParseError.tsx b/src/ui/Component/ParseError.tsx index db8accc..f49cb7c 100644 --- a/src/ui/Component/ParseError.tsx +++ b/src/ui/Component/ParseError.tsx @@ -4,12 +4,6 @@ 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)}`; @@ -20,13 +14,12 @@ export function formatErrorMesage(err: ParseError): string { 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}`; + switch (err.reason) { + case "NotFinite": + return "Number is too large or invalid."; + case "MissingFractionalDigits": + return "Invalid number format (missing fractional digits?)."; + } case "InvalidEscape": switch (err.reason.tag) { @@ -37,8 +30,56 @@ export function formatErrorMesage(err: ParseError): string { case "UnicodeOverflow": return `Unicode code point ${err.reason.value.toString(16)} is out of bounds.`; } + case "InvalidIdentifier": { + let identifierKind = ""; + switch (err.kind) { + case "variable_use": + identifierKind = "variable name"; + break; + case "field_name": + identifierKind = "field name "; + break; + case "tag_construction": + identifierKind = "tag"; + break; + case "function_call": + identifierKind = "function name"; + break; + case "signal_read": + identifierKind = "signal name"; + break; + case "pattern_binding": + identifierKind = "pattern variable"; + break; + } + + let reason = ""; + switch (err.reason.tag) { + case "Empty": + reason = "It's empty"; + break; + case "StartsWithDigit": + reason = "Can't start with a digit" + break; + case "IsKeyword": + reason = "I'ts a keyword"; + break; + } + return `Invalid ${identifierKind} '${err.text}' ${reason}.`; + } + + case "UnexpectedIdentifier": + return `Unexpected identifier encountered '${err.identifier}'`; + + 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)}.`; + // Context specific errors case "ExpectedExpression": return "Expected an expression here."; + case "ExpectedSignalExpression": return "Expected a signal expression here."; case "ExpectedFieldAssignmentSymbol": return "Expected '=' for field assignment."; case "ExpectedPatternAssignmentSymbol": return "Expected '=' for pattern assignment."; case "ExpectedPatternBindingSymbol": return "Expected '.' for pattern binding."; @@ -46,6 +87,8 @@ export function formatErrorMesage(err: ParseError): string { case "ExpectedRecordOpen": return "Expected '(' to start record."; case "ExpectedLetBlockOpen": return "Expected '{' to start let-block."; case "ExpectedLetBlockClose": return "Expected '}' to close let-block."; + case "ExpectedLetSignalBlockOpen": return "Expected '{' to start let-signal-block."; + case "ExpectedLetSignalBlockClose": return "Expected '}' to close let-signal-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."; @@ -54,11 +97,8 @@ export function formatErrorMesage(err: ParseError): string { 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 "ExpectedRecordPatternOpen": return "Expected a ':(' at start of record pattern here."; case "ExpectedRecordField": return "Expected a field name in record pattern."; - - default: - return `Unknown error: ${(err as any).tag}`; } } diff --git a/src/ui/Controls/index.tsx b/src/ui/Controls/index.tsx index e8111ad..3c47059 100644 --- a/src/ui/Controls/index.tsx +++ b/src/ui/Controls/index.tsx @@ -1,4 +1,4 @@ -import { spawnNewFunctionDraftDigith } from "src/ui/Scrowl/scrowlStore"; +import { spawnNewFunctionDraftDigith, spawnNewSignalDraftDigith } from "src/ui/Scrowl/scrowlStore"; type Props = { // TODO @@ -21,8 +21,8 @@ export function Controls(props: Props) { diff --git a/src/ui/Digith/Function/FunctionDigith.tsx b/src/ui/Digith/Function/FunctionDigith.tsx index 22cb4d6..9c56e7a 100644 --- a/src/ui/Digith/Function/FunctionDigith.tsx +++ b/src/ui/Digith/Function/FunctionDigith.tsx @@ -5,7 +5,7 @@ import { CodeEditor } from "src/ui/Component/CodeEditor"; import { sourceText } from "src/lang/parser/source_text"; import { Program } from "src/lang/program"; import { V, Validation, letValidate } from "src/ui/validation"; -import { validateExprRaw, validateParamsRaw } from "./Helpers"; +import { validateExprRaw, validateParamsRaw } from "src/ui/validation/helpers"; import { updateDigith } from "src/ui/Scrowl/scrowlStore"; import { DigithError } from "src/ui/Digith/DigithError"; @@ -100,7 +100,7 @@ export function FunctionDigith(props: { function: Digith.Function }) { -
+
}> + {(value) => ( +
+ + +
+ )} + + + ); +} + diff --git a/src/ui/Digith/index.tsx b/src/ui/Digith/index.tsx index db13e64..4d98e6c 100644 --- a/src/ui/Digith/index.tsx +++ b/src/ui/Digith/index.tsx @@ -1,10 +1,13 @@ -import { FunctionName } from "src/lang/expr"; +import { FunctionName, SignalName } from "src/lang/expr"; import { DigithId } from "src/ui/Scrowl/scrowlStore"; export type Digith = | Digith.Repl | Digith.NewFunctionDraft | Digith.Function + | Digith.NewSignalDraft + | Digith.Signal + export namespace Digith { export type Repl = { @@ -28,5 +31,18 @@ export namespace Digith { raw_body: string, } + export type NewSignalDraft = { + id: DigithId, + tag: "new-signal-draft", + raw_name: string, + raw_body: string, + } + + export type Signal = { + id: DigithId, + tag: "signal", + name: SignalName, + raw_body: string, + } } diff --git a/src/ui/Scrowl/index.tsx b/src/ui/Scrowl/index.tsx index 8ca1bc5..6eaf415 100644 --- a/src/ui/Scrowl/index.tsx +++ b/src/ui/Scrowl/index.tsx @@ -4,6 +4,8 @@ import { clearFocus, DigithId, scrowl } from "./scrowlStore"; import { NewFunctionDraftDigith } from "src/ui/Digith/Function/NewFunctionDraftDigith"; import { FunctionDigith } from "src/ui/Digith/Function/FunctionDigith"; import { Digith } from "src/ui/Digith"; +import { NewSignalDraftDigith } from "../Digith/Signal/NewSignalDraftDigith"; +import { SignalDigith } from "../Digith/Signal/SignalDigith"; // WTF are these names? // Scrowl @@ -41,6 +43,14 @@ export function Scrowl() { + + + + + + + + )} diff --git a/src/ui/Scrowl/scrowlStore.ts b/src/ui/Scrowl/scrowlStore.ts index ba85929..7a04ecc 100644 --- a/src/ui/Scrowl/scrowlStore.ts +++ b/src/ui/Scrowl/scrowlStore.ts @@ -1,7 +1,7 @@ import { createStore } from "solid-js/store"; import { Digith } from "src/ui/Digith"; import { Program } from "src/lang/program"; -import { FunctionName } from "src/lang/expr"; +import { FunctionName, SignalName } from "src/lang/expr"; export type DigithId = number; @@ -112,3 +112,49 @@ export function spawnFunctionDigith(program: Program, name: FunctionName, target return Scrowl.Result.ok(newDigith); } +export function spawnNewSignalDraftDigith() { + const id = generateId(); + + const newDraft: Digith = { + id, + tag: 'new-signal-draft', + raw_name: '', + raw_body: '', + }; + + requestFocus(id); + setScrowl("digiths", (prev) => [newDraft, ...prev]); +}; + +export function spawnSignalDigith(program: Program, name: SignalName, targetId?: DigithId): Scrowl.Result { + const lookupRes = Program.getSignal(program, name); + if (lookupRes.tag === "error") { + return Scrowl.Result.error(lookupRes.error); + } + const sigDef = lookupRes.value; + + // TODO: Maybe consider representing some read-only Digith for primitive (it would just display the name, it wouldn't have code). + if (sigDef.tag === "primitive") { + return Scrowl.Result.error({ tag: "CannotEditPrimitiveSignal", name }); + } + const userDef = sigDef.def; + const id = targetId ?? generateId(); + + const newDigith: Digith.Signal = { + id: id, + tag: "signal", + + name: userDef.name, + raw_body: userDef.raw_body, + }; + + if (targetId === undefined) { + prependDigith(newDigith); + } else { + // Swap with old signal draft. + updateDigith(targetId, newDigith); + } + + return Scrowl.Result.ok(newDigith); +} + diff --git a/src/ui/Digith/Function/Helpers.tsx b/src/ui/validation/helpers.ts similarity index 67% rename from src/ui/Digith/Function/Helpers.tsx rename to src/ui/validation/helpers.ts index 2df31b4..60d2336 100644 --- a/src/ui/Digith/Function/Helpers.tsx +++ b/src/ui/validation/helpers.ts @@ -1,7 +1,7 @@ -import { ParseError, parseExpr, parseFunctionName, parseFunctionParameters } from "src/lang/parser/parser"; +import { ParseError, parseExpr, parseFunctionName, parseFunctionParameters, parseSignalExpr } from "src/lang/parser/parser"; import { sourceText } from "src/lang/parser/source_text"; -import { Expr, FunctionName, ProductPattern } from "src/lang/expr"; -import { V } from "src/ui/validation"; +import { Expr, FunctionName, ProductPattern, SignalExpr } from "src/lang/expr"; +import { V } from "./"; // === Parser wrappers === export function validateNameRaw(input: string): V { @@ -22,3 +22,9 @@ export function validateExprRaw(input: string): V { return res.tag === "ok" ? V.ok(res.value) : V.errors([res.error]); }; +export function validateSignalExprRaw(input: string): V { + const src = sourceText(input); + const res = parseSignalExpr(src); + return res.tag === "ok" ? V.ok(res.value) : V.errors([res.error]); +}; + diff --git a/src/ui/validation.ts b/src/ui/validation/index.ts similarity index 100% rename from src/ui/validation.ts rename to src/ui/validation/index.ts