diff --git a/cmd/gim/main.go b/cmd/gim/main.go index e8d16f7..83a84f5 100644 --- a/cmd/gim/main.go +++ b/cmd/gim/main.go @@ -1,49 +1,32 @@ package main import ( - "fmt" + "os" - "git.gophernest.net/azpect/TextEditor/internal/core" - "git.gophernest.net/azpect/TextEditor/internal/editor" + "git.gophernest.net/azpect/TextEditor/internal/program" tea "github.com/charmbracelet/bubbletea" ) // 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(). - WithBuffer(&buf). - WithOptions(core.NewDefaultWinOptions()). - Build() - - model := editor.NewModelBuilder(). - AddBuffer(&buf). - AddWindow(&win). - WithActiveWindowId(win.Id). - Build() - - m, _ := tea.NewProgram(model, tea.WithAltScreen()).Run() - - final, ok := m.(*editor.Model) - if ok { - fmt.Printf("PRINTING WINDOWS: %+v\n", final.Windows()) - fmt.Printf("PRINTING ACTIVE WINDOW: %+v\n", final.ActiveWindow()) - 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()) + // + args := os.Args[1:] + var prog *tea.Program + if len(args) < 1 { + prog = program.NewProgramBuilder(). + EmptyProgram(). + WithOpt(tea.WithAltScreen()). + Build() } else { - fmt.Printf("PRINTING ALL: %+v\n", m) + prog = program.NewProgramBuilder(). + FileProgram(args[0]). + WithOpt(tea.WithAltScreen()). + Build() + } + + if _, err := prog.Run(); err != nil { + panic(err) } } diff --git a/internal/command/handlers.go b/internal/command/handlers.go index 3d2c660..58892b5 100644 --- a/internal/command/handlers.go +++ b/internal/command/handlers.go @@ -184,6 +184,8 @@ func cmdEdit(m action.Model, args []string, force bool) tea.Cmd { var lines []string + // BUG: We are unable to open and edit files owned by root. How do we handle that? + scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() diff --git a/internal/command/handlers_test.go b/internal/command/handlers_test.go index 96e4a1b..6e99a31 100644 --- a/internal/command/handlers_test.go +++ b/internal/command/handlers_test.go @@ -2945,3 +2945,939 @@ func TestCmdWriteQuitAll(t *testing.T) { } }) } + +// ================================================== +// Force Write Tests (w!) +// ================================================== + +func TestCmdWriteForce(t *testing.T) { + t.Run("force writes readonly buffer to its own file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "readonly.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"forced content"}). + ReadOnly(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, true) + + // Force should bypass readonly check + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Verify file was written + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("file not written: %v", err) + } + + expected := "forced content\n" + if string(content) != expected { + t.Errorf("got %q, want %q", string(content), expected) + } + }) + + t.Run("force writes readonly buffer to argument file", func(t *testing.T) { + tmpDir := t.TempDir() + originalFile := filepath.Join(tmpDir, "readonly_original.txt") + newFile := filepath.Join(tmpDir, "new_destination.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(originalFile). + WithLines([]string{"readonly content"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{newFile}, true) + + // Should succeed with force + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Verify new file was created + content, err := os.ReadFile(newFile) + if err != nil { + t.Fatalf("new file not created: %v", err) + } + + expected := "readonly content\n" + if string(content) != expected { + t.Errorf("got %q, want %q", string(content), expected) + } + + // Original should not exist (never written) + if _, err := os.Stat(originalFile); !os.IsNotExist(err) { + t.Error("original file should not exist") + } + }) + + t.Run("force writes scratch buffer to argument file", func(t *testing.T) { + tmpDir := t.TempDir() + targetFile := filepath.Join(tmpDir, "from_scratch.txt") + + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch"). + WithLines([]string{"scratch content", "line 2"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{targetFile}, true) + + // Force with filename should work for scratch buffer + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Verify file was written + content, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("file not written: %v", err) + } + + expected := "scratch content\nline 2\n" + if string(content) != expected { + t.Errorf("got %q, want %q", string(content), expected) + } + }) + + t.Run("force writes readonly scratch buffer to argument file", func(t *testing.T) { + tmpDir := t.TempDir() + targetFile := filepath.Join(tmpDir, "from_readonly_scratch.txt") + + // Readonly scratch buffer - double protection + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch"). + WithLines([]string{"protected content"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{targetFile}, true) + + // Force should bypass BOTH readonly AND scratch checks + if m.commandError != nil { + t.Fatalf("unexpected error with force: %v", m.commandError) + } + + // Verify file was written + content, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("file not written: %v", err) + } + + expected := "protected content\n" + if string(content) != expected { + t.Errorf("got %q, want %q", string(content), expected) + } + + // Verify modified flag was cleared + if buf.Modified { + t.Error("modified flag should be cleared after successful write") + } + }) + + t.Run("force write scratch buffer without filename still errors", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename(""). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, true) + + // Force doesn't help if there's no filename to write to + if m.commandError == nil { + t.Error("expected error when no filename provided") + } + + if !strings.Contains(m.commandError.Error(), "no file name") { + t.Errorf("error should mention no file name: %v", m.commandError) + } + }) + + t.Run("force write to invalid path still errors", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("/invalid/nonexistent/path/file.txt"). + WithLines([]string{"content"}). + ReadOnly(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, true) + + // Force bypasses buffer checks but not OS-level checks + if m.commandError == nil { + t.Error("expected error for invalid path even with force") + } + }) + + t.Run("force write without force flag still errors on readonly", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "readonly.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"content"}). + ReadOnly(). + Build() + + m := newMockModelWithBuffer(&buf) + + // force=false should still prevent write + cmdWrite(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for readonly without force flag") + } + + if !strings.Contains(m.commandError.Error(), "readonly") { + t.Errorf("error should mention readonly: %v", m.commandError) + } + }) + + t.Run("force write clears modified flag even for 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(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + if !buf.Modified { + t.Fatal("precondition: buffer should be modified") + } + + cmdWrite(m, []string{}, true) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Modified flag should be cleared + if buf.Modified { + t.Error("modified flag should be cleared after force write") + } + }) + + t.Run("force write preserves buffer type", func(t *testing.T) { + tmpDir := t.TempDir() + targetFile := filepath.Join(tmpDir, "output.txt") + + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch"). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{targetFile}, true) + + // Buffer type should remain ScratchBuffer + if buf.Type != core.ScatchBuffer { + t.Errorf("buffer type changed to %v, should remain ScatchBuffer", buf.Type) + } + + // Filename should NOT change (Vim behavior) + if buf.Filename != "scratch" { + t.Errorf("buffer filename changed to %q, should remain 'scratch'", buf.Filename) + } + }) + + t.Run("force write with empty scratch buffer", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "empty.txt") + + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename(""). + WithLines([]string{}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{filename}, true) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Should create empty file + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("file not created: %v", err) + } + + if string(content) != "" { + t.Errorf("expected empty file, got %q", string(content)) + } + }) +} + +// ================================================== +// Force WriteAll Tests (wall!) +// ================================================== + +func TestCmdWriteAllForce(t *testing.T) { + t.Run("force writes all modified readonly buffers", func(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "readonly1.txt") + file2 := filepath.Join(tmpDir, "readonly2.txt") + + buf1 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file1). + WithLines([]string{"content 1"}). + ReadOnly(). + Modified(). + Build() + + buf2 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file2). + WithLines([]string{"content 2"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmdWriteAll(m, []string{}, true) + + // Should succeed with force + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Both files should be 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("force writeall still errors on scratch buffer without filename argument", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename(""). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWriteAll(m, []string{}, true) + + // Force can bypass scratch type check, but if buffer has no filename + // it should still error with "no file name provided" + if m.commandError == nil { + t.Error("expected error for scratch buffer without filename") + } + }) + + t.Run("force writeall with mix of protected buffer types", func(t *testing.T) { + tmpDir := t.TempDir() + readonlyFile := filepath.Join(tmpDir, "readonly.txt") + normalFile := filepath.Join(tmpDir, "normal.txt") + + readonlyBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(readonlyFile). + WithLines([]string{"readonly content"}). + ReadOnly(). + Modified(). + Build() + + normalBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(normalFile). + WithLines([]string{"normal content"}). + Modified(). + Build() + + unmodifiedBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filepath.Join(tmpDir, "unmodified.txt")). + WithLines([]string{"unchanged"}). + Build() + + m := newMockModelWithBuffer(&readonlyBuf) + m.buffers = append(m.buffers, &normalBuf, &unmodifiedBuf) + + cmdWriteAll(m, []string{}, true) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Both modified files should be written + if _, err := os.Stat(readonlyFile); os.IsNotExist(err) { + t.Error("readonly file should be written with force") + } + if _, err := os.Stat(normalFile); os.IsNotExist(err) { + t.Error("normal file should be written") + } + + // Unmodified should be skipped (doesn't exist) + unmodifiedFile := filepath.Join(tmpDir, "unmodified.txt") + if _, err := os.Stat(unmodifiedFile); !os.IsNotExist(err) { + t.Error("unmodified file should not be written") + } + }) + + t.Run("force writeall clears modified flags", func(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "file1.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file1). + WithLines([]string{"content"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWriteAll(m, []string{}, true) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + if buf.Modified { + t.Error("modified flag should be cleared") + } + }) +} + +// ================================================== +// Force WriteQuit Tests (wq!) +// ================================================== + +func TestCmdWriteQuitForce(t *testing.T) { + t.Run("force write-quit with readonly buffer", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "readonly.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"content"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuit(m, []string{}, true) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // File should be written + content, err := os.ReadFile(filename) + if err != nil { + t.Errorf("file not written: %v", err) + } else if string(content) != "content\n" { + t.Errorf("content = %q", string(content)) + } + + // Should quit + 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("force write-quit with scratch buffer and filename arg", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "output.txt") + + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename("scratch"). + WithLines([]string{"scratch data"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuit(m, []string{filename}, true) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // File should be written + content, err := os.ReadFile(filename) + if err != nil { + t.Errorf("file not written: %v", err) + } else if string(content) != "scratch data\n" { + t.Errorf("content = %q", string(content)) + } + + if cmd == nil { + t.Error("expected quit command") + } + }) + + t.Run("force write-quit without filename on scratch buffer still errors", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithType(core.ScatchBuffer). + WithFilename(""). + WithLines([]string{"content"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdWriteQuit(m, []string{}, true) + + // Can't write without a filename even with force + if m.commandError == nil { + t.Error("expected error when no filename") + } + + if cmd != nil { + t.Error("should not quit on error") + } + }) +} + +// ================================================== +// Force WriteQuitAll Tests (wqall! / wqa! / xa!) +// ================================================== + +func TestCmdWriteQuitAllForce(t *testing.T) { + t.Run("force write-quit-all with readonly buffers", func(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "readonly1.txt") + file2 := filepath.Join(tmpDir, "readonly2.txt") + + buf1 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file1). + WithLines([]string{"content 1"}). + ReadOnly(). + Modified(). + Build() + + buf2 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file2). + WithLines([]string{"content 2"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmd := cmdWriteQuitAll(m, []string{}, true) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Both files should be 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)) + } + + // Should quit + 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("force write-quit-all with mix of buffer types", func(t *testing.T) { + tmpDir := t.TempDir() + readonlyFile := filepath.Join(tmpDir, "readonly.txt") + normalFile := filepath.Join(tmpDir, "normal.txt") + + readonlyBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(readonlyFile). + WithLines([]string{"readonly"}). + ReadOnly(). + Modified(). + Build() + + normalBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(normalFile). + WithLines([]string{"normal"}). + Modified(). + Build() + + unmodifiedBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename("unmodified.txt"). + WithLines([]string{"unchanged"}). + Build() + + m := newMockModelWithBuffer(&readonlyBuf) + m.buffers = append(m.buffers, &normalBuf, &unmodifiedBuf) + + cmd := cmdWriteQuitAll(m, []string{}, true) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Modified buffers should be written + if _, err := os.Stat(readonlyFile); os.IsNotExist(err) { + t.Error("readonly file should be written") + } + if _, err := os.Stat(normalFile); os.IsNotExist(err) { + t.Error("normal file should be written") + } + + if cmd == nil { + t.Error("expected quit command") + } + }) + + t.Run("force write-quit-all still errors on buffer without filename", 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() + + noNameBuf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(""). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&validBuf) + m.buffers = append(m.buffers, &noNameBuf) + + cmd := cmdWriteQuitAll(m, []string{}, true) + + // Force can't help when there's no filename + 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("force write-quit-all clears all modified flags", 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"}). + ReadOnly(). + Modified(). + Build() + + buf2 := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(file2). + WithLines([]string{"content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf1) + m.buffers = append(m.buffers, &buf2) + + cmdWriteQuitAll(m, []string{}, true) + + if buf1.Modified { + t.Error("buf1 modified flag should be cleared") + } + if buf2.Modified { + t.Error("buf2 modified flag should be cleared") + } + }) +} + +// ================================================== +// Edge Case Tests +// ================================================== + +func TestEdgeCases(t *testing.T) { + t.Run("write to file that becomes readonly during editing", func(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("test requires non-root user") + } + + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + // Create file + os.WriteFile(filename, []byte("original"), 0644) + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"modified content"}). + Modified(). + Build() + + // Make file readonly on filesystem (different from buffer readonly flag) + os.Chmod(filename, 0444) + defer os.Chmod(filename, 0644) // Cleanup + + m := newMockModelWithBuffer(&buf) + + // Regular write should fail (OS permission denied) + cmdWrite(m, []string{}, false) + + if m.commandError == nil { + t.Error("expected error for OS-level readonly file") + } + }) + + t.Run("write unmodified readonly buffer is allowed with force", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "readonly.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"content"}). + ReadOnly(). + Build() + buf.Modified = false + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, true) + + // Force write should work even for unmodified readonly + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + content, _ := os.ReadFile(filename) + if string(content) != "content\n" { + t.Errorf("file not written correctly") + } + }) + + t.Run("buffer with both readonly and modified flags", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"edited readonly content"}). + ReadOnly(). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + // Without force - should error + cmdWrite(m, []string{}, false) + if m.commandError == nil { + t.Error("expected error without force") + } + + // With force - should succeed + m.commandError = nil + cmdWrite(m, []string{}, true) + if m.commandError != nil { + t.Errorf("force write failed: %v", m.commandError) + } + + // Modified flag should be cleared + if buf.Modified { + t.Error("modified flag should be cleared") + } + }) + + t.Run("write buffer with very long filename", func(t *testing.T) { + tmpDir := t.TempDir() + // Create a long but valid filename (255 chars is typical limit) + longName := strings.Repeat("a", 200) + ".txt" + filename := filepath.Join(tmpDir, longName) + + 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) + } + + // Verify file exists + if _, err := os.Stat(filename); os.IsNotExist(err) { + t.Error("file with long name not created") + } + }) + + t.Run("write buffer with unicode in filename", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "テスト_файл_测试.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"unicode filename test"}). + Build() + + m := newMockModelWithBuffer(&buf) + + cmdWrite(m, []string{}, false) + + if m.commandError != nil { + t.Fatalf("unexpected error: %v", m.commandError) + } + + // Verify file exists and content is correct + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("file with unicode name not created: %v", err) + } + + if string(content) != "unicode filename test\n" { + t.Errorf("content = %q", string(content)) + } + }) + + t.Run("multiple sequential writes to same file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "multi_write.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"version 1"}). + Build() + + m := newMockModelWithBuffer(&buf) + + // First write + cmdWrite(m, []string{}, false) + if m.commandError != nil { + t.Fatalf("first write failed: %v", m.commandError) + } + + // Modify buffer + buf.SetLine(0, "version 2") + buf.SetModified(true) + + // Second write + cmdWrite(m, []string{}, false) + if m.commandError != nil { + t.Fatalf("second write failed: %v", m.commandError) + } + + // Verify final content + content, _ := os.ReadFile(filename) + if string(content) != "version 2\n" { + t.Errorf("final content = %q", string(content)) + } + }) + + t.Run("force quit does not write", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "not_written.txt") + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithLines([]string{"unsaved content"}). + Modified(). + Build() + + m := newMockModelWithBuffer(&buf) + + cmd := cmdQuit(m, []string{}, true) + + // Should quit without error + if m.commandError != nil { + t.Errorf("unexpected error: %v", m.commandError) + } + + if cmd == nil { + t.Fatal("expected quit command") + } + + // File should NOT be written + if _, err := os.Stat(filename); !os.IsNotExist(err) { + t.Error("file should not be written with force quit") + } + + // Buffer should still be modified (not saved) + if !buf.Modified { + t.Error("buffer should still be modified (force quit doesn't save)") + } + }) +} diff --git a/internal/command/io.go b/internal/command/io.go index 4934545..952efc7 100644 --- a/internal/command/io.go +++ b/internal/command/io.go @@ -36,7 +36,7 @@ func writeBuffer(m action.Model, buf *core.Buffer, args []string, force bool) (t return nil, fmt.Errorf("cannot write to 'readonly' buffer") } - if buf.Type == core.ScatchBuffer { + if !force && buf.Type == core.ScatchBuffer { return nil, fmt.Errorf("cannot write to buffer of type 'ScratchBuffer'") } diff --git a/internal/program/program_builder.go b/internal/program/program_builder.go new file mode 100644 index 0000000..6076057 --- /dev/null +++ b/internal/program/program_builder.go @@ -0,0 +1,120 @@ +package program + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "git.gophernest.net/azpect/TextEditor/internal/core" + "git.gophernest.net/azpect/TextEditor/internal/editor" + tea "github.com/charmbracelet/bubbletea" +) + +type ProgramBuilder struct { + model *editor.Model + opts []tea.ProgramOption +} + +func NewProgramBuilder() *ProgramBuilder { + return &ProgramBuilder{ + model: &editor.Model{}, + opts: []tea.ProgramOption{}, + } +} + +// ProgramBuilder.FileProgram: Sets the internal state of the builder to the required +// state to start the program (editor) with a filename. This is what will happen when +// a user runs 'gim '. +func (p *ProgramBuilder) FileProgram(filename string) *ProgramBuilder { + // Only difference: open the file + ext := filepath.Ext(filename) + + file, err := os.Open(filename) + notFound := errors.Is(err, os.ErrNotExist) + if err != nil && !notFound { + // TODO: Handle this + panic(fmt.Errorf("Failed to find file: %w", err)) + } + if file != nil { + defer file.Close() + } + + buf := core.NewBufferBuilder(). + WithType(core.FileBuffer). + WithFilename(filename). + WithFiletype(ext). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithOptions(core.NewDefaultWinOptions()). + Build() + + p.model = editor.NewModelBuilder(). + AddBuffer(&buf). + AddWindow(&win). + WithActiveWindowId(win.Id). + Build() + + // If we did not find the file, all we need to do is set the filename and type + if notFound { + return p + } + + // Otherwise we have to create everything, then read the file (since we need settings) + // COPIED FROM `internal/command/handlers.go` + 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(" ", p.model.Settings().TabStop)) + lines = append(lines, cleaned) + } + + // Only setting lines if we found some + if len(lines) > 0 { + p.model.ActiveBuffer().SetLines(lines) + } + + return p +} + +// ProgramBuilder.EmptyProgram: Sets the internal state of the builder to the required +// state to start the program (editor) in the current directory. This is what will +// happen when a user runs 'gim'. +func (p *ProgramBuilder) EmptyProgram() *ProgramBuilder { + buf := core.NewBufferBuilder(). + ReadOnly(). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithOptions(core.NewDefaultWinOptions()). + Build() + + p.model = editor.NewModelBuilder(). + AddBuffer(&buf). + AddWindow(&win). + WithActiveWindowId(win.Id). + Build() + + return p +} + +// ProgramBuilder.WithOpt: Add an option to the list of options that will be used when +// the program is built. +func (p *ProgramBuilder) WithOpt(opt tea.ProgramOption) *ProgramBuilder { + p.opts = append(p.opts, opt) + return p +} + +// ProgramBuilder.Build: Build and return the configured tea.Program instance. +func (p *ProgramBuilder) Build() *tea.Program { + return tea.NewProgram(p.model, p.opts...) +} diff --git a/internal/program/program_builder_test.go b/internal/program/program_builder_test.go new file mode 100644 index 0000000..4b894fb --- /dev/null +++ b/internal/program/program_builder_test.go @@ -0,0 +1,1066 @@ +package program + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/core" + tea "github.com/charmbracelet/bubbletea" +) + +// ================================================== +// EmptyProgram Tests +// ================================================== + +func TestEmptyProgram(t *testing.T) { + t.Run("creates program with readonly buffer", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + if builder.model == nil { + t.Fatal("model should not be nil") + } + + buffers := builder.model.Buffers() + if len(buffers) != 1 { + t.Errorf("expected 1 buffer, got %d", len(buffers)) + } + + buf := buffers[0] + if !buf.ReadOnly { + t.Error("buffer should be readonly") + } + }) + + t.Run("creates program with one window", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + windows := builder.model.Windows() + if len(windows) != 1 { + t.Errorf("expected 1 window, got %d", len(windows)) + } + }) + + t.Run("sets active window", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + activeWindow := builder.model.ActiveWindow() + if activeWindow == nil { + t.Fatal("active window should not be nil") + } + }) + + t.Run("window points to buffer", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + buf := builder.model.Buffers()[0] + win := builder.model.Windows()[0] + + if win.Buffer != buf { + t.Error("window buffer should point to the created buffer") + } + }) + + t.Run("active buffer is accessible", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + buf := builder.model.ActiveBuffer() + if buf == nil { + t.Fatal("active buffer should not be nil") + } + + if !buf.ReadOnly { + t.Error("active buffer should be readonly") + } + }) + + t.Run("buffer has default settings", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + settings := builder.model.Settings() + if settings.TabStop <= 0 { + t.Error("TabStop should be initialized to positive value") + } + }) + + t.Run("buffer is empty", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 1 { + t.Errorf("expected 1 empty line, got %d lines", buf.LineCount()) + } + + if buf.Line(0) != "" { + t.Errorf("expected empty line, got %q", buf.Line(0)) + } + }) + + t.Run("multiple calls create independent programs", func(t *testing.T) { + builder1 := NewProgramBuilder().EmptyProgram() + builder2 := NewProgramBuilder().EmptyProgram() + + buf1 := builder1.model.ActiveBuffer() + buf2 := builder2.model.ActiveBuffer() + + if buf1 == buf2 { + t.Error("different programs should have different buffer instances") + } + + if builder1.model == builder2.model { + t.Error("different programs should have different model instances") + } + }) +} + +// ================================================== +// FileProgram Tests - Nonexistent Files +// ================================================== + +func TestFileProgramNonexistent(t *testing.T) { + t.Run("creates buffer for nonexistent file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "newfile.txt") + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + if builder.model == nil { + t.Fatal("model should not be nil") + } + + buf := builder.model.ActiveBuffer() + if buf.Filename != filename { + t.Errorf("filename = %q, want %q", buf.Filename, filename) + } + }) + + t.Run("sets correct filetype for nonexistent file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.go") + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Filetype != ".go" { + t.Errorf("filetype = %q, want %q", buf.Filetype, ".go") + } + }) + + t.Run("sets buffer type to FileBuffer", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Type != core.FileBuffer { + t.Errorf("buffer type = %v, want FileBuffer", buf.Type) + } + }) + + t.Run("buffer is empty for nonexistent file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "newfile.txt") + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + // BufferBuilder creates buffers with 1 empty line by default + if buf.LineCount() != 1 { + t.Errorf("expected 1 empty line for new file, got %d", buf.LineCount()) + } + + if buf.Line(0) != "" { + t.Errorf("expected empty line, got %q", buf.Line(0)) + } + }) + + t.Run("creates window for nonexistent file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "newfile.txt") + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + windows := builder.model.Windows() + if len(windows) != 1 { + t.Errorf("expected 1 window, got %d", len(windows)) + } + }) + + t.Run("window buffer matches created buffer", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "newfile.txt") + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.Buffers()[0] + win := builder.model.Windows()[0] + + if win.Buffer != buf { + t.Error("window should point to the created buffer") + } + }) + + t.Run("handles file without extension", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "Makefile") + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Filetype != "" { + t.Errorf("filetype should be empty for extensionless file, got %q", buf.Filetype) + } + }) + + t.Run("handles complex extensions", func(t *testing.T) { + tmpDir := t.TempDir() + tests := []struct { + filename string + expected string + }{ + {"file.tar.gz", ".gz"}, + {"file.test.js", ".js"}, + {"file.d.ts", ".ts"}, + {"archive.tar.bz2", ".bz2"}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + fullPath := filepath.Join(tmpDir, tt.filename) + builder := NewProgramBuilder() + builder.FileProgram(fullPath) + + buf := builder.model.ActiveBuffer() + if buf.Filetype != tt.expected { + t.Errorf("filetype = %q, want %q", buf.Filetype, tt.expected) + } + }) + } + }) + + t.Run("handles filename with spaces", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "my file with spaces.txt") + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Filename != filename { + t.Errorf("filename = %q, want %q", buf.Filename, filename) + } + }) + + t.Run("handles unicode filename", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "テスト_файл_测试.txt") + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Filename != filename { + t.Errorf("filename = %q, want %q", buf.Filename, filename) + } + }) +} + +// ================================================== +// FileProgram Tests - Existing Files +// ================================================== + +func TestFileProgramExisting(t *testing.T) { + t.Run("loads content from existing file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + content := "line 1\nline 2\nline 3\n" + os.WriteFile(filename, []byte(content), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + 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("sets filename for existing file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "existing.txt") + os.WriteFile(filename, []byte("content"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Filename != filename { + t.Errorf("filename = %q, want %q", buf.Filename, filename) + } + }) + + t.Run("sets filetype from extension", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.go") + os.WriteFile(filename, []byte("package main"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Filetype != ".go" { + t.Errorf("filetype = %q, want %q", buf.Filetype, ".go") + } + }) + + t.Run("loads empty file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "empty.txt") + os.WriteFile(filename, []byte(""), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + // Empty file creates buffer with default 1 empty line since SetLines isn't called + if buf.LineCount() != 1 { + t.Errorf("expected 1 empty line for empty file, got %d", buf.LineCount()) + } + + if buf.Line(0) != "" { + t.Errorf("expected empty line, got %q", buf.Line(0)) + } + }) + + t.Run("loads file without trailing newline", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "notrailing.txt") + os.WriteFile(filename, []byte("line 1\nline 2"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 2 { + t.Errorf("expected 2 lines, got %d", buf.LineCount()) + } + + if buf.Line(1) != "line 2" { + t.Errorf("last line = %q, want %q", buf.Line(1), "line 2") + } + }) + + t.Run("loads file with only newlines", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "newlines.txt") + os.WriteFile(filename, []byte("\n\n\n"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + for i := 0; i < 3; i++ { + if buf.Line(i) != "" { + t.Errorf("line %d should be empty, got %q", i, buf.Line(i)) + } + } + }) + + t.Run("strips carriage return from CRLF", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "crlf.txt") + os.WriteFile(filename, []byte("line 1\r\nline 2\r\nline 3\r\n"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + for i := 0; i < buf.LineCount(); i++ { + line := buf.Line(i) + if strings.Contains(line, "\r") { + t.Errorf("line %d contains \\r: %q", i, line) + } + } + + if buf.Line(0) != "line 1" { + t.Errorf("line 0 = %q, want %q", buf.Line(0), "line 1") + } + }) + + t.Run("handles mixed line endings", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "mixed.txt") + os.WriteFile(filename, []byte("unix\nwindows\r\nunix again\n"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + 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("converts tabs to spaces based on tabstop", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "tabs.txt") + os.WriteFile(filename, []byte("\tindented\n\t\tdouble"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + settings := builder.model.Settings() + + // Default tabstop should be used + expectedSpaces := strings.Repeat(" ", settings.TabStop) + expected0 := expectedSpaces + "indented" + expected1 := expectedSpaces + expectedSpaces + "double" + + if buf.Line(0) != expected0 { + t.Errorf("line 0 = %q, want %q", buf.Line(0), expected0) + } + + if buf.Line(1) != expected1 { + t.Errorf("line 1 = %q, want %q", buf.Line(1), expected1) + } + }) + + t.Run("loads file with unicode content", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "unicode.txt") + content := "Hello 世界\nこんにちは\nEmoji: 😀\n" + os.WriteFile(filename, []byte(content), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + if !strings.Contains(buf.Line(0), "世界") { + t.Errorf("unicode not preserved in line 0: %q", buf.Line(0)) + } + + if !strings.Contains(buf.Line(1), "こんにちは") { + t.Errorf("unicode not preserved in line 1: %q", buf.Line(1)) + } + + if !strings.Contains(buf.Line(2), "😀") { + t.Errorf("emoji not preserved in line 2: %q", buf.Line(2)) + } + }) + + t.Run("loads file with special characters", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "special.txt") + content := "line with\ttab\nsymbols: !@#$%^&*()\n" + os.WriteFile(filename, []byte(content), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 2 { + t.Errorf("expected 2 lines, got %d", buf.LineCount()) + } + + // Tab should be converted to spaces + if !strings.Contains(buf.Line(0), "tab") { + t.Errorf("content not preserved in line 0: %q", buf.Line(0)) + } + + if buf.Line(1) != "symbols: !@#$%^&*()" { + t.Errorf("line 1 = %q, want 'symbols: !@#$%%^&*()'", buf.Line(1)) + } + }) + + t.Run("loads large file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "large.txt") + + // Create file with many lines + var content strings.Builder + lineCount := 10000 + for i := 0; i < lineCount; i++ { + content.WriteString(strings.Repeat("x", 80)) + content.WriteString("\n") + } + os.WriteFile(filename, []byte(content.String()), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != lineCount { + t.Errorf("expected %d lines, got %d", lineCount, buf.LineCount()) + } + + // Verify first and last lines + if len(buf.Line(0)) != 80 { + t.Errorf("first line length = %d, want 80", len(buf.Line(0))) + } + + if len(buf.Line(lineCount-1)) != 80 { + t.Errorf("last line length = %d, want 80", len(buf.Line(lineCount-1))) + } + }) + + t.Run("handles file with long lines", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "longlines.txt") + + longLine := strings.Repeat("x", 10000) + content := longLine + "\nshort\n" + longLine + os.WriteFile(filename, []byte(content), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + if len(buf.Line(0)) != 10000 { + t.Errorf("first long line length = %d, want 10000", len(buf.Line(0))) + } + + if buf.Line(1) != "short" { + t.Errorf("middle line = %q, want 'short'", buf.Line(1)) + } + }) + + t.Run("buffer is not readonly for file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + os.WriteFile(filename, []byte("content"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.ReadOnly { + t.Error("file buffer should not be readonly by default") + } + }) + + t.Run("buffer is not modified initially", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + os.WriteFile(filename, []byte("content"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Modified { + t.Error("buffer should not be modified after loading") + } + }) + + t.Run("creates single window", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + os.WriteFile(filename, []byte("content"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + windows := builder.model.Windows() + if len(windows) != 1 { + t.Errorf("expected 1 window, got %d", len(windows)) + } + }) + + t.Run("creates single buffer", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + os.WriteFile(filename, []byte("content"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buffers := builder.model.Buffers() + if len(buffers) != 1 { + t.Errorf("expected 1 buffer, got %d", len(buffers)) + } + }) +} + +// ================================================== +// FileProgram Tests - Error Handling +// ================================================== + +func TestFileProgramErrors(t *testing.T) { + t.Run("panics 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 + + builder := NewProgramBuilder() + + // Should panic due to permission error + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for permission denied") + } + }() + + builder.FileProgram(filename) + }) + + t.Run("handles file in nonexistent directory gracefully", func(t *testing.T) { + // Nonexistent directory - file doesn't exist + filename := "/nonexistent/directory/file.txt" + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + // Should not panic, creates buffer for new file + buf := builder.model.ActiveBuffer() + if buf.Filename != filename { + t.Errorf("filename = %q, want %q", buf.Filename, filename) + } + + // Buffer should have 1 empty line (default for new buffers) + if buf.LineCount() != 1 { + t.Errorf("expected 1 empty line for nonexistent file, got %d", buf.LineCount()) + } + + if buf.Line(0) != "" { + t.Errorf("expected empty line, got %q", buf.Line(0)) + } + }) +} + +// ================================================== +// Builder Chain and Build Tests +// ================================================== + +func TestProgramBuilderChain(t *testing.T) { + t.Run("EmptyProgram returns builder for chaining", func(t *testing.T) { + builder := NewProgramBuilder() + result := builder.EmptyProgram() + + if result != builder { + t.Error("EmptyProgram should return the builder for chaining") + } + }) + + t.Run("FileProgram returns builder for chaining", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + builder := NewProgramBuilder() + result := builder.FileProgram(filename) + + if result != builder { + t.Error("FileProgram should return the builder for chaining") + } + }) + + t.Run("WithOpt adds program option", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + // Add a program option + opt := tea.WithAltScreen() + builder.WithOpt(opt) + + if len(builder.opts) != 1 { + t.Errorf("expected 1 option, got %d", len(builder.opts)) + } + }) + + t.Run("WithOpt returns builder for chaining", func(t *testing.T) { + builder := NewProgramBuilder() + result := builder.WithOpt(tea.WithAltScreen()) + + if result != builder { + t.Error("WithOpt should return the builder for chaining") + } + }) + + t.Run("multiple WithOpt calls accumulate options", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + builder.WithOpt(tea.WithAltScreen()) + builder.WithOpt(tea.WithMouseCellMotion()) + + if len(builder.opts) != 2 { + t.Errorf("expected 2 options, got %d", len(builder.opts)) + } + }) + + t.Run("chained calls work together", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + os.WriteFile(filename, []byte("content"), 0644) + + prog := NewProgramBuilder(). + FileProgram(filename). + WithOpt(tea.WithAltScreen()). + Build() + + if prog == nil { + t.Fatal("Build should return a program") + } + }) + + t.Run("Build returns tea.Program", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + prog := builder.Build() + + if prog == nil { + t.Fatal("Build should return a program, got nil") + } + + // Verify it's actually a tea.Program + if _, ok := interface{}(prog).(*tea.Program); !ok { + t.Errorf("Build should return *tea.Program, got %T", prog) + } + }) +} + +// ================================================== +// Model State Tests +// ================================================== + +func TestModelState(t *testing.T) { + t.Run("model has default settings", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + settings := builder.model.Settings() + if settings.TabStop == 0 { + t.Error("TabStop should be initialized") + } + }) + + t.Run("model has default registers", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + registers := builder.model.Registers() + if registers == nil { + t.Error("Registers should be initialized") + } + + // Check for default register + if _, exists := registers['"']; !exists { + t.Error("default register should exist") + } + }) + + t.Run("model starts in normal mode", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + mode := builder.model.Mode() + if mode != core.NormalMode { + t.Errorf("mode = %v, want NormalMode", mode) + } + }) + + t.Run("buffers list matches window buffer", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + os.WriteFile(filename, []byte("content"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buffers := builder.model.Buffers() + activeBuffer := builder.model.ActiveBuffer() + + if len(buffers) != 1 { + t.Fatalf("expected 1 buffer in list, got %d", len(buffers)) + } + + if buffers[0] != activeBuffer { + t.Error("buffer in list should match active buffer") + } + }) + + t.Run("active window points to active buffer", func(t *testing.T) { + builder := NewProgramBuilder() + builder.EmptyProgram() + + activeWindow := builder.model.ActiveWindow() + activeBuffer := builder.model.ActiveBuffer() + + if activeWindow.Buffer != activeBuffer { + t.Error("active window buffer should match active buffer") + } + }) +} + +// ================================================== +// Integration Tests +// ================================================== + +func TestIntegration(t *testing.T) { + t.Run("complete workflow - create empty program", func(t *testing.T) { + prog := NewProgramBuilder(). + EmptyProgram(). + WithOpt(tea.WithAltScreen()). + Build() + + if prog == nil { + t.Fatal("program should not be nil") + } + }) + + t.Run("complete workflow - load existing file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.go") + content := "package main\n\nfunc main() {\n}\n" + os.WriteFile(filename, []byte(content), 0644) + + builder := NewProgramBuilder(). + FileProgram(filename). + WithOpt(tea.WithAltScreen()) + + // Verify buffer before building + buf := builder.model.ActiveBuffer() + if buf.Filename != filename { + t.Errorf("filename = %q, want %q", buf.Filename, filename) + } + + if buf.Filetype != ".go" { + t.Errorf("filetype = %q, want .go", buf.Filetype) + } + + if buf.LineCount() != 4 { + t.Errorf("expected 4 lines, got %d", buf.LineCount()) + } + + // Build program + prog := builder.Build() + if prog == nil { + t.Fatal("program should not be nil") + } + }) + + t.Run("complete workflow - create new file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "newfile.js") + + builder := NewProgramBuilder(). + FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Filename != filename { + t.Errorf("filename = %q, want %q", buf.Filename, filename) + } + + if buf.Filetype != ".js" { + t.Errorf("filetype = %q, want .js", buf.Filetype) + } + + // New buffer has 1 empty line by default + if buf.LineCount() != 1 { + t.Errorf("expected 1 empty line for new file, got %d", buf.LineCount()) + } + + prog := builder.Build() + if prog == nil { + t.Fatal("program should not be nil") + } + }) + + t.Run("multiple file loads are independent", func(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "file1.txt") + file2 := filepath.Join(tmpDir, "file2.txt") + + os.WriteFile(file1, []byte("content 1"), 0644) + os.WriteFile(file2, []byte("content 2"), 0644) + + builder1 := NewProgramBuilder().FileProgram(file1) + builder2 := NewProgramBuilder().FileProgram(file2) + + buf1 := builder1.model.ActiveBuffer() + buf2 := builder2.model.ActiveBuffer() + + if buf1 == buf2 { + t.Error("different files should have different buffers") + } + + if buf1.Filename == buf2.Filename { + t.Error("buffers should have different filenames") + } + + if buf1.Line(0) == buf2.Line(0) { + t.Error("buffers should have different content") + } + }) +} + +// ================================================== +// Edge Cases +// ================================================== + +func TestEdgeCases(t *testing.T) { + t.Run("empty string filename", func(t *testing.T) { + builder := NewProgramBuilder() + builder.FileProgram("") + + buf := builder.model.ActiveBuffer() + if buf.Filename != "" { + t.Errorf("filename should be empty string, got %q", buf.Filename) + } + + // Should treat as new file with no name - has default 1 empty line + if buf.LineCount() != 1 { + t.Errorf("expected 1 empty line, got %d", buf.LineCount()) + } + + if buf.Line(0) != "" { + t.Errorf("expected empty line, got %q", buf.Line(0)) + } + }) + + t.Run("relative path", func(t *testing.T) { + // Create file in temp dir but use relative path + tmpDir := t.TempDir() + absPath := filepath.Join(tmpDir, "test.txt") + os.WriteFile(absPath, []byte("content"), 0644) + + // Change to temp dir + oldWd, _ := os.Getwd() + defer os.Chdir(oldWd) + os.Chdir(tmpDir) + + builder := NewProgramBuilder() + builder.FileProgram("test.txt") + + buf := builder.model.ActiveBuffer() + if buf.Filename != "test.txt" { + t.Errorf("filename = %q, want 'test.txt'", buf.Filename) + } + + if buf.LineCount() != 1 { + t.Errorf("expected 1 line, got %d", buf.LineCount()) + } + }) + + t.Run("file with only spaces", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "spaces.txt") + os.WriteFile(filename, []byte(" \n \n "), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + + // Spaces should be preserved + if buf.Line(0) != " " { + t.Errorf("line 0 = %q, want ' '", buf.Line(0)) + } + }) + + t.Run("file ending with multiple newlines", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "trailing.txt") + os.WriteFile(filename, []byte("line 1\nline 2\n\n\n"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.LineCount() != 4 { + t.Errorf("expected 4 lines, got %d", buf.LineCount()) + } + + if buf.Line(0) != "line 1" { + t.Errorf("line 0 = %q", buf.Line(0)) + } + + // Last lines should be empty + if buf.Line(2) != "" || buf.Line(3) != "" { + t.Error("trailing newlines should create empty lines") + } + }) + + t.Run("dot file", func(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, ".hidden") + os.WriteFile(filename, []byte("secret"), 0644) + + builder := NewProgramBuilder() + builder.FileProgram(filename) + + buf := builder.model.ActiveBuffer() + if buf.Filename != filename { + t.Errorf("filename = %q, want %q", buf.Filename, filename) + } + + if buf.LineCount() != 1 { + t.Errorf("expected 1 line, got %d", buf.LineCount()) + } + + if buf.Line(0) != "secret" { + t.Errorf("content = %q, want 'secret'", buf.Line(0)) + } + }) +}