Skip to content

Commit

Permalink
refa: implement commander service
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed May 20, 2022
1 parent 804bbfd commit 1bd1425
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 55 deletions.
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
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')

export type Extend<O extends {}, K extends string, T> = {
[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
Expand Down Expand Up @@ -43,11 +47,9 @@ export class Command<U extends User.Field = never, G extends Channel.Field = nev
private _channelFields: FieldCollector<'channel'>[] = [['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,
Expand All @@ -72,15 +74,11 @@ export class Command<U extends User.Field = never, G extends Channel.Field = nev
return this
}

constructor(name: string, decl: string, context: Context) {
super(name, decl, context)
constructor(name: string, decl: string, ctx: Context) {
super(name, decl, ctx)
this.config = { ...Command.defaultConfig }
this._registerAlias(name)
context.app._commandList.push(this)
}

get app() {
return this.context.app
ctx.$commander._commandList.push(this)
}

get displayName() {
Expand Down Expand Up @@ -109,17 +107,17 @@ export class Command<U extends User.Field = never, G extends Channel.Field = nev
}

// register global
const previous = this.app.getCommand(name)
const previous = this.ctx.$commander.getCommand(name)
if (!previous) {
this.app._commands.set(name, this)
this.ctx.$commander._commands.set(name, this)
} else if (previous !== this) {
throw new Error(`duplicate command names: "${name}"`)
}

// add disposable
this._disposables?.push(() => {
remove(this._aliases, name)
this.app._commands.delete(name)
this.ctx.$commander._commands.delete(name)
})
}

Expand Down Expand Up @@ -149,8 +147,8 @@ export class Command<U extends User.Field = never, G extends Channel.Field = nev
if (this._disposed) return this
config.name = name
config.command = this
this.app._shortcuts.push(config)
this._disposables?.push(() => remove(this.app._shortcuts, config))
this.ctx.$commander._shortcuts.push(config)
this._disposables?.push(() => remove(this.ctx.$commander._shortcuts, config))
return this
}

Expand All @@ -161,7 +159,7 @@ export class Command<U extends User.Field = never, G extends Channel.Field = nev
const desc = typeof args[0] === 'string' ? args.shift() as string : ''
const config = args[0] as Command.Config || {}
if (this._disposed) config.patch = true
return this.context.command(def, desc, config)
return this.ctx.command(def, desc, config)
}

usage(text: Command.Usage<U, G>) {
Expand Down Expand Up @@ -191,7 +189,7 @@ export class Command<U extends User.Field = never, G extends Channel.Field = nev

match(session: Session) {
const { authority = Infinity } = (session.user || {}) as User
return this.context.match(session) && this.config.authority <= authority
return this.ctx.match(session) && this.config.authority <= authority
}

getConfig<K extends keyof Command.Config>(key: K, session: Session): Exclude<Command.Config[K], (session: Session) => any> {
Expand Down Expand Up @@ -269,21 +267,21 @@ export class Command<U extends User.Field = never, G extends Channel.Field = nev
if (index === length) throw error
const stack = coerce(error)
logger.warn(`${argv.source ||= this.stringify(args, options)}\n${stack}`)
this.app.emit(argv.session, 'command-error', argv, error)
this.ctx.emit(argv.session, 'command-error', argv, error)
}

return ''
}

dispose() {
this._disposed = true
this.app.emit('command-removed', this)
this.ctx.emit('command-removed', this)
for (const cmd of this.children.slice()) {
cmd.dispose()
}
this.app._shortcuts = this.app._shortcuts.filter(s => 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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -44,30 +44,30 @@ 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'))
.option('authority', '-a')
.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)
return output.filter(Boolean).join('\n')
}

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)
},
})
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
})
Expand Down
139 changes: 139 additions & 0 deletions packages/core/src/command/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, Command> {
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<void | string>
'command/before-attach-channel'(argv: Argv, fields: Set<Channel.Field>): void
'command/before-attach-user'(argv: Argv, fields: Set<User.Field>): 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<D extends string>(def: D, config?: Command.Config): Command<never, never, Argv.ArgumentType<D>>
command<D extends string>(def: D, desc: string, config?: Command.Config): Command<never, never, Argv.ArgumentType<D>>
}
}

export class Commander {
static readonly key = '$commander'

_commandList: Command[] = []
_commands = new Map<string, Command>() 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'],
})
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 1bd1425

Please sign in to comment.