diff --git a/cmd/gim/main.go b/cmd/gim/main.go index c688fb3..e8d16f7 100644 --- a/cmd/gim/main.go +++ b/cmd/gim/main.go @@ -11,7 +11,12 @@ import ( // main: Entry point for the Gim text editor. Creates a buffer and window, // initializes the editor model, and runs the BubbleTea TUI program. func main() { + // TODO: Read OS args and open file + + // TODO: Need to implement the force bang handling!!! Opencode has this + buf := core.NewBufferBuilder(). + ReadOnly(). Build() win := core.NewWindowBuilder(). @@ -34,6 +39,10 @@ func main() { for _, win := range final.Windows() { fmt.Printf("\t%+v\n", *win.Buffer) } + + fmt.Printf("PRINTING BUFFERS: %+v\n", final.Buffers()) + fmt.Printf("PRINTING ACTIVE BUFFER: %+v\n", final.ActiveBuffer()) + } else { fmt.Printf("PRINTING ALL: %+v\n", m) } diff --git a/internal/action/interface.go b/internal/action/interface.go index 0e57baf..53641d9 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -13,6 +13,7 @@ type Model interface { Windows() []*core.Window ActiveWindow() *core.Window Buffers() []*core.Buffer + SetBuffers(bufs []*core.Buffer) ActiveBuffer() *core.Buffer // ================================================== diff --git a/internal/command/handlers.go b/internal/command/handlers.go index d677ffa..3d2c660 100644 --- a/internal/command/handlers.go +++ b/internal/command/handlers.go @@ -1,11 +1,16 @@ package command import ( + "bufio" + "errors" "fmt" + "os" + "path/filepath" "strconv" "strings" "git.gophernest.net/azpect/TextEditor/internal/action" + "git.gophernest.net/azpect/TextEditor/internal/core" tea "github.com/charmbracelet/bubbletea" ) @@ -17,44 +22,199 @@ type ErrorMsg struct { Err error } +// -------------------------------------------------- +// Quit Commands +// -------------------------------------------------- + // cmdQuit: Handles :quit / :q command. -func cmdQuit(m action.Model, args []string) tea.Cmd { - return func() tea.Msg { - return tea.Quit() +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.SetCommandError(fmt.Errorf("unsaved changes to '%s'", buf.Filename)) + m.ActiveWindow().SetBuffer(buf) + return nil + } + } + + return tea.Quit } // cmdQuitAll: Handles :qall / :qa command. -func cmdQuitAll(m action.Model, args []string) tea.Cmd { - return func() tea.Msg { - return tea.Quit() - } +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) } -// cmdWrite: Handles :write / :w command (TODO: implement file saving). -func cmdWrite(m action.Model, args []string) tea.Cmd { - // TODO: Implement file saving - // If args provided, save to that filename - // Otherwise save to current file +// -------------------------------------------------- +// 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.SetCommandError(err) + } + 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.SetCommandError(err) + 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.SetCommandError(err) + 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.SetCommandError(err) + 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.SetCommandError(fmt.Errorf(":edit requires an argument")) + 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.SetCommandError(err) + 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 + + 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 } -// cmdWriteAll: Handles :wall / :wa command (TODO: implement saving all buffers). -func cmdWriteAll(m action.Model, args []string) tea.Cmd { - // TODO: Implement saving all buffers - return nil -} - -// cmdWriteQuit: Handles :wq command (TODO: save then quit). -func cmdWriteQuit(m action.Model, args []string) tea.Cmd { - // TODO: Save then quit - return func() tea.Msg { - return tea.Quit() - } -} +// -------------------------------------------------- +// Register Commands +// -------------------------------------------------- // cmdRegisters: Handles :register command (debug - displays register content). -func cmdRegisters(m action.Model, args []string) tea.Cmd { +func cmdRegisters(m action.Model, args []string, force bool) tea.Cmd { // TODO: This is temporary, for debugging if len(args) < 1 { m.SetCommandError(fmt.Errorf("Please provide a name. Register dump not yet implemented.")) @@ -79,6 +239,10 @@ func cmdRegisters(m action.Model, args []string) tea.Cmd { return nil } +// -------------------------------------------------- +// Settings Commands +// -------------------------------------------------- + // cmdSet: Handles :set option[=value] command for configuring editor settings. // Examples: // @@ -87,7 +251,7 @@ func cmdRegisters(m action.Model, args []string) tea.Cmd { // :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) tea.Cmd { +func cmdSet(m action.Model, args []string, force bool) tea.Cmd { if len(args) == 0 { out := fmt.Sprintf("%+v", m.Settings()) m.SetCommandOutput(out) diff --git a/internal/command/handlers_test.go b/internal/command/handlers_test.go new file mode 100644 index 0000000..96e4a1b --- /dev/null +++ b/internal/command/handlers_test.go @@ -0,0 +1,2947 @@ +package command + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" + "git.gophernest.net/azpect/TextEditor/internal/core" + tea "github.com/charmbracelet/bubbletea" +) + +// ================================================== +// Mock Model Implementation +// ================================================== + +type mockModel struct { + windows []*core.Window + activeWindow *core.Window + buffers []*core.Buffer + settings core.EditorSettings + mode core.Mode + registers map[rune]core.Register + insertKeys []string + command string + commandCursor int + commandError error + commandOutput string +} + +func newMockModel() *mockModel { + buf := core.NewBufferBuilder(). + WithLines([]string{""}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(24). + WithWidth(80). + Build() + + return &mockModel{ + windows: []*core.Window{&win}, + activeWindow: &win, + buffers: []*core.Buffer{&buf}, + settings: core.NewDefaultSettings(), + mode: core.NormalMode, + registers: core.DefaultRegisters(), + } +} + +func newMockModelWithBuffer(buf *core.Buffer) *mockModel { + win := core.NewWindowBuilder(). + WithBuffer(buf). + WithHeight(24). + WithWidth(80). + Build() + + return &mockModel{ + windows: []*core.Window{&win}, + activeWindow: &win, + buffers: []*core.Buffer{buf}, + settings: core.NewDefaultSettings(), + mode: core.NormalMode, + registers: core.DefaultRegisters(), + } +} + +// Core Data Access +func (m *mockModel) Windows() []*core.Window { return m.windows } +func (m *mockModel) ActiveWindow() *core.Window { return m.activeWindow } +func (m *mockModel) Buffers() []*core.Buffer { return m.buffers } +func (m *mockModel) SetBuffers(bufs []*core.Buffer) { m.buffers = bufs } +func (m *mockModel) ActiveBuffer() *core.Buffer { return m.activeWindow.Buffer } + +// Insert Mode State +func (m *mockModel) InsertKeys() []string { return m.insertKeys } +func (m *mockModel) SetInsertKeys(keys []string) { m.insertKeys = keys } +func (m *mockModel) SetInsertRecording(count int, action action.Action) {} +func (m *mockModel) ExitInsertMode() {} + +// Command Mode State +func (m *mockModel) Command() string { return m.command } +func (m *mockModel) SetCommand(cmd string) { m.command = cmd } +func (m *mockModel) CommandCursor() int { return m.commandCursor } +func (m *mockModel) SetCommandCursor(cur int) { m.commandCursor = cur } +func (m *mockModel) CommandError() error { return m.commandError } +func (m *mockModel) SetCommandError(err error) { m.commandError = err } +func (m *mockModel) CommandOutput() string { return m.commandOutput } +func (m *mockModel) SetCommandOutput(out string) { m.commandOutput = out } + +// Editor-wide State +func (m *mockModel) Mode() core.Mode { return m.mode } +func (m *mockModel) SetMode(mode core.Mode) { m.mode = mode } +func (m *mockModel) Settings() core.EditorSettings { return m.settings } +func (m *mockModel) SetSettings(s core.EditorSettings) { m.settings = s } + +// Registers +func (m *mockModel) Registers() map[rune]core.Register { return m.registers } +func (m *mockModel) GetRegister(name rune) (core.Register, bool) { + reg, ok := m.registers[name] + return reg, ok +} +func (m *mockModel) SetRegister(name rune, t core.RegisterType, cnt []string) error { + m.registers[name] = core.Register{Type: t, Content: cnt} + return nil +} +func (m *mockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) { + m.registers['"'] = core.Register{Type: t, Content: cnt} +} + +// ================================================== +// cmdWrite Tests +// ================================================== + +func TestCmdWrite(t *testing.T) { + t.Run("writes buffer content to file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"line 1", "line 2", "line 3"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("file not written: %v", err) + } + + expected := "line 1\nline 2\nline 3\n" + if string(content) != expected { + t.Errorf("got %q, want %q", string(content), expected) + } + }) + + t.Run("writes to argument filename instead of buffer filename", func(t *testing.T) { + tmpDir := t.TempDir() + originalFile := filepath.Join(tmpDir, "original.txt") + newFile := filepath.Join(tmpDir, "new.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(originalFile). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{newFile}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Verify new file was created + if _, err := os.Stat(newFile); os.IsNotExist(err) { + t.Error("new file not created") + } + + // Verify original file was NOT created + if _, err := os.Stat(originalFile); !os.IsNotExist(err) { + t.Error("original file should not exist") + } + + content, _ := os.ReadFile(newFile) + expected := "content\n" + if string(content) != expected { + t.Errorf("got %q, want %q", string(content), expected) + } + }) + + t.Run("truncates existing file when overwriting", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + // Write longer content first + originalContent := "this is very long original content\nwith multiple lines\nand more text\n" + os.WriteFile(filename, []byte(originalContent), 0644) + + // Create buffer with shorter content + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"short"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + content, _ := os.ReadFile(filename) + expected := "short\n" + if string(content) != expected { + t.Errorf("file not truncated properly: got %q, want %q", string(content), expected) + } + }) + + t.Run("refuses to write readonly buffer", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"content"}). + ReadOnly(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for readonly buffer") + } + + if !strings.Contains(m.commandError.Error(), "readonly") { + t.Errorf("error should mention readonly: %v", m.commandError) + } + + // Verify file was NOT created + if _, err := os.Stat(filename); !os.IsNotExist(err) { + t.Error("file should not be created for readonly buffer") + } + }) + + t.Run("refuses to write scratch buffer", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename(filename). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for scratch buffer") + } + + if !strings.Contains(m.commandError.Error(), "ScratchBuffer") { + t.Errorf("error should mention ScratchBuffer: %v", m.commandError) + } + }) + + t.Run("errors when no filename and buffer has no filename", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(""). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error when no filename available") + } + + if !strings.Contains(m.commandError.Error(), "no file name") { + t.Errorf("error should mention no file name: %v", m.commandError) + } + }) + + t.Run("errors on invalid path", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("/invalid/nonexistent/path/file.txt"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for invalid path") + } + }) + + t.Run("writes empty buffer", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "empty.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("file not created: %v", err) + } + + // Empty buffer should produce empty file + if string(content) != "" { + t.Errorf("expected empty file, got %q", string(content)) + } + }) + + t.Run("writes buffer with empty lines", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"line 1", "", "line 3", ""}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + content, _ := os.ReadFile(filename) + expected := "line 1\n\nline 3\n\n" + if string(content) != expected { + t.Errorf("got %q, want %q", string(content), expected) + } + }) + + t.Run("writes buffer with special characters", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "special.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{ + "hello\tworld", + "unicode: \u00e9\u00e8\u00ea", + "symbols: !@#$%^&*()", + }). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + content, _ := os.ReadFile(filename) + expected := "hello\tworld\nunicode: \u00e9\u00e8\u00ea\nsymbols: !@#$%^&*()\n" + if string(content) != expected { + t.Errorf("got %q, want %q", string(content), expected) + } + }) + + t.Run("writes buffer with unicode content", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "unicode.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{ + "Japanese: \u3053\u3093\u306b\u3061\u306f", + "Chinese: \u4f60\u597d", + "Emoji: \U0001F600\U0001F389\U0001F680", + "Russian: \u041f\u0440\u0438\u0432\u0435\u0442", + }). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + content, _ := os.ReadFile(filename) + lines := strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") + + if len(lines) != 4 { + t.Errorf("expected 4 lines, got %d", len(lines)) + } + + if !strings.Contains(lines[0], "\u3053\u3093\u306b\u3061\u306f") { + t.Errorf("Japanese content not preserved: %q", lines[0]) + } + }) + + t.Run("sets correct output message", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"line 1", "line 2"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandOutput == "" { + t.Error("expected output message") + } + + // Should contain filename and line count + if !strings.Contains(m.commandOutput, filename) { + t.Errorf("output should contain filename: %q", m.commandOutput) + } + if !strings.Contains(m.commandOutput, "2L") { + t.Errorf("output should contain line count: %q", m.commandOutput) + } + }) + + t.Run("writes large file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "large.txt") + + // Create buffer with many lines + lines := make([]string, 10000) + for i := range lines { + lines[i] = strings.Repeat("x", 100) + } + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines(lines). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + info, err := os.Stat(filename) + if err != nil { + t.Fatalf("file not created: %v", err) + } + + // Each line is 100 chars + newline = 101 bytes * 10000 lines + expectedSize := int64(101 * 10000) + if info.Size() != expectedSize { + t.Errorf("expected size %d, got %d", expectedSize, info.Size()) + } + }) + + t.Run("writes to file with spaces in path", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "file with spaces.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if _, err := os.Stat(filename); os.IsNotExist(err) { + t.Error("file with spaces not created") + } + }) + + t.Run("creates parent directories via argument", func(t *testing.T) { + tmpDir := t.TempDir() + // Note: cmdWrite doesn't create parent dirs, so this should fail + filename := filepath.Join(tmpDir, "nonexistent", "nested", "file.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("original.txt"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{filename}, false) + + // Current implementation doesn't create parent dirs, so expect error + if m.commandError == nil { + t.Error("expected error when parent directories don't exist") + } + }) + + t.Run("clears modified flag after successful write", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + if !m.ActiveBuffer().Modified { + t.Fatal("precondition: buffer should be modified before write") + } + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if m.ActiveBuffer().Modified { + t.Error("buffer should not be modified after successful write") + } + }) + + t.Run("clears modified flag even when writing to different file", func(t *testing.T) { + tmpDir := t.TempDir() + originalFile := filepath.Join(tmpDir, "original.txt") + newFile := filepath.Join(tmpDir, "copy.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(originalFile). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{newFile}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Modified flag is cleared even when writing to a different file + if m.ActiveBuffer().Modified { + t.Error("buffer modified flag should be cleared after write") + } + + // Buffer filename should NOT change (vim behavior) + if m.ActiveBuffer().Filename != originalFile { + t.Errorf("buffer filename should remain %q, got %q", + originalFile, m.ActiveBuffer().Filename) + } + }) + + t.Run("does not clear modified flag on write error", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("/invalid/nonexistent/path/file.txt"). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError == nil { + t.Fatal("expected error for invalid path") + } + + // Modified flag should remain true on error + if !m.ActiveBuffer().Modified { + t.Error("buffer should still be modified after failed write") + } + }) +} + +// ================================================== +// cmdEdit Tests +// ================================================== + +func TestCmdEdit(t *testing.T) { + t.Run("errors when no argument provided", func(t *testing.T) { + m := newMockModel() + + cmdEdit(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error when no argument") + } + + if !strings.Contains(m.commandError.Error(), "requires an argument") { + t.Errorf("error should mention requires argument: %v", m.commandError) + } + }) + + t.Run("creates new buffer for nonexistent file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "newfile.txt") + + m := newMockModel() + initialBufferCount := len(m.buffers) + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Should have added a new buffer + if len(m.buffers) != initialBufferCount+1 { + t.Errorf("expected %d buffers, got %d", initialBufferCount+1, len(m.buffers)) + } + + // Active window should point to new buffer + if m.activeWindow.Buffer.Filename != filename { + t.Errorf("active buffer filename = %q, want %q", + m.activeWindow.Buffer.Filename, filename) + } + + // New buffer should be empty + if m.activeWindow.Buffer.LineCount() != 1 || m.activeWindow.Buffer.Line(0) != "" { + t.Errorf("new buffer should have one empty line") + } + + // Buffer should be FileBuffer type + if m.activeWindow.Buffer.Type != core.FileBuffer { + t.Errorf("buffer type = %v, want FileBuffer", m.activeWindow.Buffer.Type) + } + }) + + t.Run("loads existing file content", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "existing.txt") + + // Create file with content + content := "line 1\nline 2\nline 3\n" + os.WriteFile(filename, []byte(content), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + buf := m.activeWindow.Buffer + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + if buf.Line(0) != "line 1" { + t.Errorf("line 0 = %q, want %q", buf.Line(0), "line 1") + } + if buf.Line(1) != "line 2" { + t.Errorf("line 1 = %q, want %q", buf.Line(1), "line 2") + } + if buf.Line(2) != "line 3" { + t.Errorf("line 2 = %q, want %q", buf.Line(2), "line 3") + } + }) + + t.Run("loads file without trailing newline", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "notrailing.txt") + + // File without trailing newline + content := "line 1\nline 2" + os.WriteFile(filename, []byte(content), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + buf := m.activeWindow.Buffer + if buf.LineCount() != 2 { + t.Errorf("expected 2 lines, got %d", buf.LineCount()) + } + }) + + t.Run("loads empty file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "empty.txt") + + os.WriteFile(filename, []byte(""), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + buf := m.activeWindow.Buffer + // Empty file should have 0 lines (scanner produces no lines) + if buf.LineCount() != 0 { + t.Errorf("expected 0 lines for empty file, got %d", buf.LineCount()) + } + }) + + t.Run("sets correct filetype from extension", func(t *testing.T) { + tmpDir := t.TempDir() + + tests := []struct { + filename string + expected string + }{ + {"test.go", ".go"}, + {"test.py", ".py"}, + {"test.js", ".js"}, + {"test.txt", ".txt"}, + {"Makefile", ""}, + {"test.tar.gz", ".gz"}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + filename := filepath.Join(tmpDir, tt.filename) + os.WriteFile(filename, []byte("content"), 0644) + + m := newMockModel() + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if m.activeWindow.Buffer.Filetype != tt.expected { + t.Errorf("filetype = %q, want %q", m.activeWindow.Buffer.Filetype, tt.expected) + } + }) + } + }) + + t.Run("converts tabs to spaces based on tabstop", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "tabs.txt") + + // File with tabs + content := "\tindented\n\t\tdouble" + os.WriteFile(filename, []byte(content), 0644) + + m := newMockModel() + m.settings.TabStop = 4 + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + buf := m.activeWindow.Buffer + + // Tab should be converted to 4 spaces + expected0 := " indented" + if buf.Line(0) != expected0 { + t.Errorf("line 0 = %q, want %q", buf.Line(0), expected0) + } + + expected1 := " double" + if buf.Line(1) != expected1 { + t.Errorf("line 1 = %q, want %q", buf.Line(1), expected1) + } + }) + + t.Run("handles different tabstop values", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "tabs.txt") + + content := "\ttest" + os.WriteFile(filename, []byte(content), 0644) + + tests := []struct { + tabstop int + expected string + }{ + {2, " test"}, + {4, " test"}, + {8, " test"}, + } + + for _, tt := range tests { + t.Run(string(rune('0'+tt.tabstop)), func(t *testing.T) { + m := newMockModel() + m.settings.TabStop = tt.tabstop + + cmdEdit(m, []string{filename}, false) + + if m.activeWindow.Buffer.Line(0) != tt.expected { + t.Errorf("with tabstop=%d: got %q, want %q", + tt.tabstop, m.activeWindow.Buffer.Line(0), tt.expected) + } + }) + } + }) + + t.Run("loads file with unicode content", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "unicode.txt") + + content := "Hello \u4e16\u754c\n\u3053\u3093\u306b\u3061\u306f\nEmoji: \U0001F600\n" + os.WriteFile(filename, []byte(content), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + buf := m.activeWindow.Buffer + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + if !strings.Contains(buf.Line(0), "\u4e16\u754c") { + t.Errorf("unicode not preserved in line 0: %q", buf.Line(0)) + } + }) + + t.Run("loads large file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "large.txt") + + // Create large file + var content strings.Builder + for range 10000 { + content.WriteString(strings.Repeat("x", 100)) + content.WriteString("\n") + } + os.WriteFile(filename, []byte(content.String()), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + buf := m.activeWindow.Buffer + if buf.LineCount() != 10000 { + t.Errorf("expected 10000 lines, got %d", buf.LineCount()) + } + }) + + t.Run("sets buffer as loaded and listed", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + os.WriteFile(filename, []byte("content"), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + buf := m.activeWindow.Buffer + if !buf.Loaded { + t.Error("buffer should be marked as loaded") + } + if !buf.Listed { + t.Error("buffer should be marked as listed") + } + }) + + t.Run("new file buffer is loaded and listed", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "newfile.txt") + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + buf := m.activeWindow.Buffer + if !buf.Loaded { + t.Error("new buffer should be marked as loaded") + } + if !buf.Listed { + t.Error("new buffer should be marked as listed") + } + }) + + t.Run("errors on permission denied", func(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("test requires non-root user") + } + + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "noperm.txt") + + // Create file then remove read permission + os.WriteFile(filename, []byte("content"), 0644) + os.Chmod(filename, 0000) + defer os.Chmod(filename, 0644) // Cleanup + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError == nil { + t.Error("expected error for permission denied") + } + }) + + t.Run("handles file with long lines", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "longlines.txt") + + // Use 50000 chars - within bufio.Scanner's default 64KB limit + longLine := strings.Repeat("x", 50000) + content := longLine + "\nshort\n" + longLine + os.WriteFile(filename, []byte(content), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + buf := m.activeWindow.Buffer + if len(buf.Line(0)) != 50000 { + t.Errorf("long line not preserved: got %d chars", len(buf.Line(0))) + } + }) + + t.Run("bufio.Scanner limitation with extremely long lines", func(t *testing.T) { + // NOTE: This documents a known limitation + // bufio.Scanner has a default max token size of ~64KB + // Lines longer than this will cause scanner.Scan() to return false + // with scanner.Err() returning bufio.ErrTooLong + // + // A future improvement would be to use bufio.Reader.ReadString + // or increase scanner buffer size with scanner.Buffer() + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "verylonglines.txt") + + // Create line exceeding 64KB limit + veryLongLine := strings.Repeat("x", 100000) + os.WriteFile(filename, []byte(veryLongLine+"\n"), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + // Currently this will result in 0 lines loaded due to scanner limitation + // This test documents the current behavior + buf := m.activeWindow.Buffer + if buf.LineCount() > 0 && len(buf.Line(0)) == 100000 { + t.Log("Scanner handled very long line - limitation may be fixed") + } else { + t.Log("Known limitation: bufio.Scanner cannot handle lines > 64KB") + } + }) + + t.Run("strips carriage return from CRLF line endings", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "crlf.txt") + + // Windows-style line endings + content := "line 1\r\nline 2\r\nline 3\r\n" + os.WriteFile(filename, []byte(content), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + buf := m.activeWindow.Buffer + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + // Verify \r is stripped from all lines + for i := 0; i < buf.LineCount(); i++ { + line := buf.Line(i) + if strings.HasSuffix(line, "\r") { + t.Errorf("line %d has trailing \\r: %q", i, line) + } + if strings.Contains(line, "\r") { + t.Errorf("line %d contains \\r: %q", i, line) + } + } + + // Verify content is correct + if buf.Line(0) != "line 1" { + t.Errorf("line 0 = %q, want %q", buf.Line(0), "line 1") + } + if buf.Line(1) != "line 2" { + t.Errorf("line 1 = %q, want %q", buf.Line(1), "line 2") + } + }) + + t.Run("handles mixed line endings", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "mixed.txt") + + // Mix of Unix (\n) and Windows (\r\n) line endings + content := "unix line\nwindows line\r\nunix again\n" + os.WriteFile(filename, []byte(content), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + buf := m.activeWindow.Buffer + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + // All lines should be clean (no \r) + for i := 0; i < buf.LineCount(); i++ { + if strings.Contains(buf.Line(i), "\r") { + t.Errorf("line %d contains \\r: %q", i, buf.Line(i)) + } + } + }) + + t.Run("does not panic when editing nonexistent file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "does_not_exist.txt") + + m := newMockModel() + + // This should NOT panic (tests the nil pointer fix) + cmdEdit(m, []string{filename}, false) + + // Should succeed - creates new buffer + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Buffer should be created with the filename + if m.activeWindow.Buffer.Filename != filename { + t.Errorf("buffer filename = %q, want %q", + m.activeWindow.Buffer.Filename, filename) + } + }) + + t.Run("adds buffer to buffer list", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + os.WriteFile(filename, []byte("content"), 0644) + + m := newMockModel() + initialCount := len(m.buffers) + + cmdEdit(m, []string{filename}, false) + + if len(m.buffers) != initialCount+1 { + t.Errorf("buffer not added: expected %d, got %d", initialCount+1, len(m.buffers)) + } + + // Find the new buffer in the list + found := false + for _, buf := range m.buffers { + if buf.Filename == filename { + found = true + break + } + } + if !found { + t.Error("new buffer not found in buffer list") + } + }) + + t.Run("handles path with spaces", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "file with spaces.txt") + os.WriteFile(filename, []byte("content"), 0644) + + m := newMockModel() + + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if m.activeWindow.Buffer.Filename != filename { + t.Errorf("filename = %q, want %q", m.activeWindow.Buffer.Filename, filename) + } + }) + + t.Run("switches to existing buffer instead of creating duplicate", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "existing.txt") + os.WriteFile(filename, []byte("original content"), 0644) + + m := newMockModel() + initialBufferCount := len(m.buffers) + + // First edit - loads the file + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error on first edit: %v", m.commandError) + } + + if len(m.buffers) != initialBufferCount+1 { + t.Errorf("expected %d buffers after first edit, got %d", initialBufferCount+1, len(m.buffers)) + } + + firstBuffer := m.activeWindow.Buffer + if firstBuffer.Filename != filename { + t.Fatalf("first buffer filename = %q, want %q", firstBuffer.Filename, filename) + } + + // Modify the buffer to verify we get the same instance + firstBuffer.SetLine(0, "modified content") + bufferId := firstBuffer.Id + + // Second edit - should switch to existing buffer, not create new one + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error on second edit: %v", m.commandError) + } + + // Should not have created a new buffer + if len(m.buffers) != initialBufferCount+1 { + t.Errorf("expected buffer count to remain %d, got %d (created duplicate buffer)", + initialBufferCount+1, len(m.buffers)) + } + + // Should be the same buffer instance + if m.activeWindow.Buffer.Id != bufferId { + t.Errorf("expected to switch to existing buffer (ID %d), got different buffer (ID %d)", + bufferId, m.activeWindow.Buffer.Id) + } + + // Should have our modifications, not reload from disk + if m.activeWindow.Buffer.Line(0) != "modified content" { + t.Errorf("expected modified buffer content %q, got %q (buffer was reloaded from disk)", + "modified content", m.activeWindow.Buffer.Line(0)) + } + }) + + t.Run("switches to existing buffer even if file was modified on disk", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "file.txt") + os.WriteFile(filename, []byte("version 1"), 0644) + + m := newMockModel() + + // First edit + cmdEdit(m, []string{filename}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + bufferId := m.activeWindow.Buffer.Id + originalLine := m.activeWindow.Buffer.Line(0) + + // Modify file on disk + os.WriteFile(filename, []byte("version 2 - changed on disk"), 0644) + + // Second edit - should switch to buffer, not reload from disk + cmdEdit(m, []string{filename}, false) + + // Should still be the same buffer + if m.activeWindow.Buffer.Id != bufferId { + t.Errorf("expected same buffer ID %d, got %d", bufferId, m.activeWindow.Buffer.Id) + } + + // Should have old content, not reload + if m.activeWindow.Buffer.Line(0) != originalLine { + t.Errorf("buffer was reloaded from disk, expected %q, got %q", + originalLine, m.activeWindow.Buffer.Line(0)) + } + + // Note: Real vim would warn about file change, but for now we just switch to buffer + }) + + t.Run("creates new buffer for different file even if similar name", func(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "test.txt") + file2 := filepath.Join(tmpDir, "test2.txt") + + os.WriteFile(file1, []byte("file 1"), 0644) + os.WriteFile(file2, []byte("file 2"), 0644) + + m := newMockModel() + + cmdEdit(m, []string{file1}, false) + bufferCount := len(m.buffers) + + cmdEdit(m, []string{file2}, false) + + // Should have created a new buffer for different file + if len(m.buffers) != bufferCount+1 { + t.Errorf("expected new buffer for different file, buffer count = %d", len(m.buffers)) + } + + if m.activeWindow.Buffer.Filename != file2 { + t.Errorf("active buffer filename = %q, want %q", m.activeWindow.Buffer.Filename, file2) + } + }) + + t.Run("matches buffer by absolute path", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "file.txt") + os.WriteFile(filename, []byte("content"), 0644) + + m := newMockModel() + + // First edit with absolute path + cmdEdit(m, []string{filename}, false) + + bufferId := m.activeWindow.Buffer.Id + bufferCount := len(m.buffers) + + // Second edit with same absolute path - should match + cmdEdit(m, []string{filename}, false) + + if len(m.buffers) != bufferCount { + t.Error("created duplicate buffer for same absolute path") + } + + if m.activeWindow.Buffer.Id != bufferId { + t.Error("did not switch to existing buffer") + } + }) +} + +// ================================================== +// Round-trip Tests (Edit then Write) +// ================================================== + +func TestEditWriteRoundTrip(t *testing.T) { + t.Run("edit then write preserves content", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "roundtrip.txt") + + original := "line 1\nline 2\nline 3\n" + os.WriteFile(filename, []byte(original), 0644) + + m := newMockModel() + + // Edit + cmdEdit(m, []string{filename}, false) + if m.commandError != nil { + t.Fatalf("edit error: %v", m.commandError) + } + + // Write + cmdWrite(m, []string{}, false) + if m.commandError != nil { + t.Fatalf("write error: %v", m.commandError) + } + + // Read back + content, _ := os.ReadFile(filename) + if string(content) != original { + t.Errorf("content not preserved:\ngot: %q\nwant: %q", string(content), original) + } + }) + + t.Run("edit new file then write creates file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "newfile.txt") + + m := newMockModel() + + // Edit (creates new buffer) + cmdEdit(m, []string{filename}, false) + if m.commandError != nil { + t.Fatalf("edit error: %v", m.commandError) + } + + // Add content to buffer + m.activeWindow.Buffer.InsertLine(0, "hello world") + + // Write + cmdWrite(m, []string{}, false) + if m.commandError != nil { + t.Fatalf("write error: %v", m.commandError) + } + + // Verify file exists + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("file not created: %v", err) + } + + expected := "hello world\n\n" // Original empty line + inserted line + if string(content) != expected { + t.Errorf("got %q, want %q", string(content), expected) + } + }) + + t.Run("edit modifies write to different file", func(t *testing.T) { + tmpDir := t.TempDir() + original := filepath.Join(tmpDir, "original.txt") + newFile := filepath.Join(tmpDir, "copy.txt") + + os.WriteFile(original, []byte("content\n"), 0644) + + m := newMockModel() + + // Edit original + cmdEdit(m, []string{original}, false) + if m.commandError != nil { + t.Fatalf("edit error: %v", m.commandError) + } + + // Modify + m.activeWindow.Buffer.SetLine(0, "modified content") + + // Write to new file + cmdWrite(m, []string{newFile}, false) + if m.commandError != nil { + t.Fatalf("write error: %v", m.commandError) + } + + // Verify new file has modified content + content, _ := os.ReadFile(newFile) + if string(content) != "modified content\n" { + t.Errorf("new file content = %q", string(content)) + } + + // Verify original is unchanged + origContent, _ := os.ReadFile(original) + if string(origContent) != "content\n" { + t.Errorf("original was modified: %q", string(origContent)) + } + }) +} + +// ================================================== +// cmdQuit Tests +// ================================================== +// +// Expected Vim behavior for quit commands with readonly buffers: +// +// :quit / :q +// - Quits if no buffers are modified (readonly or not) +// - Errors if any buffer is modified (readonly or not) +// +// :quitall / :qa +// - Same as :quit but for all windows +// +// :write / :w +// - Errors if buffer is readonly (even if not modified) +// +// :wall / :wa +// - Only writes MODIFIED buffers +// - Skips unmodified buffers (including readonly ones) +// - Errors if any MODIFIED buffer is readonly +// +// :wq +// - Writes current buffer and quits +// - Errors if buffer is readonly AND modified +// - If buffer is NOT modified, just quits (no write needed) +// +// :wqall / :wqa +// - Writes all MODIFIED buffers and quits +// - Skips unmodified buffers (including readonly ones) +// - Errors if any MODIFIED buffer is readonly +// +// Force variants (with !) override readonly protection +// ================================================== + +func TestCmdQuit(t *testing.T) { + t.Run("quits when no buffers are modified", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename("test.txt"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdQuit(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command, got nil") + } + + // Execute the command and verify it returns quit message + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("errors when buffer is modified", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename("unsaved.txt"). + WithLines([]string{"unsaved content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdQuit(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for modified buffer") + } + + if cmd != nil { + t.Error("should not return quit command when buffer is modified") + } + }) + + t.Run("error message includes filename of modified buffer", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename("important_file.txt"). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdQuit(m, []string{}, false) + + if m.commandError == nil { + t.Fatal("expected error") + } + + if !strings.Contains(m.commandError.Error(), "important_file.txt") { + t.Errorf("error should mention filename: %v", m.commandError) + } + }) + + t.Run("errors when any buffer in list is modified", func(t *testing.T) { + // Active buffer is not modified + activeBuf := core.NewBufferBuilder(). + WithFilename("active.txt"). + WithLines([]string{"content"}). + Build() + activeBuf.Modified = false + + // Another buffer IS modified + modifiedBuf := core.NewBufferBuilder(). + WithFilename("modified.txt"). + WithLines([]string{"unsaved"}). + Build() + modifiedBuf.Modified = true + + m := newMockModelWithBuffer(&activeBuf) + m.buffers = append(m.buffers, &modifiedBuf) + + cmd := cmdQuit(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error when any buffer is modified") + } + + if cmd != nil { + t.Error("should not return quit command") + } + }) + + t.Run("switches to modified buffer when quitting fails", func(t *testing.T) { + activeBuf := core.NewBufferBuilder(). + WithFilename("active.txt"). + WithLines([]string{"content"}). + Build() + activeBuf.Modified = false + + modifiedBuf := core.NewBufferBuilder(). + WithFilename("modified.txt"). + WithLines([]string{"unsaved"}). + Build() + modifiedBuf.Modified = true + + m := newMockModelWithBuffer(&activeBuf) + m.buffers = append(m.buffers, &modifiedBuf) + + cmdQuit(m, []string{}, false) + + // Should switch active window to show the modified buffer + if m.activeWindow.Buffer.Filename != "modified.txt" { + t.Errorf("should switch to modified buffer, got %q", + m.activeWindow.Buffer.Filename) + } + }) + + t.Run("handles buffer with no filename", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename(""). + WithLines([]string{"unsaved content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdQuit(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for modified buffer without filename") + } + + // Error message should still be meaningful + if !strings.Contains(m.commandError.Error(), "unsaved") { + t.Logf("Note: error message for unnamed buffer: %v", m.commandError) + } + }) + + t.Run("quits with multiple unmodified buffers", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename("file1.txt"). + WithLines([]string{"content"}). + Build() + buf1.Modified = false + + buf2 := core.NewBufferBuilder(). + WithFilename("file2.txt"). + WithLines([]string{"content"}). + Build() + buf2.Modified = false + + buf3 := core.NewBufferBuilder(). + WithFilename("file3.txt"). + WithLines([]string{"content"}). + Build() + buf3.Modified = false + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2, &buf3) + + cmd := cmdQuit(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Error("expected quit command") + } + }) +} + +// ================================================== +// cmdQuitAll Tests +// ================================================== + +func TestCmdQuitAll(t *testing.T) { + t.Run("quits when no buffers are modified", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename("test.txt"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdQuitAll(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command, got nil") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("errors when any buffer is modified", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename("saved.txt"). + WithLines([]string{"content"}). + Build() + buf1.Modified = false + + buf2 := core.NewBufferBuilder(). + WithFilename("unsaved.txt"). + WithLines([]string{"modified content"}). + Build() + buf2.Modified = true + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdQuitAll(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error when any buffer is modified") + } + + if cmd != nil { + t.Error("should not return quit command") + } + }) + + t.Run("quits with multiple unmodified buffers", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename("file1.txt"). + Build() + buf1.Modified = false + + buf2 := core.NewBufferBuilder(). + WithFilename("file2.txt"). + Build() + buf2.Modified = false + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdQuitAll(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Error("expected quit command") + } + }) + + t.Run("error mentions which buffer is modified", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename("important_document.txt"). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdQuitAll(m, []string{}, false) + + if m.commandError == nil { + t.Fatal("expected error") + } + + if !strings.Contains(m.commandError.Error(), "important_document.txt") { + t.Errorf("error should mention filename: %v", m.commandError) + } + }) + + t.Run("reports first modified buffer found", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename("first_modified.txt"). + Build() + buf1.Modified = true + + buf2 := core.NewBufferBuilder(). + WithFilename("second_modified.txt"). + Build() + buf2.Modified = true + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmdQuitAll(m, []string{}, false) + + if m.commandError == nil { + t.Fatal("expected error") + } + + // Should report first modified buffer + if !strings.Contains(m.commandError.Error(), "first_modified.txt") { + t.Errorf("should report first modified buffer: %v", m.commandError) + } + }) +} + +// ================================================== +// cmdQuitForce Tests (q!) +// ================================================== + +func TestCmdQuitForce(t *testing.T) { + t.Run("quits with modified buffer", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename("unsaved.txt"). + WithLines([]string{"unsaved content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdQuit(m, []string{}, true) + + // Should NOT error + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Should return quit command + if cmd == nil { + t.Fatal("expected quit command, got nil") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with readonly modified buffer", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename("readonly.txt"). + WithLines([]string{"modified content"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdQuit(m, []string{}, true) + + // Force quit should work even with readonly modified buffer + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with multiple modified buffers", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename("file1.txt"). + WithLines([]string{"unsaved 1"}). + Modified(). + Build() + + buf2 := core.NewBufferBuilder(). + WithFilename("file2.txt"). + WithLines([]string{"unsaved 2"}). + Modified(). + Build() + + buf3 := core.NewBufferBuilder(). + WithFilename("file3.txt"). + WithLines([]string{"unsaved 3"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2, &buf3) + + cmd := cmdQuit(m, []string{}, true) + + // Should quit regardless of multiple modified buffers + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with unmodified buffers", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename("test.txt"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdQuit(m, []string{}, false) + + // Should work with unmodified buffers too + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with buffer without filename", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithFilename(""). + WithLines([]string{"unsaved content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdQuit(m, []string{}, true) + + // Force quit works even without filename + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with scratch buffer", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch"). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdQuit(m, []string{}, true) + + // Force quit works with scratch buffers + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with mix of modified and unmodified buffers", func(t *testing.T) { + modifiedBuf := core.NewBufferBuilder(). + WithFilename("modified.txt"). + WithLines([]string{"unsaved"}). + Modified(). + Build() + + unmodifiedBuf := core.NewBufferBuilder(). + WithFilename("unmodified.txt"). + WithLines([]string{"saved"}). + Build() + + readonlyModifiedBuf := core.NewBufferBuilder(). + WithFilename("readonly.txt"). + WithLines([]string{"readonly unsaved"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&modifiedBuf) + m.buffers = append(m.buffers, &unmodifiedBuf, &readonlyModifiedBuf) + + cmd := cmdQuit(m, []string{}, true) + + // Should quit regardless of buffer states + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("does not switch buffers", func(t *testing.T) { + activeBuf := core.NewBufferBuilder(). + WithFilename("active.txt"). + WithLines([]string{"active"}). + Build() + + modifiedBuf := core.NewBufferBuilder(). + WithFilename("modified.txt"). + WithLines([]string{"unsaved"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&activeBuf) + m.buffers = append(m.buffers, &modifiedBuf) + + // Remember active buffer + activeFilename := m.activeWindow.Buffer.Filename + + cmdQuit(m, []string{}, true) + + // Should NOT switch to modified buffer (unlike regular quit) + if m.activeWindow.Buffer.Filename != activeFilename { + t.Errorf("should not switch buffers, got %q", m.activeWindow.Buffer.Filename) + } + }) +} + +// ================================================== +// cmdQuitAllForce Tests (qall! / qa!) +// ================================================== + +func TestCmdQuitAllForce(t *testing.T) { + t.Run("quits with all buffers modified", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename("file1.txt"). + WithLines([]string{"unsaved 1"}). + Modified(). + Build() + + buf2 := core.NewBufferBuilder(). + WithFilename("file2.txt"). + WithLines([]string{"unsaved 2"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdQuitAll(m, []string{}, true) + + // Should quit even with all buffers modified + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command, got nil") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with mix of modified and unmodified", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename("saved.txt"). + WithLines([]string{"content"}). + Build() + + buf2 := core.NewBufferBuilder(). + WithFilename("unsaved.txt"). + WithLines([]string{"modified content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdQuitAll(m, []string{}, true) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with readonly buffers", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename("readonly1.txt"). + WithLines([]string{"content"}). + ReadOnly(). + Modified(). + Build() + + buf2 := core.NewBufferBuilder(). + WithFilename("readonly2.txt"). + WithLines([]string{"content"}). + ReadOnly(). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdQuitAll(m, []string{}, true) + + // Force quit should work with readonly buffers + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with scratch buffers", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch1"). + WithLines([]string{"content"}). + Modified(). + Build() + + buf2 := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch2"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdQuitAll(m, []string{}, true) + + // Should quit with scratch buffers + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with buffers without filenames", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename(""). + WithLines([]string{"unsaved 1"}). + Modified(). + Build() + + buf2 := core.NewBufferBuilder(). + WithFilename(""). + WithLines([]string{"unsaved 2"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdQuitAll(m, []string{}, true) + + // Force quit works even without filenames + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with no buffers", func(t *testing.T) { + m := newMockModel() + // Clear buffers + m.buffers = []*core.Buffer{} + + cmd := cmdQuitAll(m, []string{}, true) + + // Should quit even with no buffers + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with complex buffer states", func(t *testing.T) { + // Mix of all different buffer states + buf1 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("file.txt"). + WithLines([]string{"modified file"}). + Modified(). + Build() + + buf2 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("readonly.txt"). + WithLines([]string{"readonly modified"}). + ReadOnly(). + Modified(). + Build() + + buf3 := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch"). + WithLines([]string{"scratch modified"}). + Modified(). + Build() + + buf4 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(""). + WithLines([]string{"no name"}). + Modified(). + Build() + + buf5 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("saved.txt"). + WithLines([]string{"saved content"}). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2, &buf3, &buf4, &buf5) + + cmd := cmdQuitAll(m, []string{}, true) + + // Force quit should work regardless of any buffer state + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("quits with all buffers unmodified", func(t *testing.T) { + buf1 := core.NewBufferBuilder(). + WithFilename("file1.txt"). + WithLines([]string{"content"}). + Build() + + buf2 := core.NewBufferBuilder(). + WithFilename("file2.txt"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdQuitAll(m, []string{}, true) + + // Should work with unmodified buffers too + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) +} + +// ================================================== +// cmdWriteAll Tests +// ================================================== + +func TestCmdWriteAll(t *testing.T) { + t.Run("writes all modified buffers", func(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "file1.txt") + file2 := filepath.Join(tmpDir, "file2.txt") + + buf1 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file1). + WithLines([]string{"content 1"}). + Build() + buf1.Modified = true + + buf2 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file2). + WithLines([]string{"content 2"}). + Build() + buf2.Modified = true + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmdWriteAll(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Verify both files were written + content1, err := os.ReadFile(file1) + if err != nil { + t.Errorf("file1 not written: %v", err) + } else if string(content1) != "content 1\n" { + t.Errorf("file1 content = %q", string(content1)) + } + + content2, err := os.ReadFile(file2) + if err != nil { + t.Errorf("file2 not written: %v", err) + } else if string(content2) != "content 2\n" { + t.Errorf("file2 content = %q", string(content2)) + } + }) + + t.Run("skips unmodified buffers", func(t *testing.T) { + tmpDir := t.TempDir() + modifiedFile := filepath.Join(tmpDir, "modified.txt") + unmodifiedFile := filepath.Join(tmpDir, "unmodified.txt") + + modifiedBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(modifiedFile). + WithLines([]string{"new content"}). + Build() + modifiedBuf.Modified = true + + unmodifiedBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(unmodifiedFile). + WithLines([]string{"old content"}). + Build() + unmodifiedBuf.Modified = false + + m := newMockModelWithBuffer(&modifiedBuf) + m.buffers = append(m.buffers, &unmodifiedBuf) + + cmdWriteAll(m, []string{}, false) + + // Modified file should be written + if _, err := os.Stat(modifiedFile); os.IsNotExist(err) { + t.Error("modified file should be written") + } + + // Unmodified file should NOT be written (doesn't exist) + if _, err := os.Stat(unmodifiedFile); !os.IsNotExist(err) { + t.Error("unmodified file should not be written") + } + }) + + t.Run("clears modified flag on written buffers", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWriteAll(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if buf.Modified { + t.Error("modified flag should be cleared after write") + } + }) + + t.Run("errors on modified readonly buffer", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("readonly.txt"). + WithLines([]string{"content"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWriteAll(m, []string{}, false) + + if m.commandError == nil { + t.Fatal("expected error for modified readonly buffer") + } + + if !strings.Contains(m.commandError.Error(), "readonly") { + t.Errorf("error should mention readonly: %v", m.commandError) + } + }) + + t.Run("skips unmodified readonly buffer", func(t *testing.T) { + tmpDir := t.TempDir() + writableFile := filepath.Join(tmpDir, "writable.txt") + + writableBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(writableFile). + WithLines([]string{"writable content"}). + Modified(). + Build() + + // Readonly but NOT modified - should be skipped + readonlyBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("readonly.txt"). + WithLines([]string{"readonly content"}). + ReadOnly(). + Build() + // NOT modified! + + m := newMockModelWithBuffer(&writableBuf) + m.buffers = append(m.buffers, &readonlyBuf) + + cmdWriteAll(m, []string{}, false) + + // Should succeed - readonly buffer not modified, so skip it + if m.commandError != nil { + t.Fatalf("should not error for unmodified readonly buffer: %v", m.commandError) + } + + // Writable file should be written + content, _ := os.ReadFile(writableFile) + if string(content) != "writable content\n" { + t.Errorf("writable buffer not written: %q", string(content)) + } + }) + + t.Run("errors on scratch buffer", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch"). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWriteAll(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for scratch buffer") + } + }) + + t.Run("errors on buffer with no filename", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(""). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWriteAll(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for buffer without filename") + } + + if !strings.Contains(m.commandError.Error(), "no file name") || + !strings.Contains(strings.ToLower(m.commandError.Error()), "name") { + t.Logf("error message: %v", m.commandError) + } + }) + + t.Run("succeeds with no modified buffers", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("unchanged.txt"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWriteAll(m, []string{}, false) + + // Should succeed even with nothing to write + if m.commandError != nil { + t.Errorf("unexpected error: %v", m.commandError) + } + }) + + t.Run("reports number of buffers written", func(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "file1.txt") + file2 := filepath.Join(tmpDir, "file2.txt") + + buf1 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file1). + WithLines([]string{"content"}). + Build() + buf1.Modified = true + + buf2 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file2). + WithLines([]string{"content"}). + Build() + buf2.Modified = true + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmdWriteAll(m, []string{}, false) + + // Should have some output indicating what was written + if m.commandOutput == "" { + t.Log("Note: cmdWriteAll doesn't set output message") + } else if !strings.Contains(m.commandOutput, "2") { + t.Logf("Output message: %q", m.commandOutput) + } + }) + + t.Run("stops on first error", func(t *testing.T) { + tmpDir := t.TempDir() + validFile := filepath.Join(tmpDir, "valid.txt") + + // First buffer - valid + validBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(validFile). + WithLines([]string{"content"}). + Build() + validBuf.Modified = true + + // Second buffer - invalid (no filename) + invalidBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(""). + WithLines([]string{"content"}). + Build() + invalidBuf.Modified = true + + m := newMockModelWithBuffer(&invalidBuf) // Invalid first + m.buffers = append(m.buffers, &validBuf) + + cmdWriteAll(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error") + } + + // Valid file might or might not be written depending on implementation + // (stops on first error vs continues and reports all errors) + }) +} + +// ================================================== +// cmdWriteQuit Tests +// ================================================== + +func TestCmdWriteQuit(t *testing.T) { + t.Run("writes buffer and quits", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"content to save"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuit(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Verify file was written + content, err := os.ReadFile(filename) + if err != nil { + t.Errorf("file not written: %v", err) + } else if string(content) != "content to save\n" { + t.Errorf("file content = %q", string(content)) + } + + // Verify quit command returned + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("errors on readonly buffer", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("readonly.txt"). + WithLines([]string{"content"}). + ReadOnly(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuit(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for readonly buffer") + } + + if cmd != nil { + t.Error("should not return quit command on error") + } + }) + + t.Run("errors on scratch buffer", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuit(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for scratch buffer") + } + + if cmd != nil { + t.Error("should not return quit command on error") + } + }) + + t.Run("errors on buffer with no filename", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(""). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuit(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for buffer without filename") + } + + if cmd != nil { + t.Error("should not return quit command on error") + } + }) + + t.Run("errors on invalid path", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("/invalid/nonexistent/path/file.txt"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuit(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for invalid path") + } + + if cmd != nil { + t.Error("should not return quit command on error") + } + }) + + t.Run("writes to argument filename if provided", func(t *testing.T) { + tmpDir := t.TempDir() + originalFile := filepath.Join(tmpDir, "original.txt") + newFile := filepath.Join(tmpDir, "new.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(originalFile). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuit(m, []string{newFile}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // New file should exist + if _, err := os.Stat(newFile); os.IsNotExist(err) { + t.Error("new file not created") + } + + // Original should not exist + if _, err := os.Stat(originalFile); !os.IsNotExist(err) { + t.Error("original file should not exist") + } + + if cmd == nil { + t.Error("expected quit command") + } + }) + + t.Run("clears modified flag before quit", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWriteQuit(m, []string{}, false) + + if buf.Modified { + t.Error("modified flag should be cleared") + } + }) +} + +// ================================================== +// cmdWriteQuitAll Tests (wqall / wqa / xa) +// ================================================== + +func TestCmdWriteQuitAll(t *testing.T) { + // Note: This command may not be implemented yet + // These tests define expected behavior + + t.Run("writes all buffers and quits", func(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "file1.txt") + file2 := filepath.Join(tmpDir, "file2.txt") + + buf1 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file1). + WithLines([]string{"content 1"}). + Build() + buf1.Modified = true + + buf2 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file2). + WithLines([]string{"content 2"}). + Build() + buf2.Modified = true + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdWriteQuitAll(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Verify files were written + content1, _ := os.ReadFile(file1) + if string(content1) != "content 1\n" { + t.Errorf("file1 content = %q", string(content1)) + } + + content2, _ := os.ReadFile(file2) + if string(content2) != "content 2\n" { + t.Errorf("file2 content = %q", string(content2)) + } + + // Verify quit command + if cmd == nil { + t.Fatal("expected quit command") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + }) + + t.Run("skips unmodified buffers", func(t *testing.T) { + tmpDir := t.TempDir() + modifiedFile := filepath.Join(tmpDir, "modified.txt") + unmodifiedFile := filepath.Join(tmpDir, "unmodified.txt") + + modifiedBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(modifiedFile). + WithLines([]string{"new content"}). + Build() + modifiedBuf.Modified = true + + unmodifiedBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(unmodifiedFile). + WithLines([]string{"unchanged"}). + Build() + unmodifiedBuf.Modified = false + + m := newMockModelWithBuffer(&modifiedBuf) + m.buffers = append(m.buffers, &unmodifiedBuf) + + cmd := cmdWriteQuitAll(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Should still quit even if some buffers unmodified + if cmd == nil { + t.Error("expected quit command") + } + }) + + t.Run("errors if readonly buffer is modified", func(t *testing.T) { + tmpDir := t.TempDir() + validFile := filepath.Join(tmpDir, "valid.txt") + + validBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(validFile). + WithLines([]string{"content"}). + Modified(). + Build() + + // Readonly buffer that IS modified - should cause error + readonlyBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("readonly.txt"). + WithLines([]string{"content"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&validBuf) + m.buffers = append(m.buffers, &readonlyBuf) + + cmd := cmdWriteQuitAll(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for modified readonly buffer") + } + + if !strings.Contains(m.commandError.Error(), "readonly") { + t.Errorf("error should mention readonly: %v", m.commandError) + } + + if cmd != nil { + t.Error("should not quit when readonly buffer has unsaved changes") + } + }) + + t.Run("quits with readonly buffer that is NOT modified", func(t *testing.T) { + tmpDir := t.TempDir() + writableFile := filepath.Join(tmpDir, "writable.txt") + + writableBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(writableFile). + WithLines([]string{"modified content"}). + Modified(). + Build() + + // Readonly buffer that is NOT modified - should be skipped, not prevent quit + readonlyBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("readonly.txt"). + WithLines([]string{"unchanged content"}). + ReadOnly(). + Build() + // NOT modified - this is the key + + m := newMockModelWithBuffer(&writableBuf) + m.buffers = append(m.buffers, &readonlyBuf) + + cmd := cmdWriteQuitAll(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("should not error for unmodified readonly buffer: %v", m.commandError) + } + + // Should write the modified writable buffer + content, _ := os.ReadFile(writableFile) + if string(content) != "modified content\n" { + t.Errorf("writable buffer not written: %q", string(content)) + } + + // Should return quit command + if cmd == nil { + t.Error("expected quit command (readonly buffer not modified)") + } + }) + + t.Run("errors if any buffer has no filename", func(t *testing.T) { + noNameBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(""). + WithLines([]string{"content"}). + Build() + noNameBuf.Modified = true + + m := newMockModelWithBuffer(&noNameBuf) + + cmd := cmdWriteQuitAll(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for buffer without filename") + } + + if cmd != nil { + t.Error("should not quit when write fails") + } + }) + + t.Run("quits with no modified buffers", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("unchanged.txt"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuitAll(m, []string{}, false) + + // Should still quit even if nothing to write + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Error("expected quit command") + } + }) + + t.Run("clears modified flags on all buffers", func(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "file1.txt") + file2 := filepath.Join(tmpDir, "file2.txt") + + buf1 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file1). + WithLines([]string{"content"}). + Build() + buf1.Modified = true + + buf2 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file2). + WithLines([]string{"content"}). + Build() + buf2.Modified = true + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmdWriteQuitAll(m, []string{}, false) + + if buf1.Modified { + t.Error("buf1 modified flag should be cleared") + } + if buf2.Modified { + t.Error("buf2 modified flag should be cleared") + } + }) +} diff --git a/internal/command/io.go b/internal/command/io.go new file mode 100644 index 0000000..4934545 --- /dev/null +++ b/internal/command/io.go @@ -0,0 +1,83 @@ +package command + +import ( + "bufio" + "fmt" + "os" + + "git.gophernest.net/azpect/TextEditor/internal/action" + "git.gophernest.net/azpect/TextEditor/internal/core" + tea "github.com/charmbracelet/bubbletea" +) + +func writeBuffer(m action.Model, buf *core.Buffer, args []string, force bool) (tea.Cmd, error) { + // Key Steps: + // - Backup Creation (if backup is set): Copy original to .bak file + // - Line Ending Application: Apply fileformat setting (unix=\n, dos=\r\n, mac=\r) + // - Character Encoding: Convert from internal representation to fileencoding + // - Atomic Write: Write to temporary file first (.swp or similar) + // - Atomic Rename: Rename temp file to target filename (atomic operation) + // - Metadata Update: Clear modified flag, update timestamp + // Safety Mechanisms: + // " Vim's write safety + // 1. Check file permissions + // 2. If file exists and 'writebackup' set: + // - Create backup (.bak) + // 3. Write to temporary file (.swp~) + // 4. Verify write succeeded + // 5. Rename temp to target (atomic) + // 6. Remove backup if 'backup' not set + // 7. Update buffer metadata + + // TODO: Implement atomic and safe writes + + // Check readonly flag ONLY if not forced with ! + if !force && buf.ReadOnly { + return nil, fmt.Errorf("cannot write to 'readonly' buffer") + } + + if buf.Type == core.ScatchBuffer { + return nil, fmt.Errorf("cannot write to buffer of type 'ScratchBuffer'") + } + + // Get the filename; differs by the type + var filename string + if len(args) > 0 { + filename = args[0] + } else { + if buf.Filename == "" { + return nil, fmt.Errorf("cannot write: no file name provided") + } + filename = buf.Filename + } + + file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664) + if err != nil { + return nil, err + } + defer file.Close() + + // Same write operation regardless of where the file came from + var bytes int + + // Using a bufio.Writer because its more efficient + writer := bufio.NewWriter(file) + for _, line := range buf.Lines { + n, err := writer.WriteString(line + "\n") + if err != nil { + return nil, err + } + + bytes += n + } + + if err := writer.Flush(); err != nil { + return nil, err + } + + output := fmt.Sprintf("'%s', %dL %db written", filename, buf.LineCount(), bytes) + m.SetCommandOutput(output) + buf.SetModified(false) + + return nil, nil +} diff --git a/internal/command/registry.go b/internal/command/registry.go index a03a0cf..cb69f96 100644 --- a/internal/command/registry.go +++ b/internal/command/registry.go @@ -10,9 +10,9 @@ import ( // Command: Represents a command that can be executed from command mode. type Command struct { - Name string // Full name: "quit" - ShortForm string // Minimum abbreviation: "q" - Handler func(m action.Model, args []string) tea.Cmd // Handler function + Name string // Full name: "quit" + ShortForm string // Minimum abbreviation: "q" + Handler func(m action.Model, args []string, force bool) tea.Cmd // Handler function } // Registry: Holds all registered commands. @@ -75,24 +75,34 @@ func (r *Registry) Lookup(input string) (*Command, error) { } // Parse: Splits a command line into command name and arguments. -func Parse(cmdLine string) (name string, args []string) { +func Parse(cmdLine string) (name string, args []string, force bool) { parts := strings.Fields(cmdLine) if len(parts) == 0 { - return "", nil + return "", nil, false } - return parts[0], parts[1:] + + name = parts[0] + args = parts[1:] + + // Check if command ends with ! (force flag) + if strings.HasSuffix(name, "!") { + name = strings.TrimSuffix(name, "!") + force = true + } + + return name, args, force } // Registry.Execute: Parses and executes a command line. func (r *Registry) Execute(m action.Model, cmdLine string) (tea.Cmd, error) { - name, args := Parse(cmdLine) + name, args, force := Parse(cmdLine) cmd, err := r.Lookup(name) if err != nil { return nil, err } - return cmd.Handler(m, args), nil + return cmd.Handler(m, args, force), nil } // DefaultRegistry is the global command registry @@ -132,6 +142,12 @@ func (r *Registry) registerDefaults() { Handler: cmdWriteQuit, }) + r.Register(Command{ + Name: "wqall", + ShortForm: "wqa", + Handler: cmdWriteQuitAll, + }) + // Set command r.Register(Command{ Name: "set", @@ -145,4 +161,11 @@ func (r *Registry) registerDefaults() { ShortForm: "reg", Handler: cmdRegisters, }) + + // File commands + r.Register(Command{ + Name: "edit", + ShortForm: "e", + Handler: cmdEdit, + }) } diff --git a/internal/command/registry_test.go b/internal/command/registry_test.go index 8c043a0..f053366 100644 --- a/internal/command/registry_test.go +++ b/internal/command/registry_test.go @@ -89,6 +89,26 @@ func TestRegistryLookup(t *testing.T) { } }) + t.Run("e matches edit", func(t *testing.T) { + cmd, err := r.Lookup("e") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "edit" { + t.Errorf("cmd.Name = %q, want \"edit\"", cmd.Name) + } + }) + + t.Run("ed matches edit", func(t *testing.T) { + cmd, err := r.Lookup("ed") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "edit" { + t.Errorf("cmd.Name = %q, want \"edit\"", cmd.Name) + } + }) + t.Run("unknown command returns error", func(t *testing.T) { _, err := r.Lookup("xyz") if err == nil { @@ -106,17 +126,20 @@ func TestRegistryLookup(t *testing.T) { func TestParse(t *testing.T) { t.Run("command only", func(t *testing.T) { - name, args := Parse("quit") + name, args, force := Parse("quit") if name != "quit" { t.Errorf("name = %q, want \"quit\"", name) } if len(args) != 0 { t.Errorf("len(args) = %d, want 0", len(args)) } + if force { + t.Error("force should be false") + } }) t.Run("command with one arg", func(t *testing.T) { - name, args := Parse("set number") + name, args, force := Parse("set number") if name != "set" { t.Errorf("name = %q, want \"set\"", name) } @@ -126,10 +149,13 @@ func TestParse(t *testing.T) { if args[0] != "number" { t.Errorf("args[0] = %q, want \"number\"", args[0]) } + if force { + t.Error("force should be false") + } }) t.Run("command with multiple args", func(t *testing.T) { - name, args := Parse("set number tabstop=4") + name, args, force := Parse("set number tabstop=4") if name != "set" { t.Errorf("name = %q, want \"set\"", name) } @@ -142,26 +168,74 @@ func TestParse(t *testing.T) { if args[1] != "tabstop=4" { t.Errorf("args[1] = %q, want \"tabstop=4\"", args[1]) } + if force { + t.Error("force should be false") + } }) t.Run("empty string", func(t *testing.T) { - name, args := Parse("") + name, args, force := Parse("") if name != "" { t.Errorf("name = %q, want \"\"", name) } if args != nil { t.Errorf("args = %v, want nil", args) } + if force { + t.Error("force should be false") + } }) t.Run("whitespace only", func(t *testing.T) { - name, args := Parse(" ") + name, args, force := Parse(" ") if name != "" { t.Errorf("name = %q, want \"\"", name) } if args != nil { t.Errorf("args = %v, want nil", args) } + if force { + t.Error("force should be false") + } + }) + + t.Run("command with force flag", func(t *testing.T) { + name, args, force := Parse("quit!") + if name != "quit" { + t.Errorf("name = %q, want \"quit\"", name) + } + if len(args) != 0 { + t.Errorf("len(args) = %d, want 0", len(args)) + } + if !force { + t.Error("force should be true") + } + }) + + t.Run("write command with force", func(t *testing.T) { + name, _, force := Parse("w!") + if name != "w" { + t.Errorf("name = %q, want \"w\"", name) + } + if !force { + t.Error("force should be true") + } + }) + + t.Run("command with force and args", func(t *testing.T) { + name, args, force := Parse("w! file.txt") + if name != "w" { + t.Errorf("name = %q, want \"w\"", name) + } + if len(args) != 1 { + t.Errorf("len(args) = %d, want 1", len(args)) + } + if args[0] != "file.txt" { + t.Errorf("args[0] = %q, want \"file.txt\"", args[0]) + } + if !force { + t.Error("force should be true") + } }) } diff --git a/internal/core/buffer.go b/internal/core/buffer.go index 0a1fa8e..367e9ad 100644 --- a/internal/core/buffer.go +++ b/internal/core/buffer.go @@ -4,9 +4,18 @@ type BufferOptions struct { // tabstop expandtab } +type BufferType int + +const ( + ScatchBuffer BufferType = iota + FileBuffer + DirectoryBuffer +) + type Buffer struct { // Buffer data - Id int + Id int + Type BufferType // File data Filename string @@ -17,6 +26,7 @@ type Buffer struct { Modified bool Loaded bool Listed bool + ReadOnly bool // Options BufferOptions // UndoTree TODO: This will be big @@ -36,16 +46,17 @@ func (b *Buffer) Line(idx int) string { } // Buffer.SetLine: Set the content of the line at an index. Does nothing if the -// index is out of bounds. +// index is out of bounds. This function sets the modified flag. func (b *Buffer) SetLine(idx int, content string) { if idx >= 0 && idx < len(b.Lines) { b.Lines[idx] = content } + b.Modified = true } // Buffer.InsertLine: Insert a line with content at an index. The index is clamped // to valid bounds (0 to len(Lines)). The new line is inserted before the line at -// the given index. +// the given index. This function sets the modified flag. func (b *Buffer) InsertLine(idx int, content string) { if idx < 0 { idx = 0 @@ -54,14 +65,16 @@ func (b *Buffer) InsertLine(idx int, content string) { idx = len(b.Lines) } b.Lines = append(b.Lines[:idx], append([]string{content}, b.Lines[idx:]...)...) + b.Modified = true } // Buffer.DeleteLine: Delete a line at an index. Does nothing if the index is out -// of bounds. +// of bounds. This function sets the modified flag. func (b *Buffer) DeleteLine(idx int) { if idx >= 0 && idx < len(b.Lines) { b.Lines = append(b.Lines[:idx], b.Lines[idx+1:]...) } + b.Modified = true } // Buffer.LineCount: Get the number of lines in the buffer. @@ -109,3 +122,9 @@ func (b *Buffer) SetLoaded(loaded bool) { func (b *Buffer) SetListed(listed bool) { b.Listed = listed } + +// Buffer.SetType: Set the buffers type. This type is used to determine handling +// of I/O functions. +func (b *Buffer) SetType(t BufferType) { + b.Type = t +} diff --git a/internal/core/buffer_builder.go b/internal/core/buffer_builder.go index f2e1173..f7ab433 100644 --- a/internal/core/buffer_builder.go +++ b/internal/core/buffer_builder.go @@ -12,13 +12,15 @@ type BufferBuilder struct { func NewBufferBuilder() *BufferBuilder { return &BufferBuilder{ buffer: Buffer{ - Id: 0, // This is set when built + Id: 0, // This is set when built + Type: ScatchBuffer, // Default buffer type Filename: "", Filetype: "", Lines: []string{""}, Modified: false, Loaded: false, Listed: false, + ReadOnly: false, }, } } @@ -62,6 +64,20 @@ func (b *BufferBuilder) Listed() *BufferBuilder { return b } +// BufferBuilder.ReadOnly: Sets the readonly flag of the buffer being built. By default, +// buffers are built with the readonly flag being false. +func (b *BufferBuilder) ReadOnly() *BufferBuilder { + b.buffer.ReadOnly = true + return b +} + +// BufferBuilder.Listed: Sets the type of the buffer being built. By default, buffers +// are build with the ScatchBuffer type. +func (b *BufferBuilder) WithType(t BufferType) *BufferBuilder { + b.buffer.Type = t + return b +} + // BufferBuilder.Build: Build the final buffer and return it to the caller. Final // step in the process. This is where the ID is set, so many buffers can be "in-progress" // but the ID will be set when they are built. Meaning, this is not thread safe. diff --git a/internal/core/window.go b/internal/core/window.go index 4fe1bbe..c3d5721 100644 --- a/internal/core/window.go +++ b/internal/core/window.go @@ -40,8 +40,10 @@ type Window struct { // Window.ClampCursor: Clamps the cursor in the all directions to ensure the cursor // does not go into an invalid position. Such as negative values or past the end of // the line. In the Y direction it validates that the cursor does not pass the end -// of the content or attempt to be "above" the content (negative value). -func (w *Window) clampCursor() { +// of the content or attempt to be "above" the content (negative value). This function +// is automatically called in any time the cursor changes. It only needs to be called +// when a force clamp is needed. +func (w *Window) ClampCursor() { // Clamp line to valid range [0, lineCount-1] maxLine := max(w.Buffer.LineCount()-1, 0) if w.Cursor.Line < 0 { @@ -50,8 +52,15 @@ func (w *Window) clampCursor() { w.Cursor.Line = maxLine } + // Handle empty buffer - no lines to clamp column against + if w.Buffer.LineCount() == 0 { + w.Cursor.Line = 0 + w.Cursor.Col = 0 + return + } + // Clamp column to valid range [0, lineLen] - lineLen := len(w.Buffer.Lines[w.Cursor.Line]) // Safe now - Line is valid + lineLen := len(w.Buffer.Lines[w.Cursor.Line]) if w.Cursor.Col < 0 { w.Cursor.Col = 0 } else if lineLen == 0 { @@ -111,27 +120,29 @@ func (w *Window) SetNumber(number int) { } // Window.SetBuffer: Sets the buffer that this window should display. This is used when -// switching between buffers or opening a new file in the current window. +// switching between buffers or opening a new file in the current window. This function +// does clamp the cursor to the current buffer func (w *Window) SetBuffer(buffer *Buffer) { w.Buffer = buffer + w.ClampCursor() } // Window.SetCursor: Sets the cursor position in this window to the given position. func (w *Window) SetCursor(cursor Position) { w.Cursor = cursor - w.clampCursor() + w.ClampCursor() } // Window.SetCursorLine: Sets the line number of the cursor position. func (w *Window) SetCursorLine(line int) { w.Cursor.Line = line - w.clampCursor() + w.ClampCursor() } // Window.SetCursorCol: Sets the column number of the cursor position. func (w *Window) SetCursorCol(col int) { w.Cursor.Col = col - w.clampCursor() + w.ClampCursor() } // Window.SetCursorPos: Sets both the line and column of the cursor position. This is @@ -139,7 +150,7 @@ func (w *Window) SetCursorCol(col int) { func (w *Window) SetCursorPos(line, col int) { w.Cursor.Line = line w.Cursor.Col = col - w.clampCursor() + w.ClampCursor() } // Window.SetAnchor: Sets the anchor position in this window. The anchor is used for diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go index e372d5a..764c773 100644 --- a/internal/editor/helpers_test.go +++ b/internal/editor/helpers_test.go @@ -38,6 +38,13 @@ func sendKeys(tm *teatest.TestModel, keys ...string) { } } +// sendKeyString is a convenience function for sending many keys. +func sendKeyString(tm *teatest.TestModel, keyString string) { + for _, key := range keyString { + sendKeys(tm, string(key)) + } +} + // TestModelOption is a functional option for configuring test models type TestModelOption func(*testModelConfig) diff --git a/internal/editor/integration_command_test.go b/internal/editor/integration_command_test.go index bed890d..6bbb9e3 100644 --- a/internal/editor/integration_command_test.go +++ b/internal/editor/integration_command_test.go @@ -8,8 +8,6 @@ import ( // NOTE: AI Generated tests -// Default settings are: Number=true, RelativeNumber=true, TabSize=2, ScrollOff=8 - func TestCommandSetBoolean(t *testing.T) { t.Run("':set nonumber' disables line numbers", func(t *testing.T) { // Default has Number=true @@ -339,3 +337,16 @@ func TestCommandModeErrors(t *testing.T) { } }) } + +func TestCommandEdit(t *testing.T) { + t.Run(":edit with no args fails", func(t *testing.T) { + tm := newTestModel(t) + sendKeyString(tm, ":edit") + sendKeys(tm, "enter") + + m := getFinalModel(t, tm) + if m.commandError == nil { + t.Error("expected commandError to be set for edit without args") + } + }) +} diff --git a/internal/editor/model.go b/internal/editor/model.go index 81f2b5f..178d473 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -80,6 +80,10 @@ func (m *Model) Buffers() []*core.Buffer { return m.buffers } +func (m *Model) SetBuffers(bufs []*core.Buffer) { + m.buffers = bufs +} + func (m *Model) ActiveBuffer() *core.Buffer { win := m.ActiveWindow() return win.Buffer diff --git a/internal/editor/view.go b/internal/editor/view.go index b9d4e74..723eeba 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -199,7 +199,21 @@ func drawStatusBar(w *core.Window, mode core.Mode) string { // leftBar: Returns the left side of the status bar showing the current mode. func leftBar(w *core.Window, mode core.Mode) string { buf := w.Buffer - return fmt.Sprintf(" %s %s", mode.ToString(), buf.Filename) + + var flags []string + if buf.Modified { + flags = append(flags, "!") + } + if buf.ReadOnly { + flags = append(flags, "x") + } + + var flagStr string + if len(flags) > 0 { + flagStr = "(" + strings.Join(flags, "") + ")" + } + + return fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr) } // rightBar: Returns the right side of the status bar showing cursor position