diff --git a/packages/core/src/command.ts b/packages/core/src/command/command.ts similarity index 87% rename from packages/core/src/command.ts rename to packages/core/src/command/command.ts index 5e53219b4a..bf153034db 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command/command.ts @@ -1,9 +1,10 @@ -import { Awaitable, coerce, Dict, Logger, remove, Schema } from '@koishijs/utils' +import { coerce, Dict, Logger, remove, Schema } from '@koishijs/utils' +import { Awaitable } from 'cosmokit' +import { Context, Disposable } from 'cordis' import { Argv } from './parser' -import { Context, Disposable, Next } from './context' -import { Channel, User } from './database' -import { Computed, FieldCollector, Session } from './session' -import * as internal from './internal' +import { Next } from '../protocol' +import { Channel, User } from '../database' +import { Computed, FieldCollector, Session } from '../protocol/session' const logger = new Logger('command') @@ -11,6 +12,9 @@ export type Extend = { [P in K | keyof O]?: (P extends keyof O ? O[P] : unknown) & (P extends K ? T : unknown) } +export interface CommandService { +} + export namespace Command { export interface Shortcut { name?: string | RegExp @@ -43,11 +47,9 @@ export class Command[] = [['locale']] private _actions: Command.Action[] = [] private _checkers: Command.Action[] = [async (argv) => { - return this.app.serial(argv.session, 'command/before-execute', argv) + return this.ctx.serial(argv.session, 'command/before-execute', argv) }] - public static enableHelp: typeof internal.enableHelp - static defaultConfig: Command.Config = { authority: 1, showWarning: true, @@ -72,15 +74,11 @@ export class Command { remove(this._aliases, name) - this.app._commands.delete(name) + this.ctx.$commander._commands.delete(name) }) } @@ -149,8 +147,8 @@ export class Command remove(this.app._shortcuts, config)) + this.ctx.$commander._shortcuts.push(config) + this._disposables?.push(() => remove(this.ctx.$commander._shortcuts, config)) return this } @@ -161,7 +159,7 @@ export class Command) { @@ -191,7 +189,7 @@ export class Command(key: K, session: Session): Exclude any> { @@ -269,7 +267,7 @@ export class Command s.command !== this) - this._aliases.forEach(name => this.app._commands.delete(name)) - remove(this.app._commandList, this) + this.ctx.$commander._shortcuts = this.ctx.$commander._shortcuts.filter(s => s.command !== this) + this._aliases.forEach(name => this.ctx.$commander._commands.delete(name)) + remove(this.ctx.$commander._commandList, this) if (this.parent) { remove(this.parent.children, this) } diff --git a/packages/core/src/internal/help.ts b/packages/core/src/command/help.ts similarity index 90% rename from packages/core/src/internal/help.ts rename to packages/core/src/command/help.ts index e965f7123a..aac8dbfd15 100644 --- a/packages/core/src/internal/help.ts +++ b/packages/core/src/command/help.ts @@ -1,8 +1,8 @@ -import { Argv } from '../parser' -import { Command } from '../command' -import { Context } from '../context' +import { Argv } from './parser' +import { Command } from './command' +import { Context } from 'cordis' import { Channel, Tables, User } from '../database' -import { FieldCollector, Session } from '../session' +import { FieldCollector, Session } from '../protocol/session' interface HelpOptions { showHidden?: boolean @@ -27,11 +27,11 @@ export default function help(ctx: Context, config: HelpConfig = {}) { ctx.on('command-added', cmd => cmd.use(enableHelp)) } - const app = ctx.app + const $ = ctx.$commander function findCommand(target: string) { - const command = app._commands.resolve(target) + const command = $._commands.resolve(target) if (command) return command - const shortcut = app._shortcuts.find(({ name }) => { + const shortcut = $._shortcuts.find(({ name }) => { return typeof name === 'string' ? name === target : name.test(target) }) if (shortcut) return shortcut.command @@ -44,7 +44,7 @@ export default function help(ctx: Context, config: HelpConfig = {}) { session.collect(key, { ...argv, command, args: [], options: { help: true } }, fields) } - const cmd = ctx.command('help [command:string]', { authority: 0, ...config }) + const cmd = $.command('help [command:string]', { authority: 0, ...config }) .userFields(['authority']) .userFields(createCollector('user')) .channelFields(createCollector('channel')) @@ -52,7 +52,7 @@ export default function help(ctx: Context, config: HelpConfig = {}) { .option('showHidden', '-H') .action(async ({ session, options }, target) => { if (!target) { - const commands = app._commandList.filter(cmd => cmd.parent === null) + const commands = $._commandList.filter(cmd => cmd.parent === null) const output = formatCommands('.global-prolog', session, commands, options) const epilog = session.text('.global-epilog') if (epilog) output.push(epilog) @@ -60,14 +60,14 @@ export default function help(ctx: Context, config: HelpConfig = {}) { } const command = findCommand(target) - if (!command?.context.match(session)) { + if (!command?.ctx.match(session)) { return session.suggest({ target, items: getCommandNames(session), prefix: session.text('suggest.help-prefix'), suffix: session.text('suggest.help-suffix'), async apply(suggestion) { - return showHelp(ctx.getCommand(suggestion), this as any, options) + return showHelp($.getCommand(suggestion), this as any, options) }, }) } @@ -79,7 +79,7 @@ export default function help(ctx: Context, config: HelpConfig = {}) { } export function getCommandNames(session: Session) { - return session.app._commandList + return session.app.$commander._commandList .filter(cmd => cmd.match(session) && !cmd.config.hidden) .flatMap(cmd => cmd._aliases) } @@ -141,13 +141,13 @@ function getOptions(command: Command, session: Session<'authority'>, config: Hel let line = `${authority}${option.syntax}` const description = session.text(option.descPath ?? [`commands.${command.name}.options.${option.name}`, '']) if (description) line += ' ' + description - line = command.app.chain('help/option', line, option, command, session) + line = command.ctx.chain('help/option', line, option, command, session) output.push(' ' + line) for (const value in option.valuesSyntax) { let line = `${authority}${option.valuesSyntax[value]}` const description = session.text([`commands.${command.name}.options.${option.name}.${value}`, '']) if (description) line += ' ' + description - line = command.app.chain('help/option', line, option, command, session) + line = command.ctx.chain('help/option', line, option, command, session) output.push(' ' + line) } }) diff --git a/packages/core/src/command/index.ts b/packages/core/src/command/index.ts new file mode 100644 index 0000000000..a64714a364 --- /dev/null +++ b/packages/core/src/command/index.ts @@ -0,0 +1,139 @@ +import { Context } from 'cordis' +import { Awaitable } from 'cosmokit' +import { Command } from './command' +import { Argv } from './parser' +import runtime from './runtime' +import validate from './validate' +import help, { HelpConfig } from './help' +import { Channel, User } from '../database' +import { Session } from '../protocol' + +export * from './command' +export * from './help' +export * from './runtime' +export * from './parser' +export * from './validate' + +interface CommandMap extends Map { + resolve(key: string): Command +} + +declare module 'cordis' { + interface Context extends Commander.Delegates { + $commander: Commander + } + + interface Events { + 'before-parse'(content: string, session: Session): Argv + 'command-added'(command: Command): void + 'command-removed'(command: Command): void + 'command-error'(argv: Argv, error: any): void + 'command/before-execute'(argv: Argv): Awaitable + 'command/before-attach-channel'(argv: Argv, fields: Set): void + 'command/before-attach-user'(argv: Argv, fields: Set): void + 'help/command'(output: string[], command: Command, session: Session): void + 'help/option'(output: string, option: Argv.OptionDeclaration, command: Command, session: Session): string + } +} + +export namespace Commander { + export interface Config { + help?: false | HelpConfig + } + + export interface Delegates { + command(def: D, config?: Command.Config): Command> + command(def: D, desc: string, config?: Command.Config): Command> + } +} + +export class Commander { + static readonly key = '$commander' + + _commandList: Command[] = [] + _commands = new Map() as CommandMap + _shortcuts: Command.Shortcut[] = [] + + constructor(private ctx: Context, private config: Commander.Config = {}) { + ctx.plugin(runtime) + ctx.plugin(validate) + ctx.plugin(help, config.help) + } + + protected get caller(): Context { + return this[Context.current] || this.ctx + } + + resolve(key: string) { + if (!key) return + const segments = key.split('.') + let i = 1, name = segments[0], cmd: Command + while ((cmd = this.getCommand(name)) && i < segments.length) { + name = cmd.name + '.' + segments[i++] + } + return cmd + } + + getCommand(name: string) { + return this._commands.get(name) + } + + command(def: string, ...args: [Command.Config?] | [string, Command.Config?]) { + const desc = typeof args[0] === 'string' ? args.shift() as string : '' + const config = args[0] as Command.Config + const path = def.split(' ', 1)[0].toLowerCase() + const decl = def.slice(path.length) + const segments = path.split(/(?=[./])/g) + + let parent: Command, root: Command + const list: Command[] = [] + segments.forEach((segment, index) => { + const code = segment.charCodeAt(0) + const name = code === 46 ? parent.name + segment : code === 47 ? segment.slice(1) : segment + let command = this.getCommand(name) + if (command) { + if (parent) { + if (command === parent) { + throw new Error(`cannot set a command (${command.name}) as its own subcommand`) + } + if (command.parent) { + if (command.parent !== parent) { + throw new Error(`cannot create subcommand ${path}: ${command.parent.name}/${command.name} already exists`) + } + } else { + command.parent = parent + parent.children.push(command) + } + } + return parent = command + } + command = new Command(name, decl, this.caller) + list.push(command) + if (!root) root = command + if (parent) { + command.parent = parent + command.config.authority = parent.config.authority + parent.children.push(command) + } + parent = command + }) + + if (desc) this.caller.i18n.define('', `commands.${parent.name}.description`, desc) + Object.assign(parent.config, config) + list.forEach(command => this.caller.emit('command-added', command)) + if (!config?.patch) { + if (root) this.caller.state.disposables.unshift(() => root.dispose()) + return parent + } + + if (root) root.dispose() + const command = Object.create(parent) + command._disposables = this.caller.state.disposables + return command + } +} + +Context.service(Commander.key, { + constructor: Commander, + methods: ['command'], +}) diff --git a/packages/core/src/parser.ts b/packages/core/src/command/parser.ts similarity index 98% rename from packages/core/src/parser.ts rename to packages/core/src/command/parser.ts index b11abe3b33..7db42f8c6a 100644 --- a/packages/core/src/parser.ts +++ b/packages/core/src/command/parser.ts @@ -1,8 +1,9 @@ import { camelCase, Dict, escapeRegExp, paramCase, segment, Time } from '@koishijs/utils' import { Command } from './command' -import { Context, Next } from './context' -import { Channel, User } from './database' -import { Session } from './session' +import { Context } from 'cordis' +import { Channel, User } from '../database' +import { Session } from '../protocol/session' +import { Next } from '../protocol' export interface Token { rest?: string @@ -379,7 +380,7 @@ export namespace Argv { private _namedOptions: OptionDeclarationMap = {} private _symbolicOptions: OptionDeclarationMap = {} - constructor(public readonly name: string, declaration: string, public context: Context) { + constructor(public readonly name: string, declaration: string, public ctx: Context) { if (!name) throw new Error('expect a command name') const decl = this._arguments = parseDecl(declaration) this.declaration = decl.stripped @@ -434,7 +435,7 @@ export namespace Argv { } if (desc) { - this.context.i18n.define('', path, desc) + this.ctx.i18n.define('', path, desc) } this._assignOption(option, names, this._namedOptions) diff --git a/packages/core/src/internal/runtime.ts b/packages/core/src/command/runtime.ts similarity index 89% rename from packages/core/src/internal/runtime.ts rename to packages/core/src/command/runtime.ts index e05773cb29..58b0bf599b 100644 --- a/packages/core/src/internal/runtime.ts +++ b/packages/core/src/command/runtime.ts @@ -1,7 +1,7 @@ import { defineProperty, valueMap } from '@koishijs/utils' -import { Argv } from '../parser' -import { Context } from '../context' -import { Session } from '../session' +import { Argv } from './parser' +import { Context } from 'cordis' +import { Session } from '../protocol/session' export default function runtime(ctx: Context) { ctx.before('parse', (content, session) => { @@ -20,9 +20,9 @@ export default function runtime(ctx: Context) { ctx.before('parse', (content, session) => { const { parsed, quote } = session if (parsed.prefix || quote) return - for (const shortcut of ctx.app._shortcuts) { + for (const shortcut of ctx.$commander._shortcuts) { const { name, fuzzy, command, prefix, options = {}, args = [] } = shortcut - if (prefix && !parsed.appel || !command.context.match(session)) continue + if (prefix && !parsed.appel || !command.ctx.match(session)) continue if (typeof name === 'string') { if (!fuzzy && content !== name || !content.startsWith(name)) continue const message = content.slice(name.length) @@ -66,7 +66,7 @@ export default function runtime(ctx: Context) { }) function executeHelp(session: Session, name: string) { - if (!ctx.getCommand('help')) return + if (!ctx.$commander.getCommand('help')) return return session.execute({ name: 'help', args: [name], @@ -82,7 +82,7 @@ export default function runtime(ctx: Context) { if (command['_actions'].length) return // subcommand redirect const arg0 = args.shift() || '' - const subcommand = ctx.getCommand(command.name + '.' + arg0) + const subcommand = ctx.$commander.getCommand(command.name + '.' + arg0) if (subcommand) { // save command names const commands = session['__redirected_commands'] ||= [ diff --git a/packages/core/src/internal/validate.ts b/packages/core/src/command/validate.ts similarity index 97% rename from packages/core/src/internal/validate.ts rename to packages/core/src/command/validate.ts index 3aff7a7e9e..51d87b7c04 100644 --- a/packages/core/src/internal/validate.ts +++ b/packages/core/src/command/validate.ts @@ -1,5 +1,5 @@ -import { Argv } from '../parser' -import { Context } from '../context' +import { Argv } from './parser' +import { Context } from 'cordis' export default function validate(ctx: Context) { // add user fields