diff --git a/pkg/core/src/index.ts b/pkg/core/src/index.ts index 075db3b1..21c85e5d 100644 --- a/pkg/core/src/index.ts +++ b/pkg/core/src/index.ts @@ -4,3 +4,209 @@ export * from '@slangroom/core/lexer'; export * from '@slangroom/core/visitor'; export * from '@slangroom/core/plugin'; export * from '@slangroom/core/slangroom'; + +/** + * The Core of Slangroom. + * + * @remarks + * - The lexer module defines the lexer that is used to split up strings of + * characters of a line by whitespace, while keeping the whitespace intact + * inside identifiers. It generates tokens with position information. + * + * - The parser module defines the parser that is used to parse the list of + * tokens of a line. It creates a CST (Concrete Syntax Tree) with a list of + * possible matches of plugin definitions. + * + * - The visitor module defines the visitor that is used to generate an AST + * (Abstract Syntax Tree) out of the given CST. It keeps the information of + * what needs to be provided to which plugin definition. + * + * - The plugin module defines the plugins subsystem, where a plugin can define + * multiple plugin definitions, each of which defines a unique plugin + * definition inside of that parcitular plugin. + * + * - The Slangroom module is the entrypoint to the whole system. It uses a list + * of plugins to execute a contract as if the custom statements defined in a + * contract is actually run by Zenroom itself, a seamless experience. + * + * @example + * Let's define an example plugin with a single, simple plugin definitons: + * ```ts + * // file: my-plugin.ts + * import {Plugin} from "@slangroom/core"; + * + * const p = new Plugin(); + * + * p.new("love asche", ctx => { + * return ctx.pass("everything is okay, and this is my return value"); + * }); + * + * export const myPlugin = p; + * ``` + * + * The callback function of the plugin definition above would be ran when a + * custom statements of the following possible forms are matched (everything but + * the "I" is case-insensitive): + * ``` + * Given I love Asche + * Then I love Asche + * Given I love Asche and output into 'result' + * Then I love Asche and output into 'other_result' + * ``` + * + * The statements starting with Given are executed before the actual Zenroom + * execution takes place, and statements starting with Then are executed after + * the actual execution takes place. + * + * We can later use that definion with a Slangroom instance: + * ```ts + * import {Slangroom} from '@slangroom/core'; + * import {myPlugin} from "my-plugin"; + * + * const sl = new Slangroom(myPlugin); + * + * const {result, logs} = sl.execute(contract, params) + * ``` + * + * @example + * Let's define an example plugin with parameters now: + * ```ts + * // file: my-plugin.ts + * import {Plugin} from "@slangroom/core"; + * + * const p = new Plugin(); + * + * p.new(["first_number", "second_number"], "add up", ctx => { + * const first = ctx.fetch("first_number"); + * if (typeof first !== "number") + * return ctx.fail("first_number must be a number") + * const second = ctx.fetch("second_number"); + * if (typeof second !== "number") + * return ctx.fail("second_number must be a number") + * return ctx.pass(first + second); + * }); + * + * export const myPlugin = p; + * ``` + * + * The callback function of the plugin definition above would be ran when a + * custom statements of the following possible forms are matched (everything but + * the "I" is case-insensitive): + * ``` + * Given I send first_number 'ident1' and send second_number 'ident2' and add up + * Then I send first_number 'ident1' and send second_number 'ident2' and add up + * Given I send second_number 'ident1' and send first_number 'ident2' and add up + * Then I send second_number 'ident1' and send first_number 'ident2' and add up + * Given I send first_number 'ident1' and send second_number 'ident2' and add up and output into 'result' + * Then I send first_number 'ident1' and send second_number 'ident2' and add up and output into 'another_result' + * Given I send second_number 'ident1' and send first_number 'ident2' and add up and add up and output into 'result' + * Then I send second_number 'ident1' and send first_number 'ident2' and add up and add up and output into 'other_result' + * ``` + * + * The statements starting with Given are executed before the actual Zenroom + * execution takes place, and statements starting with Then are executed after + * the actual execution takes place. The first four statements don't make much + * sense, as the whole reason we created this plugin is to use its return value, + * but, but this is allowed by design. + * + * We can later use that definion with a Slangroom instance: + * ```ts + * import {Slangroom} from '@slangroom/core'; + * import {myPlugin} from "my-plugin"; + * + * const sl = new Slangroom(myPlugin); + * + * const {result, logs} = sl.execute(contract, params) + * ``` + * + * @example + * Let's define an example plugin with parameters and open now: + * ```ts + * // file: my-plugin.ts + * import {Plugin} from "@slangroom/core"; + * + * const p = new Plugin(); + * + * p.new("open", ["content"], "write to file", ctx => { + * const path = ctx.fetchOpen()[0]; + * const cont = ctx.fetch("content"); + * if (typeof cont !== "string") + * return ctx.fail("content must be a number"); + * const {result, error} = fs.writeToFile(path, cont); + * if (error) + * return ctx.fail(error); + * return ctx.pass(result); + * }); + * + * export const myPlugin = p; + * ``` + * + * The callback function of the plugin definition above would be ran when a + * custom statements of the following possible forms are matched (everything but + * the "I" is case-insensitive): + * ``` + * Given I open 'ident1' and send content 'ident1' and write to file + * Then I open 'ident1' and send content 'ident1' and write to file + * Given I open 'ident1' and send content 'ident1' and write to file and output into 'result' + * Then I open 'ident1' and send content 'ident1' and write to file and ountput into 'other_result' + * ``` + * + * The statements starting with Given are executed before the actual Zenroom + * execution takes place, and statements starting with Then are executed after + * the actual execution takes place. + * + * We can later use that definion with a Slangroom instance: + * ```ts + * import {Slangroom} from '@slangroom/core'; + * import {myPlugin} from "my-plugin"; + * + * const sl = new Slangroom(myPlugin); + * + * const {result, logs} = sl.execute(contract, params) + * ``` + * + * @example + * We can also just define a plugin that uses connect and a phrase: + * ```ts + * // file: my-plugin.ts + * import {Plugin} from "@slangroom/core"; + * + * const p = new Plugin(); + * + * p.new("connect", "ping once", ctx => { + * const host = ctx.fetchConnect()[0]; + * const {result, error} = net.pingHost(host); + * if (error) + * return ctx.fail(error); + * return ctx.pass(result); + * }); + * + * export const myPlugin = p; + * ``` + * + * The callback function of the plugin definition above would be ran when a + * custom statements of the following possible forms are matched (everything but + * the "I" is case-insensitive): + * ``` + * Given I connect to 'ident1' and ping once + * Then I connect to 'ident1' and ping once + * Given I connect to 'ident1' and ping once and output into 'result' + * Then I connect to 'ident1' and ping once and output into 'other_result' + * ``` + * + * The statements starting with Given are executed before the actual Zenroom + * execution takes place, and statements starting with Then are executed after + * the actual execution takes place. + * + * We can later use that definion with a Slangroom instance: + * ```ts + * import {Slangroom} from '@slangroom/core'; + * import {myPlugin} from "my-plugin"; + * + * const sl = new Slangroom(myPlugin); + * + * const {result, logs} = sl.execute(contract, params) + * ``` + * + * @packageDocumentation + */ diff --git a/pkg/core/src/lexer.ts b/pkg/core/src/lexer.ts index c0118b8a..d309b121 100644 --- a/pkg/core/src/lexer.ts +++ b/pkg/core/src/lexer.ts @@ -1,17 +1,75 @@ +/** + * A whitespace-separated string of characters with position information. + * + * @remarks + * The whole instance of of this class must be thought as a concrete, immutable + * unit. + * + * When that string of characters corresponds to an identifier, which is wrapped + * in a pair of single-quotes, the whitespace inside the quotes remains intact. + * + * @example + * `love` and `Asche` are tokens, so are `'transfer id'` and `'http method'`. + */ export class Token { + /** + * The raw string of a token, such as `Asche`. + */ + readonly raw: string; + + /** + * The lowercased version of {@link raw} string, such as `asche`. + */ readonly name: string; + + /** + * The start position of the token in the given line, must be non-negative. + */ + readonly start: number; + + /** + * The end position of the token in the given line, must be >= {@link start}. + */ + readonly end: number; + + /** + * Whether this token is an identifier or not. It is an identifier if the + * first character of {@link raw} is a single-quote. + */ readonly isIdent: boolean; - constructor( - readonly raw: string, - readonly start: number, - readonly end: number, - ) { + /** + * Creates a new instance of {@link Token}. + * + * @throws {@link Error} + * If {@link raw} is empty. + * + * @throws {@link Error} + * If {@link start} is negative. + * + * @throws {@link Error} + * If {@link end} is less than {@link start}. + */ + constructor(raw: string, start: number, end: number) { + if (!raw) throw new Error('raw cannot be empty string'); + if (start < 0) throw new Error('start cannot be negative'); + if (end < start) throw new Error('end cannot be less than start'); + + this.raw = raw; + this.start = start; + this.end = end; this.name = this.raw.toLowerCase(); this.isIdent = this.raw.charAt(0) === "'"; } } +/** + * An error encountered during the lexican analysis phrase. + * + * @privateRemarks + * Currently, we only have an error of unclosed single-quotes. I don't know if + * we'll ever need anything other than this. + */ export class LexError extends Error { constructor(t: Token) { super(); @@ -20,6 +78,12 @@ export class LexError extends Error { } } +/** + * Analyzes the given line lexically to generate an array of tokens. + * + * @throws {@link LexError} + * If any error during lexing is encountered. + */ export const lex = (line: string): Token[] => { const tokens: Token[] = []; const c = [...line]; diff --git a/pkg/core/src/lexicon.ts b/pkg/core/src/lexicon.ts deleted file mode 100644 index 247b9c7b..00000000 --- a/pkg/core/src/lexicon.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { createToken, Lexer, type TokenType } from '@slangroom/deps/chevrotain'; - -/** - * The central place to create our lexicon/vocabulary. - * - * It was needed because the lexer and the parser will need a way to have access - * to a shared but dynamic set of tokens. - */ -export class Lexicon { - #store = new Map(); - - constructor() { - this.#store.set( - 'whitespace', - createToken({ - name: 'Whitespace', - pattern: /\s+/, - group: Lexer.SKIPPED, - }), - ); - - this.#store.set( - 'comment', - createToken({ - name: 'Comment', - pattern: /#[^\n\r]*/, - group: 'comments', - }), - ); - - this.#store.set( - 'identifier', - createToken({ - name: 'Identifier', - pattern: /'(?:[^\\']|\\(?:[bfnrtv'\\/]|u[0-9a-fA-F]{4}))*'/, - }), - ); - } - - /** - * Returns the token by name and create it if it doesn't exist. - * - * @returns the token found in the lexicon or created if it isn't. - */ - token(name: string) { - const first = name[0]?.toUpperCase(); - const rest = name.slice(1).toLowerCase(); - if (!first) throw new Error('name must not be empty string'); - - const tokname = `${first}${rest}`; - const tokregex = tokname.toLowerCase(); - const found = this.#store.get(tokregex); - if (found) return found; - - const tok = createToken({ - name: tokname, - pattern: new RegExp(tokregex, 'i'), - }); - this.#store.set(tokregex, tok); - return tok; - } - - /** - * The array of all unique lexemes/tokens. - */ - get tokens() { - // remember: values() returns in insertion order - return [...this.#store.values()]; - } -} diff --git a/pkg/core/src/parser.ts b/pkg/core/src/parser.ts index 45bd1a25..f4516a51 100644 --- a/pkg/core/src/parser.ts +++ b/pkg/core/src/parser.ts @@ -1,6 +1,36 @@ import { PluginMap, Token, type PluginMapKey } from '@slangroom/core'; +/** + * Represents an error encountered during the parsing phrase. + * + * @remarks + * The class must not be used directly with `new`. Instead, the static + * constructor methods must be used. + * + * In case of: + * - a wrong type of token is found, use {@link wrong}; + * - a token is not provided, missing, use {@link missing}; + * - an extra token is found, use {@link extra}: + */ export class ParseError extends Error { + /** + * Represents an error case where a wrong type of one or multiple tokens are + * found. If a token is not found, use {@link missing}. + * + * @example + * An alternative can be provided with the second parameter: + * ```ts + * if (token.name !== "asche") + * throw ParseError.wrong(token, "asche") + * ``` + * + * @example + * Multiple alternatives (e.g. "foo", or "bar", or "baz") can be provided also: + * ```ts + * if (token.name !== "given" || token.name !== "then") + * throw ParseError.wrong(token, "given", "then") + * ``` + */ static wrong(have: Token, wantFirst: string, ...wantRest: string[]) { const wants = [wantFirst, ...wantRest]; return new ParseError( @@ -10,11 +40,38 @@ export class ParseError extends Error { ); } + /** + * Represents an error case where a token is missing. If a token is found + * but of a wrong type, use {@link wrong}. + * + * @example + * What is expected can be provided with the first parameter: + * ```ts + * if (tokenDoesntExist) + * throw ParseError.missing("asche") + * ``` + * + * @example + * What is expected could be multiple (e.g. "foo", or "bar", or "baz") also: + * ```ts + * if (tokenDoesntExist) + * throw ParseError.missing("given", "then") + * ``` + */ static missing(wantFirst: string, ...wantRest: string[]) { const wants = [wantFirst, ...wantRest]; return new ParseError(`missing token(s): ${wants.join(', ')}`); } + /** + * Represents an error case where an unexpected extra token is found. + * + * @example + * ```ts + * if (token) + * throw ParseError.extra(token) + * ``` + */ static extra(token: Token) { return new ParseError(`extra token (${token.start}, ${token.end}): ${token.raw}`); } @@ -28,28 +85,104 @@ export class ParseError extends Error { } } +/** + * The CST (Concrete Syntax Tree) of a parsed line with error information. + */ export type Cst = { + /** + * Whether the line starts with "Given" or "Then" tokens, or none. + */ givenThen?: 'given' | 'then'; + + /** + * Any errors not involving the match of a plugin is found during parsing. + * Errors such as "Given is not found" or "I needs to be capitalized" would + * fit in this category. + */ errors: ParseError[]; + + /** + * List of possible matches of plugins with their possible errors for ranking and + * reporting. + */ matches: Match[]; }; +/** + * A possible match of a plugin using its {@link PluginMapKey}. + */ export type Match = { + /** + * List of bindings of the plugin, pairs of parameters of the plugin and + * their corresponding values, which are JSON keys that point to the actual + * values. + * + * @example + * `Given I send foo 'bar' and send baz 'quz' ...` would result in a map of + * bindings like this: + * + * ``` + * { + * "foo": "bar", + * "baz": "quz", + * } + * + * Here, `foo` and `baz` are parameters of a plugin, and `bar` and `quz` are + * keys to the actual values (found in `data` or `keys` section of Zenroom). + * ``` + */ bindings: Map; + + /** + * The key of the match, a {@link PluginMapKey}. This is what allows us to + * know that a match belongs to a certain plugin. + */ key: PluginMapKey; + + /** + * List of possible parsing errors specific to the plugin. Currently, this + * is what allows us to rank the list of possible matches. + */ err: ParseError[]; + + /** + * Whether this line wants to output the result of its plugin to a variable, + * later to be used in other statements, perhaps. + */ into?: string; } & ( | { + /** + * Whether this line uses Open or Connect. Having neither is an + * error that would show up in {@link err}. + */ open?: string; + + /** + * Whether this line uses Open or Connect. Having neither is an + * error that would show up in {@link err}. + */ connect?: never; } | { + /** + * Whether this line uses Open or Connect. Having neither is an + * error that would show up in {@link err}. + */ open?: never; + + /** + * Whether this line uses Open or Connect. Having neither is an + * error that would show up in {@link err}. + */ connect?: string; } ); +/** + * Parses the given tokens of a lexed line and plugins, and generates a CST with possible + * matches of those plugins. + */ export const parse = (p: PluginMap, t: Token[]): Cst => { const cst: Cst = { matches: [], diff --git a/pkg/core/src/plugin.ts b/pkg/core/src/plugin.ts index 942b2cbf..6c610f00 100644 --- a/pkg/core/src/plugin.ts +++ b/pkg/core/src/plugin.ts @@ -1,12 +1,65 @@ import type { Jsonable } from '@slangroom/shared'; import type { Ast } from '@slangroom/core'; +/** + * The representation of a plugin definition. It is also used as a key in + * {@link PluginMap}. + */ export type PluginMapKey = { + /** + * The phrase of the plugin. + * + * @example + * The `love asche` of the supposed plugin definition + * ``` + * Given I send howmuch 'quantity' and love asche. + * ``` + * is the phrase part. + * + * It must only be composed of alpha-numerical values with underscores or + * dashes in between them (let's call them "words" here), and those "words" + * must be split by whitespace (ensured by the plugin definition as just a + * single blank space). + */ phrase: string; + + /** + * An unordered list of parameters of a plugin. + * + * @example + * The `object` and `header` of the supposed plugin definition + * ``` + * Given I connect to 'url' and send object 'obj' and send headers 'hdr' and do http post request + * ``` + * are the example of parameters of that definition. A plugin might not + * have any parameters. + */ params?: string[]; + + /** + * Whether a plugin definition uses Open or Connect, or not. + * + * @example + * The `connect to 'url'` of the plugin definiton + * ``` + * Given I connect to 'url' and send http get request + * ``` + * is the Connect. + * + * @example + * The `open 'url'` of the plugin definiton + * ``` + * Given I open 'path' and read from file + * ``` + * is the Open. + */ openconnect?: 'open' | 'connect'; }; +/** + * An error indicating that in a {@link PluginMap}, there exist more than a + * single unique {@link PluginMapKey}. + */ export class DuplicatePluginError extends Error { constructor({ openconnect, params, phrase }: PluginMapKey) { super( @@ -18,9 +71,33 @@ export class DuplicatePluginError extends Error { } } +/** + * A custom map implementation that uses {@link PluginMapKey} for the key parts, + * and {@link PluginExecutor} for the value parts. + * + * @remarks + * The reason we needed a custom implementation of a map is because of the fact + * that that {@link Map} class in JS uses the reference values of objects to + * check for uniqueness, not the fields and values of those fields in an object, + * which is a sensible default in a JS sensible manner, but doesn't work for us... + */ export class PluginMap { + /** + * The datastore backing our implementation of a map. + * + * We use an array for the implementation, and each method mutating this + * array ensures that only a single unique value of {@link PluginMapKey} + * exists in the entire array. + */ #store: [PluginMapKey, PluginExecutor][] = []; + /** + * Finds the index of the value inside the {@link #store}, where + * {@link their} key matches with our's. + * + * @param their Their {@link PluginMapKey} to match against ours. + * @returns the index of the match if found, or `-1` otherwise. + */ #index(their: PluginMapKey) { return this.#store.findIndex(([our]) => { const openconn = their.openconnect === our.openconnect; @@ -32,38 +109,174 @@ export class PluginMap { }); } + /** + * Loops over each value of the map, with a callback function taking a + * single argument of an array of pairs of {@link PluginMapKey} and + * {@link PluginExecutor}, respectively. + * + * @privateRemarks + * I don't know why I can't just use `forEach = this.#store.forEach`, but it + * just doesn't work. So I have to define it this way, which provides a + * limited set of what's possible with {@link #store.forEach}. + */ forEach(cb: (value: [PluginMapKey, PluginExecutor]) => void) { this.#store.forEach(cb); } + /** + * If exists, looks up the {@link PluginExecutor} referenced by {@link k}. + * + * @param k The key to look up with. + * + * @returns the {@link PluginExecutor} if exists. + */ get(k: PluginMapKey) { return this.#store[this.#index(k)]?.[1]; } + /** + * Checks whether the given key {@link k} exists in the map. + * + * @param k The key to look up with. + * + * @returns Whether the key exists or not. + */ has(k: PluginMapKey) { return this.#index(k) !== -1; } + /** + * Sets the value {@link v} using the key {@link v}. + * + * @param k The key to point to the value {@link v}. + * @param v The value to be pointed to by the key {@link k}. + * + * @throws {@link DuplicatePluginError} + * If the key {@link k} already exists in the map. + */ set(k: PluginMapKey, v: PluginExecutor) { if (this.has(k)) throw new DuplicatePluginError(k); this.#store.push([k, v]); } } +/** + * A callback function taking a single argument, which is called the "context", + * that is used to decide what to do with the plugin (access parameters, make + * the execution pass or fail, etc.). + * + * @example + * ```ts + * const lowercasify = (ctx) => { + * const foo = ctx.fetch("foo"); + * if (typeof foo !== "string") + * return ctx.fail("foo must be string"); + * return ctx.pass(foo.toLowerCase()); + * }; + * ``` + */ export type PluginExecutor = (ctx: PluginContext) => PluginResult | Promise; +/** + * Collects each custom implementation of a statement under a single unit, which + * is called a "plugin". + * + * @remarks + * A plugin can contain one or many implementations of custom statements, which + * are unique in the same definition of the plugin. + */ export class Plugin { - store = new PluginMap(); + /** + * The list of plugin definitions belonging to this plugin. + */ + readonly store = new PluginMap(); + /** + * Defines a new plugin that has only a phrase. + * + * @param phrase The phrase of the definition. + * @param executor The callback function to be ran when the plugin + * definition matches with a given line. + * + * @throws {@link Error} + * If {@link phrase} is ill-formed. + * + * @throws {@link DuplicatePluginError} + * If the definition is duplicated. + */ new(phrase: string, executor: PluginExecutor): PluginExecutor; + + /** + * Defines a new plugin that has a list of parameters along with a phrase. + * + * @param params The list of parameters to be needed by this plugin. + * @param phrase The phrase of the definition. + * @param executor The callback function to be ran when the plugin + * definition matches with a given line. + * + * @throws {@link Error} + * If any value of {@link params} is ill-formed. + * + * @throws {@link Error} + * If {@link params} has duplicated entries. + * + * @throws {@link Error} + * If {@link phrase} is ill-formed. + * + * @throws {@link DuplicatePluginError} + * If the definition is duplicated. + */ new(params: string[], phrase: string, executor: PluginExecutor): PluginExecutor; + + /** + * Defines a new plugin that has an open or connect along with a phrase. + * + * @param openconnect Whether this definition uses open or connect. + * @param phrase The phrase of the definition. + * @param executor The callback function to be ran when the plugin + * definition matches with a given line. + * + * @throws {@link Error} + * If {@link phrase} is ill-formed. + * + * @throws {@link DuplicatePluginError} + * If the definition is duplicated. + */ new(openconnect: 'open' | 'connect', phrase: string, executor: PluginExecutor): PluginExecutor; + + /** + * Defines a new plugin that has an open or connect along with a list of + * params and a phrase. + * + * @param openconnect Whether this definition uses open or connect. + * @param params The list of parameters to be needed by this plugin. + * @param phrase The phrase of the definition. + * @param executor The callback function to be ran when the plugin + * definition matches with a given line. + * + * @throws {@link Error} + * If any value of {@link params} is ill-formed. + * + * @throws {@link Error} + * If {@link params} has duplicated entries. + * + * @throws {@link Error} + * If {@link phrase} is ill-formed. + * + * @throws {@link DuplicatePluginError} + * If the definition is duplicated. + */ new( openconnect: 'open' | 'connect', params: string[], phrase: string, executor: PluginExecutor, ): PluginExecutor; + + /** + * The concerete implementation of the many overridden {@link new} methods. + * See them for their documentation. + */ new( phraseOrParamsOrOpenconnect: string | string[] | 'open' | 'connect', executorOrPhraseOrParams: PluginExecutor | string | string[], @@ -166,14 +379,22 @@ export const isSane = (str: string) => /^[a-z][a-z0-9]*([_-][a-z0-9]+)*$/.test(s // Todo: Maybe we should adapt some sort of monad library. /** - * Result of a Plugin execution. + * Result of a plugin execution. */ export type PluginResult = ResultOk | ResultErr; + +/** + * Result of a plugin execution, indicating success. + */ export type ResultOk = { ok: true; value: Jsonable }; + +/** + * Result of a plugin execution, indicating failure. + */ export type ResultErr = { ok: false; error: any }; /** - * The Plugin Context. It has every info a Plugin needs, plus some utilities. + * The Plugin Context. It has every info a plugin needs, plus some utilities. */ export type PluginContext = { /** diff --git a/pkg/core/src/slangroom.ts b/pkg/core/src/slangroom.ts index f5d2c218..4a087f89 100644 --- a/pkg/core/src/slangroom.ts +++ b/pkg/core/src/slangroom.ts @@ -2,11 +2,38 @@ import { getIgnoredStatements } from '@slangroom/ignored'; import { type ZenOutput, ZenParams, zencodeExec } from '@slangroom/shared'; import { lex, parse, visit, Plugin, PluginMap, PluginContextImpl } from '@slangroom/core'; +/** + * Just a utility type to ease typing. + */ export type Plugins = Plugin | Plugin[]; +/** + * The entrypoint to the Slangroom software. + * + * @example + * ```ts + * import {http} from "@slangroom/http"; + * import {git} from "@slangroom/git"; + * + * const sl = new Slangroom(http, git); + * const {result, logs} = sl.execute(contractWithCustomStatements, zenroomParameters) + * ``` + */ export class Slangroom { + /** + * The datastore that stores our plugins. + */ #plugins = new PluginMap(); + /** + * Creates an instance of {@link Slangroom}. + * + * @param first A plugin or list of it. + * @param rest A plugin or list of it, spreaded. + * + * @throws {@link @slangroom/core/plugin#DuplicatePluginError} + * If any of the plugin definitions have duplicates. + */ constructor(first: Plugins, ...rest: Plugins[]) { const p = this.#plugins; [first, ...rest].forEach(function recurse(x) { @@ -15,6 +42,25 @@ export class Slangroom { }); } + /** + * Executes a given contract with parameters using the list of plugins + * provided at the class ininitation. + * + * @param contract The Zenroom contract with optional custom statements. + * @param optParams The Zenroom parameters to be supplied. + * + * @returns The output of Zenroom execution along with custom executors + * applied to it (before or after). + * + * @throws {@link Error} + * If there exists any general errors of the parsed lines. + * + * @throws {@link Error} + * If there exists any errors on any matches. + * + * @throws {@link Error} + * If no plugin definitions can be matched against a custom statement. + */ async execute(contract: string, optParams?: Partial): Promise { const paramsGiven = requirifyZenParams(optParams); const ignoredLines = await getIgnoredStatements(contract, paramsGiven); @@ -51,6 +97,9 @@ export class Slangroom { } } +/** + * Converts a partial {@link ZenParams} into a required {@link ZenParams}. + */ const requirifyZenParams = (params?: Partial): Required => { if (!params) return { data: {}, keys: {}, conf: '', extra: {} }; if (!params.data) params.data = {}; diff --git a/pkg/core/src/visitor.ts b/pkg/core/src/visitor.ts index f2b1ddf1..20f2375f 100644 --- a/pkg/core/src/visitor.ts +++ b/pkg/core/src/visitor.ts @@ -1,29 +1,108 @@ -import type { Cst, PluginMapKey } from '@slangroom/core'; +import type { Cst, Match, PluginMapKey } from '@slangroom/core'; import type { Jsonable, ZenParams } from '@slangroom/shared'; +/** + * The AST (Abstract Syntax Tree) representation of a custom statement, + * associated with its plugin definition. + * + * @remarks + * This is basically what allows us to feed all required parameters to a plugin + * definition and store the result to some variable, if wanted. + */ export type Ast = { + /** + * The plugin definition associated with this certain AST. + */ key: PluginMapKey; + + /** + * A set of values associated with the parameters. + * + * @example + * Given a statement like this: + * ``` + * Given I send foo 'bar' and send baz 'quz' ... + * ``` + * + * and a parameters provided like this: + * ```json + * { + * "bar": { + * "i love": "Asche", + * "array example": ["domates", "biber", "patlican"] + * }, + * "quz": "simple string" + * } + * ``` + * + * this would be populated as following: + * ```ts + * new Map([ + * ["foo", { + * "i love": "Asche", + * "array example": ["domates", "biber", "patlican"] + * }], + * ["baz", "simple string"] + * ]); + * ``` + */ params: Map; + + /** + * The name of the variable (if any) to output the result of this plugin. + * + * @example + * Given a statement like this: + * ``` + * Given I send http request and output into 'result' + * ``` + * + * this would be `result`. + */ into?: string; } & ( - | { + | { + /** + * The value of the variable used in Open, must be a string or a + * list of string, former of which is converted to an array for + * convenience. + */ open?: [string, ...string[]]; connect?: never; - } - | { + } + | { open?: never; + /** + * The value of the variable used in Connect, must be a string or a + * list of string, former of which is converted to an array for + * convenience. + */ connect?: [string, ...string[]]; - } -); + } + ); +/** + * Visits the given CST with parameters and generates the AST for it. + * + * @param cst The Concrete Syntax Tree. + * @param params The Zenroom parameters. + * + * @returns The generated AST. + * + * @throws {@link Error} + * If the given {@link cst} contains any general errors. + * + * @throws {@link Error} + * If the given {@link cst} doesn't have exactly one match. + * + * @throws {@link Error} + * If the given {@link cst}'s match contains any errors. + */ export const visit = (cst: Cst, params: ZenParams): Ast => { if (cst.errors.length) throw new Error('cst must not have any general errors'); - if (!cst.givenThen) throw new Error('cst must have a given or then'); - if (cst.matches.some((x) => x.err.length > 0)) - throw new Error('cst must not have any match errors'); - - const m = cst.matches[0]; - if (!m) throw new Error('cst must have a valid match'); + if (cst.matches.length !== 1) throw new Error('cst must have only one match'); + const m = cst.matches[0] as Match; + if (m.err.length) throw new Error("cst's match must not have any errors"); const ast: Ast = { key: m.key, @@ -31,8 +110,8 @@ export const visit = (cst: Cst, params: ZenParams): Ast => { }; if (m.into) ast.into = m.into; - if (m.open) ast.open = fetchOpen(params, m.open); - if (m.connect) ast.connect = fetchConnect(params, m.connect); + if (m.open) ast.open = fetchOpenconnect(params, m.open); + if (m.connect) ast.connect = fetchOpenconnect(params, m.connect); m.bindings?.forEach((ident, name) => { const val = fetchDatakeys(params, ident); @@ -42,16 +121,40 @@ export const visit = (cst: Cst, params: ZenParams): Ast => { return ast; }; +/** + * Gets the value of {@link rhs} from the `data` or `keys` part of Zenroom + * parameters. + * + * @remarks + * The {@link rhs} name is used in a sense that rhs is `'bar'` while the lhs + * is `foo`. + * ``` + * Given I send foo 'bar' and ... + * ``` + * + * @param params The Zenroom parameters. + * @param rsh The right hand side value, the identifier used as a key. + */ const getDatakeys = (params: ZenParams, rhs: string): undefined | Jsonable => params.data[rhs] ? params.data[rhs] : params.keys[rhs]; +/** + * Same as {@link getDatakeys}, but throws error if {@link rhs} doesn't exist. + * + * @throws {@link Error} + * If {@link rhs} doesn't exist. + */ const fetchDatakeys = (params: ZenParams, rhs: string): Jsonable => { const val = getDatakeys(params, rhs); if (!val) throw new Error('cannot be undefined'); return val; }; -const getOpen = (params: ZenParams, rhs: string): string[] => { +/** + * Basically, a wrapper around {@link getDatakeys} that ensures the type of the + * returned value. + */ +const getOpenconnect = (params: ZenParams, rhs: string): string[] => { const val = getDatakeys(params, rhs); if (typeof val === 'string') return [val]; if (Array.isArray(val)) { @@ -61,11 +164,14 @@ const getOpen = (params: ZenParams, rhs: string): string[] => { return []; }; -const fetchOpen = (params: ZenParams, rhs: string): [string, ...string[]] => { - const val = getOpen(params, rhs); - if (val.length === 0) throw new Error('a connect is required'); +/** + * Same as {@link getOpenconnect}, but throws an error if {@link rhs} doesn't exist. + * + * @throws {@link Error} + * If {@link rhs} doesn't exist. + */ +const fetchOpenconnect = (params: ZenParams, rhs: string): [string, ...string[]] => { + const val = getOpenconnect(params, rhs); + if (val.length === 0) throw new Error(`${rhs} must contain a value`); return val as [string, ...string[]]; }; - -const fetchConnect = (params: ZenParams, rhs: string): [string, ...string[]] => - fetchOpen(params, rhs);