tuix
Version:
A performant TUI framework for Bun with JSX and reactive state management
377 lines (323 loc) • 10.2 kB
text/typescript
/**
* CLI Command Router
*
* Routes parsed commands to their handlers with support for lazy loading
*/
import type { CLIConfig, CommandConfig, ParsedArgs, Handler, LazyHandler } from "./types"
export interface RouteResult {
handler: Handler | LazyHandler | null
config: CommandConfig | null
isLazy: boolean
}
export class CLIRouter {
private _commands: Record<string, CommandConfig> = {}
private _middleware: Array<(handler: Handler) => Handler> = []
constructor(private config: CLIConfig) {
// Initialize with commands from config
this._commands = { ...(config.commands || {}) }
}
/**
* Route a parsed command to its handler
*/
route(parsedArgs: ParsedArgs): RouteResult {
if (parsedArgs.command.length === 0) {
return {
handler: null,
config: null,
isLazy: false
}
}
const commandConfig = this.findCommandConfig(parsedArgs.command)
if (!commandConfig) {
return {
handler: null,
config: null,
isLazy: false
}
}
const handler = commandConfig.handler
if (!handler) {
return {
handler: null,
config: commandConfig,
isLazy: false
}
}
// Check if handler is lazy (function that returns Promise)
const isLazy = this.isLazyHandler(handler)
return {
handler,
config: commandConfig,
isLazy
}
}
/**
* Find command configuration for a command path
*/
findCommandConfig(commandPath: string[]): CommandConfig | null {
let currentCommands = this.config.commands || {}
let currentConfig: CommandConfig | null = null
for (const command of commandPath) {
currentConfig = currentCommands[command] || null
if (!currentConfig) {
return null
}
// If this is not the last command, continue to subcommands
if (commandPath.indexOf(command) < commandPath.length - 1) {
currentCommands = currentConfig.commands || {}
}
}
return currentConfig
}
/**
* Get all available commands at a given path
*/
getAvailableCommands(commandPath: string[] = []): string[] {
let currentCommands = this.config.commands || {}
// Navigate to the command path
for (const command of commandPath) {
const commandConfig = currentCommands[command]
if (!commandConfig) {
return []
}
currentCommands = commandConfig.commands || {}
}
return Object.keys(currentCommands).filter(name => {
const config = currentCommands[name]
return config && !config.hidden
})
}
/**
* Get command aliases
*/
getCommandAliases(commandName: string): string[] {
const commands = this.config.commands || {}
const config = commands[commandName]
return config?.aliases || []
}
/**
* Resolve command name (including aliases)
*/
resolveCommandName(name: string, currentCommands?: Record<string, CommandConfig>): string | null {
const commands = currentCommands || this.config.commands || {}
// Direct match
if (commands[name]) {
return name
}
// Check aliases
for (const [commandName, config] of Object.entries(commands)) {
if (config.aliases?.includes(name)) {
return commandName
}
}
return null
}
/**
* Check if a handler is lazy (returns a Promise<Handler>)
*/
private isLazyHandler(handler: Handler | LazyHandler): boolean {
// A lazy handler should be a function with no parameters that returns another function
// Regular handlers take args as parameter
return typeof handler === 'function' && handler.length === 0
}
/**
* Execute a handler (lazy or synchronous)
*/
async executeHandler(
handler: Handler | LazyHandler,
args: any,
isLazy: boolean = false
): Promise<any> {
try {
if (isLazy) {
// Lazy handler - first call returns the actual handler
const actualHandler = await (handler as LazyHandler)()
return await this.callHandler(actualHandler, args)
} else {
// Direct handler
return await this.callHandler(handler as Handler, args)
}
} catch (error) {
// Enhance error with context
if (error instanceof Error) {
error.message = `Command execution failed: ${error.message}`
}
throw error
}
}
/**
* Call a handler function with proper error handling
*/
private async callHandler(handler: Handler, args: any): Promise<any> {
const result = handler(args)
// Handle both sync and async handlers
if (result instanceof Promise) {
return await result
}
return result
}
/**
* Validate that a command path exists
*/
validateCommandPath(commandPath: string[]): boolean {
return this.findCommandConfig(commandPath) !== null
}
/**
* Get the complete command hierarchy
*/
getCommandHierarchy(): CommandHierarchy {
return this.buildHierarchy(this.config.commands || {}, [])
}
private buildHierarchy(
commands: Record<string, CommandConfig>,
path: string[]
): CommandHierarchy {
const hierarchy: CommandHierarchy = {}
for (const [name, config] of Object.entries(commands)) {
const currentPath = [...path, name]
hierarchy[name] = {
path: currentPath,
description: config.description,
hasHandler: !!config.handler,
aliases: config.aliases || [],
hidden: config.hidden || false,
subcommands: config.commands
? this.buildHierarchy(config.commands, currentPath)
: {}
}
}
return hierarchy
}
// =============================================================================
// Command Management API (for test compatibility)
// =============================================================================
/**
* Get all available command names
*/
getCommands(): string[] {
return Object.keys(this._commands || {})
}
/**
* Add a command dynamically
*/
addCommand(name: string, config: CommandConfig): void {
if (!this._commands) {
this._commands = {}
}
this._commands[name] = config
}
/**
* Get a specific command configuration
*/
getCommand(name: string): CommandConfig | null {
return this._commands?.[name] || null
}
/**
* Execute a command by name
*/
async execute(commandName: string, args: any = {}, options: any = {}): Promise<any> {
const command = this.getCommand(commandName)
if (!command) {
throw new Error(`Unknown command: ${commandName}`)
}
if (!command.handler) {
throw new Error(`Command ${commandName} has no handler`)
}
// Apply middleware to handler (in reverse order so first added is outermost)
let handler = command.handler
for (let i = this._middleware.length - 1; i >= 0; i--) {
handler = this._middleware[i](handler)
}
// Execute the handler
if (typeof handler === 'function') {
// For test compatibility, merge args and options
const combinedArgs = { ...args, ...options }
// Check if it's a zero-argument function (could be lazy or regular)
if (handler.length === 0) {
// Could be lazy handler or zero-arg regular handler
const result = await handler()
if (typeof result === 'function') {
// It's a lazy handler, result is the actual handler
return result(combinedArgs)
} else {
// It's a regular zero-arg handler, result is the final result
return result
}
} else {
// Regular handler with args - check arity to decide calling convention
if (handler.length === 2) {
// Handler expects (args, options)
return (handler as any)(args, options)
} else {
// Handler expects combined args
return handler(combinedArgs)
}
}
}
throw new Error(`Invalid handler for command: ${commandName}`)
}
/**
* Add middleware that wraps command handlers
*/
addMiddleware(middleware: (handler: Handler) => Handler): void {
this._middleware.push(middleware)
}
}
export interface CommandHierarchy {
[commandName: string]: {
path: string[]
description: string
hasHandler: boolean
aliases: string[]
hidden: boolean
subcommands: CommandHierarchy
}
}
/**
* Route suggestion system for handling unknown commands
*/
export class CommandSuggestions {
constructor(private router: CLIRouter) {}
/**
* Get suggestions for a misspelled or unknown command
*/
getSuggestions(unknownCommand: string, commandPath: string[] = []): string[] {
const availableCommands = this.router.getAvailableCommands(commandPath)
// Calculate edit distance and return closest matches
const suggestions = availableCommands
.map(cmd => ({
command: cmd,
distance: this.levenshteinDistance(unknownCommand, cmd)
}))
.filter(item => item.distance <= 3) // Only suggest close matches
.sort((a, b) => a.distance - b.distance)
.slice(0, 3) // Top 3 suggestions
.map(item => item.command)
return suggestions
}
/**
* Calculate Levenshtein distance between two strings
*/
private levenshteinDistance(str1: string, str2: string): number {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null))
for (let i = 0; i <= str1.length; i++) {
matrix[0]![i] = i
}
for (let j = 0; j <= str2.length; j++) {
matrix[j]![0] = j
}
for (let j = 1; j <= str2.length; j++) {
for (let i = 1; i <= str1.length; i++) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1
matrix[j]![i] = Math.min(
matrix[j]![i - 1]! + 1, // deletion
matrix[j - 1]![i]! + 1, // insertion
matrix[j - 1]![i - 1]! + indicator // substitution
)
}
}
return matrix[str2.length]![str1.length]!
}
}
// Alias for test compatibility
export { CLIRouter as Router }