diff --git a/src/Yuuko.ts b/src/Yuuko.ts index 1be6564..5c6d51f 100644 --- a/src/Yuuko.ts +++ b/src/Yuuko.ts @@ -1,10 +1,22 @@ /** @module Yuuko */ -// Exort all things from other files +// Export all things from other files + +// Main classes export * from './Client'; export * from './Command'; export * from './EventListener'; +// Argument parsing core +export * from './argumentParsing'; + +// Argument types for basic values +export * from './argumentTypes/basic/IntegerArgument'; +export * from './argumentTypes/basic/NumberArgument'; + +// Argument types for Discord stuff +export * from './argumentTypes/discord/MemberArgument'; + // Also export plain Eris for convenience working with its types/etc import * as Eris from 'eris'; export {Eris}; diff --git a/src/argumentParsing.ts b/src/argumentParsing.ts new file mode 100644 index 0000000..d929eea --- /dev/null +++ b/src/argumentParsing.ts @@ -0,0 +1,61 @@ +/** An argument type's options object. */ +export interface ArgumentSpecifier { + type: string; +} + +/** An argument's options interface and its return type inside a type tuple. */ +export type ArgumentType = [A, R]; + +/** + * A function that takes an args array and the options for the argument and + * returns a value that matches the expected type of the argument. + */ +export type ArgumentResolver = (args: string[], opts: A[0]) => A[1] | Promise; + +// A map containing all argument parsers registered +const argResolvers = new Map(); + +/** Registers a custom argument type for use with `parseArgs` */ +export function registerArgumentType (id: T[0]['type'], func: ArgumentResolver): void { + // We're storing parsers as generic ArgumentResolvers, rather than their + // more specific "true" types. Type safety is still guaranteed since we've + // checked the type string here and we check the type string again when + // calling `parseArgs`, ensuring that the "true" type of the resolver here + // will always match the type expected by the parsing code. + argResolvers.set(id, func as unknown as ArgumentResolver); +} + +/** Parses command arguments according to a list of argument types. */ +// There is still no good way to handle array types like this +export async function parseArgs (args: string[], specifiers: [T[0][0]]): Promise<[T[0][1]]>; +export async function parseArgs (args: string[], specifiers: [T[0][0], T[1][0]]): Promise<[T[0][1], T[1][1]]>; +export async function parseArgs (args: string[], specifiers: [T[0][0], T[1][0], T[2][0]]): Promise<[T[0][1], T[1][1], T[2][1]]>; +export async function parseArgs (args: string[], specifiers: [T[0][0], T[1][0], T[2][0], T[3][0]]): Promise<[T[0][1], T[1][1], T[2][1], T[3][1]]>; +export async function parseArgs (args: string[], specifiers: [T[0][0], T[1][0], T[2][0], T[3][0], T[4][0]]): Promise<[T[0][1], T[1][1], T[2][1], T[3][1], T[4][1]]>; +export async function parseArgs (args: string[], specifiers: [T[0][0], T[1][0], T[2][0], T[3][0], T[4][0], T[5][0]]): Promise<[T[0][1], T[1][1], T[2][1], T[3][1], T[4][1], T[5][1]]>; +export async function parseArgs (args: string[], specifiers: [T[0][0], T[1][0], T[2][0], T[3][0], T[4][0], T[5][0], T[6][0]]): Promise<[T[0][1], T[1][1], T[2][1], T[3][1], T[4][1], T[5][1], T[6][1]]>; +export async function parseArgs (args: string[], specifiers: [T[0][0], T[1][0], T[2][0], T[3][0], T[4][0], T[5][0], T[6][0], T[7][0]]): Promise<[T[0][1], T[1][1], T[2][1], T[3][1], T[4][1], T[5][1], T[6][1], T[7][1]]>; +export async function parseArgs (args: string[], specifiers: [T[0][0], T[1][0], T[2][0], T[3][0], T[4][0], T[5][0], T[6][0], T[7][0], T[8][0]]): Promise<[T[0][1], T[1][1], T[2][1], T[3][1], T[4][1], T[5][1], T[6][1], T[7][1], T[8][1]]>; +export async function parseArgs (args: string[], specifiers: [T[0][0], T[1][0], T[2][0], T[3][0], T[4][0], T[5][0], T[6][0], T[7][0], T[8][0], T[9][0]]): Promise<[T[0][1], T[1][1], T[2][1], T[3][1], T[4][1], T[5][1], T[6][1], T[7][1], T[8][1], T[9][1]]>; +export async function parseArgs (args: string[], specifiers: ArgumentSpecifier[]): Promise { + const returnValues: any[] = []; + for (const specifier of specifiers) { + const resolver = argResolvers.get(specifier.type); + if (!resolver) { + throw new Error(`No resolver for type ${specifier.type}`); + } + + try { + // Resolvers must be processed serially since they modify the `args` + // array, and changes need to be reflected for the next resolver + // eslint-disable-next-line no-await-in-loop + returnValues.push(await resolver(args, specifier)); + } catch (error) { + // The resolver threw - re-throw the error, adding context + error.specifier = specifier; + error.argIndex = specifiers.indexOf(specifier); + throw error; + } + } + return returnValues; +} diff --git a/src/argumentTypes/basic/IntegerArgument.ts b/src/argumentTypes/basic/IntegerArgument.ts new file mode 100644 index 0000000..0f2033b --- /dev/null +++ b/src/argumentTypes/basic/IntegerArgument.ts @@ -0,0 +1,31 @@ +import {ArgumentType, registerArgumentType} from '../../Yuuko'; + +/** An argument that represents an integer number. */ +type NumberArgument = ArgumentType<{ + type: 'number'; + /** + * The lower bound of accepted integers. Defaults to + * `Number.MIN_SAFE_INTEGER`. + */ + lowerBound?: number; + /** + * The upper bound of accepted integers. Defaults to + * `Number.MAX_SAFE_INTEGER`. + */ + upperBound?: number; + /** The radix/base used for parsing. Defaults to `10`. */ + radix?: number; +}, number>; + +registerArgumentType('number', (args, { + lowerBound = Number.MIN_SAFE_INTEGER, + upperBound = Number.MAX_SAFE_INTEGER, + radix = 10, +}) => { + // shift()! is appropriate here solely because `parseFloat(undefined)` is `NaN` + const num = parseInt(args.shift()!, radix); + if (num < lowerBound || num > upperBound) { + throw new RangeError(`${num} is not between ${lowerBound} and ${upperBound}`); + } + return num; +}); diff --git a/src/argumentTypes/basic/NumberArgument.ts b/src/argumentTypes/basic/NumberArgument.ts new file mode 100644 index 0000000..2655ceb --- /dev/null +++ b/src/argumentTypes/basic/NumberArgument.ts @@ -0,0 +1,22 @@ +import {ArgumentType, registerArgumentType} from '../../Yuuko'; + +/** An argument that represents a floating-pointer number. */ +type NumberArgument = ArgumentType<{ + type: 'number'; + /** The lower bound of accepted integers. Defaults to `-Infinity`. */ + lowerBound?: number; + /** The upper bound of accepted integers. Defaults to `Infinity`. */ + upperBound?: number; +}, number>; + +registerArgumentType('number', (args, { + lowerBound = -Infinity, + upperBound = Infinity, +}) => { + // shift()! is appropriate here solely because `parseFloat(undefined)` is `NaN` + const num = parseFloat(args.shift()!); + if (num < lowerBound || num > upperBound) { + throw new RangeError(`${num} is not between ${lowerBound} and ${upperBound}`); + } + return num; +}); diff --git a/src/argumentTypes/discord/MemberArgument.ts b/src/argumentTypes/discord/MemberArgument.ts new file mode 100644 index 0000000..9379594 --- /dev/null +++ b/src/argumentTypes/discord/MemberArgument.ts @@ -0,0 +1,50 @@ +import Eris from 'eris'; +import {ArgumentType, registerArgumentType} from '../../Yuuko'; + +export type MemberArgument = ArgumentType<{ + type: 'member'; + /** If provided, the word "me" will be interpreted the given member. */ + me?: Eris.Member; + /** The guild to search for the member in. */ + guild: Eris.Guild; +}, Eris.Member>; + +registerArgumentType('member', async (args, { + me, + guild, +}) => { + if (!args.length) { + throw new Error('Not enough arguments'); + } + + // The "me" keyword, if we're provided with a context for it + if (me && args[0].toLowerCase() === 'me') { + args.shift(); + return me; + } + + let match; + + // Mentions and straight IDs + match = args[0].match(/^(?:<@!?)?(\d+)>?(?:\s+|$)/); + if (match) { + const member = guild.members.get(match[1]) || await guild.getRESTMember(match[1]).catch(() => undefined); + if (member) { + args.shift(); + return member; + } + } + + // User tags (name#discrim) + match = args[0].match(/^([^#]{2,32})#(\d{4})(?:\s+|$)/); + if (match) { + const member = guild.members.find(m => m.username === match[1] && m.discriminator === match[2]); + if (member) { + args.shift(); + return member; + } + } + + // Found nothing + throw new Error('No member found'); +});