package command import ( "bufio" "errors" "fmt" "os" "path/filepath" "slices" "strconv" "strings" "git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/style" "github.com/alecthomas/chroma/v2/styles" tea "github.com/charmbracelet/bubbletea" ) // QuitMsg: Message signaling the application should quit. type QuitMsg struct{} // ErrorMsg: Message signaling an error to display. type ErrorMsg struct { Err error } // -------------------------------------------------- // Quit Commands // -------------------------------------------------- // cmdQuit: Handles :quit / :q command. func cmdQuit(m action.Model, args []string, force bool) tea.Cmd { // :q! forces quit, ignoring unsaved changes if force { return tea.Quit } bufs := m.Buffers() // Cannot exit if any buffer has unsaved changes for _, buf := range bufs { if buf.Modified { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("unsaved changes to '%s'", buf.Filename)}, Inline: true, IsError: true, }) m.ActiveWindow().SetBuffer(buf) return nil } } return tea.Quit } // cmdQuitAll: Handles :qall / :qa command. func cmdQuitAll(m action.Model, args []string, force bool) tea.Cmd { // TODO: Until splits are implemented, this is the same as cmdQuit return cmdQuit(m, args, force) } // -------------------------------------------------- // File Commands (write & edit) // -------------------------------------------------- // cmdWrite: Handles :write / :w command func cmdWrite(m action.Model, args []string, force bool) tea.Cmd { buf := m.ActiveBuffer() cmd, err := writeBuffer(m, buf, args, force) if err != nil { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{err.Error()}, Inline: true, IsError: true, }) } return cmd } // cmdWriteAll: Handles :wall / :wa command func cmdWriteAll(m action.Model, args []string, force bool) tea.Cmd { var cmds []tea.Cmd bufs := m.Buffers() for _, buf := range bufs { if buf.Modified { cmd, err := writeBuffer(m, buf, args, force) if err != nil { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{err.Error()}, Inline: true, IsError: true, }) return nil } cmds = append(cmds, cmd) } } return tea.Batch(cmds...) } // cmdWriteQuit: Handles :wq command func cmdWriteQuit(m action.Model, args []string, force bool) tea.Cmd { buf := m.ActiveBuffer() cmd, err := writeBuffer(m, buf, args, force) if err != nil { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{err.Error()}, Inline: true, IsError: true, }) return cmd } return tea.Batch(cmd, tea.Quit) } // cmdWriteQuitAll: Handles :wqall / :wqa / :xa command. // Writes all modified buffers then quits. func cmdWriteQuitAll(m action.Model, args []string, force bool) tea.Cmd { var cmds []tea.Cmd bufs := m.Buffers() for _, buf := range bufs { if buf.Modified { cmd, err := writeBuffer(m, buf, args, force) if err != nil { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{err.Error()}, Inline: true, IsError: true, }) return nil } cmds = append(cmds, cmd) } } cmds = append(cmds, tea.Quit) return tea.Batch(cmds...) } // cmdEdit: Handles :edit / :e func cmdEdit(m action.Model, args []string, force bool) tea.Cmd { // must have arguments, cant edit nothing if len(args) < 1 { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{":edit requires an argument"}, Inline: true, IsError: true, }) return nil } // Vim's Approach: // " When you do :edit filename.txt // 1. Check if file exists and is readable (if not, open new buffer) // 2. Detect file encoding (UTF-8, etc.) // 3. Read entire file into memory // 4. Split by line endings (respecting fileformat) // 5. Create new buffer with these lines // 6. Set buffer metadata: // - buftype = "" (normal file) // - modified = 0 (not modified) // - fileformat = "unix" | "dos" | "mac" // - fileencoding = "utf-8" (etc.) filename := args[0] ext := filepath.Ext(filename) // If the buffer already exists, just switch to it. bufs := m.Buffers() for _, buf := range bufs { if buf.Filename == filename { m.ActiveWindow().SetBuffer(buf) return nil } } file, err := os.Open(filename) notFound := errors.Is(err, os.ErrNotExist) if err != nil && !notFound { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{err.Error()}, Inline: true, IsError: true, }) return nil } if file != nil { defer file.Close() } // Create a buffer with the new file name, writing the file will // handle the saving logic if notFound { buf := core.NewBufferBuilder(). WithType(core.FileBuffer). WithFilename(filename). WithFiletype(ext). Listed(). Loaded(). Build() m.SetBuffers(append(m.Buffers(), &buf)) m.ActiveWindow().SetBuffer(&buf) // Need to adjust the cursor when we make a new file m.ActiveWindow().ClampCursor() return nil } var lines []string // BUG: We are unable to open and edit files owned by root. How do we handle that? scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() line = strings.TrimSuffix(line, "\r") // BUG: This is bad, we don't want to this, but we have to cleaned := strings.ReplaceAll(line, "\t", strings.Repeat(" ", m.Settings().TabStop)) lines = append(lines, cleaned) } buf := core.NewBufferBuilder(). WithType(core.FileBuffer). WithFilename(filename). WithFiletype(ext). WithLines(lines). Listed(). Loaded(). Build() m.SetBuffers(append(m.Buffers(), &buf)) m.ActiveWindow().SetBuffer(&buf) return nil } // -------------------------------------------------- // Register Commands // -------------------------------------------------- // cmdRegisters: Handles :register command func cmdRegisters(m action.Model, args []string, force bool) tea.Cmd { if len(args) < 1 { regs := m.Registers() lines := []string{"Type Name Content"} for name, reg := range regs { if len(reg.Content) > 0 { line := fmt.Sprintf( " %s \"%c %s", reg.Type.ToString(), name, strings.Join(reg.Content, "\\n"), ) lines = append(lines, line) } } m.SetMode(core.CommandOutputMode) m.SetCommandOutput(&core.CommandOutput{ Title: ":reg", Lines: lines, Inline: false, IsError: false, }) return nil } // BUG: We can actually handle many now // if len(args[0]) != 1 { // m.SetCommandOutput(&core.CommandOutput{ // Lines: []string{"Name should be a single character."}, // Inline: true, // IsError: true, // }) // return nil // } names := []rune(args[0]) lines := []string{"Type Name Content"} for _, name := range names { reg, ok := m.GetRegister(name) if ok && len(reg.Content) > 0 { line := fmt.Sprintf( " %s \"%c %s", reg.Type.ToString(), name, strings.Join(reg.Content, "\\n"), ) lines = append(lines, line) } } m.SetMode(core.CommandOutputMode) m.SetCommandOutput(&core.CommandOutput{ Title: ":reg", Lines: lines, Inline: false, IsError: false, }) return nil } // -------------------------------------------------- // History Commands // -------------------------------------------------- func cmdHistory(m action.Model, args []string, force bool) tea.Cmd { _, _ = args, force history := m.CommandHistory() reversed := slices.Clone(history) slices.Reverse(reversed) m.SetMode(core.CommandOutputMode) m.SetCommandOutput(&core.CommandOutput{ Title: ":history", Lines: reversed, Inline: false, IsError: false, }) return nil } // -------------------------------------------------- // Buffer Commands // -------------------------------------------------- // Switching // - :b — switch to buffer number n // - :b — switch by partial filename match // - :bn — next buffer (wraps) // - :bp — previous buffer (wraps, currently panics on wrap-back) // - :bf — first buffer // - :bl — last buffer // Opening / closing // - :e — open file into new buffer // - :bd — delete (unload) current buffer // - :bd — delete buffer n // - :bw — wipe buffer completely // TODO: Implement this // cmdListBuffers: Handles :buffers & :ls command. Lists the active buffers. func cmdListBuffers(m action.Model, args []string, force bool) tea.Cmd { _, _ = args, force // What we should display // ------------------------------ // - % — current buffer // - # — alternate buffer (last active) // - a — active (loaded and visible) // - h — hidden (loaded but not visible) // - + — modified (unsaved changes) // - - — not modifiable curBuf := m.ActiveBuffer() bufs := m.Buffers() var lines []string for _, buf := range bufs { // Skip unlisted buffers if !buf.Listed { continue } var flags strings.Builder if buf.Id == curBuf.Id { flags.WriteRune('%') } else { flags.WriteRune(' ') } // TODO: Implement alternate buffer // Cannot really display the a and h, since we don't have visible flags yet // For now, we will have a loaded flag, 'l' if buf.Loaded { flags.WriteRune('l') } flags.WriteRune(' ') if buf.Modified { flags.WriteRune('+') } if buf.ReadOnly { flags.WriteRune('-') } line := fmt.Sprintf("%3d %s \"%s\"", buf.Id, flags.String(), buf.Filename) lines = append(lines, line) } m.SetMode(core.CommandOutputMode) m.SetCommandOutput(&core.CommandOutput{ Title: ":buffers", Lines: lines, Inline: false, IsError: false, }) return nil } // cmdNextBuffer: Handles :bn command. Moves to the next buffer based on ID. func cmdNextBuffer(m action.Model, args []string, force bool) tea.Cmd { _, _ = args, force bufs := m.Buffers() curBuf := m.ActiveBuffer() ids := make([]int, len(bufs)) var curIndex int for i, buf := range bufs { if buf.Listed { ids[i] = buf.Id if buf.Id == curBuf.Id { curIndex = i } } } nextId := (curIndex + 1) % len(ids) m.ActiveWindow().SetBuffer(bufs[nextId]) return nil } // cmdPrevBuffer: Handles :bp command. Moves to the previous buffer based on ID. func cmdPrevBuffer(m action.Model, args []string, force bool) tea.Cmd { _, _ = args, force bufs := m.Buffers() curBuf := m.ActiveBuffer() ids := make([]int, len(bufs)) var curIndex int for i, buf := range bufs { if buf.Listed { ids[i] = buf.Id if buf.Id == curBuf.Id { curIndex = i } } } prevId := ((curIndex - 1) + len(ids)) % len(ids) m.ActiveWindow().SetBuffer(bufs[prevId]) return nil } // cmdFirstBuffer: Handles :bf command. Moves to the first buffer based on ID. func cmdFirstBuffer(m action.Model, args []string, force bool) tea.Cmd { _, _ = args, force bufs := m.Buffers() ids := make([]int, len(bufs)) for i, buf := range bufs { if buf.Listed { ids[i] = buf.Id } } m.ActiveWindow().SetBuffer(bufs[0]) return nil } // cmdLastBuffer: Handles :bf command. Moves to the last buffer based on ID. func cmdLastBuffer(m action.Model, args []string, force bool) tea.Cmd { _, _ = args, force bufs := m.Buffers() ids := make([]int, len(bufs)) for i, buf := range bufs { if buf.Listed { ids[i] = buf.Id } } m.ActiveWindow().SetBuffer(bufs[len(bufs)-1]) return nil } // cmdSelectBuffer: Handles :b command. Moves to the selected buffer based on ID or filename. func cmdSelectBuffer(m action.Model, args []string, force bool) tea.Cmd { _ = force // Cannot function without args if len(args) == 0 { return nil } if len(args) > 1 { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("Trailing characters: %s", strings.Join(args[1:], " "))}, Inline: true, IsError: true, }) return nil } bufs := m.Buffers() // If we can parse the input as number, try an ID tgtId, err := strconv.Atoi(args[0]) if err == nil { for i, buf := range bufs { if buf.Id == tgtId && buf.Listed { m.ActiveWindow().SetBuffer(bufs[i]) return nil } } m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("Buffer id %d does not exist", tgtId)}, Inline: true, IsError: true, }) return nil } // Otherwise, try to match using filename query := args[0] var matches []int for i, buf := range bufs { if strings.Contains(buf.Filename, query) && buf.Listed { matches = append(matches, i) } } if len(matches) == 0 { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("No matches for for %s", query)}, Inline: true, IsError: true, }) return nil } if len(matches) > 1 { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("More than one match for %s", query)}, Inline: true, IsError: true, }) return nil } m.ActiveWindow().SetBuffer(bufs[matches[0]]) return nil } // cmdDeleteBuffer: Handles :bd command. Deletes (unloads) a buffer. func cmdDeleteBuffer(m action.Model, args []string, force bool) tea.Cmd { // This will be as dynamic as possible, just get a list of indexes, then unlist them all var indexes []int bufs := m.Buffers() // If the deleted buffer was the active one, Vim switches to the most recent entry in the jump // list that points into a loaded buffer. This is not simply "the previous buffer" — it's // jump-list-based, so it could be any recently visited loaded buffer. // THOUGH: I am not building vim, so it does not have to be the same // Need to close any windows associated with the closed buffers. Once many windows are implemented. // No args, unlist current buffer if len(args) == 0 { curBuf := m.ActiveBuffer() if curBuf.Modified && !force { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("No write since last change to buffer %d (Add ! to continue)", curBuf.Id)}, Inline: true, IsError: true, }) return nil } for i, buf := range bufs { if buf.Id == curBuf.Id { indexes = append(indexes, i) } } } if len(args) > 0 { // Arg can be ID or name ArgumentList: for _, arg := range args { // Try to get ID, if we can, move until we find it id, err := strconv.Atoi(arg) if err == nil { for index, buf := range bufs { if buf.Id == id && buf.Listed { if buf.Modified && !force { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("No write since last change to buffer %d (Add ! to continue)", buf.Id)}, Inline: true, IsError: true, }) return nil } indexes = append(indexes, index) continue ArgumentList } } continue ArgumentList } // Failed to parse, fuzzy match on names var matches []int for index, buf := range bufs { if strings.Contains(buf.Filename, arg) && buf.Listed { matches = append(matches, index) } } if len(matches) > 1 { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("More than one match for %s", arg)}, Inline: true, IsError: true, }) return nil } if len(matches) > 0 { if bufs[matches[0]].Modified && !force { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf( "No write since last change to buffer %d (Add ! to continue)", bufs[matches[0]].Id), }, Inline: true, IsError: true, }) return nil } indexes = append(indexes, matches[0]) continue ArgumentList } m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("No matching buffer for %s", arg)}, Inline: true, IsError: true, }) return nil } } // Simple error output if len(indexes) == 0 { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{"No buffers were deleted"}, Inline: true, IsError: true, }) return nil } // Now we can delete the buffers for _, i := range indexes { bufs[i].SetListed(false) } // Switch to first listed buffer // TODO: Switch to alternate buffer if available, once implemented if !m.ActiveBuffer().Listed { for _, buf := range bufs { if buf.Listed { m.ActiveWindow().SetBuffer(buf) break } } } return nil } // -------------------------------------------------- // Settings Commands // -------------------------------------------------- // cmdSet: Handles :set option[=value] command for configuring editor settings. // Examples: // // :set number - enable number // :set nonumber - disable number // :set number! - toggle number // :set tabstop=4 - set tabstop to 4 // :set ts=4 - set tabstop to 4 (abbreviation) func cmdSet(m action.Model, args []string, force bool) tea.Cmd { if len(args) == 0 { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("%+v", m.Settings())}, Inline: true, }) return nil } for _, arg := range args { if err := parseSetOption(m, arg); err != nil { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{err.Error()}, Inline: true, IsError: true, }) return nil } } return nil } // Setting: Represents a configurable editor option. type Setting struct { Name string ShortForm string Type SettingType Get func(m action.Model) any Set func(m action.Model, val any) } // SettingType: Enumeration of setting value types. type SettingType int const ( BoolSetting SettingType = iota IntSetting StringSetting ) // settingsMap defines all available settings (both global and window-local) var settingsMap = []Setting{ // Global editor settings { Name: "tabstop", ShortForm: "ts", Type: IntSetting, Get: func(m action.Model) any { return m.Settings().TabStop }, Set: func(m action.Model, val any) { s := m.Settings() s.TabStop = val.(int) m.SetSettings(s) }, }, // Window-local settings { Name: "number", ShortForm: "nu", Type: BoolSetting, Get: func(m action.Model) any { return m.ActiveWindow().Options.Number }, Set: func(m action.Model, val any) { w := m.ActiveWindow() o := w.Options o.Number = val.(bool) w.SetOptions(o) }, }, { Name: "relativenumber", ShortForm: "rnu", Type: BoolSetting, Get: func(m action.Model) any { return m.ActiveWindow().Options.RelativeNumber }, Set: func(m action.Model, val any) { w := m.ActiveWindow() o := w.Options o.RelativeNumber = val.(bool) w.SetOptions(o) }, }, { Name: "scrolloff", ShortForm: "so", Type: IntSetting, Get: func(m action.Model) any { return m.ActiveWindow().Options.ScrollOff }, Set: func(m action.Model, val any) { w := m.ActiveWindow() o := w.Options o.ScrollOff = val.(int) w.SetOptions(o) }, }, { Name: "guttersize", ShortForm: "gu", Type: IntSetting, Get: func(m action.Model) any { return m.ActiveWindow().Options.GutterSize }, Set: func(m action.Model, val any) { w := m.ActiveWindow() o := w.Options o.GutterSize = val.(int) w.SetOptions(o) }, }, } // lookupSetting: Finds a setting by name, short form, or prefix. func lookupSetting(name string) *Setting { for i := range settingsMap { s := &settingsMap[i] if name == s.Name || name == s.ShortForm { return s } // Prefix matching if len(name) >= len(s.ShortForm) && strings.HasPrefix(s.Name, name) { return s } } return nil } // parseSetOption: Parses and applies a single :set option. func parseSetOption(m action.Model, opt string) error { // Handle toggle: option! if name, ok := strings.CutSuffix(opt, "!"); ok { setting := lookupSetting(name) if setting != nil && setting.Type == BoolSetting { currentVal := setting.Get(m).(bool) setting.Set(m, !currentVal) } return nil } // Handle disable: nooption if name, ok := strings.CutPrefix(opt, "no"); ok { setting := lookupSetting(name) if setting != nil && setting.Type == BoolSetting { setting.Set(m, false) } return nil } // Handle assignment: option=value if strings.Contains(opt, "=") { parts := strings.SplitN(opt, "=", 2) name, value := parts[0], parts[1] setting := lookupSetting(name) if setting != nil { switch setting.Type { case IntSetting: intVal, err := strconv.Atoi(value) if err != nil { return err } setting.Set(m, intVal) case StringSetting: setting.Set(m, value) case BoolSetting: // Handle :set option=true / :set option=false boolVal := value == "true" || value == "1" || value == "yes" setting.Set(m, boolVal) } } return nil } // Handle enable: option (boolean only) setting := lookupSetting(opt) if setting != nil && setting.Type == BoolSetting { setting.Set(m, true) } return nil } // -------------------------------------------------- // Colorscheme Commands // -------------------------------------------------- func cmdColorscheme(m action.Model, args []string, force bool) tea.Cmd { _ = force // No args, just print the current scheme if len(args) == 0 { s := m.Styles().ChromaStyle if s == nil { return nil } m.SetCommandOutput(&core.CommandOutput{ Lines: []string{s.Name}, Inline: true, IsError: false, }) return nil } // Args given, set the scheme name := strings.Join(args, " ") chromaStyle := styles.Registry[name] if chromaStyle == nil { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("colorscheme not found: %s", name)}, Inline: true, IsError: true, }) return nil } m.SetStyles(style.ChromaStyles(chromaStyle)) return nil } func cmdListColorschemes(m action.Model, args []string, force bool) tea.Cmd { _, _ = args, force colors := styles.Names() m.SetMode(core.CommandOutputMode) m.SetCommandOutput(&core.CommandOutput{ Title: ":colorschemes", Lines: colors, Inline: false, IsError: false, }) return nil } func cmdUndoList(m action.Model, args []string, force bool) tea.Cmd { _, _ = args, force lines := m.ActiveBuffer().UndoStack.List() // For now, display an error when empty if len(lines) == 0 { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{"Undo stack is empty"}, Inline: true, IsError: true, }) return nil } m.SetMode(core.CommandOutputMode) m.SetCommandOutput(&core.CommandOutput{ Title: ":undo", Lines: lines, Inline: false, IsError: false, }) return nil }