package commandctrl import ( "bytes" "context" "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/pkg/errors" "log" "os" "path/filepath" "strings" "ucl.lmika.dev/ucl" "ucl.lmika.dev/ucl/builtins" "lmika.dev/cmd/dynamo-browse/internal/common/ui/events" "github.com/lmika/shellwords" ) const commandsCategory = "commands" type cmdMessage struct { cmd string } type CommandController struct { uclInst *ucl.Inst historyProvider IterProvider commandList *CommandList lookupExtensions []CommandLookupExtension completionProvider CommandCompletionProvider uiStateProvider UIStateProvider cmdChan chan cmdMessage msgChan chan tea.Msg interactive bool } func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*CommandController, error) { cc := &CommandController{ historyProvider: historyProvider, commandList: nil, lookupExtensions: nil, cmdChan: make(chan cmdMessage), msgChan: make(chan tea.Msg), interactive: true, } options := []ucl.InstOption{ ucl.WithOut(ucl.LineHandler(cc.printLine)), ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.FS(nil)), } for _, pkg := range pkgs { options = append(options, pkg.InstOptions()...) } cc.uclInst = ucl.New(options...) for _, pkg := range pkgs { pkg.ConfigureUCL(cc.uclInst) } execCtx := execContext{ctrl: cc} ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) for _, pkg := range pkgs { if err := pkg.RunPrelude(ctx, cc.uclInst); err != nil { return nil, err } } go cc.cmdLooper() return cc, nil } func (c *CommandController) AddCommands(ctx *CommandList) { ctx.parent = c.commandList c.commandList = ctx } func (c *CommandController) StartMessageSender(msgSender func(tea.Msg)) { for msg := range c.msgChan { msgSender(msg) } } func (c *CommandController) SetUIStateProvider(provider UIStateProvider) { c.uiStateProvider = provider } func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension) { c.lookupExtensions = append(c.lookupExtensions, ext) } func (c *CommandController) SetCommandCompletionProvider(provider CommandCompletionProvider) { c.completionProvider = provider } func (c *CommandController) Prompt() tea.Msg { return events.PromptForInputMsg{ Prompt: ":", History: c.historyProvider.Iter(context.Background(), commandsCategory), OnDone: func(value string) tea.Msg { return c.Execute(value) }, // TEMP OnTabComplete: func(value string) (string, bool) { if c.completionProvider == nil { return "", false } if strings.HasPrefix(value, "sa ") || strings.HasPrefix(value, "da ") { tokens := shellwords.Split(strings.TrimSpace(value)) lastToken := tokens[len(tokens)-1] options := c.completionProvider.AttributesWithPrefix(lastToken) if len(options) == 1 { return value[:len(value)-len(lastToken)] + options[0], true } } return "", false }, // END TEMP } } func (c *CommandController) Execute(commandInput string) tea.Msg { return c.execute(ExecContext{FromFile: false}, commandInput) } func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Msg { input := strings.TrimSpace(commandInput) if input == "" { return nil } select { case c.cmdChan <- cmdMessage{cmd: input}: // good default: return events.Error(errors.New("command currently running")) } return nil } func (c *CommandController) ExecuteAndWait(ctx context.Context, commandInput string) (any, error) { return c.uclInst.EvalString(ctx, commandInput) } func (c *CommandController) Invoke(invokable ucl.Invokable, args []any) (msg tea.Msg) { execCtx := execContext{ctrl: c} ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) res, err := invokable.Invoke(ctx, args) if err != nil { msg = events.Error(err) } else if res != nil { msg = events.StatusMsg(fmt.Sprint(res)) } if execCtx.requestRefresh { c.postMessage(events.ResultSetUpdated{}) } return msg } func (c *CommandController) cmdLooper() { execCtx := execContext{ctrl: c} ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) for { select { case cmdChan := <-c.cmdChan: res, err := c.ExecuteAndWait(ctx, cmdChan.cmd) if err != nil { c.postMessage(events.Error(err)) } else if res != nil { c.postMessage(events.StatusMsg(fmt.Sprint(res))) } if execCtx.requestRefresh { c.postMessage(events.ResultSetUpdated{}) } } } } func (c *CommandController) Alias(commandName string) Command { return func(ctx ExecContext, args ucl.CallArgs) tea.Msg { command := c.lookupCommand(commandName) if command == nil { return events.Error(errors.New("no such command: " + commandName)) } return command(ctx, args) } } func (c *CommandController) lookupCommand(name string) Command { for ctx := c.commandList; ctx != nil; ctx = ctx.parent { if cmd, ok := ctx.Commands[name]; ok { return cmd } } for _, exts := range c.lookupExtensions { if cmd := exts.LookupCommand(name); cmd != nil { return cmd } } return nil } func (c *CommandController) LoadExtensions(ctx context.Context, baseDirs []string) error { log.Printf("loading extensions: %v", baseDirs) for _, baseDir := range baseDirs { baseDir = os.ExpandEnv(baseDir) descendIntoSubDirs := !strings.HasSuffix(baseDir, ".") if stat, err := os.Stat(baseDir); err != nil { if os.IsNotExist(err) { continue } return err } else if !stat.IsDir() { continue } log.Printf("walking %v", baseDir) if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { if !descendIntoSubDirs && path != baseDir { return filepath.SkipDir } return nil } if strings.HasSuffix(info.Name(), ".ucl") { if err := c.ExecuteFile(ctx, path); err != nil { log.Println(err) } log.Printf("loaded %v\n", path) } return nil }); err != nil { return err } } return nil } func (c *CommandController) ExecuteFile(ctx context.Context, filename string) error { oldInteractive := c.interactive c.interactive = false defer func() { c.interactive = oldInteractive }() baseFilename := filepath.Base(filename) execCtx := execContext{ctrl: c} ctx = context.WithValue(context.Background(), commandCtlKey, &execCtx) if rcFile, err := os.ReadFile(filename); err == nil { if err := c.executeFile(ctx, rcFile); err != nil { return errors.Wrapf(err, "error executing %v", baseFilename) } } else { return errors.Wrapf(err, "error loading %v", baseFilename) } return nil } func (c *CommandController) executeFile(ctx context.Context, file []byte) error { if _, err := c.uclInst.Eval(ctx, bytes.NewReader(file), ucl.WithSubEnv()); err != nil { return err } return nil } func (c *CommandController) Inst() *ucl.Inst { return c.uclInst } func (c *CommandController) cmdInvoker(ctx context.Context, name string, args ucl.CallArgs) (any, error) { command := c.lookupCommand(name) if command == nil { return nil, errors.New("no such command: " + name) } res := command(ExecContext{}, args) if errMsg, isErrMsg := res.(events.ErrorMsg); isErrMsg { return nil, errMsg } return teaMsgWrapper{res}, nil } func (c *CommandController) printLine(s string) { if c.msgChan == nil || !c.interactive { log.Println(s) return } select { case c.msgChan <- events.StatusMsg(s): default: log.Println(s) } } func (c *CommandController) postMessage(msg tea.Msg) { if c.msgChan == nil { return } c.msgChan <- msg } type teaMsgWrapper struct { msg tea.Msg }