From c67969116a2c1fb7dfb9fc4560c0058fff095574 Mon Sep 17 00:00:00 2001 From: "Vincent Yanzee J. Tan" Date: Mon, 9 Jun 2025 07:47:22 +0800 Subject: [PATCH 1/9] impl initial sifter --- src/sifter/index.ts | 15 +++ src/sifter/parser.ts | 83 ++++++++++++++ src/sifter/state.ts | 252 +++++++++++++++++++++++++++++++++++++++++++ src/sifter/types.ts | 77 +++++++++++++ 4 files changed, 427 insertions(+) create mode 100644 src/sifter/index.ts create mode 100644 src/sifter/parser.ts create mode 100644 src/sifter/state.ts create mode 100644 src/sifter/types.ts diff --git a/src/sifter/index.ts b/src/sifter/index.ts new file mode 100644 index 0000000..a6f2cd7 --- /dev/null +++ b/src/sifter/index.ts @@ -0,0 +1,15 @@ +/** + * Sifter -- A command parser. + * This plugin is part of the bricklib project. + */ + +import * as bricklib from '../bricklib/index.js'; + +export * from './parser.js'; +export * from './state.js'; +export type * from './types.ts'; + + +bricklib.plugin.newPlugin('sifter', () => { + /* no-op */ +}); diff --git a/src/sifter/parser.ts b/src/sifter/parser.ts new file mode 100644 index 0000000..c05a1f5 --- /dev/null +++ b/src/sifter/parser.ts @@ -0,0 +1,83 @@ +import { ParseContext, ParseResult } from './state.js'; +import type { CmdArgument, CmdOption, CmdVerb } from './types.ts'; + +/** + * Parse argument. This will throw an error if there's no at least 1 + * argument left on the token stream. This will not mutate `result` + * on transformation error. + * @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 +{ + if (ctx.isEndOfTokens) + throw 'Insufficient arguments'; + result.set(argDef.id, argDef.type(ctx)); +} + +/** + * Parse option arguments. This will parse all the required arguments + * mandatorily. Optional arguments will parse until one fails. After + * a failed optional argument, only the defaults will be set. It will + * also require parsing the first argument if `reqFirstOptArg` is set. + * @param ctx The parsing context. + * @param result Where to set the result. + * @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); + }); +} + + +declare const scriptArgs: string[]; + +const info: CmdOption = { + id: 'opt', + names: ['--opt', '-o'], + args: [ + { + id: 'num', + type: (ctx) => +ctx.consumeToken(), + }, + { + id: 'hello', + type: (ctx) => ctx.consumeToken(), + default: 1, + optional: true, + } + ] +}; + +const ctx = new ParseContext(scriptArgs.slice(1)); +const result = new ParseResult(); + +parseOptionArguments(ctx, result, info.args); +console.log(JSON.stringify(result.getMap())); diff --git a/src/sifter/state.ts b/src/sifter/state.ts new file mode 100644 index 0000000..71fd2a0 --- /dev/null +++ b/src/sifter/state.ts @@ -0,0 +1,252 @@ +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 token string. + */ + public consumeToken(): string + { + if (this.isEndOfTokens) + return null; + 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; + + /** + * 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/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[], +}; From 6e1f6c5e2d25463b39f364338c8d6c2895916608 Mon Sep 17 00:00:00 2001 From: "Vincent Yanzee J. Tan" Date: Mon, 9 Jun 2025 09:31:09 +0800 Subject: [PATCH 2/9] impl long option parser --- src/sifter/parser.ts | 97 ++++++++++++++++++++++++++++++++++++-------- src/sifter/state.ts | 4 ++ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/sifter/parser.ts b/src/sifter/parser.ts index c05a1f5..610d204 100644 --- a/src/sifter/parser.ts +++ b/src/sifter/parser.ts @@ -24,7 +24,7 @@ export function parseArgument(ctx: ParseContext, result: ParseResult, * a failed optional argument, only the defaults will be set. It will * also require parsing the first argument if `reqFirstOptArg` is set. * @param ctx The parsing context. - * @param result Where to set the result. + * @param result Where to set the results. * @param optArgDefs The option-argument definitions. * @throws This function can throw errors. */ @@ -56,28 +56,89 @@ export function parseOptionArguments(ctx: ParseContext, result: ParseResult, }); } +/** + * Parse long options. + * @param ctx The parsing context. + * @param result Where to set the results. + * @param optDefs Where to lookup option defs. + */ +export function parseLongOption(ctx: ParseContext, result: ParseResult, + optDefs: CmdOption[]): void +{ + const state = ctx.getCurrentState(); + if (state.stopOptions || ctx.isEndOfTokens) + return; + + const tok = ctx.currentToken; + if (!tok.startsWith('--')) + return; + + /* 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; + + /* split the tokens from the token stream */ + ctx.replaceToken(optName); + ctx.consumeToken(); + if (eqIdx != -1) + ctx.insertToken(eqArg); + + const optDef = optDefs.find(def => def.names.includes(optName)); + if (!optDef) + throw 'Unknown option: ' + optName; + + /* count the occurences of the option */ + result.set(optDef.id, (result.get(optDef.id) ?? 0) + 1); + if (optDef.args) + parseOptionArguments(ctx, result, optDef.args); +} + declare const scriptArgs: string[]; -const info: CmdOption = { - id: 'opt', - names: ['--opt', '-o'], - args: [ - { - id: 'num', - type: (ctx) => +ctx.consumeToken(), - }, - { - id: 'hello', - type: (ctx) => ctx.consumeToken(), - default: 1, - optional: true, - } - ] -}; +const info: CmdOption[] = [ + { + id: 'opt', + names: ['--opt', '--bac', '-o'], + args: [ + { + id: 'num', + type: (ctx) => +ctx.consumeToken(), + }, + { + id: 'hello', + type: (ctx) => ctx.consumeToken(), + default: 1, + optional: true, + } + ] + }, + { + id: 'another', + names: ['--another', '--ano', '-a'], + args: [ + { + id: 'val', + type: (ctx) => { + const tok = ctx.consumeToken(); + if (!/^[+-]?[0-9]+(?:\.[0-9]+)?$/.test(tok)) + throw 'invalid number: ' + tok; + return +tok; + }, + optional: true, + default: 0, + } + ] + } +]; const ctx = new ParseContext(scriptArgs.slice(1)); const result = new ParseResult(); -parseOptionArguments(ctx, result, info.args); +while (!ctx.isEndOfTokens) + parseLongOption(ctx, result, info); console.log(JSON.stringify(result.getMap())); diff --git a/src/sifter/state.ts b/src/sifter/state.ts index 71fd2a0..cd8bf22 100644 --- a/src/sifter/state.ts +++ b/src/sifter/state.ts @@ -249,4 +249,8 @@ export type ParseState = { * the first option argument. */ reqFirstOptArg?: boolean, + /** + * Whether to stop parsing options. + */ + stopOptions?: boolean, }; From 7f118041d22462fe984bfb6da7f995ba8b1e828c Mon Sep 17 00:00:00 2001 From: "Vincent Yanzee J. Tan" Date: Mon, 9 Jun 2025 10:19:56 +0800 Subject: [PATCH 3/9] impl short option parser --- src/sifter/parser.ts | 64 +++++++++++++++++++++++++++++++++++++++----- src/sifter/state.ts | 12 +++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/sifter/parser.ts b/src/sifter/parser.ts index 610d204..df3d8e4 100644 --- a/src/sifter/parser.ts +++ b/src/sifter/parser.ts @@ -61,6 +61,7 @@ export function parseOptionArguments(ctx: ParseContext, result: ParseResult, * @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 @@ -72,6 +73,7 @@ export function parseLongOption(ctx: ParseContext, result: ParseResult, const tok = ctx.currentToken; if (!tok.startsWith('--')) return; + ctx.consumeToken(); /* extract '--flag' and 'val' from '--flag=val' */ const eqIdx = tok.indexOf('='); @@ -81,9 +83,7 @@ export function parseLongOption(ctx: ParseContext, result: ParseResult, state.optionName = optName; state.reqFirstOptArg = eqIdx != -1; - /* split the tokens from the token stream */ - ctx.replaceToken(optName); - ctx.consumeToken(); + /* eq arg has to be separate */ if (eqIdx != -1) ctx.insertToken(eqArg); @@ -92,9 +92,54 @@ export function parseLongOption(ctx: ParseContext, result: ParseResult, throw 'Unknown option: ' + optName; /* count the occurences of the option */ - result.set(optDef.id, (result.get(optDef.id) ?? 0) + 1); - if (optDef.args) + 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 (state.stopOptions || 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 = optDefs.find(def => def.names.includes(optName)); + if (!optDef) + throw 'Unknown option: ' + 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; + } } @@ -133,12 +178,19 @@ const info: CmdOption[] = [ default: 0, } ] + }, + { + id: 'verbose', + names: ['-v', '--verbose'], } ]; const ctx = new ParseContext(scriptArgs.slice(1)); const result = new ParseResult(); -while (!ctx.isEndOfTokens) +while (!ctx.isEndOfTokens) { parseLongOption(ctx, result, info); + if (ctx.currentToken?.[1] != '-') + parseShortOption(ctx, result, info); +} console.log(JSON.stringify(result.getMap())); diff --git a/src/sifter/state.ts b/src/sifter/state.ts index cd8bf22..39cdf9a 100644 --- a/src/sifter/state.ts +++ b/src/sifter/state.ts @@ -134,6 +134,18 @@ export class ParseResult = any> */ 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. From b1aae3f49da89f949415029fd1eddc8f606b32c7 Mon Sep 17 00:00:00 2001 From: "Vincent Yanzee J. Tan" Date: Mon, 9 Jun 2025 10:53:51 +0800 Subject: [PATCH 4/9] refactor findOption --- src/sifter/parser.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/sifter/parser.ts b/src/sifter/parser.ts index df3d8e4..89367e6 100644 --- a/src/sifter/parser.ts +++ b/src/sifter/parser.ts @@ -87,9 +87,7 @@ export function parseLongOption(ctx: ParseContext, result: ParseResult, if (eqIdx != -1) ctx.insertToken(eqArg); - const optDef = optDefs.find(def => def.names.includes(optName)); - if (!optDef) - throw 'Unknown option: ' + optName; + const optDef = findOption(optDefs, optName); /* count the occurences of the option */ result.count(optDef.id); @@ -120,9 +118,7 @@ export function parseShortOption(ctx: ParseContext, result: ParseResult, const ch = tok[i]; const optName = '-' + ch; - const optDef = optDefs.find(def => def.names.includes(optName)); - if (!optDef) - throw 'Unknown option: ' + optName; + const optDef = findOption(optDefs, optName); result.count(optDef.id); if (!optDef.args?.length) @@ -142,6 +138,20 @@ export function parseShortOption(ctx: ParseContext, result: ParseResult, } } +/** + * 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; +} + declare const scriptArgs: string[]; From eed85e698e7de3ac189fb6d71d95ecca821f58e2 Mon Sep 17 00:00:00 2001 From: "Vincent Yanzee J. Tan" Date: Mon, 9 Jun 2025 20:50:08 +0800 Subject: [PATCH 5/9] impl verb & subverb parser --- src/sifter/parser.ts | 231 +++++++++++++++++++++++++++++++++---------- src/sifter/state.ts | 9 +- 2 files changed, 180 insertions(+), 60 deletions(-) diff --git a/src/sifter/parser.ts b/src/sifter/parser.ts index 89367e6..a443990 100644 --- a/src/sifter/parser.ts +++ b/src/sifter/parser.ts @@ -2,9 +2,7 @@ import { ParseContext, ParseResult } from './state.js'; import type { CmdArgument, CmdOption, CmdVerb } from './types.ts'; /** - * Parse argument. This will throw an error if there's no at least 1 - * argument left on the token stream. This will not mutate `result` - * on transformation error. + * Parse argument. * @param ctx The parsing context. * @param result Where to set the result. * @param argDef The argument definition. @@ -13,16 +11,11 @@ import type { CmdArgument, CmdOption, CmdVerb } from './types.ts'; export function parseArgument(ctx: ParseContext, result: ParseResult, argDef: CmdArgument): void { - if (ctx.isEndOfTokens) - throw 'Insufficient arguments'; result.set(argDef.id, argDef.type(ctx)); } /** - * Parse option arguments. This will parse all the required arguments - * mandatorily. Optional arguments will parse until one fails. After - * a failed optional argument, only the defaults will be set. It will - * also require parsing the first argument if `reqFirstOptArg` is set. + * Parse option arguments. * @param ctx The parsing context. * @param result Where to set the results. * @param optArgDefs The option-argument definitions. @@ -67,7 +60,7 @@ export function parseLongOption(ctx: ParseContext, result: ParseResult, optDefs: CmdOption[]): void { const state = ctx.getCurrentState(); - if (state.stopOptions || ctx.isEndOfTokens) + if (ctx.isEndOfTokens) return; const tok = ctx.currentToken; @@ -105,7 +98,7 @@ export function parseShortOption(ctx: ParseContext, result: ParseResult, optDefs: CmdOption[]): void { const state = ctx.getCurrentState(); - if (state.stopOptions || ctx.isEndOfTokens) + if (ctx.isEndOfTokens) return; const tok = ctx.currentToken; @@ -147,60 +140,190 @@ export function parseShortOption(ctx: ParseContext, result: ParseResult, */ export function findOption(optDefs: CmdOption[], optName: string): CmdOption { - const def = optDefs.find(def => def.names.includes(optName)); + 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; -declare const scriptArgs: string[]; + while (!ctx.isEndOfTokens) { + const tok = ctx.currentToken; -const info: CmdOption[] = [ - { - id: 'opt', - names: ['--opt', '--bac', '-o'], - args: [ - { - id: 'num', - type: (ctx) => +ctx.consumeToken(), - }, - { - id: 'hello', - type: (ctx) => ctx.consumeToken(), - default: 1, - optional: true, + if (tok[0] == '-' && tok.length >= 2 && !stopOptions) { + /* short opts */ + if (tok[1] != '-') { + parseShortOption(ctx, result, verbDef.options); + continue; } - ] - }, - { - id: 'another', - names: ['--another', '--ano', '-a'], - args: [ - { - id: 'val', - type: (ctx) => { - const tok = ctx.consumeToken(); - if (!/^[+-]?[0-9]+(?:\.[0-9]+)?$/.test(tok)) - throw 'invalid number: ' + tok; - return +tok; - }, - optional: true, - default: 0, + /* end-of-options delimeter */ + if (tok.length == 2) { + stopOptions = true; + ctx.consumeToken(); + continue; } - ] - }, - { - id: 'verbose', - names: ['-v', '--verbose'], + /* 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); +} + + + + + + +declare const scriptArgs: string[]; + +const info: CmdVerb = { + id: 'cmd', + name: 'cmd', + options: [ + { + id: 'opt', + names: ['--opt', '--bac', '-o'], + args: [ + { + id: 'num', + type: (ctx) => +ctx.consumeToken(), + }, + { + id: 'hello', + type: (ctx) => ctx.consumeToken(), + default: 1, + optional: true, + } + ] + }, + { + id: 'another', + names: ['--another', '--ano', '-a'], + args: [ + { + id: 'val', + type: (ctx) => { + const tok = ctx.consumeToken(); + if (!/^[+-]?[0-9]+(?:\.[0-9]+)?$/.test(tok)) + throw 'invalid number: ' + tok; + return +tok; + }, + optional: true, + default: 0, + } + ] + }, + { + id: 'verbose', + names: ['-v', '--verbose'], + } + ], + args: [ + { + id: 'arg1S', + type: (ctx) => ctx.consumeToken(), + }, + { + id: 'arg2B', + type: (ctx) => { + const tok = ctx.consumeToken(); + const val = ['false', 'true'].indexOf(tok); + if (val == -1) throw 'invalid boolean: ' + tok; + return !!val; + }, + }, + { + id: 'arg3N', + type: (ctx) => +ctx.consumeToken(), + optional: true, + default: 391 + } + ], + subverbs: [ + { + id: 'sub', + name: 'sub', + args: [ + { + id: 'subArg', + type: (ctx) => ctx.consumeToken(), + optional: true, + } + ], + options: [ + { + id: 'subOpt', + names: ['--sub', '-s'], + args: [ + { + id: 'subOptArg', + type: (ctx) => ctx.consumeToken(), + } + ] + } + ] + } + ] +}; const ctx = new ParseContext(scriptArgs.slice(1)); const result = new ParseResult(); -while (!ctx.isEndOfTokens) { - parseLongOption(ctx, result, info); - if (ctx.currentToken?.[1] != '-') - parseShortOption(ctx, result, info); -} +parseVerb(ctx, result, info); console.log(JSON.stringify(result.getMap())); diff --git a/src/sifter/state.ts b/src/sifter/state.ts index 39cdf9a..45a0423 100644 --- a/src/sifter/state.ts +++ b/src/sifter/state.ts @@ -88,12 +88,13 @@ export class ParseContext /** * Returns the current token and advance the position pointer. - * @returns The current token string. + * @returns The current (consumed) token string. + * @throws When there's no more tokens to consume. */ public consumeToken(): string { if (this.isEndOfTokens) - return null; + throw 'Unexpected end of input'; return this._current.tokens[this._current.position++]; } @@ -261,8 +262,4 @@ export type ParseState = { * the first option argument. */ reqFirstOptArg?: boolean, - /** - * Whether to stop parsing options. - */ - stopOptions?: boolean, }; From 1b8b56fed6d1dd375d3ba9c41b2ebf4cee21b943 Mon Sep 17 00:00:00 2001 From: "Vincent Yanzee J. Tan" Date: Mon, 9 Jun 2025 21:08:28 +0800 Subject: [PATCH 6/9] remove test code --- src/sifter/parser.ts | 103 ------------------------------------------- 1 file changed, 103 deletions(-) diff --git a/src/sifter/parser.ts b/src/sifter/parser.ts index a443990..5b75c38 100644 --- a/src/sifter/parser.ts +++ b/src/sifter/parser.ts @@ -224,106 +224,3 @@ export function parseSubVerb(ctx: ParseContext, result: ParseResult, parseVerb(ctx, subRes, verbDef); result.set(verbDef.id, subRes); } - - - - - - -declare const scriptArgs: string[]; - -const info: CmdVerb = { - id: 'cmd', - name: 'cmd', - options: [ - { - id: 'opt', - names: ['--opt', '--bac', '-o'], - args: [ - { - id: 'num', - type: (ctx) => +ctx.consumeToken(), - }, - { - id: 'hello', - type: (ctx) => ctx.consumeToken(), - default: 1, - optional: true, - } - ] - }, - { - id: 'another', - names: ['--another', '--ano', '-a'], - args: [ - { - id: 'val', - type: (ctx) => { - const tok = ctx.consumeToken(); - if (!/^[+-]?[0-9]+(?:\.[0-9]+)?$/.test(tok)) - throw 'invalid number: ' + tok; - return +tok; - }, - optional: true, - default: 0, - } - ] - }, - { - id: 'verbose', - names: ['-v', '--verbose'], - } - ], - args: [ - { - id: 'arg1S', - type: (ctx) => ctx.consumeToken(), - }, - { - id: 'arg2B', - type: (ctx) => { - const tok = ctx.consumeToken(); - const val = ['false', 'true'].indexOf(tok); - if (val == -1) throw 'invalid boolean: ' + tok; - return !!val; - }, - }, - { - id: 'arg3N', - type: (ctx) => +ctx.consumeToken(), - optional: true, - default: 391 - } - ], - subverbs: [ - { - id: 'sub', - name: 'sub', - args: [ - { - id: 'subArg', - type: (ctx) => ctx.consumeToken(), - optional: true, - } - ], - options: [ - { - id: 'subOpt', - names: ['--sub', '-s'], - args: [ - { - id: 'subOptArg', - type: (ctx) => ctx.consumeToken(), - } - ] - } - ] - } - ] -}; - -const ctx = new ParseContext(scriptArgs.slice(1)); -const result = new ParseResult(); - -parseVerb(ctx, result, info); -console.log(JSON.stringify(result.getMap())); From c3905a659414aeac73b2f14d5938350517ab9a58 Mon Sep 17 00:00:00 2001 From: "Vincent Yanzee J. Tan" Date: Mon, 9 Jun 2025 21:27:37 +0800 Subject: [PATCH 7/9] add some sifter helpers --- src/sifter/index.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/sifter/index.ts b/src/sifter/index.ts index a6f2cd7..184476e 100644 --- a/src/sifter/index.ts +++ b/src/sifter/index.ts @@ -3,7 +3,11 @@ * 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'; @@ -13,3 +17,38 @@ 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; +} From 13fbe6da3e1549995b1bc86b1039a39218e1d888 Mon Sep 17 00:00:00 2001 From: "Vincent Yanzee J. Tan" Date: Wed, 11 Jun 2025 09:00:37 +0800 Subject: [PATCH 8/9] impl type transformers --- src/sifter/index.ts | 1 + src/sifter/transforms.ts | 104 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/sifter/transforms.ts diff --git a/src/sifter/index.ts b/src/sifter/index.ts index 184476e..892cc45 100644 --- a/src/sifter/index.ts +++ b/src/sifter/index.ts @@ -11,6 +11,7 @@ import type { CmdVerb } from './types.ts'; export * from './parser.js'; export * from './state.js'; +export * from './transforms.js'; export type * from './types.ts'; 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; + }; +} From b08a464e908a615b88ed6fa61e251be01086b5fa Mon Sep 17 00:00:00 2001 From: "Vincent Yanzee J. Tan" Date: Wed, 11 Jun 2025 09:04:24 +0800 Subject: [PATCH 9/9] test sifter --- src/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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; -})); +});