diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 64536d91..e8840f6e 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -34,7 +34,7 @@ import { } from "matrix-bot-sdk"; import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES } from "./models/ListRule"; -import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; +import { COMMAND_PREFIX, handleCommand, MjolnirContext } from "./commands/CommandHandler"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { htmlEscape } from "./utils"; import { ReportManager } from "./report/ReportManager"; @@ -50,7 +50,7 @@ import { ProtectionManager } from "./protections/ProtectionManager"; import { RoomMemberManager } from "./RoomMembers"; import ProtectedRoomsConfig from "./ProtectedRoomsConfig"; import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter"; -import { MatrixCommandTable } from "./commands/MatrixInterfaceCommand"; +import { MatrixCommandTable } from "./commands/interface-manager/MatrixInterfaceCommand"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -98,7 +98,7 @@ export class Mjolnir { */ public readonly reportManager: ReportManager; - private readonly matrixCommandTable: MatrixCommandTable; + private readonly matrixCommandTable: MatrixCommandTable; /** * Adds a listener to the client that will automatically accept invitations. diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 676fb26d..9ad3ed0e 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -31,6 +31,7 @@ import { DataStore, PgDataStore } from ".//datastore"; import { Api } from "./Api"; import { IConfig } from "./config/config"; import { AccessControl } from "./AccessControl"; +import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; const log = new Logger("AppService"); /** @@ -40,6 +41,7 @@ const log = new Logger("AppService"); export class MjolnirAppService { private readonly api: Api; + private readonly commands: AppserviceCommandHandler; /** * The constructor is private because we want to ensure intialization steps are followed, @@ -48,11 +50,12 @@ export class MjolnirAppService { private constructor( public readonly config: IConfig, public readonly bridge: Bridge, - private readonly mjolnirManager: MjolnirManager, + public readonly mjolnirManager: MjolnirManager, private readonly accessControl: AccessControl, private readonly dataStore: DataStore, ) { this.api = new Api(config.homeserver.url, mjolnirManager); + this.commands = new AppserviceCommandHandler(this); } /** @@ -144,6 +147,7 @@ export class MjolnirAppService { } this.accessControl.handleEvent(mxEvent['room_id'], mxEvent); this.mjolnirManager.onEvent(request, context); + this.commands.handleEvent(mxEvent); } /** @@ -151,6 +155,7 @@ export class MjolnirAppService { * @param port The port that the appservice should listen on to receive transactions from the homeserver. */ private async start(port: number) { + await this.bridge.getBot().getClient().joinRoom(this.config.adminRoom); log.info("Starting MjolnirAppService, Matrix-side to listen on port", port); this.api.start(this.config.webAPI.port); await this.bridge.listen(port); diff --git a/src/appservice/MjolnirManager.ts b/src/appservice/MjolnirManager.ts index 51b8fc40..850efbb3 100644 --- a/src/appservice/MjolnirManager.ts +++ b/src/appservice/MjolnirManager.ts @@ -3,7 +3,7 @@ import { Request, WeakEvent, BridgeContext, Bridge, Intent, Logger } from "matri import { getProvisionedMjolnirConfig } from "../config"; import PolicyList from "../models/PolicyList"; import { Permalinks, MatrixClient } from "matrix-bot-sdk"; -import { DataStore } from "./datastore"; +import { DataStore, MjolnirRecord } from "./datastore"; import { AccessControl } from "./AccessControl"; import { Access } from "../models/AccessControlUnit"; import { randomUUID } from "crypto"; @@ -12,6 +12,9 @@ import { MatrixEmitter } from "../MatrixEmitter"; const log = new Logger('MjolnirManager'); +// FIXME: AAAAAAAAaaaaaaaaaaaaa there's some inconsistency between "mjolnir id" "mjolnirRecord.localpart" and "user if of the mjolnir" +// all over this file. + /** * The MjolnirManager is responsible for: * * Provisioning new mjolnir instances. @@ -20,6 +23,7 @@ const log = new Logger('MjolnirManager'); */ export class MjolnirManager { private readonly mjolnirs: Map = new Map(); + private readonly unstartedMjolnirs: Map = new Map(); private constructor( private readonly dataStore: DataStore, @@ -38,7 +42,7 @@ export class MjolnirManager { */ public static async makeMjolnirManager(dataStore: DataStore, bridge: Bridge, accessControl: AccessControl): Promise { const mjolnirManager = new MjolnirManager(dataStore, bridge, accessControl); - await mjolnirManager.createMjolnirsFromDataStore(); + await mjolnirManager.startMjolnirs(await dataStore.list()); return mjolnirManager; } @@ -50,7 +54,8 @@ export class MjolnirManager { * @returns A new managed mjolnir. */ public async makeInstance(requestingUserId: string, managementRoomId: string, client: MatrixClient): Promise { - const intentListener = new MatrixIntentListener(await client.getUserId()); + const mxid = await client.getUserId(); + const intentListener = new MatrixIntentListener(mxid); const managedMjolnir = new ManagedMjolnir( requestingUserId, await Mjolnir.setupMjolnirFromConfig( @@ -61,7 +66,8 @@ export class MjolnirManager { intentListener, ); await managedMjolnir.start(); - this.mjolnirs.set(await client.getUserId(), managedMjolnir); + this.mjolnirs.set(mxid, managedMjolnir); + this.unstartedMjolnirs.delete(mxid); return managedMjolnir; } @@ -141,6 +147,14 @@ export class MjolnirManager { } } + public reportUnstartedMjolnir(code: UnstartedMjolnir.FailCode, cause: any, mjolnirRecord: MjolnirRecord): void { + this.unstartedMjolnirs.set(mjolnirRecord.local_part, new UnstartedMjolnir(mjolnirRecord, code, cause)); + } + + public getUnstartedMjolnirs(): UnstartedMjolnir[] { + return [...this.unstartedMjolnirs.values()]; + } + /** * Utility that creates a matrix client for a virtual user on our homeserver with the specified loclapart. * @param localPart The localpart of the virtual user we need a client for. @@ -152,29 +166,44 @@ export class MjolnirManager { return mjIntent; } + /** + * Attempt to start a mjolnir, and notify its management room of any failure to start. + * Will be added to `this.unstartedMjolnirs` if we fail to start it AND it is not already running. + * @param mjolnirRecord The record for the mjolnir that we want to start. + */ + public async startMjolnir(mjolnirRecord: MjolnirRecord): Promise { + // if a mjolnir is in `this.mjonirs` it is started, as if it is present, it is going to be given Matrix events. + if (this.mjolnirs.has(mjolnirRecord.local_part)) { + throw new TypeError(`${mjolnirRecord.local_part} is already running, we cannot start it.`); + } + const mjIntent = await this.makeMatrixIntent(mjolnirRecord.local_part); + const access = this.accessControl.getUserAccess(mjolnirRecord.owner); + if (access.outcome !== Access.Allowed) { + // Don't await, we don't want to clobber initialization just because we can't tell someone they're no longer allowed. + mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`); + this.reportUnstartedMjolnir(UnstartedMjolnir.FailCode.Unauthorized, access.outcome, mjolnirRecord); + } else { + await this.makeInstance( + mjolnirRecord.owner, + mjolnirRecord.management_room, + mjIntent.matrixClient, + ).catch((e: any) => { + log.error(`Could not start mjolnir ${mjolnirRecord.local_part} for ${mjolnirRecord.owner}:`, e); + // Don't await, we don't want to clobber initialization if this fails. + mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir could not be started. Please alert the administrator`); + this.reportUnstartedMjolnir(UnstartedMjolnir.FailCode.StartError, e, mjolnirRecord); + }); + } + } + // TODO: We need to check that an owner still has access to the appservice each time they send a command to the mjolnir or use the web api. // https://github.com/matrix-org/mjolnir/issues/410 /** * Used at startup to create all the ManagedMjolnir instances and start them so that they will respond to users. */ - private async createMjolnirsFromDataStore() { - for (const mjolnirRecord of await this.dataStore.list()) { - const mjIntent = await this.makeMatrixIntent(mjolnirRecord.local_part); - const access = this.accessControl.getUserAccess(mjolnirRecord.owner); - if (access.outcome !== Access.Allowed) { - // Don't await, we don't want to clobber initialization just because we can't tell someone they're no longer allowed. - mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`); - } else { - await this.makeInstance( - mjolnirRecord.owner, - mjolnirRecord.management_room, - mjIntent.matrixClient, - ).catch((e: any) => { - log.error(`Could not start mjolnir ${mjolnirRecord.local_part} for ${mjolnirRecord.owner}:`, e); - // Don't await, we don't want to clobber initialization if this fails. - mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir could not be started. Please alert the administrator`); - }); - } + public async startMjolnirs(mjolnirRecords: MjolnirRecord[]): Promise { + for (const mjolnirRecord of mjolnirRecords) { + await this.startMjolnir(mjolnirRecord); } } } @@ -272,3 +301,20 @@ export class MatrixIntentListener extends EventEmitter implements MatrixEmitter // Nothing to do. } } + +export class UnstartedMjolnir { + constructor( + public readonly mjolnirRecord: MjolnirRecord, + public readonly failCode: UnstartedMjolnir.FailCode, + public readonly cause: any, + ) { + + } +} + +export namespace UnstartedMjolnir { + export enum FailCode { + Unauthorized = "Unauthorized", + StartError = "StartError", + } +} \ No newline at end of file diff --git a/src/appservice/config/config.harness.yaml b/src/appservice/config/config.harness.yaml index daa93389..c548e703 100644 --- a/src/appservice/config/config.harness.yaml +++ b/src/appservice/config/config.harness.yaml @@ -8,6 +8,7 @@ db: connectionString: "postgres://mjolnir-tester:mjolnir-test@localhost:8083/mjolnir-test-db" accessControlList: "#access-control-list:localhost:9999" +adminRoom: "#access-control-list:localhost:9999" webAPI: port: 9001 diff --git a/src/appservice/config/config.ts b/src/appservice/config/config.ts index ca015f96..c872e874 100644 --- a/src/appservice/config/config.ts +++ b/src/appservice/config/config.ts @@ -46,8 +46,10 @@ export interface IConfig { webAPI: { port: number }, - /** A policy room for controlling access to the appservice */ + /** A policy room for controlling access to the appservice -- just replace with mangement room tbh bloody hell m8 */ accessControlList: string, + /** The admin room for the appservice bot. Not called managementRoom like mjolnir on purpose, so they're not mixed in code somehow. */ + adminRoom: string, /** configuration for matrix-appservice-bridge's Logger */ logging?: LoggingOpts, } diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index d5c80bec..5d25138e 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -53,13 +53,16 @@ import { execKickCommand } from "./KickCommand"; import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; import { parse as tokenize } from "shell-quote"; import { execSinceCommand } from "./SinceCommand"; -import { MatrixCommandTable } from "./MatrixInterfaceCommand"; -import { readCommand } from "./CommandReader"; +import { MatrixCommandTable, MatrixContext } from "./interface-manager/MatrixInterfaceCommand"; +import { readCommand } from "./interface-manager/CommandReader"; +export interface MjolnirContext extends MatrixContext { + mjolnir: Mjolnir, +} export const COMMAND_PREFIX = "!mjolnir"; -export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir, commandTable: MatrixCommandTable) { +export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir, commandTable: MatrixCommandTable) { const cmd = event['content']['body']; const parts = cmd.trim().split(' ').filter(p => p.trim().length > 0); @@ -136,9 +139,11 @@ export async function handleCommand(roomId: string, event: { content: { body: st return await execMakeRoomAdminCommand(roomId, event, mjolnir, parts); } else { const readItems = readCommand(cmd).slice(1); // remove "!mjolnir" - const command = commandTable.findAMatchingCommand(parts.slice(1)); + const command = commandTable.findAMatchingCommand(readItems); if (command) { - return await command.invoke(mjolnir, roomId, event, readItems); + return await command.invoke({ + mjolnir, roomId, event, client: mjolnir.client + }, ...readItems); } // Help menu diff --git a/src/commands/interface-manager/CommandReader.ts b/src/commands/interface-manager/CommandReader.ts index e3c36285..91813dc7 100644 --- a/src/commands/interface-manager/CommandReader.ts +++ b/src/commands/interface-manager/CommandReader.ts @@ -6,29 +6,40 @@ import { UserID } from "matrix-bot-sdk"; import { MatrixRoomReference } from "./MatrixRoomReference"; -/** - * Helper for peeking and reading character by character. - */ -class StringStream { + +export class SuperCoolStream any|undefined}> { private position: number /** * Makes the super cool string stream. * @param source A string to act as the source of the stream. * @param start Where in the string we should start reading. */ - constructor(private readonly source: string, start = 0) { + constructor(private readonly source: T, start = 0) { this.position = start; } - public peekChar(eof = undefined) { + public peekItem(eof = undefined) { return this.source.at(this.position) ?? eof; } - public readChar(eof = undefined) { + public readItem(eof = undefined) { return this.source.at(this.position++) ?? eof; } } +/** + * Helper for peeking and reading character by character. + */ +class StringStream extends SuperCoolStream { + public peekChar(...args: any[]) { + return this.peekItem(...args); + } + + public readChar(...args: any[]) { + return this.readItem(...args); + } +} + /** Whitespace we want to nom. */ const WHITESPACE = [' ', '\r', '\f', '\v', '\n', '\t']; @@ -40,7 +51,7 @@ const WHITESPACE = [' ', '\r', '\f', '\v', '\n', '\t']; * just a list. * This allows commands to be dispatched based on `ReadItem`s and allows * for more efficient (in terms of code) parsing of arguments, - * as I will demonstrate . + * as I will demonstrate . * * The technique used is somewhat inefficient in terms of resources, * but that is a compromise we are willing to make in order @@ -122,7 +133,7 @@ function defineReadItem(dispatchCharacter: string, macro: ReadMacro) { } /** - * Helper that consumes from `stream` and appends to `output` until a character is peeked matching `regex`. + * Helper that consumes from `stream` and appends to `output` until a character is peeked matching `regex`. * @param regex A regex for a character to stop at. * @param stream A stream to consume from. * @param output An array of characters. diff --git a/src/commands/interface-manager/MatrixInterfaceCommand.ts b/src/commands/interface-manager/MatrixInterfaceCommand.ts index 678e6feb..1f9e0d21 100644 --- a/src/commands/interface-manager/MatrixInterfaceCommand.ts +++ b/src/commands/interface-manager/MatrixInterfaceCommand.ts @@ -27,28 +27,38 @@ limitations under the License. /** * When we do move these components into their own library, - * I'd like to remove the dependency on matrix-bot-sdk. + * I'd like to remove the dependency on matrix-bot-sdk. */ import { ApplicationCommand, ApplicationFeature, getApplicationFeature } from "./ApplicationCommand"; import { ValidationError, ValidationResult } from "./Validation"; import { RichReply, LogService, MatrixClient } from "matrix-bot-sdk"; import { ReadItem } from "./CommandReader"; +import { MatrixSendClient } from "../../MatrixEmitter"; -type CommandLookupEntry = Map>; + +/** + * 💀 . o O ( at least I don't have to remember the types ) + * https://matrix-client.matrix.org/_matrix/media/r0/download/matrix.org/nbisOFhCcTzNicZrfixWMHZn + * Probably am "doing something wrong", and no, trying to make this protocol isn't it. + */ + +type CommandLookupEntry = Map>; type BaseFunction = (...args: any) => Promise; -const FLATTENED_MATRIX_COMMANDS = new Set>(); +const FLATTENED_MATRIX_COMMANDS = new Set>(); const THIS_COMMAND_SYMBOL = Symbol("thisCommand"); -type ParserSignature Promise> = ( - this: MatrixInterfaceCommand, +export interface MatrixContext { + client: MatrixSendClient, + roomId: string, + event: any, +} + +type ParserSignature Promise> = ( + this: MatrixInterfaceCommand, // The idea then is that this can be extended to include a Mjolnir or whatever. - options: { - client: MatrixClient, - roomId: string, - event: any, - }, + context: C, ...parts: ReadItem[]) => Promise>>; type RendererSignature> = ( @@ -72,10 +82,10 @@ type RendererSignature> = ( * When confirmation is required in the middle of a traditional command ie preview kick * the preview command should be a distinct command. */ -class MatrixInterfaceCommand Promise> { +class MatrixInterfaceCommand Promise> { constructor( public readonly commandParts: string[], - private readonly parser: ParserSignature, + private readonly parser: ParserSignature, public readonly applicationCommand: ApplicationCommand, private readonly renderer: RendererSignature>, private readonly validationErrorHandler?: (client: MatrixClient, roomId: string, event: any, validationError: ValidationError) => Promise @@ -91,14 +101,17 @@ class MatrixInterfaceCommand Promise * along with the result of the executor. * @param args These will be the arguments to the parser function. */ - public async invoke(...args: Parameters>): Promise { + public async invoke(...args: Parameters>): Promise { const parseResults = await this.parser(...args); + const matrixContext: MatrixContext = args.at(0) as MatrixContext; if (parseResults.isErr()) { - this.reportValidationError.apply(this, [...args.slice(0, -1), parseResults.err]); + this.reportValidationError.apply(this, [matrixContext.client, matrixContext.roomId, matrixContext.event, parseResults.err]); return; } const executorResult: ReturnType = await this.applicationCommand.executor.apply(this, parseResults.ok); - await this.renderer.apply(this, [...args.slice(0, -1), executorResult]); + // just give the renderer the MatrixContext. + + await this.renderer.apply(this, [matrixContext.client, matrixContext.roomId, matrixContext.event, executorResult]); } private async reportValidationError(client: MatrixClient, roomId: string, event: any, validationError: ValidationError): Promise { @@ -121,9 +134,9 @@ class MatrixInterfaceCommand Promise * @param applicationCommmand The ApplicationCommand this is an interface wrapper for. * @param renderer Render the result of the application command back to a room. */ -export function defineMatrixInterfaceCommand Promise>( +export function defineMatrixInterfaceCommand Promise>( commandParts: string[], - parser: ParserSignature, + parser: ParserSignature, applicationCommmand: ApplicationCommand, renderer: RendererSignature>) { FLATTENED_MATRIX_COMMANDS.add( @@ -140,9 +153,9 @@ export function defineMatrixInterfaceCommand { public readonly features: ApplicationFeature[]; - private readonly flattenedCommands: Set>; + private readonly flattenedCommands: Set>; private readonly commands: CommandLookupEntry = new Map(); constructor(featureNames: string[]) { @@ -158,24 +171,25 @@ export class MatrixCommandTable { const commandHasFeatures = (command: ApplicationCommand) => { return command.requiredFeatures.every(feature => this.features.includes(feature)) } - this.flattenedCommands = new Set([...FLATTENED_MATRIX_COMMANDS].filter(interfaceCommand => commandHasFeatures(interfaceCommand.applicationCommand))); + this.flattenedCommands = new Set([...FLATTENED_MATRIX_COMMANDS] + .filter(interfaceCommand => commandHasFeatures(interfaceCommand.applicationCommand))); [...this.flattenedCommands].forEach(this.internCommand, this); } - public findAMatchingCommand(parts: string[]) { - const getCommand = (table: CommandLookupEntry): undefined|MatrixInterfaceCommand => { + public findAMatchingCommand(readItems: ReadItem[]) { + const getCommand = (table: CommandLookupEntry): undefined|MatrixInterfaceCommand => { const command = table.get(THIS_COMMAND_SYMBOL); if (command instanceof Map) { throw new TypeError("There is an implementation bug, only commands should be stored under the command symbol"); } return command; }; - const tableHelper = (table: CommandLookupEntry, nextParts: string[]): undefined|MatrixInterfaceCommand => { - if (nextParts.length === 0) { + const tableHelper = (table: CommandLookupEntry, nextParts: ReadItem[]): undefined|MatrixInterfaceCommand => { + if (nextParts.length === 0 || typeof nextParts.at(0) !== 'string') { // Then they might be using something like "!mjolnir status" return getCommand(table); } - const entry = table.get(nextParts.shift()!); + const entry = table.get(nextParts.shift()! as string); if (!entry) { // The reason there's no match is because this is the command arguments, rather than subcommand notation. return getCommand(table); @@ -186,10 +200,18 @@ export class MatrixCommandTable { return tableHelper(entry, nextParts); } }; - return tableHelper(this.commands, [...parts]); + return tableHelper(this.commands, [...readItems]); } - private internCommand(command: MatrixInterfaceCommand) { + public async invokeAMatchingCommand(context: C, readItems: ReadItem[]): Promise { + const command = this.findAMatchingCommand(readItems); + if (command) { + const itmesWithoutCommandDesignators = readItems.slice(command.commandParts.length) + await command.invoke(context, ...itmesWithoutCommandDesignators); + } + } + + private internCommand(command: MatrixInterfaceCommand) { const internCommandHelper = (table: CommandLookupEntry, commandParts: string[]): void => { if (commandParts.length === 0) { if (table.has(THIS_COMMAND_SYMBOL)) { diff --git a/src/commands/interface-manager/ParamaterParsing.ts b/src/commands/interface-manager/ParamaterParsing.ts new file mode 100644 index 00000000..b6f3f6d1 --- /dev/null +++ b/src/commands/interface-manager/ParamaterParsing.ts @@ -0,0 +1,153 @@ +/** + * Copyright (C) 2022 Gnuxie + * All rights reserved. + * + * This file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * Which includes the following license notice: + * +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + * + * However, this file is modified and the modifications in this file + * are NOT distributed, contributed, or committed under the Apache License. + */ + +import { Keyword, ReadItem, SuperCoolStream } from "./CommandReader"; +import { ValidationError, ValidationResult } from "./Validation"; + +class ArgumentStream extends SuperCoolStream { + +} + +type PredicateIsParamater = (readItem: ReadItem) => ValidationResult; + +interface DestructableRest { + rest: ReadItem[], + // Pisses me off to no end that this is how it has to work. + [prop: string]: ReadItem|ReadItem[], +} + +export class RestParser { + public parseRest(stream: ArgumentStream): ValidationResult { + const items: ReadItem[] = []; + while (stream.peekItem()) { + items.push(stream.readItem()); + } + return ValidationResult.Ok({ rest: 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 +} + +interface KeywordPropertyDescription { + readonly isFlag: boolean; + readonly propertyPredicate?: PredicateIsParamater; + readonly name: string, + +} + +// 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(private readonly description: KeywordsDescription) { + super(); + } + + /** + * TODO: Prototype pollution must be part of integration tests for this + * @param items + */ + public parseRest(itemStream: ArgumentStream): ValidationResult { + const destructable: DestructableRest = { rest: [] }; + // Wrong, we can't use position, we need a stream. + 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: ValidationResult = (() => { + if (itemStream.peekItem() !== undefined && !(itemStream.peekItem() instanceof Keyword)) { + const associatedProperty = itemStream.readItem(); + return ValidationResult.Ok(associatedProperty); + } else { + if (!description.isFlag) { + return ValidationError.Result('keyword verification failed', `An associated argument was not provided for the keyword ${description.name}.`) + } + return ValidationResult.Ok(true); + } + })(); + if (associatedProperty.isErr()) { + return ValidationResult.Err(associatedProperty.err); + } + destructable[description.name] = associatedProperty.ok; + + } else { + destructable.rest.push(item); + } + } + return ValidationResult.Ok(destructable); + } +} + +export interface ParsedArguments { + readonly immediateArguments: ReadItem[], + readonly rest?: DestructableRest, +} + +export function paramaters(paramaters: PredicateIsParamater[], restParser: undefined|RestParser = undefined): (...readItems: ReadItem[]) => ValidationResult { + return (...readItems: ReadItem[]) => { + const itemStream = new ArgumentStream(readItems); + for (const paramater of paramaters) { + if (itemStream.peekItem() === undefined) { + // FIXME asap: we need a proper paramater description? + return ValidationError.Result('expected an argument', `An argument for the paramater ${paramater} was expected but was not provided.`); + } + const item = itemStream.readItem()!; + const result = paramater(item); + if (result.err) { + return ValidationResult.Err(result.err); + } + } + if (restParser) { + const result = restParser.parseRest(itemStream); + if (result.isErr()) { + return ValidationResult.Err(result.err); + } + return ValidationResult.Ok({ immediateArguments: readItems, rest: result.ok }); + } else { + return ValidationResult.Ok({ immediateArguments: readItems }); + } + } +} + +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 ValidationResult.Ok(true); + } else { + // FIXME asap: again, we need some context as to what the argument is? + return ValidationError.Result('invalid paramater', `The argument must match the paramater description ${matches}`); + } + } +} diff --git a/test/commands/ParamaterParsingTest.ts b/test/commands/ParamaterParsingTest.ts new file mode 100644 index 00000000..35345f51 --- /dev/null +++ b/test/commands/ParamaterParsingTest.ts @@ -0,0 +1,21 @@ +import { readCommand, ReadItem } from "../../src/commands/interface-manager/CommandReader"; +import { paramaters } from "../../src/commands/interface-manager/ParamaterParsing"; +import { ValidationError, ValidationResult } from "../../src/commands/interface-manager/Validation"; +import expect from "expect"; + +describe('The argument parser goddamn works', function() { + it('can parse a simple argument list', function() { + const paramaterDescription = paramaters([ + (item: ReadItem) => { + if (typeof item === 'string') { + return ValidationResult.Ok(true) + } else { + return ValidationError.Result('invalid', 'was expecting a string here m8'); + } + } + ]); + expect(paramaterDescription(...readCommand("hello")).ok).toBe(true); + // hmm what about providing too many arguments when there's no rest? + // how to conveniently test all the edge cases? + }) +}) \ No newline at end of file