diff --git a/src/appservice/bot/ListCommand.tsx b/src/appservice/bot/ListCommand.tsx index e8b2b407..9c690a0c 100644 --- a/src/appservice/bot/ListCommand.tsx +++ b/src/appservice/bot/ListCommand.tsx @@ -79,7 +79,7 @@ const restart = defineInterfaceCommand({ acceptor: findPresentationType("UserID"), } ]), - command: async(context, mjolnirId: UserID): Promise> => { + command: async(context, _keywords, mjolnirId: UserID): Promise> => { const mjolnirManager = context.appservice.mjolnirManager; const mjolnir = mjolnirManager.findUnstartedMjolnir(mjolnirId.localpart); if (mjolnir?.mjolnirRecord === undefined) { diff --git a/src/commands/interface-manager/InterfaceCommand.ts b/src/commands/interface-manager/InterfaceCommand.ts index 43dd00ce..fccc00c8 100644 --- a/src/commands/interface-manager/InterfaceCommand.ts +++ b/src/commands/interface-manager/InterfaceCommand.ts @@ -30,7 +30,7 @@ limitations under the License. */ import { ReadItem } from "./CommandReader"; -import { ParamaterParser, ArgumentStream, IArgumentListParser } from "./ParamaterParsing"; +import { ParamaterParser, ArgumentStream, IArgumentListParser, ParsedKeywords } from "./ParamaterParsing"; import { CommandResult } from "./Validation"; /** @@ -39,7 +39,7 @@ import { CommandResult } from "./Validation"; * Probably am "doing something wrong", and no, trying to make this protocol isn't it. */ -export type BaseFunction = (...args: any) => Promise>; +export type BaseFunction = (keywords: ParsedKeywords, ...args: any) => Promise>; type CommandLookupEntry = { next?: Map>, @@ -118,6 +118,18 @@ export function findCommandTable(name: string return entry as CommandTable; } +/** + * Used to find a table command at the internal DSL level, not as a client for commands. + */ +export function findTableCommand(tableName: string|symbol, ...designator: string[]): InterfaceCommand { + const table = findCommandTable(tableName); + const command = table.findAMatchingCommand(new ArgumentStream(designator)); + if (command === undefined || !designator.every(part => command.designator.includes(part))) { + throw new TypeError(`Could not find a table command in the table ${tableName.toString()} with the designator ${JSON.stringify(designator)}`) + } + return command as InterfaceCommand; +} + export class InterfaceCommand { constructor( public readonly argumentListParser: IArgumentListParser, @@ -147,7 +159,11 @@ export class InterfaceCommand { // The inner type is irrelevant when it is Err, i don't know how to encode this in TS's type system but whatever. return paramaterDescription as ReturnType>; } - return await this.command.apply(context, [...paramaterDescription.ok.immediateArguments, paramaterDescription.ok.rest]); + return await this.command.apply(context, [ + paramaterDescription.ok.keywords, + ...paramaterDescription.ok.immediateArguments, + ...paramaterDescription.ok.rest ?? [] + ]); } } diff --git a/src/commands/interface-manager/MatrixHelpRenderer.ts b/src/commands/interface-manager/MatrixHelpRenderer.tsx similarity index 55% rename from src/commands/interface-manager/MatrixHelpRenderer.ts rename to src/commands/interface-manager/MatrixHelpRenderer.tsx index 5e959f22..de19ec95 100644 --- a/src/commands/interface-manager/MatrixHelpRenderer.ts +++ b/src/commands/interface-manager/MatrixHelpRenderer.tsx @@ -3,11 +3,13 @@ */ import { MatrixSendClient } from "../../MatrixEmitter"; -import { htmlEscape } from "../../utils"; import { BaseFunction, InterfaceCommand } from "./InterfaceCommand"; import { MatrixContext, MatrixInterfaceAdaptor } from "./MatrixInterfaceAdaptor"; -import { ArgumentParseError, KeywordParser } from "./ParamaterParsing"; +import { ArgumentParseError, RestDescription } from "./ParamaterParsing"; import { CommandError, CommandResult } from "./Validation"; +import { JSXFactory } from "./JSXFactory"; +import { DocumentNode } from "./DeadDocument"; +import { renderMatrixAndSend } from "./DeadDocumentMatrix"; function requiredArgument(argumentName: string): string { return `<${argumentName}>`; @@ -19,37 +21,20 @@ function keywordArgument(keyword: string): string { } // they should be allowed to name the rest argument... -function restArgument(): string { - return `[...rest]`; +function restArgument(rest: RestDescription): string { + return `[...${rest.name}]`; } function renderCommandHelp(command: InterfaceCommand): string { - let text = ''; - for (const designator of command.designator) { - text += `${designator} ` - } - for (const description of command.argumentListParser.descriptions) { - text += `${requiredArgument(description.name)} `; - } - const restParser = command.argumentListParser.restParser; - if (restParser !== undefined) { - // not too happy with how keywords are represented here., like there's just the keys with no context smh. - if (restParser instanceof KeywordParser) { - for (const keyword of Object.keys(restParser.description)) { - if (keyword === "allowOtherKeys") { - continue; - } - // ahh fuck what about defaults for keys? - text += `${keywordArgument(keyword)} `; - } - if (restParser.description.allowOtherKeys) { - text += `${restArgument()} `; - } - } else { - text += `${restArgument()} `; - } - } - return text; + const rest = command.argumentListParser.rest; + const keywords = command.argumentListParser.keywords; + return [ + ...command.designator, + ...command.argumentListParser.descriptions + .map(d => requiredArgument(d.name)), + ...rest ? [restArgument(rest)] : [], + ...Object.keys(keywords.description).map(k => keywordArgument(k)), + ].join(' '); } // What is really needed is a rendering protocol, that works with bullshit text+html that's really just string building like we're doing here or some other media format @@ -71,7 +56,11 @@ export async function tickCrossRenderer(this: MatrixInterfaceAdaptor, error: ArgumentParseError): string { - let html = ''; - html += `There was a problem when parsing the "${error.paramater.name}" paramater for this command.
` - html += htmlEscape(renderCommandHelp(command)); - html += '
'; - html += error.message + '
'; - html += ''; - // everything in the command excluding the current argument. + +function formattedArgumentHint(command: InterfaceCommand, error: ArgumentParseError): string { const argumentsUpToError = error.stream.source.slice(0, error.stream.getPosition()); let commandContext = 'Command context:'; for (const designator of command.designator) { @@ -95,8 +78,15 @@ function renderArgumentParseError(command: InterfaceCommand, error for (const argument of argumentsUpToError) { commandContext += ` ${JSON.stringify(argument)}`; } - html += commandContext; - html += ` ${error.stream.peekItem()}\n${Array(commandContext.length + 1).join(' ')} ^ expected ${error.paramater.acceptor.name} here`; - html += ''; - return html; + let badArgument = ` ${error.stream.peekItem()}\n${Array(commandContext.length + 1).join(' ')} ^ expected ${error.paramater.acceptor.name} here`; + return commandContext + badArgument; +} + +function renderArgumentParseError(command: InterfaceCommand, error: ArgumentParseError): DocumentNode { + return

+ There was a problem when parsing the {error.paramater.name} parameter for this command.
+ {renderCommandHelp(command)}
+ {error.message}
+

{formattedArgumentHint(command, error)}
+

} diff --git a/src/commands/interface-manager/ParamaterParsing.ts b/src/commands/interface-manager/ParamaterParsing.ts index 58636907..c303622d 100644 --- a/src/commands/interface-manager/ParamaterParsing.ts +++ b/src/commands/interface-manager/ParamaterParsing.ts @@ -85,80 +85,197 @@ makePresentationType({ validator: simpleTypeValidator('string', (item: ReadItem) => typeof item === 'string'), }) -interface DestructableRest { - rest: ReadItem[], - // Pisses me off to no end that this is how it has to work. - [prop: string]: ReadItem|ReadItem[], -} +/** + * Describes a rest paramater for a command. + * This consumes any arguments left over in the call to a command + * into an array and ensures that each can be accepted by the `acceptor`. + * + * Any keywords in the rest of the command will be given to the `keywordParser`. + */ +export class RestDescription implements ParamaterDescription { + constructor( + public readonly name: string, + /** The presentation type of each item. */ + public readonly acceptor: PresentationType, + public readonly description?: string, + ) { -export class RestParser { - public parseRest(stream: ArgumentStream): CommandResult { + } + + /** + * Parse the rest of a command. + * @param stream An argument stream that starts at the rest of a command. + * @param keywordParser Used to store any keywords found in the rest of the command. + * @returns A CommandResult of ReadItems associated with the rest of the command. + * If a ReadItem or Keyword is invalid for the command, then an error will be returned. + */ + public parseRest(stream: ArgumentStream, keywordParser: KeywordParser): CommandResult { const items: ReadItem[] = []; - while (stream.peekItem()) { - items.push(stream.readItem()); + while (stream.peekItem() !== undefined) { + const keywordResult = keywordParser.parseKeywords(stream); + if (keywordResult.isErr()) { + return CommandResult.Err(keywordResult.err); + } + if (stream.peekItem() !== undefined) { + const validationResult = this.acceptor.validator(stream.peekItem()); + if (validationResult.isErr()) { + return ArgumentParseError.Result( + validationResult.err.message, + { paramater: this, stream } + ); + } + items.push(stream.readItem()); + } } - return CommandResult.Ok({ rest: items }); + return CommandResult.Ok(items); } } -// Maybe we can get around the index type restriction by making "rest" a protected keyword? -interface KeywordsDescription { - readonly [prop: string]: KeywordPropertyDescription|boolean; - readonly allowOtherKeys: boolean +/** + * This is an interface for an object which describes which keyword + * argument that can be accepted by a command. + */ +interface KeywordArgumentsDescription { + readonly [prop: string]: KeywordPropertyDescription|undefined; } +/** + * An extension of ParamaterDescription, some keyword arguments + * may just be flags that have no associated property in syntax, + * and their presence is to associate the value `true`. + */ interface KeywordPropertyDescription extends ParamaterDescription { readonly isFlag: boolean; } -// Things that are also needed that are not done yet: -// 1) We need to figure out what happens to aliases for keywords.. -// 2) We need to sort out the predicates thing. -export class KeywordParser extends RestParser { - constructor(public readonly description: KeywordsDescription) { - super(); +/** + * Describes all of the keyword arguments for a command. + */ +export class KeywordsDescription { + constructor( + public readonly description: KeywordArgumentsDescription, + public readonly allowOtherKeys?: boolean, + ) { + } /** - * TODO: Prototype pollution must be part of integration tests for this - * @param itemStream stream of arguments. + * @returns A parser that will create a map of all keywords and their associated properties. */ - public parseRest(itemStream: ArgumentStream): CommandResult { - const destructable: DestructableRest = { rest: [] }; - while (itemStream.peekItem() !== undefined) { - const item = itemStream.readItem(); - if (item instanceof Keyword) { - const description = this.description[item.designator]; - if (typeof description === 'boolean') { - throw new TypeError("Shouldn't be a boolean mate"); - } - const associatedProperty: CommandResult = (() => { - if (itemStream.peekItem() !== undefined && !(itemStream.peekItem() instanceof Keyword)) { - const property = itemStream.readItem(); - return CommandResult.Ok(property); - } else { - if (!description.isFlag) { - return ArgumentParseError.Result(`An associated argument was not provided for the keyword ${description.name}.`, { paramater: description, stream: itemStream }) - } - return CommandResult.Ok(true); - } - })(); - if (associatedProperty.isErr()) { - return CommandResult.Err(associatedProperty.err); - } - destructable[description.name] = associatedProperty.ok; + public getParser(): KeywordParser { + return new KeywordParser(this); + } +} +/** + * A read only map of keywords to their associated properties. + */ +export class ParsedKeywords { + constructor ( + private readonly descriptions: KeywordArgumentsDescription, + private readonly keywords: ReadonlyMap + ) { + + } + + public getKeyword(keyword: string, defaultValue: T|undefined = undefined): T|undefined { + const keywordDescription = this.descriptions[keyword]; + if (keywordDescription === undefined) { + throw new TypeError(`${keyword} is not a keyword that has been expected for this command.`); + } + const value = this.keywords.get(keyword); + if (value !== undefined) { + return value as T; + } else { + return defaultValue; + } + } +} + +/** + * A helper that gets instantiated for each command invoccation to parse and build + * the map representing the association between keywords and their properties. + */ +class KeywordParser { + private readonly arguments = new Map(); + + constructor( + public readonly description: KeywordsDescription + ) { + } + + public getKeywords(): ParsedKeywords { + return new ParsedKeywords(this.description.description, this.arguments); + } + + + private readKeywordAssociatedProperty(keyword: KeywordPropertyDescription, itemStream: ArgumentStream): CommandResult { + if (itemStream.peekItem() !== undefined && !(itemStream.peekItem() instanceof Keyword)) { + const validationResult = keyword.acceptor.validator(itemStream.peekItem()); + if (validationResult.isOk()) { + return CommandResult.Ok(itemStream.readItem()); } else { - destructable.rest.push(item); + return ArgumentParseError.Result(validationResult.err.message, { paramater: keyword, stream: itemStream }); + } + } else { + if (!keyword.isFlag) { + return ArgumentParseError.Result(`An associated argument was not provided for the keyword ${keyword.name}.`, { paramater: keyword, stream: itemStream }); + } else { + return CommandResult.Ok(true); + } + } + } + + public parseKeywords(itemStream: ArgumentStream): CommandResult { + while (itemStream.peekItem() !== undefined && itemStream.peekItem() instanceof Keyword) { + const item = itemStream.readItem() as Keyword; + const description = this.description.description[item.designator]; + if (description === undefined) { + if (this.description.allowOtherKeys) { + throw new TypeError("Allow other keys is umimplemented"); + // i don't think this can be implemented, + // how do you tell an extra key is a flag or has an associated + // property? + } else { + return UnexpectedArgumentError.Result( + `Encountered unexpected keyword argument: ${item.designator}`, + { stream: itemStream } + ); + } + } else { + const associatedPropertyResult = this.readKeywordAssociatedProperty(description, itemStream); + if (associatedPropertyResult.isErr()) { + return associatedPropertyResult; + } else { + this.arguments.set(description.name, associatedPropertyResult.ok); + } + } + + } + return CommandResult.Ok(this); + } + + public parseRest(itemStream: ArgumentStream, restDescription?: RestDescription): CommandResult { + if (restDescription !== undefined) { + return restDescription.parseRest(itemStream, this) + } else { + const result = this.parseKeywords(itemStream); + if (result.isErr()) { + return CommandResult.Err(result.err); + } + if (itemStream.peekItem() !== undefined) { + return CommandError.Result(`There is an unexpected non-keyword argument ${JSON.stringify(itemStream.peekItem())}`); + } else { + return CommandResult.Ok(undefined); } } - return CommandResult.Ok(destructable); } } export interface ParsedArguments { readonly immediateArguments: ReadItem[], - readonly rest?: DestructableRest, + readonly rest?: ReadItem[], + readonly keywords: ParsedKeywords, } export interface ParamaterDescription { @@ -174,14 +291,15 @@ export type ParamaterParser = (...readItems: ReadItem[]) => CommandResult { + const keywordsParser = keywords.getParser(); const itemStream = new ArgumentStream(readItems); for (const paramater of descriptions) { + // it eats any keywords at any point in the stream + // as they can appear at any point technically. + const keywordResult = keywordsParser.parseKeywords(itemStream); + if (keywordResult.isErr()) { + return CommandResult.Err(keywordResult.err); + } if (itemStream.peekItem() === undefined) { return ArgumentParseError.Result(`An argument for the paramater ${paramater.name} was expected but was not provided.`, { paramater, stream: itemStream }); } const result = paramater.acceptor.validator(itemStream.peekItem()); - if (result.err) { + if (result.isErr()) { // should really allow the help to be printed later on and keep the whole context? return ArgumentParseError.Result(result.err.message, { paramater, stream: itemStream }); } itemStream.readItem(); } - if (restParser) { - const result = restParser.parseRest(itemStream); - if (result.isErr()) { - return CommandResult.Err(result.err); - } - return CommandResult.Ok({ immediateArguments: readItems, rest: result.ok }); - } else { - return CommandResult.Ok({ immediateArguments: readItems }); + const restResult = keywordsParser.parseRest(itemStream, rest); + if (restResult.isErr()) { + return CommandResult.Err(restResult.err); } + const immediateArguments = restResult.ok === undefined + || restResult.ok.length === 0 + ? readItems + : readItems.slice(0, readItems.indexOf(restResult.ok[0]) + 1) + return CommandResult.Ok({ + immediateArguments: immediateArguments, + keywords: keywordsParser.getKeywords(), + rest: restResult.ok + }); } } } -export class ArgumentParseError extends CommandError { +export class AbstractArgumentParseError extends CommandError { constructor( - public readonly paramater: ParamaterDescription, public readonly stream: ArgumentStream, message: string) { super(message) } + public static Result(message: string, options: { stream: ArgumentStream }): CommandResult { + return CommandResult.Err(new AbstractArgumentParseError(options.stream, message)); + } +} + +export class ArgumentParseError extends AbstractArgumentParseError { + constructor( + public readonly paramater: ParamaterDescription, + stream: ArgumentStream, + message: string) { + super(stream, message) + } + public static Result(message: string, options: { paramater: ParamaterDescription, stream: ArgumentStream }): CommandResult { return CommandResult.Err(new ArgumentParseError(options.paramater, options.stream, message)); } } +export class UnexpectedArgumentError extends AbstractArgumentParseError { + public static Result(message: string, options: { stream: ArgumentStream }): CommandResult { + return CommandResult.Err(new UnexpectedArgumentError(options.stream, message)); + } +} + /** * I don't think we should use `union` and it should be replaced by a presentationTypeTranslator * these are specific to applications e.g. imagine you want to resolve an alias or something. * It oculd also work by making an anonymous presentation type, but dunno about that. */ -export function union(...predicates: PredicateIsParamater[]): PredicateIsParamater { - return (item: ReadItem) => { - const matches = predicates.map(predicate => predicate(item)); - const oks = matches.filter(result => result.isOk()); - if (oks.length > 0) { - return CommandResult.Ok(true); - } else { - // FIXME asap: again, we need some context as to what the argument is? - return CommandError.Result(`The argument must match the paramater description ${matches}`); +export function union(...presentationTypes: PresentationType[]): PresentationType { + const name = presentationTypes.map(type => type.name).join(" | "); + return { + name, + validator: (readItem: ReadItem) => { + if (presentationTypes.some(p => p.validator(readItem).isOk())) { + return CommandResult.Ok(true); + } else { + return CommandError.Result(`Read item didn't match any of the presentaiton types ${name}`); + } } } }