diff --git a/index.html b/index.html index 7a0e9e4..5eb371c 100644 --- a/index.html +++ b/index.html @@ -5,9 +5,6 @@ - - - - + diff --git a/src/3x5.ts b/src/3x5.ts index 16b29dc..1c65d70 100644 --- a/src/3x5.ts +++ b/src/3x5.ts @@ -1,22 +1,35 @@ -/** - * @typedef {object} Card Basic unit of information, also an "actor" in the programming system - * @property {number} id Unique identifier - * @property {Record} fields Key-value properties on the card - * @property {string} code Eventually: a markdown string containing code, but for now, just code - */ +import { parse } from "./notcl"; /** - * @typedef {"action" | "render"} ScriptType "Mode" of the environment a script runs in; determines access to mutability features and such. + * Basic unit of information, also an "actor" in the programming system + */ +type Card = { + /** Unique identifier */ + id: number; + /** Key-value properties on the card */ + fields: Record; + /** Eventually: a markdown string containing code, but for now, just code */ + code: string; +}; + +/** + * "Mode" of the environment a script runs in; determines access to mutability features and such. * * "action": response to a UI action; allowed to modify card fields and access time and random numbers. * * "render": deterministic generation of display markup from card and workspace state; can only modify temporary variables. */ +type ScriptType = "action" | "render"; + /** - * @typedef {object} Vm State for running a script in. - * @property {ScriptType} mode Mutability status - * @property {string} output Markup to render / output + * State for running a script in. */ +type Vm = { + /** Mutability status */ + mode: ScriptType; + /** Markup to render / output */ + output: string; +}; /** * @param {Vm} state VM state @@ -24,7 +37,7 @@ * @returns {string} Markup to render / output */ function renderCard(state, code) { - const script = Notcl.parse(code); + const script = parse(code); if (script[0]) { state.output = JSON.stringify(script[1], null, 2); } else { @@ -88,7 +101,7 @@ const debugDisplay = document.createElement("pre"); function render() { const vm = { - mode: /** @type {ScriptType} */ ("render"), + mode: /** @type {ScriptType} */ "render", output: "", }; const html = renderCard(vm, theCard.code); diff --git a/src/helpers.ts b/src/helpers.ts index a41c47a..7e5969d 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,9 +1,9 @@ const escapeDiv = document.createElement("div"); /** - * @param {string} text Potentially dangerous text - * @returns {string} Text safe to embed in HTML + * @param text Potentially dangerous text + * @returns Text safe to embed in HTML **/ -function escapeHtml(text) { +export function escapeHtml(text: string): string { escapeDiv.textContent = text; return escapeDiv.innerHTML; } diff --git a/src/notcl.ts b/src/notcl.ts index c8b8724..6a1730b 100644 --- a/src/notcl.ts +++ b/src/notcl.ts @@ -1,115 +1,106 @@ +import { escapeHtml } from "./helpers"; +import { AtLeast, Choose, End, Regex, Sequence, Use } from "./peg"; + +export type Word = { + text: string; +}; +export type Command = Word[]; +export type Script = Command[]; + +const InterCommandWhitespace = Regex(/\s+/y).expects("whitespace"); + +const Comment = Regex(/#[^\n]*/y) + .expects("#") + .map(() => []); + +const PreCommand = AtLeast(0, InterCommandWhitespace); + +const PreWordWhitespace = Regex(/[^\S\n;]+/y).expects("whitespace"); + +const BasicWord = Regex(/(?!\{)[^\s;]+/y) + .map(([word]) => ({ text: word })) + .expects("BASIC_WORD"); + +// WIP, need to be able to escape braces correctly + +const Brace = Sequence( + Regex(/\{/y).expects("{"), + AtLeast( + 0, + Choose( + Use(() => Brace) + .expects("{") + .map((text) => `{${text}}`), + Regex(/[^{}]+/y) + .expects("text") + .map(([text]) => text) + ) + ), + Regex(/\}/y).expects("}") +).map(([_left, fragments, _right]) => fragments.join("")); + +const Word = Choose( + BasicWord, + Brace.map((text) => ({ text })) +); + +const CommandTerminator = Regex(/[\n;]/y) + .expects("NEWLINE | ;") + .map(() => true); + +const Command = Sequence( + Word, + AtLeast( + 0, + Sequence(PreWordWhitespace, Word).map(([, word]) => word) + ), + AtLeast(0, PreWordWhitespace) +).map(([word, moreWords]) => [word].concat(moreWords)); + +const Script = Sequence( + AtLeast( + 0, + Choose( + PreWordWhitespace.map(() => []), + CommandTerminator.map(() => []), + Sequence(Comment, Choose(CommandTerminator, End())).map(() => []), + Sequence(Command, Choose(CommandTerminator, End())).map( + ([words]) => words + ) + ) + ), + End() +).map(([commands]) => commands.filter((command) => command.length > 0)); + +const ERROR_CONTEXT = /(?<=([^\n]{0,50}))([^\n]{0,50})/y; + /** - * @typedef {Notcl.Command[]} Notcl.Script - * @typedef {Notcl.Word[]} Notcl.Command - * @typedef {object} Notcl.Word - * @property {string} text + * Parse out a Notcl script into an easier-to-interpret representation. + * No script is actually executed yet. + * + * @param - code to parse + * @returns - parsed list of commands, or error message on failure */ +export function parse(code: string): [true, Script] | [false, string] { + /* Preprocess */ + // fold line endings + code = code.replace(/(? { - const { AtLeast, Choose, End, Regex, Sequence, Use } = Peg; + /* Parse */ + const [commands, errorPos, expected] = Script(code, 0); - const InterCommandWhitespace = Regex(/\s+/y).expects("whitespace"); - - const Comment = Regex(/#[^\n]*/y) - .expects("#") - .map(() => []); - - const PreCommand = AtLeast(0, InterCommandWhitespace); - - const PreWordWhitespace = Regex(/[^\S\n;]+/y).expects("whitespace"); - - const BasicWord = Regex(/(?!\{)[^\s;]+/y) - .map(([word]) => ({ text: word })) - .expects("BASIC_WORD"); - - // WIP, need to be able to escape braces correctly - - /** @type {Peg.Pattern} */ - const Brace = Sequence( - Regex(/\{/y).expects("{"), - AtLeast( - 0, - Choose( - Use(() => Brace) - .expects("{") - .map((text) => `{${text}}`), - Regex(/[^{}]+/y) - .expects("text") - .map(([text]) => text) - ) - ), - Regex(/\}/y).expects("}") - ).map(([_left, fragments, _right]) => fragments.join("")); - - const Word = Choose( - BasicWord, - Brace.map((text) => ({ text })) - ); - - const CommandTerminator = Regex(/[\n;]/y) - .expects("NEWLINE | ;") - .map(() => true); - - /** @type {Peg.Pattern} */ - const Command = Sequence( - Word, - AtLeast( - 0, - Sequence(PreWordWhitespace, Word).map(([, word]) => word) - ), - AtLeast(0, PreWordWhitespace) - ).map(([word, moreWords]) => [word].concat(moreWords)); - - /** @type {Peg.Pattern} */ - const Script = Sequence( - AtLeast( - 0, - Choose( - PreWordWhitespace.map(() => []), - CommandTerminator.map(() => []), - Sequence(Comment, Choose(CommandTerminator, End())).map(() => []), - Sequence(Command, Choose(CommandTerminator, End())).map( - ([words]) => words - ) - ) - ), - End() - ).map(([commands]) => commands.filter((command) => command.length > 0)); - - const ERROR_CONTEXT = /(?<=([^\n]{0,50}))([^\n]{0,50})/y; - - return { - /** - * Parse out a Notcl script into an easier-to-interpret representation. - * No script is actually executed yet. - * - * @param {string} code to parse - * @returns {[true, Notcl.Script] | [false, string]} parsed list of commands, or error message on failure - */ - parse(code) { - /* Preprocess */ - // fold line endings - code = code.replace(/(?Error at position ${errorPos} + if (commands) { + return [true, commands[0]]; + } else { + ERROR_CONTEXT.lastIndex = errorPos; + const [, before, after] = ERROR_CONTEXT.exec(code)!; + return [ + false, + `
Error at position ${errorPos}
 ${escapeHtml(before + "" + after)}
 ${"-".repeat(before.length)}^
 
 Expected: ${escapeHtml(expected)}
`, - ]; - } - }, - }; -})(); + ]; + } +} diff --git a/src/peg.ts b/src/peg.ts index f1136c9..8016f9e 100644 --- a/src/peg.ts +++ b/src/peg.ts @@ -1,44 +1,48 @@ /** * A Pattern is a function that matches against a string starting at a given index. * - * If it matches successfully, it returns some captured value, and the index following the match. - * - * On success or failure, it returns the furthest point the pattern could make sense of, and a description of what was expected next at that point. - * - * For simple patterns, the "furthest point" may just be the following index; however, some more complex patterns might succeed, - * but consume less input than they would have been able to if some other expected symbol was found. Reporting - * the furthest a pattern could hypothetically have gotten can help generate better error messages if no valid parse tree is found. - * - * @template T - * @callback Peg.PatternCall - * @param {string} source - the string being parsed - * @param {number} index - the index in the string to begin matching from - * @returns {[[T, number] | null, number, string]} - [successValue, furthest symbol attempted, expected pattern] */ -/** - * @template T - * @typedef {object} Peg.PatternExt - * @property {(map: (value: T) => U) => Peg.Pattern} map Creates a pattern that wraps another pattern, transforming the returned value on a match - * @property {string} expectLabel A human-readable annotation describing the pattern for error messages - * @property {(label: string) => Peg.Pattern} expects Adds a human-readable annotation describing the pattern - */ -/** - * @template T - * @typedef {Peg.PatternCall & Peg.PatternExt} Peg.Pattern - */ -var Peg = window.Peg ?? {}; +export type Pattern = PatternFunc & { + /** + * Creates a pattern that wraps another pattern, transforming the returned value on a match. + * + * @param map - Mapping function + */ + map(map: (value: T) => U): Pattern; + + /** A human-readable annotation describing the pattern for error messages */ + expectLabel: string; + + /** Adds a human-readable annotation describing the pattern */ + expects(label: string): Pattern; +}; +type PatternFunc = { + /** + * If the pattern matches successfully, it returns some captured value, and the index following the match. + * + * It may also return an error, if that error may have prevented the pattern from matching more than it did. + * + * Some more complex patterns might succeed, but consume less input than they would have been able to if some + * other expected symbol was found. Reporting the furthest a pattern could hypothetically have gotten can help generate + * better error messages if no valid parse tree is found. + * + * @param source - the string being parsed + * @param index - the index in the string to begin matching from + * @returns - [successValue, furthest symbol attempted, expected pattern] + */ + (source: string, index: number): [[T, number] | null, number, string]; +}; /** * Makes a pattern from a function, adding helper methods. * - * @template T * @param {(source: string, index: number) => ([[T, number] | null, number, string])} matchFunc * @returns {Peg.Pattern} */ -Peg.WrapPattern = function (matchFunc) { - const pattern = /** @type {Peg.Pattern} */ (matchFunc); - pattern.map = function (map) { - return Peg.WrapPattern(function (source, index) { +function WrapPattern(matchFunc: PatternFunc) { + const pattern = matchFunc as Pattern; + pattern.map = (map) => { + return WrapPattern((source, index) => { const [value, furthest, expected] = pattern(source, index); return [value ? [map(value[0]), value[1]] : null, furthest, expected]; }).expects(pattern.expectLabel); @@ -51,31 +55,26 @@ Peg.WrapPattern = function (matchFunc) { }; return pattern; -}; +} /** * Proxies to a pattern retrieved from an accessor function. * * Allows using a pattern recursively in its own definition, by returning the value of the const assigned to. * - * @template T - * @param {() => Peg.Pattern} getPattern - * @returns {Peg.Pattern} + * @param getPattern */ -Peg.Use = function (getPattern) { - return Peg.WrapPattern(function (source, index) { - return getPattern()(source, index); - }).expects(String(getPattern)); -}; +export function Use(getPattern: () => Pattern): Pattern { + return WrapPattern((source, index) => getPattern()(source, index)).expects( + String(getPattern) + ); +} /** * Creates a pattern matching a regex & returning any captures. The regex needs to be sticky (using the //y modifier) - * @param {RegExp} regex - * @return {Peg.Pattern} */ -Peg.Regex = function (regex) { - /** @type {Peg.Pattern} */ - const pattern = Peg.WrapPattern(function (source, index) { +export function Regex(regex: RegExp): Pattern { + const pattern = WrapPattern((source, index) => { regex.lastIndex = index; const matches = regex.exec(source); return matches @@ -83,19 +82,18 @@ Peg.Regex = function (regex) { : [null, index, pattern.expectLabel]; }).expects(regex.source); return pattern; -}; +} /** * Creates a pattern that tries the given patterns, in order, until it finds one that matches at the current index. - * @template T * @param {...Peg.Pattern} patterns - * @return {Peg.Pattern} + * @return {} */ -Peg.Choose = function (...patterns) { +export function Choose(...patterns: Pattern[]): Pattern { const genericExpected = patterns .map((pattern) => pattern.expectLabel) .join(" | "); - return Peg.WrapPattern(function (source, index) { + return WrapPattern((source, index) => { let furthestFound = index; let furthestExpected = genericExpected; for (const pattern of patterns) { @@ -111,21 +109,20 @@ Peg.Choose = function (...patterns) { } return [null, furthestFound, furthestExpected]; }).expects(genericExpected); -}; +} /** * Creates a pattern that concatenates the given patterns, returning a tuple of their captured values. * * For example, if A matches "a" and captures 1, while B matches "b" and captures null, * then `Sequence(A,B)` will match "ab" and capture [1, null] - * @template {unknown[]} T - * @param {{[K in keyof T]: Peg.Pattern}} patterns - * @return {Peg.Pattern} */ -Peg.Sequence = function (...patterns) { +export function Sequence( + ...patterns: { [K in keyof T]: Pattern } +): Pattern { const genericExpected = patterns[0]?.expectLabel ?? "(nothing)"; - return Peg.WrapPattern(function (source, index) { - const values = /** @type {T} */ (/** @type {unknown} */ ([])); + return WrapPattern((source, index) => { + const values: unknown[] = []; let furthestFound = index; let furthestExpected = genericExpected; for (const pattern of patterns) { @@ -142,9 +139,9 @@ Peg.Sequence = function (...patterns) { values.push(value[0]); index = value[1]; } - return [[values, index], furthestFound, furthestExpected]; + return [[values as T, index], furthestFound, furthestExpected]; }).expects(genericExpected); -}; +} /** * Creates a pattern that matches consecutive runs of the given pattern, returning an array of all captures. @@ -154,14 +151,12 @@ Peg.Sequence = function (...patterns) { * If the given pattern does not consume input, the matching will be terminated to prevent an eternal loop. * * Note that if the minimum run is zero, this pattern will always succeed, but might not consume any input. - * @template {unknown} T * @param {number} min - * @param {Peg.Pattern} pattern - * @return {Peg.Pattern} + */ -Peg.AtLeast = function (min, pattern) { - return Peg.WrapPattern(function (source, index) { - const values = /** @type {T[]} */ ([]); +export function AtLeast(min, pattern: Pattern): Pattern { + return WrapPattern(function (source, index) { + const values: T[] = []; let furthestFound = index; let furthestExpected = pattern.expectLabel; do { @@ -185,20 +180,18 @@ Peg.AtLeast = function (min, pattern) { return [null, furthestFound, furthestExpected]; } }).expects(pattern.expectLabel); -}; +} /** * Creates a pattern that matches the end of input - * @return {Peg.Pattern} */ -Peg.End = () => { - /** @type {Peg.Pattern} */ - const end = Peg.WrapPattern(function End(source, index) { +export function End(): Pattern { + const end = WrapPattern(function End(source, index) { return [ - source.length == index ? [/** @type {true} */ (true), index] : null, + source.length == index ? [true, index] : null, index, end.expectLabel, ]; }).expects(""); return end; -}; +}