diff --git a/src/index.ts b/src/index.ts index 2a2062f..2bafc69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import { FormCancelationReason } from '@minecraft/server-ui'; import * as bricklib from './bricklib/index.js'; import * as gatepass from './gatepass/index.js'; -import * as sift from './sift/index.js'; +import * as sifter from './sifter/index.js'; /* load plugins */ const load = bricklib.plugin.loadPlugin; load('gatepass'); -load('sift'); +load('sifter'); const mgr = new bricklib.command.CommandManager(); @@ -26,16 +26,16 @@ const def = { args: [ { id: 'text', - type: sift.parsers.variadic(sift.parsers.string()), + type: sifter.restType(sifter.stringType()), } ] }; -mgr.registerCommand(...sift.makeCommand(def, (args, src) => { +sifter.registerCommand(mgr, def, (args, src) => { gatepass.assertPermission('chat.echo', src); - src.sendMessage(args.text.join(' ')); + src.sendMessage(args.get('text')?.join(' ')); return 0; -})); +}); diff --git a/src/sifter/index.ts b/src/sifter/index.ts new file mode 100644 index 0000000..892cc45 --- /dev/null +++ b/src/sifter/index.ts @@ -0,0 +1,55 @@ +/** + * Sifter -- A command parser. + * This plugin is part of the bricklib project. + */ + +import { Player } from '@minecraft/server'; +import * as bricklib from '../bricklib/index.js'; +import { parseVerb } from './parser.js'; +import { ParseContext, ParseResult } from './state.js'; +import type { CmdVerb } from './types.ts'; + +export * from './parser.js'; +export * from './state.js'; +export * from './transforms.js'; +export type * from './types.ts'; + + +bricklib.plugin.newPlugin('sifter', () => { + /* no-op */ +}); + + +/** + * Parse a command definition. + * @param cmdDef The parsing rules. + * @param args The tokenized arguments. + * @returns The parsing result. + */ +export function parseCommand(cmdDef: CmdVerb, args: string[]): ParseResult +{ + const ctx = new ParseContext(args); + ctx.consumeToken(); /* skip the first arg (name of the cmd) */ + const res = new ParseResult(); + parseVerb(ctx, res, cmdDef); + return res; +} + +/** + * Register a custom command using sifter as its parser. + * @param mgr The bricklib custom command manager. + * @param cmdDef The command parsing definition. + * @param fn The callback function. + * @returns The registered names for the cmd. + */ +export function registerCommand( + mgr: bricklib.command.CommandManager, cmdDef: CmdVerb, + fn: (args: ParseResult, src: Player) => number): string[] +{ + let names = [cmdDef.name]; + if (cmdDef.aliases) + names = names.concat(cmdDef.aliases); + mgr.registerCommand(names, + (src, args) => fn(parseCommand(cmdDef, args), src)); + return names; +} diff --git a/src/sifter/parser.ts b/src/sifter/parser.ts new file mode 100644 index 0000000..5b75c38 --- /dev/null +++ b/src/sifter/parser.ts @@ -0,0 +1,226 @@ +import { ParseContext, ParseResult } from './state.js'; +import type { CmdArgument, CmdOption, CmdVerb } from './types.ts'; + +/** + * Parse argument. + * @param ctx The parsing context. + * @param result Where to set the result. + * @param argDef The argument definition. + * @throws This function can throw errors. + */ +export function parseArgument(ctx: ParseContext, result: ParseResult, + argDef: CmdArgument): void +{ + result.set(argDef.id, argDef.type(ctx)); +} + +/** + * Parse option arguments. + * @param ctx The parsing context. + * @param result Where to set the results. + * @param optArgDefs The option-argument definitions. + * @throws This function can throw errors. + */ +export function parseOptionArguments(ctx: ParseContext, result: ParseResult, + optArgDefs: CmdArgument[]): void +{ + const state = ctx.getCurrentState(); + if (state.reqFirstOptArg && !optArgDefs?.length) + throw `Option ${state.optionName} does not need any argument`; + + let doArgs = true; + optArgDefs?.forEach((argDef, idx) => { + + if (doArgs) + try { + ctx.pushState(); + parseArgument(ctx, result, argDef); + ctx.completeState(); + return; + } + catch (e) { + if (!argDef.optional || (state.reqFirstOptArg && idx == 0)) + throw e; + ctx.restoreState(); + doArgs = false; + } + + result.set(argDef.id, argDef.default); + }); +} + +/** + * Parse long options. + * @param ctx The parsing context. + * @param result Where to set the results. + * @param optDefs Where to lookup option defs. + * @throws This function can throw errors. + */ +export function parseLongOption(ctx: ParseContext, result: ParseResult, + optDefs: CmdOption[]): void +{ + const state = ctx.getCurrentState(); + if (ctx.isEndOfTokens) + return; + + const tok = ctx.currentToken; + if (!tok.startsWith('--')) + return; + ctx.consumeToken(); + + /* extract '--flag' and 'val' from '--flag=val' */ + const eqIdx = tok.indexOf('='); + const optName = eqIdx == -1 ? tok : tok.slice(0, eqIdx); + const eqArg = eqIdx == -1 ? null : tok.slice(eqIdx + 1); + + state.optionName = optName; + state.reqFirstOptArg = eqIdx != -1; + + /* eq arg has to be separate */ + if (eqIdx != -1) + ctx.insertToken(eqArg); + + const optDef = findOption(optDefs, optName); + + /* count the occurences of the option */ + result.count(optDef.id); + parseOptionArguments(ctx, result, optDef.args); +} + +/** + * Parse short options. + * @param ctx The parsing context. + * @param result Where to set the results. + * @param optDefs Where to lookup option defs. + * @throws This function can throw errors. + */ +export function parseShortOption(ctx: ParseContext, result: ParseResult, + optDefs: CmdOption[]): void +{ + const state = ctx.getCurrentState(); + if (ctx.isEndOfTokens) + return; + + const tok = ctx.currentToken; + if (!tok.startsWith('-')) + return; + ctx.consumeToken(); + + /* loop through each char */ + for (let i = 1; i < tok.length; i++) { + const ch = tok[i]; + const optName = '-' + ch; + + const optDef = findOption(optDefs, optName); + + result.count(optDef.id); + if (!optDef.args?.length) + continue; + + /* option def has arguments */ + const adjArg = tok.slice(i+1); + state.optionName = optName; + state.reqFirstOptArg = !!adjArg.length; + + /* option args must be in a separate token */ + if (adjArg.length) + ctx.insertToken(adjArg); + + parseOptionArguments(ctx, result, optDef.args); + break; + } +} + +/** + * Get a long/short option definition. + * @param optDefs The option defs list. + * @param optName The name of the option. + * @returns Option def. + * @throws When the option's not found. + */ +export function findOption(optDefs: CmdOption[], optName: string): CmdOption +{ + const def = optDefs?.find(def => def.names.includes(optName)); + if (!def) throw 'Unknown option: ' + optName; + return def; +} + +/** + * Parse a verb. + * @param ctx The parsing context. + * @param result Where to set the results. + * @param verbDef The verb's definition. + * @throws This function can throw errors. + */ +export function parseVerb(ctx: ParseContext, result: ParseResult, + verbDef: CmdVerb): void +{ + let stopOptions = false; + let argIdx = 0; + + while (!ctx.isEndOfTokens) { + const tok = ctx.currentToken; + + if (tok[0] == '-' && tok.length >= 2 && !stopOptions) { + /* short opts */ + if (tok[1] != '-') { + parseShortOption(ctx, result, verbDef.options); + continue; + } + /* end-of-options delimeter */ + if (tok.length == 2) { + stopOptions = true; + ctx.consumeToken(); + continue; + } + /* long opts */ + parseLongOption(ctx, result, verbDef.options); + continue; + } + + /* positional arguments */ + if (argIdx < verbDef.args?.length) { + const argDef = verbDef.args[argIdx++]; + parseArgument(ctx, result, argDef); + continue; + } + + /* try subcommands */ + if (verbDef.subverbs?.length) { + parseSubVerb(ctx, result, verbDef.subverbs); + break; + } + + throw 'Too many arguments'; + } + + /* set the defaults of other optional positional params */ + while (argIdx < verbDef.args?.length) { + const argDef = verbDef.args[argIdx++]; + if (!argDef.optional) + throw 'Insufficient arguments'; + result.set(argDef.id, argDef.default); + } +} + +/** + * Parse a subverb. + * @param ctx The parsing context. + * @param result Where to set the results. + * @param verbDefs Where to lookup subverb defs. + * @throws This function can throw errors. + */ +export function parseSubVerb(ctx: ParseContext, result: ParseResult, + verbDefs: CmdVerb[]): void +{ + const subName = ctx.consumeToken(); + const verbDef = verbDefs.find(def => + def.name == subName || def.aliases?.includes(subName)); + + if (!verbDef) + throw 'Unknown subcommand: ' + subName; + + const subRes = new ParseResult(); + parseVerb(ctx, subRes, verbDef); + result.set(verbDef.id, subRes); +} diff --git a/src/sifter/state.ts b/src/sifter/state.ts new file mode 100644 index 0000000..45a0423 --- /dev/null +++ b/src/sifter/state.ts @@ -0,0 +1,265 @@ +import * as bricklib from '../bricklib/index.js'; + +/** + * @class + * Parsing context. + */ +export class ParseContext +{ + /** + * @private + */ + private _current: ParseState; + private _states: ParseState[] = []; + + /** + * @constructor + * Creates a new parser. + * @param args The args to work with. + */ + constructor(args: string[]) + { + this._current = { + position: 0, + tokens: args, + }; + } + + /** + * Save the current state. Useful for trial parsing. + * @returns Itself. + */ + public pushState(): this + { + this._states.push(this._current); + this._current = { + position: this._current.position, + tokens: [...this._current.tokens], + }; + return this; + } + + /** + * Restore the previous state, discarding the current one. + * Useful if trial parsing fails. + * @returns Itself. + */ + public restoreState(): this + { + this._current = this._states.pop(); + return this; + } + + /** + * Discards the previous state. This could signal a successful + * trial parsing. + * @returns Itself. + */ + public completeState(): this + { + this._states.pop(); + return this; + } + + /** + * Returns the current state. + * @returns The current state. + */ + public getCurrentState(): ParseState + { + return this._current; + } + + /** + * Whether it is the end of the token stream. + */ + public get isEndOfTokens(): boolean + { + return this._current.position >= this._current.tokens.length; + } + + /** + * The current token string. + */ + public get currentToken(): string + { + return this._current.tokens[this._current.position] ?? null; + } + + /** + * Returns the current token and advance the position pointer. + * @returns The current (consumed) token string. + * @throws When there's no more tokens to consume. + */ + public consumeToken(): string + { + if (this.isEndOfTokens) + throw 'Unexpected end of input'; + return this._current.tokens[this._current.position++]; + } + + /** + * Insert a token into the current position of the current stream. + * This replaces the value of `currentToken`. + * @param tok The token to insert. + * @returns Itself. + */ + public insertToken(tok: string): this + { + this._current.tokens.splice(this._current.position, 0, tok); + return this; + } + + /** + * Replace the token at the current position of the current stream. + * This also replaces the value of `currentToken`. + * @param tok The token to be substituted. + * @returns Itself. + */ + public replaceToken(tok: string): this + { + this._current.tokens.splice(this._current.position, 1, tok); + return this; + } +} + +/** + * @class + * The parsing result. + */ +export class ParseResult = any> + implements Iterable> +{ + /** + * @private + */ + private _result: T = Object.create(null) as T; + + /** + * Increment a counter. Note: this is not strictly typed. + * @param key The result key. + */ + public count(key: K): void + { + let cnt: any = this.get(key); + if (typeof cnt !== 'number') + cnt = 0; + this.set(key, cnt + 1); + } + + /** + * Lookups a result key. + * @param key The key. + * @returns The value assigned to key. + */ + public get(key: K): T[K] + { + return this._result[key]; + } + + /** + * Sets a value for `key`. + * @param key The key. + * @param val The value to set. + */ + public set(key: K, val: T[K]): void + { + this._result[key] = val; + } + + /** + * Check whether a key exists. + * @param key The key to check. + * @returns True if the key exists. + */ + public has(key: K): boolean + { + return key in this._result; + } + + /** + * Delete a key from the result record. + * @param key The key to delete. + */ + public del(key: K): boolean + { + return this.has(key) ? delete this._result[key] : false; + } + + /** + * Clear the result record. + */ + public clear(): void + { + this._result = Object.create(null) as T; + } + + /** + * Get the keys that's been set onto the record. + * @returns Array of keys. + */ + public keys(): (keyof T)[] + { + return Reflect.ownKeys(this._result); + } + + /** + * Returns the entries of the current record in an array. + * @returns The entries. + */ + public entries(): bricklib.utils.EntryOf[] + { + return this.keys().map(k => [k, this._result[k]]); + } + + /** + * Merge another result record into the current one. + * @param other The other result record. + */ + public merge(other: ParseResult): void + { + for (const k in other._result) + this._result[k] = other._result[k]; + } + + /** + * Get the raw result map. (I trust you!) + * @returns The result map. + */ + public getMap(): T + { + return this._result; + } + + /** + * Allows this class to be used in for..of + * @returns An iterable iterator. + */ + public [Symbol.iterator](): Iterator> + { + return this.entries()[Symbol.iterator](); + } +} + +/** + * Parser state. + */ +export type ParseState = { + /** + * Stream position. + */ + position: number, + /** + * Token stream. + */ + tokens: string[], + /** + * If we're currently processing an option, the name of that option. + */ + optionName?: string, + /** + * If we're currently processing an option, set this to true to require + * the first option argument. + */ + reqFirstOptArg?: boolean, +}; diff --git a/src/sifter/transforms.ts b/src/sifter/transforms.ts new file mode 100644 index 0000000..caf8e31 --- /dev/null +++ b/src/sifter/transforms.ts @@ -0,0 +1,104 @@ +import { ParseContext } from './state.js'; +import type { Transformer } from './types.ts'; + +/** + * A raw string parser. + * @returns A cmd token transformer. + */ +export function stringType() +{ + return (ctx: ParseContext): string => { + return ctx.consumeToken(); + }; +} + +/** + * A float parser. + * @returns A cmd token transformer. + */ +export function floatType() +{ + return (ctx: ParseContext): number => { + let tok = ctx.consumeToken(); + const sign = tok[0] == '-' ? -1 : 1; + if ('+-'.includes(tok[0])) + tok = tok.slice(1); + + if (tok == 'inf') return sign * Infinity; + if (tok == 'nan') return NaN; + + if (!/^[0-9]*(\.[0-9]+)?$/.test(tok)) + throw 'invalid float: ' + tok; + + const val = parseFloat(tok); + if (isNaN(val)) + throw 'couldn\'t parse float: ' + tok; + return val * sign; + }; +} + +/** + * A boolean parser. + * @returns A cmd token transformer. + */ +export function boolType() +{ + return (ctx: ParseContext): boolean => { + let tok = ctx.consumeToken(); + const idx = ['false', 'true'].indexOf(tok); + + if (idx == -1) + throw 'invalid boolean: ' + tok; + return !!idx; + }; +} + +/** + * An integer parser. + * @returns A cmd token transformer. + */ +export function intType() +{ + return (ctx: ParseContext): number => { + const tok = ctx.consumeToken(); + const val = parseInt(tok); + + if (isNaN(val)) + throw 'invalid integer: ' + tok; + return val; + }; +} + +/** + * A rest argument parser. + * @param parser The parser to transform values. + * @returns A cmd token transformer. + */ +export function restType(parser: Transformer) +{ + return (ctx: ParseContext): T[] => { + const vals: T[] = []; + + while (!ctx.isEndOfTokens) + vals.push(parser(ctx)); + return vals; + }; +} + +/** + * An enumeration parser. + * @param opts The enum options. + * @returns The selected value. + */ +export function enumType(...opts: string[]) +{ + return (ctx: ParseContext): any => { + const tok = ctx.consumeToken(); + + if (opts.includes(tok)) + throw 'unknown enum option: ' + tok + '\n' + + 'expected one of: ' + opts.join(', '); + + return tok; + }; +} diff --git a/src/sifter/types.ts b/src/sifter/types.ts new file mode 100644 index 0000000..7a86112 --- /dev/null +++ b/src/sifter/types.ts @@ -0,0 +1,77 @@ +import type { ParseContext } from './state.ts'; + +/** + * A token transformer. + */ +export type Transformer = (ctx: ParseContext) => T; + +/** + * Command argument definition. + */ +export type CmdArgument = { + /** + * Identifier for the arg. + */ + id: PropertyKey, + /** + * The token transformer to parse types. + */ + type: Transformer, + /** + * Whether this argument is optional. + */ + optional?: boolean, + /** + * Default value if this arg is optional. + */ + default?: T, +}; + +/** + * Command option definition. + */ +export type CmdOption = { + /** + * Identifier for the option. + */ + id: PropertyKey, + /** + * Names used to identify this option. '--name' to define long a name, + * '-o' to define a short name. + */ + names: string[], + /** + * Option arguments. + */ + args?: CmdArgument[], +}; + +/** + * Command verb definition. + */ +export type CmdVerb = { + /** + * Identifier for the option. + */ + id: PropertyKey, + /** + * Name of the verb. + */ + name: string, + /** + * Optional verb name aliases. + */ + aliases?: string[], + /** + * Verb positional arguments. + */ + args?: CmdArgument[], + /** + * Verb-specific options. + */ + options?: CmdOption[], + /** + * Sub-verbs. + */ + subverbs?: CmdVerb[], +};