It's time for mjolnir-shell, a presentation style interface.

While this isn't a true presentation style interface,
the idea is that there is an argument stream to commands
that we pattern match commands against, and there are
different mediums that the commands can be invoked from.

There are translators between presentation types
and also between commands and mediums to do things like
render the result of the command for Matrix etc.

This is all inspired by the Common Lisp Interface Manager (CLIM).
But there are significant differences since, hello, this is
essentially being made for Matrix bots and appservices.

This commit is NOT contributed under the Apache-2.0 License.
Copyright (C) 2022 Gnuxie <Gnuxie@protonmail.com>
All rights reserved.
This commit is contained in:
gnuxie
2022-12-09 20:07:12 +00:00
committed by gnuxie
parent 4b254e59f8
commit d7adaef0bf
10 changed files with 334 additions and 68 deletions
+3 -3
View File
@@ -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<MjolnirContext>;
/**
* Adds a listener to the client that will automatically accept invitations.
+6 -1
View File
@@ -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);
+68 -22
View File
@@ -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</*the user id of the mjolnir*/string, ManagedMjolnir> = new Map();
private readonly unstartedMjolnirs: Map</** user id of the mjolnir */string, UnstartedMjolnir> = 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<MjolnirManager> {
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<ManagedMjolnir> {
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<void> {
// 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<void> {
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",
}
}
@@ -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
+3 -1
View File
@@ -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,
}
+10 -5
View File
@@ -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<MjolnirContext>) {
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
@@ -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<T extends { at: (...args: any) => 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<string> {
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 <link here when i've done it>.
* as I will demonstrate <link here when i've done it>.
*
* 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.
@@ -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<string|symbol, CommandLookupEntry|MatrixInterfaceCommand<BaseFunction>>;
/**
* 💀 . 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<string|symbol, CommandLookupEntry|MatrixInterfaceCommand<MatrixContext, BaseFunction>>;
type BaseFunction = (...args: any) => Promise<any>;
const FLATTENED_MATRIX_COMMANDS = new Set<MatrixInterfaceCommand<BaseFunction>>();
const FLATTENED_MATRIX_COMMANDS = new Set<MatrixInterfaceCommand<MatrixContext, BaseFunction>>();
const THIS_COMMAND_SYMBOL = Symbol("thisCommand");
type ParserSignature<ExecutorType extends (...args: any) => Promise<any>> = (
this: MatrixInterfaceCommand<ExecutorType>,
export interface MatrixContext {
client: MatrixSendClient,
roomId: string,
event: any,
}
type ParserSignature<C extends MatrixContext, ExecutorType extends (...args: any) => Promise<any>> = (
this: MatrixInterfaceCommand<C, ExecutorType>,
// 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<ValidationResult<Parameters<ExecutorType>>>;
type RendererSignature<ExecutorReturnType extends Promise<any>> = (
@@ -72,10 +82,10 @@ type RendererSignature<ExecutorReturnType extends Promise<any>> = (
* When confirmation is required in the middle of a traditional command ie preview kick
* the preview command should be a distinct command.
*/
class MatrixInterfaceCommand<ExecutorType extends (...args: any) => Promise<any>> {
class MatrixInterfaceCommand<C extends MatrixContext, ExecutorType extends (...args: any) => Promise<any>> {
constructor(
public readonly commandParts: string[],
private readonly parser: ParserSignature<ExecutorType>,
private readonly parser: ParserSignature<C, ExecutorType>,
public readonly applicationCommand: ApplicationCommand<ExecutorType>,
private readonly renderer: RendererSignature<ReturnType<ExecutorType>>,
private readonly validationErrorHandler?: (client: MatrixClient, roomId: string, event: any, validationError: ValidationError) => Promise<void>
@@ -91,14 +101,17 @@ class MatrixInterfaceCommand<ExecutorType extends (...args: any) => Promise<any>
* along with the result of the executor.
* @param args These will be the arguments to the parser function.
*/
public async invoke(...args: Parameters<ParserSignature<ExecutorType>>): Promise<void> {
public async invoke(...args: Parameters<ParserSignature<C, ExecutorType>>): Promise<void> {
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<ExecutorType> = 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<void> {
@@ -121,9 +134,9 @@ class MatrixInterfaceCommand<ExecutorType extends (...args: any) => Promise<any>
* @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<ExecutorType extends (...args: any) => Promise<any>>(
export function defineMatrixInterfaceCommand<C extends MatrixContext, ExecutorType extends (...args: any) => Promise<any>>(
commandParts: string[],
parser: ParserSignature<ExecutorType>,
parser: ParserSignature<C, ExecutorType>,
applicationCommmand: ApplicationCommand<ExecutorType>,
renderer: RendererSignature<ReturnType<ExecutorType>>) {
FLATTENED_MATRIX_COMMANDS.add(
@@ -140,9 +153,9 @@ export function defineMatrixInterfaceCommand<ExecutorType extends (...args: any)
/**
* This can be used by mjolnirs or an appservice bot.
*/
export class MatrixCommandTable {
export class MatrixCommandTable<C extends MatrixContext> {
public readonly features: ApplicationFeature[];
private readonly flattenedCommands: Set<MatrixInterfaceCommand<BaseFunction>>;
private readonly flattenedCommands: Set<MatrixInterfaceCommand<C, BaseFunction>>;
private readonly commands: CommandLookupEntry = new Map();
constructor(featureNames: string[]) {
@@ -158,24 +171,25 @@ export class MatrixCommandTable {
const commandHasFeatures = (command: ApplicationCommand<BaseFunction>) => {
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<BaseFunction> => {
public findAMatchingCommand(readItems: ReadItem[]) {
const getCommand = (table: CommandLookupEntry): undefined|MatrixInterfaceCommand<C, BaseFunction> => {
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<BaseFunction> => {
if (nextParts.length === 0) {
const tableHelper = (table: CommandLookupEntry, nextParts: ReadItem[]): undefined|MatrixInterfaceCommand<C, BaseFunction> => {
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<BaseFunction>) {
public async invokeAMatchingCommand(context: C, readItems: ReadItem[]): Promise<void> {
const command = this.findAMatchingCommand(readItems);
if (command) {
const itmesWithoutCommandDesignators = readItems.slice(command.commandParts.length)
await command.invoke(context, ...itmesWithoutCommandDesignators);
}
}
private internCommand(command: MatrixInterfaceCommand<C, BaseFunction>) {
const internCommandHelper = (table: CommandLookupEntry, commandParts: string[]): void => {
if (commandParts.length === 0) {
if (table.has(THIS_COMMAND_SYMBOL)) {
@@ -0,0 +1,153 @@
/**
* Copyright (C) 2022 Gnuxie <Gnuxie@protonmail.com>
* 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<ReadItem[]> {
}
type PredicateIsParamater = (readItem: ReadItem) => ValidationResult<true>;
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<DestructableRest> {
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<DestructableRest> {
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<any> = (() => {
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<ParsedArguments> {
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}`);
}
}
}
+21
View File
@@ -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?
})
})