github.com/aretext/aretext@v1.3.0/state/document_test.go (about) 1 package state 2 3 import ( 4 "io" 5 "os" 6 "path/filepath" 7 "testing" 8 "time" 9 10 "github.com/stretchr/testify/assert" 11 "github.com/stretchr/testify/require" 12 13 "github.com/aretext/aretext/config" 14 "github.com/aretext/aretext/syntax" 15 ) 16 17 func createTestFile(t *testing.T, contents string) (path string, cleanup func()) { 18 f, err := os.CreateTemp(t.TempDir(), "aretext-") 19 require.NoError(t, err) 20 defer f.Close() 21 22 _, err = io.WriteString(f, contents) 23 require.NoError(t, err) 24 25 cleanup = func() { os.Remove(f.Name()) } 26 return f.Name(), cleanup 27 } 28 29 func startOfDocLocator(LocatorParams) uint64 { return 0 } 30 31 func TestLoadDocumentShowStatus(t *testing.T) { 32 // Start with an empty document. 33 state := NewEditorState(100, 100, nil, nil) 34 defer state.fileWatcher.Stop() 35 assert.Equal(t, "", state.documentBuffer.textTree.String()) 36 assert.Equal(t, "", state.FileWatcher().Path()) 37 38 // Load a document. 39 path, cleanup := createTestFile(t, "abcd") 40 LoadDocument(state, path, true, startOfDocLocator) 41 42 // Expect that the text and watcher are installed. 43 assert.Equal(t, "abcd", state.documentBuffer.textTree.String()) 44 assert.Equal(t, path, state.FileWatcher().Path()) 45 46 // Expect success message. 47 assert.Contains(t, state.statusMsg.Text, "Opened") 48 assert.Equal(t, StatusMsgStyleSuccess, state.statusMsg.Style) 49 50 // Delete the test file. 51 cleanup() 52 53 // Load a non-existent path, expect error msg. 54 LoadDocument(state, path, true, startOfDocLocator) 55 defer state.fileWatcher.Stop() 56 assert.Contains(t, state.statusMsg.Text, "Could not open") 57 assert.Equal(t, StatusMsgStyleError, state.statusMsg.Style) 58 } 59 60 func TestLoadDocumentSameFile(t *testing.T) { 61 // Load the initial document. 62 path, cleanup := createTestFile(t, "abcd\nefghi\njklmnop\nqrst") 63 defer cleanup() 64 state := NewEditorState(5, 3, nil, nil) 65 defer state.fileWatcher.Stop() 66 LoadDocument(state, path, true, startOfDocLocator) 67 state.documentBuffer.cursor.position = 22 68 69 // Scroll to cursor at end of document. 70 ScrollViewToCursor(state) 71 assert.Equal(t, uint64(16), state.documentBuffer.view.textOrigin) 72 73 // Update the file with shorter text and reload. 74 err := os.WriteFile(path, []byte("ab"), 0644) 75 require.NoError(t, err) 76 ReloadDocument(state) 77 defer state.fileWatcher.Stop() 78 79 // Expect that the cursor moved back to the end of the text, 80 // and the view scrolled to make the cursor visible. 81 assert.Equal(t, "ab", state.documentBuffer.textTree.String()) 82 assert.Equal(t, uint64(1), state.documentBuffer.cursor.position) 83 assert.Equal(t, uint64(0), state.documentBuffer.view.textOrigin) 84 } 85 86 func TestLoadDocumentDifferentFile(t *testing.T) { 87 // Load the initial document. 88 path, cleanup := createTestFile(t, "abcd\nefghi\njklmnop\nqrst") 89 defer cleanup() 90 state := NewEditorState(5, 3, nil, nil) 91 defer state.fileWatcher.Stop() 92 LoadDocument(state, path, true, startOfDocLocator) 93 state.documentBuffer.cursor.position = 22 94 95 // Scroll to cursor at end of document. 96 ScrollViewToCursor(state) 97 assert.Equal(t, uint64(16), state.documentBuffer.view.textOrigin) 98 99 // Set the syntax. 100 SetSyntax(state, syntax.LanguageJson) 101 assert.Equal(t, syntax.LanguageJson, state.documentBuffer.syntaxLanguage) 102 103 // Load a new document with a shorter text. 104 path2, cleanup2 := createTestFile(t, "ab") 105 defer cleanup2() 106 LoadDocument(state, path2, true, startOfDocLocator) 107 defer state.fileWatcher.Stop() 108 109 // Expect that the cursor, view, and syntax are reset. 110 assert.Equal(t, "ab", state.documentBuffer.textTree.String()) 111 assert.Equal(t, uint64(0), state.documentBuffer.cursor.position) 112 assert.Equal(t, uint64(0), state.documentBuffer.view.textOrigin) 113 assert.Equal(t, syntax.LanguagePlaintext, state.documentBuffer.syntaxLanguage) 114 } 115 116 func TestLoadPrevDocument(t *testing.T) { 117 // Load the initial document. 118 path, cleanup := createTestFile(t, "abcd\nefghi\njklmnop\nqrst") 119 defer cleanup() 120 state := NewEditorState(5, 3, nil, nil) 121 defer state.fileWatcher.Stop() 122 LoadDocument(state, path, true, startOfDocLocator) 123 MoveCursor(state, func(LocatorParams) uint64 { 124 return 7 125 }) 126 127 // Load another document. 128 path2, cleanup2 := createTestFile(t, "xyz") 129 defer cleanup2() 130 LoadDocument(state, path2, true, startOfDocLocator) 131 defer state.fileWatcher.Stop() 132 assert.Equal(t, "xyz", state.documentBuffer.textTree.String()) 133 134 // Return to the previous document. 135 LoadPrevDocument(state) 136 assert.Equal(t, "abcd\nefghi\njklmnop\nqrst", state.documentBuffer.textTree.String()) 137 assert.Equal(t, path, state.fileWatcher.Path()) 138 assert.Equal(t, uint64(7), state.documentBuffer.cursor.position) 139 } 140 141 func TestLoadNextDocument(t *testing.T) { 142 // Load the initial document. 143 path, cleanup := createTestFile(t, "abcd\nefghi\njklmnop\nqrst") 144 defer cleanup() 145 state := NewEditorState(5, 3, nil, nil) 146 defer state.fileWatcher.Stop() 147 LoadDocument(state, path, true, startOfDocLocator) 148 MoveCursor(state, func(LocatorParams) uint64 { return 7 }) 149 150 // Load another document. 151 path2, cleanup2 := createTestFile(t, "qrs\ntuv\nwxyz") 152 defer cleanup2() 153 LoadDocument(state, path2, true, startOfDocLocator) 154 defer state.fileWatcher.Stop() 155 assert.Equal(t, path2, state.fileWatcher.Path()) 156 MoveCursor(state, func(LocatorParams) uint64 { return 5 }) 157 158 // Return to the previous document. 159 LoadPrevDocument(state) 160 assert.Equal(t, path, state.fileWatcher.Path()) 161 162 // Forward to the next document. 163 LoadNextDocument(state) 164 assert.Equal(t, path2, state.fileWatcher.Path()) 165 assert.Equal(t, "qrs\ntuv\nwxyz", state.documentBuffer.textTree.String()) 166 assert.Equal(t, uint64(5), state.documentBuffer.cursor.position) 167 } 168 169 func TestLoadDocumentIncrementLoadCount(t *testing.T) { 170 // Start with an empty document. 171 state := NewEditorState(100, 100, nil, nil) 172 defer state.fileWatcher.Stop() 173 assert.Equal(t, state.DocumentLoadCount(), 0) 174 175 // Load a document. 176 path, cleanup := createTestFile(t, "abcd") 177 LoadDocument(state, path, true, startOfDocLocator) 178 defer cleanup() 179 180 // Expect that the load count was bumped. 181 assert.Equal(t, state.DocumentLoadCount(), 1) 182 } 183 184 func TestReloadDocumentAlignCursorAndScroll(t *testing.T) { 185 // Load the initial document. 186 initialText := "abcd\nefghi\njklmnop\nqrst" 187 path, cleanup := createTestFile(t, initialText) 188 defer cleanup() 189 state := NewEditorState(5, 3, nil, nil) 190 defer state.fileWatcher.Stop() 191 LoadDocument(state, path, true, startOfDocLocator) 192 state.documentBuffer.cursor.position = 14 193 194 // Scroll to cursor at end of document. 195 ScrollViewToCursor(state) 196 assert.Equal(t, uint64(5), state.documentBuffer.view.textOrigin) 197 198 // Add some lines to the beginning of the document. 199 insertedText := "123\n456\n789\nqrs\ntuv\nwx\nyz\n" 200 err := os.WriteFile(path, []byte(insertedText+initialText), 0644) 201 require.NoError(t, err) 202 203 // Reload the document. 204 ReloadDocument(state) 205 defer state.fileWatcher.Stop() 206 207 // Expect that the cursor and scroll position moved to the 208 // equivalent line in the new document. 209 assert.Equal(t, insertedText+initialText, state.documentBuffer.textTree.String()) 210 assert.Equal(t, uint64(40), state.documentBuffer.cursor.position) 211 assert.Equal(t, uint64(31), state.documentBuffer.view.textOrigin) 212 } 213 214 func TestReloadDocumentWithMenuOpen(t *testing.T) { 215 // Load the initial document. 216 path, cleanup := createTestFile(t, "abcd\nefghi\njklmnop\nqrst") 217 defer cleanup() 218 state := NewEditorState(5, 3, nil, nil) 219 defer state.fileWatcher.Stop() 220 LoadDocument(state, path, true, startOfDocLocator) 221 222 // Open the command menu 223 ShowMenu(state, MenuStyleCommand, nil) 224 assert.Equal(t, state.InputMode(), InputModeMenu) 225 226 // Update the file with shorter text and reload. 227 err := os.WriteFile(path, []byte("ab"), 0644) 228 require.NoError(t, err) 229 ReloadDocument(state) 230 defer state.fileWatcher.Stop() 231 232 // Expect that the input mode is normal and the menu is hidden. 233 assert.Equal(t, "ab", state.documentBuffer.textTree.String()) 234 assert.Equal(t, InputModeNormal, state.InputMode()) 235 } 236 237 func TestReloadDocumentPreserveSearchQueryAndDirection(t *testing.T) { 238 testCases := []struct { 239 name string 240 direction SearchDirection 241 completeSearch bool 242 }{ 243 { 244 name: "search forward, complete search", 245 direction: SearchDirectionForward, 246 completeSearch: true, 247 }, 248 { 249 name: "search backward, complete search", 250 direction: SearchDirectionBackward, 251 completeSearch: true, 252 }, 253 { 254 name: "search forward, incomplete search", 255 direction: SearchDirectionForward, 256 completeSearch: false, 257 }, 258 { 259 name: "search backward, incomplete search", 260 direction: SearchDirectionBackward, 261 completeSearch: false, 262 }, 263 } 264 265 for _, tc := range testCases { 266 t.Run(tc.name, func(t *testing.T) { 267 // Load the initial document. 268 path, cleanup := createTestFile(t, "abcd\nefghi\njklmnop\nqrst") 269 defer cleanup() 270 state := NewEditorState(5, 3, nil, nil) 271 defer state.fileWatcher.Stop() 272 LoadDocument(state, path, true, startOfDocLocator) 273 274 // Text search. 275 StartSearch(state, tc.direction, SearchCompleteMoveCursorToMatch) 276 AppendRuneToSearchQuery(state, 'e') 277 AppendRuneToSearchQuery(state, 'f') 278 AppendRuneToSearchQuery(state, 'g') 279 if tc.completeSearch { 280 CompleteSearch(state, true) 281 } 282 283 // Update the file with shorter text and reload. 284 err := os.WriteFile(path, []byte("abcefghijk"), 0644) 285 require.NoError(t, err) 286 ReloadDocument(state) 287 defer state.fileWatcher.Stop() 288 289 // Expect we're in normal mode after reload. 290 assert.Equal(t, InputModeNormal, state.InputMode()) 291 292 // Expect that the search query and direction are preserved. 293 expectedSearch := searchState{query: "efg", direction: tc.direction} 294 if tc.completeSearch { 295 expectedSearch.history = []string{"efg"} 296 } 297 assert.Equal(t, expectedSearch, state.documentBuffer.search) 298 }) 299 } 300 } 301 302 func TestSaveDocument(t *testing.T) { 303 // Start with an empty document. 304 state := NewEditorState(100, 100, nil, nil) 305 defer state.fileWatcher.Stop() 306 307 // Load an existing document. 308 path, cleanup := createTestFile(t, "") 309 defer cleanup() 310 LoadDocument(state, path, true, startOfDocLocator) 311 312 // Modify and save the document 313 InsertRune(state, 'x') 314 SaveDocument(state) 315 316 // Expect a success message. 317 assert.Contains(t, state.statusMsg.Text, "Saved") 318 assert.Equal(t, StatusMsgStyleSuccess, state.statusMsg.Style) 319 320 // Check that the changes were persisted 321 contents, err := os.ReadFile(path) 322 require.NoError(t, err) 323 assert.Equal(t, "x\n", string(contents)) 324 } 325 326 func TestSaveDocumentIfUnsavedChanges(t *testing.T) { 327 // Start with an empty document. 328 state := NewEditorState(100, 100, nil, nil) 329 defer state.fileWatcher.Stop() 330 path := filepath.Join(t.TempDir(), "test-save-document-if-unsaved-changes.txt") 331 LoadDocument(state, path, false, func(LocatorParams) uint64 { return 0 }) 332 333 // Save the document. The file should be created even though the document is empty. 334 SaveDocumentIfUnsavedChanges(state) 335 _, err := os.Stat(path) 336 require.NoError(t, err) 337 338 // Change the document on disk so we can detect if the file changes on next save. 339 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644) 340 require.NoError(t, err) 341 defer f.Close() 342 _, err = io.WriteString(f, "abcdefg") 343 require.NoError(t, err) 344 345 // Save again, but expect that the file is NOT saved since there are no unsaved changes. 346 SaveDocumentIfUnsavedChanges(state) 347 contents, err := os.ReadFile(path) 348 require.NoError(t, err) 349 assert.Equal(t, "abcdefg", string(contents)) 350 351 // Modify and save the document, then check that the file was changed. 352 BeginUndoEntry(state) 353 InsertRune(state, 'x') 354 CommitUndoEntry(state) 355 SaveDocumentIfUnsavedChanges(state) 356 contents, err = os.ReadFile(path) 357 require.NoError(t, err) 358 assert.Equal(t, "x\n", string(contents)) 359 } 360 361 func TestAbortIfFileChanged(t *testing.T) { 362 testCases := []struct { 363 name string 364 didChange bool 365 expectAbort bool 366 }{ 367 { 368 name: "no changes should commit", 369 didChange: false, 370 expectAbort: false, 371 }, 372 { 373 name: "changes should abort", 374 didChange: true, 375 expectAbort: true, 376 }, 377 } 378 379 for _, tc := range testCases { 380 t.Run(tc.name, func(t *testing.T) { 381 // Load the initial document. 382 path, cleanup := createTestFile(t, "") 383 defer cleanup() 384 state := NewEditorState(100, 100, nil, nil) 385 defer state.fileWatcher.Stop() 386 LoadDocument(state, path, true, startOfDocLocator) 387 388 // Modify the file. 389 if tc.didChange { 390 f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 391 require.NoError(t, err) 392 defer f.Close() 393 _, err = io.WriteString(f, "test") 394 require.NoError(t, err) 395 } 396 397 // Attempt an operation, but abort if the file changed. 398 AbortIfFileChanged(state, func(state *EditorState) { 399 SetStatusMsg(state, StatusMsg{ 400 Style: StatusMsgStyleSuccess, 401 Text: "Operation executed", 402 }) 403 }) 404 405 if tc.expectAbort { 406 assert.Equal(t, StatusMsgStyleError, state.statusMsg.Style) 407 assert.Contains(t, state.statusMsg.Text, "changed since last save") 408 } else { 409 assert.Equal(t, StatusMsgStyleSuccess, state.statusMsg.Style) 410 assert.Equal(t, "Operation executed", state.statusMsg.Text) 411 } 412 }) 413 } 414 } 415 416 func TestAbortIfFileChangedNewFile(t *testing.T) { 417 dir := t.TempDir() 418 419 path := filepath.Join(dir, "aretext-does-not-exist") 420 state := NewEditorState(100, 100, nil, nil) 421 defer state.fileWatcher.Stop() 422 LoadDocument(state, path, false, startOfDocLocator) 423 424 // File doesn't exist on disk, so the operation should succeed. 425 AbortIfFileChanged(state, func(state *EditorState) { 426 SetStatusMsg(state, StatusMsg{ 427 Style: StatusMsgStyleSuccess, 428 Text: "Operation executed", 429 }) 430 }) 431 assert.Equal(t, StatusMsgStyleSuccess, state.statusMsg.Style) 432 assert.Equal(t, "Operation executed", state.statusMsg.Text) 433 434 // Create the file on disk. 435 f, err := os.Create(path) 436 require.NoError(t, err) 437 defer f.Close() 438 439 _, err = io.WriteString(f, "abcd") 440 require.NoError(t, err) 441 442 // Now the operation should abort. 443 AbortIfFileChanged(state, func(state *EditorState) { 444 SetStatusMsg(state, StatusMsg{ 445 Style: StatusMsgStyleSuccess, 446 Text: "Operation executed", 447 }) 448 }) 449 assert.Equal(t, StatusMsgStyleError, state.statusMsg.Style) 450 assert.Contains(t, state.statusMsg.Text, "changed since last save") 451 } 452 453 func TestAbortIfFileChangedExistingFileDeleted(t *testing.T) { 454 // Load the initial document. 455 path, cleanup := createTestFile(t, "abc") 456 state := NewEditorState(100, 100, nil, nil) 457 defer state.fileWatcher.Stop() 458 LoadDocument(state, path, true, startOfDocLocator) 459 460 // Delete the file. 461 cleanup() 462 463 // The operation should fail, since the file was deleted. 464 AbortIfFileChanged(state, func(state *EditorState) { 465 SetStatusMsg(state, StatusMsg{ 466 Style: StatusMsgStyleSuccess, 467 Text: "Operation executed", 468 }) 469 }) 470 assert.Equal(t, StatusMsgStyleError, state.statusMsg.Style) 471 assert.Contains(t, state.statusMsg.Text, "moved or deleted") 472 } 473 474 func TestDeduplicateCustomMenuItems(t *testing.T) { 475 // Configure custom menu items with duplicate names. 476 configRuleSet := config.RuleSet{ 477 { 478 Name: "customMenuCommands", 479 Pattern: "**", 480 Config: map[string]any{ 481 "menuCommands": []any{ 482 map[string]any{ 483 "name": "foo", 484 "shellCmd": "echo 'foo'", 485 "mode": "insert", 486 }, 487 map[string]any{ 488 "name": "bar", 489 "shellCmd": "echo 'bar'", 490 "mode": "insert", 491 }, 492 map[string]any{ 493 "name": "foo", // duplicate 494 "shellCmd": "echo 'foo2'", 495 "mode": "insert", 496 }, 497 }, 498 }, 499 }, 500 } 501 502 // Load the document. 503 path, cleanup := createTestFile(t, "") 504 state := NewEditorState(100, 100, configRuleSet, nil) 505 defer state.fileWatcher.Stop() 506 LoadDocument(state, path, true, startOfDocLocator) 507 defer cleanup() 508 509 // Show the menu and search for 'f', which should match all custom items. 510 ShowMenu(state, MenuStyleCommand, nil) 511 AppendRuneToMenuSearch(state, 'f') 512 513 // Expect that the search results include only two items, 514 // since "foo" was deduplicated. 515 results, selectedIdx := state.Menu().SearchResults() 516 assert.Equal(t, len(results), 2) 517 assert.Equal(t, selectedIdx, 0) 518 assert.Equal(t, results[0].Name, "foo") 519 assert.Equal(t, results[1].Name, "bar") 520 521 // Execute the "foo" item and wait for the shell cmd to complete. 522 ExecuteSelectedMenuItem(state) 523 select { 524 case action := <-state.TaskResultChan(): 525 action(state) 526 case <-time.After(5 * time.Second): 527 require.Fail(t, "Timed out") 528 } 529 530 // Check that the second "foo" command was invoked, which should 531 // have inserted "foo2" into the document. 532 text := state.DocumentBuffer().TextTree().String() 533 assert.Equal(t, text, "foo2\n") 534 } 535 536 func TestNewDocument(t *testing.T) { 537 tmpDir := t.TempDir() 538 path := filepath.Join(tmpDir, "test.txt") 539 540 state := NewEditorState(100, 100, nil, nil) 541 defer state.fileWatcher.Stop() 542 err := NewDocument(state, path) 543 require.NoError(t, err) 544 545 assert.Equal(t, path, state.FileWatcher().Path()) 546 } 547 548 func TestNewDocumentFileAlreadyExists(t *testing.T) { 549 path, cleanup := createTestFile(t, "abcd") 550 defer cleanup() 551 552 state := NewEditorState(100, 100, nil, nil) 553 defer state.fileWatcher.Stop() 554 err := NewDocument(state, path) 555 assert.ErrorContains(t, err, "File already exists") 556 } 557 558 func TestRenameDocument(t *testing.T) { 559 tmpDir := t.TempDir() 560 path := filepath.Join(tmpDir, "before.txt") 561 _, err := os.Create(path) 562 require.NoError(t, err) 563 564 state := NewEditorState(100, 100, nil, nil) 565 defer state.fileWatcher.Stop() 566 LoadDocument(state, path, true, startOfDocLocator) 567 568 newPath := filepath.Join(filepath.Dir(path), "renamed.txt") 569 err = RenameDocument(state, newPath) 570 require.NoError(t, err) 571 assert.Equal(t, newPath, state.FileWatcher().Path()) 572 } 573 574 func TestRenameDocumentSrcFileNotSaved(t *testing.T) { 575 tmpDir := t.TempDir() 576 path := filepath.Join(tmpDir, "test.txt") 577 578 state := NewEditorState(100, 100, nil, nil) 579 defer state.fileWatcher.Stop() 580 LoadDocument(state, path, true, startOfDocLocator) 581 582 newPath := filepath.Join(filepath.Dir(path), "renamed.txt") 583 err := RenameDocument(state, newPath) 584 require.NoError(t, err) 585 assert.Equal(t, newPath, state.FileWatcher().Path()) 586 } 587 588 func TestRenameDocumentDestFileAlreadyExists(t *testing.T) { 589 tmpDir := t.TempDir() 590 path := filepath.Join(tmpDir, "test.txt") 591 592 state := NewEditorState(100, 100, nil, nil) 593 defer state.fileWatcher.Stop() 594 LoadDocument(state, path, true, startOfDocLocator) 595 596 newPath := filepath.Join(filepath.Dir(path), "renamed.txt") 597 _, err := os.Create(newPath) 598 require.NoError(t, err) 599 600 err = RenameDocument(state, newPath) 601 assert.ErrorContains(t, err, "File already exists") 602 }