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}`);
+ }
}
}
}