diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 4dfd5a7..f92bcaf 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -1,6 +1,6 @@ # Project Guidelines -kte is Kyle's Text Editor — a simple, fast text editor written in C++17. +kte is Kyle's Text Editor — a simple, fast text editor written in C++20. It replaces the earlier C implementation, ke (see the ke manual in `docs/ke.md`). The @@ -43,7 +43,7 @@ The file `docs/ke.md` contains the canonical reference for keybindings. ## Contributing/Development Notes -- C++ standard: C++17. +- C++ standard: C++20. - Keep dependencies minimal. - Prefer small, focused changes that preserve ke’s UX unless explicitly changing @@ -55,3 +55,4 @@ The file `docs/ke.md` contains the canonical reference for keybindings. for now). - Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`. + diff --git a/Buffer.cc b/Buffer.cc index 2e1e0b1..6190c00 100644 --- a/Buffer.cc +++ b/Buffer.cc @@ -8,6 +8,7 @@ #include #include "Buffer.h" +#include "SwapRecorder.h" #include "UndoSystem.h" #include "UndoTree.h" // For reconstructing highlighter state on copies @@ -390,6 +391,8 @@ Buffer::insert_text(int row, int col, std::string_view text) if (!text.empty()) { content_.Insert(off, text.data(), text.size()); rows_cache_dirty_ = true; + if (swap_rec_) + swap_rec_->OnInsert(row, col, text); } } @@ -443,6 +446,7 @@ Buffer::delete_text(int row, int col, std::size_t len) row = 0; if (col < 0) col = 0; + const std::size_t start = content_.LineColToByteOffset(static_cast(row), static_cast(col)); std::size_t r = static_cast(row); @@ -462,16 +466,19 @@ Buffer::delete_text(int row, int col, std::size_t len) break; // Consume newline between lines as one char, if there is a next line if (r + 1 < lc) { - if (remaining > 0) { - remaining -= 1; // the newline - r += 1; - c = 0; - } + remaining -= 1; // the newline + r += 1; + c = 0; } else { // At last line and still remaining: delete to EOF - std::size_t total = content_.Size(); - content_.Delete(start, total - start); + const std::size_t total = content_.Size(); + const std::size_t actual = (total > start) ? (total - start) : 0; + if (actual == 0) + return; + content_.Delete(start, actual); rows_cache_dirty_ = true; + if (swap_rec_) + swap_rec_->OnDelete(row, col, actual); return; } } @@ -479,8 +486,11 @@ Buffer::delete_text(int row, int col, std::size_t len) // Compute end offset at (r,c) std::size_t end = content_.LineColToByteOffset(r, c); if (end > start) { - content_.Delete(start, end - start); + const std::size_t actual = end - start; + content_.Delete(start, actual); rows_cache_dirty_ = true; + if (swap_rec_) + swap_rec_->OnDelete(row, col, actual); } } @@ -488,15 +498,18 @@ Buffer::delete_text(int row, int col, std::size_t len) void Buffer::split_line(int row, const int col) { + int c = col; if (row < 0) row = 0; - if (col < 0) - row = 0; + if (c < 0) + c = 0; const std::size_t off = content_.LineColToByteOffset(static_cast(row), - static_cast(col)); + static_cast(c)); const char nl = '\n'; content_.Insert(off, &nl, 1); rows_cache_dirty_ = true; + if (swap_rec_) + swap_rec_->OnInsert(row, c, std::string_view("\n", 1)); } @@ -508,11 +521,14 @@ Buffer::join_lines(int row) std::size_t r = static_cast(row); if (r + 1 >= content_.LineCount()) return; + const int col = static_cast(content_.GetLine(r).size()); // Delete the newline between line r and r+1 std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits::max()); // end_of_line now equals line end (clamped before newline). The newline should be exactly at this position. content_.Delete(end_of_line, 1); rows_cache_dirty_ = true; + if (swap_rec_) + swap_rec_->OnDelete(row, col, 1); } @@ -527,6 +543,12 @@ Buffer::insert_row(int row, const std::string_view text) const char nl = '\n'; content_.Insert(off + text.size(), &nl, 1); rows_cache_dirty_ = true; + if (swap_rec_) { + // Avoid allocation: emit the row text insertion (if any) and the newline insertion. + if (!text.empty()) + swap_rec_->OnInsert(row, 0, text); + swap_rec_->OnInsert(row, static_cast(text.size()), std::string_view("\n", 1)); + } } @@ -541,10 +563,15 @@ Buffer::delete_row(int row) auto range = content_.GetLineRange(r); // [start,end) // If not last line, ensure we include the separating newline by using end as-is (which points to next line start) // If last line, end may equal total_size_. We still delete [start,end) which removes the last line content. - std::size_t start = range.first; - std::size_t end = range.second; - content_.Delete(start, end - start); + const std::size_t start = range.first; + const std::size_t end = range.second; + const std::size_t actual = (end > start) ? (end - start) : 0; + if (actual == 0) + return; + content_.Delete(start, actual); rows_cache_dirty_ = true; + if (swap_rec_) + swap_rec_->OnDelete(row, 0, actual); } diff --git a/Buffer.h b/Buffer.h index f07d62e..d917b88 100644 --- a/Buffer.h +++ b/Buffer.h @@ -418,6 +418,23 @@ public: } + // In visual-line (multi-cursor) mode, the UI should highlight only the per-line + // cursor "spot" (Curx clamped to each line length), not the entire line. + [[nodiscard]] bool VisualLineSpotSelected(std::size_t y, std::size_t sx) const + { + if (!visual_line_active_) + return false; + if (y < VisualLineStartY() || y > VisualLineEndY()) + return false; + std::string_view ln = GetLineView(y); + // `GetLineView()` returns the raw range, which may include a trailing '\n'. + if (!ln.empty() && ln.back() == '\n') + ln.remove_suffix(1); + const std::size_t spot = std::min(Curx(), ln.size()); + return sx == spot; + } + + [[nodiscard]] std::string AsString() const; // Syntax highlighting integration (per-buffer) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f491da..44e8b7b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,13 +4,13 @@ project(kte) include(GNUInstallDirs) set(CMAKE_CXX_STANDARD 20) -set(KTE_VERSION "1.6.0") +set(KTE_VERSION "1.6.1") # Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.") set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.") -set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.") +set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.") set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI") option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF) option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF) @@ -300,9 +300,17 @@ if (BUILD_TESTS) tests/Test.h tests/TestHarness.h tests/test_daily_driver_harness.cc + tests/test_daily_workflows.cc tests/test_buffer_io.cc + tests/test_buffer_rows.cc + tests/test_command_semantics.cc + tests/test_kkeymap.cc + tests/test_swap_recorder.cc + tests/test_swap_writer.cc + tests/test_swap_replay.cc tests/test_piece_table.cc tests/test_search.cc + tests/test_search_replace_flow.cc tests/test_reflow_paragraph.cc tests/test_undo.cc tests/test_visual_line_mode.cc @@ -314,6 +322,8 @@ if (BUILD_TESTS) Command.cc HelpText.cc Swap.cc + KKeymap.cc + SwapRecorder.h OptimizedSearch.cc UndoNode.cc UndoTree.cc diff --git a/Command.cc b/Command.cc index 52bfd46..1b7a6e7 100644 --- a/Command.cc +++ b/Command.cc @@ -1988,21 +1988,44 @@ cmd_insert_text(CommandContext &ctx) const std::size_t sy = buf->VisualLineStartY(); const std::size_t ey = buf->VisualLineEndY(); const auto &rows = buf->Rows(); + UndoSystem *u = buf->Undo(); + std::uint64_t gid = 0; + if (u) + gid = u->BeginGroup(); + (void) gid; + + std::string ins; + if (repeat == 1) { + ins = ctx.arg; + } else { + ins.reserve(ctx.arg.size() * static_cast(repeat)); + for (int i = 0; i < repeat; ++i) + ins += ctx.arg; + } for (std::size_t yy = sy; yy <= ey; ++yy) { if (yy >= rows.size()) break; std::size_t xx = x; if (xx > rows[yy].size()) xx = rows[yy].size(); - for (int i = 0; i < repeat; ++i) { - buf->insert_text(static_cast(yy), static_cast(xx), std::string_view(ctx.arg)); - xx += ctx.arg.size(); + if (!ins.empty()) { + buf->SetCursor(xx, yy); + if (u) + u->Begin(UndoType::Insert); + buf->insert_text(static_cast(yy), static_cast(xx), std::string_view(ins)); + xx += ins.size(); + if (u) { + u->Append(std::string_view(ins)); + u->commit(); + } } if (yy == y) { cx = xx; cy = yy; } } + if (u) + u->EndGroup(); buf->SetDirty(true); buf->SetCursor(cx, cy); ensure_cursor_visible(ctx.editor, *buf); @@ -2933,26 +2956,41 @@ cmd_backspace(CommandContext &ctx) const std::size_t sy = buf->VisualLineStartY(); const std::size_t ey = buf->VisualLineEndY(); const auto &rows = buf->Rows(); - std::size_t cx = x; + std::uint64_t gid = 0; + if (u) + gid = u->BeginGroup(); + (void) gid; + std::size_t cx = x; for (std::size_t yy = sy; yy <= ey; ++yy) { if (yy >= rows.size()) break; std::size_t xx = x; if (xx > rows[yy].size()) xx = rows[yy].size(); + std::string deleted; for (int i = 0; i < repeat; ++i) { if (xx == 0) break; + const auto &rows_view = buf->Rows(); + if (yy < rows_view.size() && (xx - 1) < rows_view[yy].size()) + deleted.insert(deleted.begin(), rows_view[yy][xx - 1]); buf->delete_text(static_cast(yy), static_cast(xx - 1), 1); --xx; } + if (u && !deleted.empty()) { + buf->SetCursor(xx, yy); + u->Begin(UndoType::Delete); + u->Append(std::string_view(deleted)); + u->commit(); + } if (yy == y) cx = xx; } + if (u) + u->EndGroup(); buf->SetDirty(true); buf->SetCursor(cx, y); ensure_cursor_visible(ctx.editor, *buf); - (void) u; return true; } for (int i = 0; i < repeat; ++i) { @@ -3014,21 +3052,35 @@ cmd_delete_char(CommandContext &ctx) const std::size_t sy = buf->VisualLineStartY(); const std::size_t ey = buf->VisualLineEndY(); const auto &rows = buf->Rows(); + std::uint64_t gid = 0; + if (u) + gid = u->BeginGroup(); + (void) gid; for (std::size_t yy = sy; yy <= ey; ++yy) { if (yy >= rows.size()) break; std::size_t xx = x; if (xx > rows[yy].size()) xx = rows[yy].size(); + std::string deleted; for (int i = 0; i < repeat; ++i) { - if (xx >= buf->Rows()[yy].size()) + const auto &rows_view = buf->Rows(); + if (yy >= rows_view.size() || xx >= rows_view[yy].size()) break; + deleted.push_back(rows_view[yy][xx]); buf->delete_text(static_cast(yy), static_cast(xx), 1); } + if (u && !deleted.empty()) { + buf->SetCursor(xx, yy); + u->Begin(UndoType::Delete); + u->Append(std::string_view(deleted)); + u->commit(); + } } + if (u) + u->EndGroup(); buf->SetDirty(true); ensure_cursor_visible(ctx.editor, *buf); - (void) u; return true; } for (int i = 0; i < repeat; ++i) { @@ -3218,8 +3270,63 @@ cmd_yank(CommandContext &ctx) } ensure_at_least_one_line(*buf); int repeat = ctx.count > 0 ? ctx.count : 1; - for (int i = 0; i < repeat; ++i) { - insert_text_at_cursor(*buf, text); + std::string ins; + if (repeat == 1) { + ins = text; + } else { + ins.reserve(text.size() * static_cast(repeat)); + for (int i = 0; i < repeat; ++i) + ins += text; + } + + UndoSystem *u = buf->Undo(); + // Visual-line mode: broadcast yank to beginning-of-line on every affected line. + if (buf->VisualLineActive()) { + const std::size_t sy = buf->VisualLineStartY(); + const std::size_t ey = buf->VisualLineEndY(); + const std::size_t y0 = buf->Cury(); + + std::uint64_t gid = 0; + if (u) + gid = u->BeginGroup(); + (void) gid; + + // Iterate from bottom to top so insertions don't invalidate remaining line indices. + for (std::size_t yy = ey + 1; yy-- > sy;) { + buf->SetCursor(0, yy); + if (u) + u->Begin(UndoType::Paste); + insert_text_at_cursor(*buf, ins); + if (u) { + u->Append(std::string_view(ins)); + u->commit(); + } + } + if (u) + u->EndGroup(); + + // Keep the point on the primary cursor line (as it was before yank), at the end of the + // inserted text for that line. + std::size_t nl_count = 0; + std::size_t last_nl = std::string::npos; + for (std::size_t i = 0; i < ins.size(); ++i) { + if (ins[i] == '\n') { + ++nl_count; + last_nl = i; + } + } + const std::size_t delta_y = nl_count; + const std::size_t delta_x = (last_nl == std::string::npos) ? ins.size() : (ins.size() - last_nl - 1); + const std::size_t above = (y0 >= sy) ? (y0 - sy) : 0; + buf->SetCursor(delta_x, y0 + delta_y + above * nl_count); + } else { + if (u) + u->Begin(UndoType::Paste); + insert_text_at_cursor(*buf, ins); + if (u) { + u->Append(std::string_view(ins)); + u->commit(); + } } // Yank is a paste operation; it should clear the mark/region and any selection highlighting. buf->ClearMark(); diff --git a/Editor.cc b/Editor.cc index 91af31b..6a41b55 100644 --- a/Editor.cc +++ b/Editor.cc @@ -128,8 +128,8 @@ Editor::AddBuffer(const Buffer &buf) buffers_.push_back(buf); // Attach swap recorder if (swap_) { - buffers_.back().SetSwapRecorder(swap_.get()); swap_->Attach(&buffers_.back()); + buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back())); } if (buffers_.size() == 1) { curbuf_ = 0; @@ -143,8 +143,8 @@ Editor::AddBuffer(Buffer &&buf) { buffers_.push_back(std::move(buf)); if (swap_) { - buffers_.back().SetSwapRecorder(swap_.get()); swap_->Attach(&buffers_.back()); + buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back())); } if (buffers_.size() == 1) { curbuf_ = 0; @@ -171,8 +171,8 @@ Editor::OpenFile(const std::string &path, std::string &err) return false; // Ensure swap recorder is attached for this buffer if (swap_) { - cur.SetSwapRecorder(swap_.get()); swap_->Attach(&cur); + cur.SetSwapRecorder(swap_->RecorderFor(&cur)); swap_->NotifyFilenameChanged(cur); } // Setup highlighting using registry (extension + shebang) @@ -197,22 +197,18 @@ Editor::OpenFile(const std::string &path, std::string &err) eng->InvalidateFrom(0); } } - // Defensive: ensure any active prompt is closed after a successful open - CancelPrompt(); - return true; - } - } + // Defensive: ensure any active prompt is closed after a successful open + CancelPrompt(); + return true; + } + } Buffer b; if (!b.OpenFromFile(path, err)) { return false; } - if (swap_) { - b.SetSwapRecorder(swap_.get()); - // path is known, notify - swap_->Attach(&b); - swap_->NotifyFilenameChanged(b); - } + // NOTE: swap recorder/attach must happen after the buffer is stored in its + // final location (vector) because swap manager keys off Buffer*. // Initialize syntax highlighting by extension + shebang via registry (v2) b.EnsureHighlighter(); std::string first = ""; @@ -239,10 +235,13 @@ Editor::OpenFile(const std::string &path, std::string &err) } // Add as a new buffer and switch to it std::size_t idx = AddBuffer(std::move(b)); - SwitchTo(idx); - // Defensive: ensure any active prompt is closed after a successful open - CancelPrompt(); - return true; + if (swap_) { + swap_->NotifyFilenameChanged(buffers_[idx]); + } + SwitchTo(idx); + // Defensive: ensure any active prompt is closed after a successful open + CancelPrompt(); + return true; } @@ -284,6 +283,10 @@ Editor::CloseBuffer(std::size_t index) if (index >= buffers_.size()) { return false; } + if (swap_) { + swap_->Detach(&buffers_[index]); + buffers_[index].SetSwapRecorder(nullptr); + } buffers_.erase(buffers_.begin() + static_cast(index)); if (buffers_.empty()) { curbuf_ = 0; diff --git a/ImGuiRenderer.cc b/ImGuiRenderer.cc index f0dee1f..06bb7ee 100644 --- a/ImGuiRenderer.cc +++ b/ImGuiRenderer.cc @@ -308,14 +308,10 @@ ImGuiRenderer::Draw(Editor &ed) } // Draw selection background (over search highlight; under text) - if (sel_active || vsel_active) { + if (sel_active) { bool line_has = false; std::size_t sx = 0, ex = 0; - if (vsel_active && i >= vsel_sy && i <= vsel_ey) { - sx = 0; - ex = line.size(); - line_has = ex > sx; - } else if (i < sel_sy || i > sel_ey) { + if (i < sel_sy || i > sel_ey) { line_has = false; } else if (sel_sy == sel_ey) { sx = sel_sx; @@ -351,6 +347,30 @@ ImGuiRenderer::Draw(Editor &ed) } } } + if (vsel_active && i >= vsel_sy && i <= vsel_ey) { + // Visual-line (multi-cursor) mode: highlight only the per-line cursor spot. + const std::size_t spot_sx = std::min(buf->Curx(), line.size()); + const std::size_t rx_start = src_to_rx(spot_sx); + std::size_t rx_end = rx_start; + if (spot_sx < line.size()) { + rx_end = src_to_rx(spot_sx + 1); + } else { + // EOL spot: draw a 1-cell highlight just past the last character. + rx_end = rx_start + 1; + } + if (rx_end > coloffs_now) { + std::size_t vx0 = (rx_start > coloffs_now) + ? (rx_start - coloffs_now) + : 0; + std::size_t vx1 = rx_end - coloffs_now; + ImVec2 p0 = ImVec2(line_pos.x + static_cast(vx0) * space_w, + line_pos.y); + ImVec2 p1 = ImVec2(line_pos.x + static_cast(vx1) * space_w, + line_pos.y + line_h); + ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg); + ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); + } + } // Emit entire line to an expanded buffer (tabs -> spaces) for (std::size_t src = 0; src < line.size(); ++src) { char c = line[src]; diff --git a/KKeymap.cc b/KKeymap.cc index eebd917..57cf220 100644 --- a/KKeymap.cc +++ b/KKeymap.cc @@ -17,6 +17,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool case 'd': out = CommandId::KillLine; return true; + case 's': + out = CommandId::Save; + return true; case 'q': out = CommandId::QuitNow; return true; @@ -42,6 +45,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool case 'a': out = CommandId::MarkAllAndJumpEnd; return true; + case ' ': // C-k SPACE + out = CommandId::ToggleMark; + return true; case 'i': out = CommandId::BufferNew; // C-k i new empty buffer return true; diff --git a/README.md b/README.md index fbfe74e..7d12b2e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ Project Goals Keybindings ----------- -kte maintains ke’s command model while internals evolve. Highlights (subject to refinement): +kte maintains ke’s command model while internals evolve. Highlights ( +subject to refinement): - K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or `C-g`. @@ -52,7 +53,8 @@ See `ke.md` for the canonical ke reference retained for now. Build and Run ------------- -Prerequisites: C++17 compiler, CMake, and ncurses development headers/libs. +Prerequisites: C++20 compiler, CMake, and ncurses development +headers/libs. Dependencies by platform ------------------------ @@ -62,30 +64,38 @@ Dependencies by platform - `brew install ncurses` - Optional GUI (enable with `-DBUILD_GUI=ON`): - `brew install sdl2 freetype` - - OpenGL is provided by the system framework on macOS; no package needed. + - OpenGL is provided by the system framework on macOS; no + package needed. - Debian/Ubuntu - Terminal (default): - `sudo apt-get install -y libncurses5-dev libncursesw5-dev` - Optional GUI (enable with `-DBUILD_GUI=ON`): - - `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev` - - The `mesa-common-dev` package provides OpenGL headers/libs (`libGL`). + - + `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev` + - The `mesa-common-dev` package provides OpenGL headers/libs ( + `libGL`). - NixOS/Nix - Terminal (default): - Ad-hoc shell: `nix-shell -p cmake gcc ncurses` - Optional GUI (enable with `-DBUILD_GUI=ON`): - - Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL` - - With flakes/devshell (example `flake.nix` inputs not provided): include - `ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell. + - Ad-hoc shell: + `nix-shell -p cmake gcc ncurses SDL2 freetype libGL` + - With flakes/devshell (example `flake.nix` inputs not provided): + include + `ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your + devShell. Notes ----- -- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable it by +- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable + it by configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are installed for your platform. -- If you previously configured with GUI ON and want to disable it, reconfigure +- If you previously configured with GUI ON and want to disable it, + reconfigure the build directory with `-DBUILD_GUI=OFF`. Example build: @@ -113,7 +123,8 @@ built as `kge`) or request the GUI from `kte`: GUI build example ----------------- -To build with the optional GUI (after installing the GUI dependencies listed above): +To build with the optional GUI (after installing the GUI dependencies +listed above): ``` cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON diff --git a/Swap.cc b/Swap.cc index bd71f71..495c5dd 100644 --- a/Swap.cc +++ b/Swap.cc @@ -5,6 +5,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -18,23 +21,66 @@ namespace { constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'}; constexpr std::uint32_t VERSION = 1; -// Write all bytes in buf to fd, handling EINTR and partial writes. -static bool write_full(int fd, const void *buf, size_t len) + +static fs::path +xdg_state_home() { - const std::uint8_t *p = static_cast(buf); - while (len > 0) { - ssize_t n = ::write(fd, p, len); - if (n < 0) { - if (errno == EINTR) - continue; - return false; - } - if (n == 0) - return false; // shouldn't happen for regular files; treat as error - p += static_cast(n); - len -= static_cast(n); - } - return true; + if (const char *p = std::getenv("XDG_STATE_HOME")) { + if (*p) + return fs::path(p); + } + if (const char *home = std::getenv("HOME")) { + if (*home) + return fs::path(home) / ".local" / "state"; + } + // Last resort: still provide a stable per-user-ish location. + return fs::temp_directory_path() / "kte" / "state"; +} + + +static std::uint64_t +fnv1a64(std::string_view s) +{ + std::uint64_t h = 14695981039346656037ULL; + for (unsigned char ch: s) { + h ^= (std::uint64_t) ch; + h *= 1099511628211ULL; + } + return h; +} + + +static std::string +hex_u64(std::uint64_t v) +{ + static const char *kHex = "0123456789abcdef"; + char out[16]; + for (int i = 15; i >= 0; --i) { + out[i] = kHex[v & 0xFULL]; + v >>= 4; + } + return std::string(out, sizeof(out)); +} + + +// Write all bytes in buf to fd, handling EINTR and partial writes. +static bool +write_full(int fd, const void *buf, size_t len) +{ + const std::uint8_t *p = static_cast(buf); + while (len > 0) { + ssize_t n = ::write(fd, p, len); + if (n < 0) { + if (errno == EINTR) + continue; + return false; + } + if (n == 0) + return false; // shouldn't happen for regular files; treat as error + p += static_cast(n); + len -= static_cast(n); + } + return true; } } @@ -50,6 +96,8 @@ SwapManager::SwapManager() SwapManager::~SwapManager() { + // Best-effort: drain queued records before stopping the writer. + Flush(); running_.store(false); cv_.notify_all(); if (worker_.joinable()) @@ -62,30 +110,108 @@ SwapManager::~SwapManager() void -SwapManager::Attach(Buffer * /*buf*/) +SwapManager::Flush(Buffer *buf) { - // Stage 1: lazy-open on first record; nothing to do here. + (void) buf; // stage 1: flushes all buffers + std::unique_lock lk(mtx_); + const std::uint64_t target = next_seq_; + // Wake the writer in case it's waiting on the interval. + cv_.notify_one(); + cv_.wait(lk, [&] { + return queue_.empty() && inflight_ == 0 && last_processed_ >= target; + }); } void -SwapManager::Detach(Buffer * /*buf*/) +SwapManager::BufferRecorder::OnInsert(int row, int col, std::string_view bytes) { - // Stage 1: keep files open until manager destruction; future work can close per-buffer. + m_.RecordInsert(buf_, row, col, bytes); +} + + +void +SwapManager::BufferRecorder::OnDelete(int row, int col, std::size_t len) +{ + m_.RecordDelete(buf_, row, col, len); +} + + +SwapRecorder * +SwapManager::RecorderFor(Buffer *buf) +{ + if (!buf) + return nullptr; + std::lock_guard lg(mtx_); + auto it = recorders_.find(buf); + if (it != recorders_.end()) + return it->second.get(); + // Create on-demand. Recording calls will no-op until Attach() has been called. + auto rec = std::make_unique(*this, *buf); + SwapRecorder *ptr = rec.get(); + recorders_[buf] = std::move(rec); + return ptr; +} + + +void +SwapManager::Attach(Buffer *buf) +{ + if (!buf) + return; + std::lock_guard lg(mtx_); + JournalCtx &ctx = journals_[buf]; + if (ctx.path.empty()) + ctx.path = ComputeSidecarPath(*buf); + // Ensure a recorder exists as well. + if (recorders_.find(buf) == recorders_.end()) { + recorders_[buf] = std::make_unique(*this, *buf); + } +} + + +void +SwapManager::Detach(Buffer *buf) +{ + if (!buf) + return; + { + std::lock_guard lg(mtx_); + auto it = journals_.find(buf); + if (it != journals_.end()) { + it->second.suspended = true; + } + } + Flush(buf); + std::lock_guard lg(mtx_); + auto it = journals_.find(buf); + if (it != journals_.end()) { + close_ctx(it->second); + journals_.erase(it); + } + recorders_.erase(buf); } void SwapManager::NotifyFilenameChanged(Buffer &buf) { + { + std::lock_guard lg(mtx_); + auto it = journals_.find(&buf); + if (it == journals_.end()) + return; + it->second.suspended = true; + } + Flush(&buf); std::lock_guard lg(mtx_); auto it = journals_.find(&buf); if (it == journals_.end()) return; JournalCtx &ctx = it->second; - // Close existing file handle, update path; lazily reopen on next write close_ctx(ctx); - ctx.path = ComputeSidecarPath(buf); + ctx.path = ComputeSidecarPath(buf); + ctx.suspended = false; } @@ -93,47 +219,100 @@ void SwapManager::SetSuspended(Buffer &buf, bool on) { std::lock_guard lg(mtx_); - auto path = ComputeSidecarPath(buf); - // Create/update context for this buffer - JournalCtx &ctx = journals_[&buf]; - ctx.path = path; - ctx.suspended = on; + auto it = journals_.find(&buf); + if (it == journals_.end()) + return; + it->second.suspended = on; } SwapManager::SuspendGuard::SuspendGuard(SwapManager &m, Buffer *b) : m_(m), buf_(b), prev_(false) { - // Suspend recording while guard is alive - if (buf_) - m_.SetSuspended(*buf_, true); + if (!buf_) + return; + { + std::lock_guard lg(m_.mtx_); + auto it = m_.journals_.find(buf_); + if (it != m_.journals_.end()) { + prev_ = it->second.suspended; + it->second.suspended = true; + } + } } SwapManager::SuspendGuard::~SuspendGuard() { - if (buf_) - m_.SetSuspended(*buf_, false); + if (!buf_) + return; + std::lock_guard lg(m_.mtx_); + auto it = m_.journals_.find(buf_); + if (it != m_.journals_.end()) { + it->second.suspended = prev_; + } } std::string SwapManager::ComputeSidecarPath(const Buffer &buf) { - if (buf.IsFileBacked() || !buf.Filename().empty()) { + // Always place swap under an XDG home-appropriate state directory. + // This avoids cluttering working directories and prevents stomping on + // swap files when multiple different paths share the same basename. + fs::path root = xdg_state_home() / "kte" / "swap"; + + auto encode_path = [](std::string s) -> std::string { + // Turn an absolute path like "/home/kyle/tmp/test.txt" into + // "home!kyle!tmp!test.txt" so swap files are human-identifiable. + // + // Notes: + // - We strip a single leading path separator so absolute paths don't start with '!'. + // - We replace both '/' and '\\' with '!'. + // - We leave other characters as-is (spaces are OK on POSIX). + if (!s.empty() && (s[0] == '/' || s[0] == '\\')) + s.erase(0, 1); + for (char &ch: s) { + if (ch == '/' || ch == '\\') + ch = '!'; + } + return s; + }; + + if (!buf.Filename().empty()) { fs::path p(buf.Filename()); - fs::path dir = p.parent_path(); - std::string base = p.filename().string(); - std::string side = "." + base + ".kte.swp"; - return (dir / side).string(); + std::string key; + try { + key = fs::weakly_canonical(p).string(); + } catch (...) { + try { + key = fs::absolute(p).string(); + } catch (...) { + key = buf.Filename(); + } + } + std::string encoded = encode_path(key); + if (!encoded.empty()) { + std::string name = encoded + ".swp"; + // Avoid filesystem/path length issues; fall back to hashed naming. + // NAME_MAX is often 255 on POSIX, but keep extra headroom. + if (name.size() <= 200) { + return (root / name).string(); + } + } + + // Fallback: stable, shorter name based on basename + hash. + std::string base = p.filename().string(); + const std::string name = base + "." + hex_u64(fnv1a64(key)) + ".swp"; + return (root / name).string(); } - // unnamed: $TMPDIR/kte/unnamed-.kte.swp (best-effort) - const char *tmp = std::getenv("TMPDIR"); - fs::path t = tmp ? fs::path(tmp) : fs::temp_directory_path(); - fs::path d = t / "kte"; - char bufptr[32]; - std::snprintf(bufptr, sizeof(bufptr), "%p", (const void *) &buf); - return (d / (std::string("unnamed-") + bufptr + ".kte.swp")).string(); + + // Unnamed buffers: unique within the process. + static std::atomic ctr{0}; + const std::uint64_t n = ++ctr; + const int pid = (int) ::getpid(); + const std::string name = "unnamed-" + std::to_string(pid) + "-" + std::to_string(n) + ".swp"; + return (root / name).string(); } @@ -163,53 +342,69 @@ SwapManager::ensure_parent_dir(const std::string &path) bool -SwapManager::write_header(JournalCtx &ctx) +SwapManager::write_header(int fd) { - if (ctx.fd < 0) + if (fd < 0) return false; - // Write a simple 64-byte header + // Fixed 64-byte header (v1) + // [magic 8][version u32][flags u32][created_time u64][reserved/padding] std::uint8_t hdr[64]; std::memset(hdr, 0, sizeof(hdr)); std::memcpy(hdr, MAGIC, 8); - std::uint32_t ver = VERSION; - std::memcpy(hdr + 8, &ver, sizeof(ver)); + // version (little-endian) + hdr[8] = static_cast(VERSION & 0xFFu); + hdr[9] = static_cast((VERSION >> 8) & 0xFFu); + hdr[10] = static_cast((VERSION >> 16) & 0xFFu); + hdr[11] = static_cast((VERSION >> 24) & 0xFFu); + // flags = 0 + // created_time (unix seconds; little-endian) std::uint64_t ts = static_cast(std::time(nullptr)); - std::memcpy(hdr + 16, &ts, sizeof(ts)); - ssize_t w = ::write(ctx.fd, hdr, sizeof(hdr)); - return (w == (ssize_t) sizeof(hdr)); + put_le64(hdr + 16, ts); + return write_full(fd, hdr, sizeof(hdr)); } bool -SwapManager::open_ctx(JournalCtx &ctx) +SwapManager::open_ctx(JournalCtx &ctx, const std::string &path) { if (ctx.fd >= 0) return true; - if (!ensure_parent_dir(ctx.path)) + if (!ensure_parent_dir(path)) return false; - // Create or open with 0600 perms - int fd = ::open(ctx.path.c_str(), O_CREAT | O_RDWR, 0600); + int flags = O_CREAT | O_WRONLY | O_APPEND; +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif + int fd = ::open(path.c_str(), flags, 0600); if (fd < 0) return false; - // Detect if file is new/empty to write header + // Ensure permissions even if file already existed. + (void) ::fchmod(fd, 0600); struct stat st{}; if (fstat(fd, &st) != 0) { ::close(fd); return false; } - ctx.fd = fd; - ctx.file = fdopen(fd, "ab"); - if (!ctx.file) { + // If an existing file is too small to contain the fixed header, truncate + // and restart. + if (st.st_size > 0 && st.st_size < 64) { ::close(fd); - ctx.fd = -1; - return false; + int tflags = O_CREAT | O_WRONLY | O_TRUNC | O_APPEND; +#ifdef O_CLOEXEC + tflags |= O_CLOEXEC; +#endif + fd = ::open(path.c_str(), tflags, 0600); + if (fd < 0) + return false; + (void) ::fchmod(fd, 0600); + st.st_size = 0; } + ctx.fd = fd; + ctx.path = path; if (st.st_size == 0) { - ctx.header_ok = write_header(ctx); + ctx.header_ok = write_header(fd); } else { - ctx.header_ok = true; // trust existing file for stage 1 - // Seek to end to append - ::lseek(ctx.fd, 0, SEEK_END); + ctx.header_ok = true; // stage 1: trust existing header } return ctx.header_ok; } @@ -218,16 +413,12 @@ SwapManager::open_ctx(JournalCtx &ctx) void SwapManager::close_ctx(JournalCtx &ctx) { - if (ctx.file) { - std::fflush((FILE *) ctx.file); - ::fsync(ctx.fd); - std::fclose((FILE *) ctx.file); - ctx.file = nullptr; - } if (ctx.fd >= 0) { + (void) ::fsync(ctx.fd); ::close(ctx.fd); ctx.fd = -1; } + ctx.header_ok = false; } @@ -240,35 +431,48 @@ SwapManager::crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed for (std::uint32_t i = 0; i < 256; ++i) { std::uint32_t c = i; for (int j = 0; j < 8; ++j) - c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); + c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); table[i] = c; } inited = true; } std::uint32_t c = ~seed; for (std::size_t i = 0; i < len; ++i) - c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8); + c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8); return ~c; } void -SwapManager::put_varu64(std::vector &out, std::uint64_t v) +SwapManager::put_le32(std::vector &out, std::uint32_t v) { - while (v >= 0x80) { - out.push_back(static_cast(v) | 0x80); - v >>= 7; - } - out.push_back(static_cast(v)); + out.push_back(static_cast(v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); + out.push_back(static_cast((v >> 16) & 0xFFu)); + out.push_back(static_cast((v >> 24) & 0xFFu)); } void -SwapManager::put_u24(std::uint8_t dst[3], std::uint32_t v) +SwapManager::put_le64(std::uint8_t *dst, std::uint64_t v) { - dst[0] = static_cast((v >> 16) & 0xFF); - dst[1] = static_cast((v >> 8) & 0xFF); - dst[2] = static_cast(v & 0xFF); + dst[0] = static_cast(v & 0xFFu); + dst[1] = static_cast((v >> 8) & 0xFFu); + dst[2] = static_cast((v >> 16) & 0xFFu); + dst[3] = static_cast((v >> 24) & 0xFFu); + dst[4] = static_cast((v >> 32) & 0xFFu); + dst[5] = static_cast((v >> 40) & 0xFFu); + dst[6] = static_cast((v >> 48) & 0xFFu); + dst[7] = static_cast((v >> 56) & 0xFFu); +} + + +void +SwapManager::put_u24_le(std::uint8_t dst[3], std::uint32_t v) +{ + dst[0] = static_cast(v & 0xFFu); + dst[1] = static_cast((v >> 8) & 0xFFu); + dst[2] = static_cast((v >> 16) & 0xFFu); } @@ -277,6 +481,7 @@ SwapManager::enqueue(Pending &&p) { { std::lock_guard lg(mtx_); + p.seq = ++next_seq_; queue_.emplace_back(std::move(p)); } cv_.notify_one(); @@ -288,16 +493,20 @@ SwapManager::RecordInsert(Buffer &buf, int row, int col, std::string_view text) { { std::lock_guard lg(mtx_); - if (journals_[&buf].suspended) + auto it = journals_.find(&buf); + if (it == journals_.end() || it->second.suspended) return; } Pending p; p.buf = &buf; p.type = SwapRecType::INS; - // payload: varint row, varint col, varint len, bytes - put_varu64(p.payload, static_cast(std::max(0, row))); - put_varu64(p.payload, static_cast(std::max(0, col))); - put_varu64(p.payload, static_cast(text.size())); + // payload v1: [encver u8=1][row u32][col u32][nbytes u32][bytes] + if (text.size() > 0xFFFFFFFFu) + return; + p.payload.push_back(1); + put_le32(p.payload, static_cast(std::max(0, row))); + put_le32(p.payload, static_cast(std::max(0, col))); + put_le32(p.payload, static_cast(text.size())); p.payload.insert(p.payload.end(), reinterpret_cast(text.data()), reinterpret_cast(text.data()) + text.size()); enqueue(std::move(p)); @@ -309,15 +518,20 @@ SwapManager::RecordDelete(Buffer &buf, int row, int col, std::size_t len) { { std::lock_guard lg(mtx_); - if (journals_[&buf].suspended) + auto it = journals_.find(&buf); + if (it == journals_.end() || it->second.suspended) return; } + if (len > 0xFFFFFFFFu) + return; Pending p; p.buf = &buf; p.type = SwapRecType::DEL; - put_varu64(p.payload, static_cast(std::max(0, row))); - put_varu64(p.payload, static_cast(std::max(0, col))); - put_varu64(p.payload, static_cast(len)); + // payload v1: [encver u8=1][row u32][col u32][len u32] + p.payload.push_back(1); + put_le32(p.payload, static_cast(std::max(0, row))); + put_le32(p.payload, static_cast(std::max(0, col))); + put_le32(p.payload, static_cast(len)); enqueue(std::move(p)); } @@ -327,14 +541,17 @@ SwapManager::RecordSplit(Buffer &buf, int row, int col) { { std::lock_guard lg(mtx_); - if (journals_[&buf].suspended) + auto it = journals_.find(&buf); + if (it == journals_.end() || it->second.suspended) return; } Pending p; p.buf = &buf; p.type = SwapRecType::SPLIT; - put_varu64(p.payload, static_cast(std::max(0, row))); - put_varu64(p.payload, static_cast(std::max(0, col))); + // payload v1: [encver u8=1][row u32][col u32] + p.payload.push_back(1); + put_le32(p.payload, static_cast(std::max(0, row))); + put_le32(p.payload, static_cast(std::max(0, col))); enqueue(std::move(p)); } @@ -344,13 +561,16 @@ SwapManager::RecordJoin(Buffer &buf, int row) { { std::lock_guard lg(mtx_); - if (journals_[&buf].suspended) + auto it = journals_.find(&buf); + if (it == journals_.end() || it->second.suspended) return; } Pending p; p.buf = &buf; p.type = SwapRecType::JOIN; - put_varu64(p.payload, static_cast(std::max(0, row))); + // payload v1: [encver u8=1][row u32] + p.payload.push_back(1); + put_le32(p.payload, static_cast(std::max(0, row))); enqueue(std::move(p)); } @@ -358,59 +578,91 @@ SwapManager::RecordJoin(Buffer &buf, int row) void SwapManager::writer_loop() { - while (running_.load()) { + for (;;) { std::vector batch; { std::unique_lock lk(mtx_); if (queue_.empty()) { + if (!running_.load()) + break; cv_.wait_for(lk, std::chrono::milliseconds(cfg_.flush_interval_ms)); } if (!queue_.empty()) { batch.swap(queue_); + inflight_ += batch.size(); } } if (batch.empty()) continue; - // Group by buffer path to minimize fsyncs for (const Pending &p: batch) { process_one(p); + { + std::lock_guard lg(mtx_); + if (p.seq > last_processed_) + last_processed_ = p.seq; + if (inflight_ > 0) + --inflight_; + } + cv_.notify_all(); } - // Throttled fsync: best-effort - // Iterate unique contexts and fsync if needed - // For stage 1, fsync all once per interval + // Throttled fsync: best-effort (grouped) + std::vector to_sync; std::uint64_t now = now_ns(); - for (auto &kv: journals_) { - JournalCtx &ctx = kv.second; - if (ctx.fd >= 0) { - if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >= cfg_. - fsync_interval_ms) { - ::fsync(ctx.fd); - ctx.last_fsync_ns = now; + { + std::lock_guard lg(mtx_); + for (auto &kv: journals_) { + JournalCtx &ctx = kv.second; + if (ctx.fd >= 0) { + if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >= + cfg_.fsync_interval_ms) { + ctx.last_fsync_ns = now; + to_sync.push_back(ctx.fd); + } } } } + for (int fd: to_sync) { + (void) ::fsync(fd); + } } + // Wake any waiters. + cv_.notify_all(); } void SwapManager::process_one(const Pending &p) { + if (!p.buf) + return; Buffer &buf = *p.buf; - // Resolve context by path derived from buffer - std::string path = ComputeSidecarPath(buf); - // Get or create context keyed by this buffer pointer (stage 1 simplification) - JournalCtx &ctx = journals_[p.buf]; - if (ctx.path.empty()) - ctx.path = path; - if (!open_ctx(ctx)) + + JournalCtx *ctxp = nullptr; + std::string path; + { + std::lock_guard lg(mtx_); + auto it = journals_.find(p.buf); + if (it == journals_.end()) + return; + if (it->second.suspended) + return; + if (it->second.path.empty()) + it->second.path = ComputeSidecarPath(buf); + path = it->second.path; + ctxp = &it->second; + } + if (!ctxp) + return; + if (!open_ctx(*ctxp, path)) + return; + if (p.payload.size() > 0xFFFFFFu) return; // Build record: [type u8][len u24][payload][crc32 u32] std::uint8_t len3[3]; - put_u24(len3, static_cast(p.payload.size())); + put_u24_le(len3, static_cast(p.payload.size())); std::uint8_t head[4]; head[0] = static_cast(p.type); @@ -422,13 +674,170 @@ SwapManager::process_one(const Pending &p) c = crc32(head, sizeof(head), c); if (!p.payload.empty()) c = crc32(p.payload.data(), p.payload.size(), c); + std::uint8_t crcbytes[4]; + crcbytes[0] = static_cast(c & 0xFFu); + crcbytes[1] = static_cast((c >> 8) & 0xFFu); + crcbytes[2] = static_cast((c >> 16) & 0xFFu); + crcbytes[3] = static_cast((c >> 24) & 0xFFu); - // Write (handle partial writes and check results) - bool ok = write_full(ctx.fd, head, sizeof(head)); - if (ok && !p.payload.empty()) - ok = write_full(ctx.fd, p.payload.data(), p.payload.size()); - if (ok) - ok = write_full(ctx.fd, &c, sizeof(c)); - (void) ok; // stage 1: best-effort; future work could mark ctx error state + // Write (handle partial writes and check results) + bool ok = write_full(ctxp->fd, head, sizeof(head)); + if (ok && !p.payload.empty()) + ok = write_full(ctxp->fd, p.payload.data(), p.payload.size()); + if (ok) + ok = write_full(ctxp->fd, crcbytes, sizeof(crcbytes)); + (void) ok; // stage 1: best-effort; future work could mark ctx error state +} + + +static bool +read_exact(std::ifstream &in, void *dst, std::size_t n) +{ + in.read(static_cast(dst), static_cast(n)); + return in.good() && static_cast(in.gcount()) == n; +} + + +static std::uint32_t +read_le32(const std::uint8_t b[4]) +{ + return (std::uint32_t) b[0] | ((std::uint32_t) b[1] << 8) | ((std::uint32_t) b[2] << 16) | ( + (std::uint32_t) b[3] << 24); +} + + +static bool +parse_u32_le(const std::vector &p, std::size_t &off, std::uint32_t &out) +{ + if (off + 4 > p.size()) + return false; + out = (std::uint32_t) p[off] | ((std::uint32_t) p[off + 1] << 8) | ((std::uint32_t) p[off + 2] << 16) | + ((std::uint32_t) p[off + 3] << 24); + off += 4; + return true; +} + + +bool +SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err) +{ + err.clear(); + std::ifstream in(swap_path, std::ios::binary); + if (!in) { + err = "Failed to open swap file for replay: " + swap_path; + return false; + } + + std::uint8_t hdr[64]; + if (!read_exact(in, hdr, sizeof(hdr))) { + err = "Swap file truncated (header): " + swap_path; + return false; + } + if (std::memcmp(hdr, MAGIC, 8) != 0) { + err = "Swap file has bad magic: " + swap_path; + return false; + } + const std::uint32_t ver = read_le32(hdr + 8); + if (ver != VERSION) { + err = "Unsupported swap version: " + std::to_string(ver); + return false; + } + + for (;;) { + std::uint8_t head[4]; + in.read(reinterpret_cast(head), sizeof(head)); + const std::size_t got_head = static_cast(in.gcount()); + if (got_head == 0 && in.eof()) { + return true; // clean EOF + } + if (got_head != sizeof(head)) { + err = "Swap file truncated (record header): " + swap_path; + return false; + } + + const SwapRecType type = static_cast(head[0]); + const std::size_t len = (std::size_t) head[1] | ((std::size_t) head[2] << 8) | ( + (std::size_t) head[3] << 16); + std::vector payload; + payload.resize(len); + if (len > 0 && !read_exact(in, payload.data(), len)) { + err = "Swap file truncated (payload): " + swap_path; + return false; + } + std::uint8_t crcbytes[4]; + if (!read_exact(in, crcbytes, sizeof(crcbytes))) { + err = "Swap file truncated (crc): " + swap_path; + return false; + } + const std::uint32_t want_crc = read_le32(crcbytes); + std::uint32_t got_crc = 0; + got_crc = crc32(head, sizeof(head), got_crc); + if (!payload.empty()) + got_crc = crc32(payload.data(), payload.size(), got_crc); + if (got_crc != want_crc) { + err = "Swap file CRC mismatch: " + swap_path; + return false; + } + + // Apply record + std::size_t off = 0; + if (payload.empty()) { + err = "Swap record missing payload"; + return false; + } + const std::uint8_t encver = payload[off++]; + if (encver != 1) { + err = "Unsupported swap payload encoding"; + return false; + } + switch (type) { + case SwapRecType::INS: { + std::uint32_t row = 0, col = 0, nbytes = 0; + if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le( + payload, off, nbytes)) { + err = "Malformed INS payload"; + return false; + } + if (off + nbytes > payload.size()) { + err = "Truncated INS payload bytes"; + return false; + } + buf.insert_text((int) row, (int) col, + std::string_view(reinterpret_cast(payload.data() + off), nbytes)); + break; + } + case SwapRecType::DEL: { + std::uint32_t row = 0, col = 0, dlen = 0; + if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le( + payload, off, dlen)) { + err = "Malformed DEL payload"; + return false; + } + buf.delete_text((int) row, (int) col, (std::size_t) dlen); + break; + } + case SwapRecType::SPLIT: { + std::uint32_t row = 0, col = 0; + if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col)) { + err = "Malformed SPLIT payload"; + return false; + } + buf.split_line((int) row, (int) col); + break; + } + case SwapRecType::JOIN: { + std::uint32_t row = 0; + if (!parse_u32_le(payload, off, row)) { + err = "Malformed JOIN payload"; + return false; + } + buf.join_lines((int) row); + break; + } + default: + // Ignore unknown types for forward-compat in stage 1 + break; + } + } } } // namespace kte \ No newline at end of file diff --git a/Swap.h b/Swap.h index 194b106..64817d1 100644 --- a/Swap.h +++ b/Swap.h @@ -7,11 +7,14 @@ #include #include #include +#include #include #include #include #include +#include "SwapRecorder.h" + class Buffer; namespace kte { @@ -31,30 +34,12 @@ struct SwapConfig { unsigned fsync_interval_ms{1000}; // at most once per second }; -// Lightweight interface that Buffer can call without depending on full manager impl -class SwapRecorder { -public: - virtual ~SwapRecorder() = default; - - virtual void RecordInsert(Buffer &buf, int row, int col, std::string_view text) = 0; - - virtual void RecordDelete(Buffer &buf, int row, int col, std::size_t len) = 0; - - virtual void RecordSplit(Buffer &buf, int row, int col) = 0; - - virtual void RecordJoin(Buffer &buf, int row) = 0; - - virtual void NotifyFilenameChanged(Buffer &buf) = 0; - - virtual void SetSuspended(Buffer &buf, bool on) = 0; -}; - // SwapManager manages sidecar swap files and a single background writer thread. -class SwapManager final : public SwapRecorder { +class SwapManager final { public: SwapManager(); - ~SwapManager() override; + ~SwapManager(); // Attach a buffer to begin journaling. Safe to call multiple times; idempotent. void Attach(Buffer *buf); @@ -62,17 +47,34 @@ public: // Detach and close journal. void Detach(Buffer *buf); - // SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs) - void NotifyFilenameChanged(Buffer &buf) override; + // Block until all currently queued records have been written. + // If buf is non-null, flushes all records (stage 1) but is primarily intended + // for tests and shutdown. + void Flush(Buffer *buf = nullptr); - // SwapRecorder - void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override; + // Obtain a per-buffer recorder adapter that emits records for that buffer. + // The returned pointer is owned by the SwapManager and remains valid until + // Detach(buf) or SwapManager destruction. + SwapRecorder *RecorderFor(Buffer *buf); - void RecordDelete(Buffer &buf, int row, int col, std::size_t len) override; + // Notify that the buffer's filename changed (e.g., SaveAs) + void NotifyFilenameChanged(Buffer &buf); - void RecordSplit(Buffer &buf, int row, int col) override; + // Replay a swap journal into an already-open buffer. + // On success, the buffer content reflects all valid journal records. + // On failure (corrupt/truncated/invalid), the buffer is left in whatever + // state results from applying records up to the failure point; callers should + // treat this as a recovery failure and surface `err`. + static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err); - void RecordJoin(Buffer &buf, int row) override; + // Test-only hook to keep swap path logic centralized. + // (Avoid duplicating naming rules in unit tests.) +#ifdef KTE_TESTS + static std::string ComputeSwapPathForTests(const Buffer &buf) + { + return ComputeSidecarPath(buf); + } +#endif // RAII guard to suspend recording for internal operations class SuspendGuard { @@ -88,12 +90,32 @@ public: }; // Per-buffer toggle - void SetSuspended(Buffer &buf, bool on) override; + void SetSuspended(Buffer &buf, bool on); private: + class BufferRecorder final : public SwapRecorder { + public: + BufferRecorder(SwapManager &m, Buffer &b) : m_(m), buf_(b) {} + + void OnInsert(int row, int col, std::string_view bytes) override; + + void OnDelete(int row, int col, std::size_t len) override; + + private: + SwapManager &m_; + Buffer &buf_; + }; + + void RecordInsert(Buffer &buf, int row, int col, std::string_view text); + + void RecordDelete(Buffer &buf, int row, int col, std::size_t len); + + void RecordSplit(Buffer &buf, int row, int col); + + void RecordJoin(Buffer &buf, int row); + struct JournalCtx { std::string path; - void *file{nullptr}; // FILE* int fd{-1}; bool header_ok{false}; bool suspended{false}; @@ -106,6 +128,7 @@ private: SwapRecType type{SwapRecType::INS}; std::vector payload; // framed payload only bool urgent_flush{false}; + std::uint64_t seq{0}; }; // Helpers @@ -115,17 +138,19 @@ private: static bool ensure_parent_dir(const std::string &path); - static bool write_header(JournalCtx &ctx); + static bool write_header(int fd); - static bool open_ctx(JournalCtx &ctx); + static bool open_ctx(JournalCtx &ctx, const std::string &path); static void close_ctx(JournalCtx &ctx); static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0); - static void put_varu64(std::vector &out, std::uint64_t v); + static void put_le32(std::vector &out, std::uint32_t v); - static void put_u24(std::uint8_t dst[3], std::uint32_t v); + static void put_le64(std::uint8_t dst[8], std::uint64_t v); + + static void put_u24_le(std::uint8_t dst[3], std::uint32_t v); void enqueue(Pending &&p); @@ -136,9 +161,13 @@ private: // State SwapConfig cfg_{}; std::unordered_map journals_; + std::unordered_map > recorders_; std::mutex mtx_; std::condition_variable cv_; std::vector queue_; + std::uint64_t next_seq_{0}; + std::uint64_t last_processed_{0}; + std::uint64_t inflight_{0}; std::atomic running_{false}; std::thread worker_; }; diff --git a/SwapRecorder.h b/SwapRecorder.h new file mode 100644 index 0000000..fe11973 --- /dev/null +++ b/SwapRecorder.h @@ -0,0 +1,19 @@ +// SwapRecorder.h - minimal swap journal recording interface for Buffer mutations +#pragma once + +#include +#include + +namespace kte { +// SwapRecorder is a tiny, non-blocking callback interface. +// Implementations must return quickly; Buffer calls these hooks after a +// mutation succeeds. +class SwapRecorder { +public: + virtual ~SwapRecorder() = default; + + virtual void OnInsert(int row, int col, std::string_view bytes) = 0; + + virtual void OnDelete(int row, int col, std::size_t len) = 0; +}; +} // namespace kte \ No newline at end of file diff --git a/TerminalRenderer.cc b/TerminalRenderer.cc index 4f5ad39..1a8d1e0 100644 --- a/TerminalRenderer.cc +++ b/TerminalRenderer.cc @@ -126,12 +126,7 @@ TerminalRenderer::Draw(Editor &ed) const bool vsel_active = buf->VisualLineActive(); const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0; const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0; - auto is_src_in_sel = [&](std::size_t y, std::size_t sx) -> bool { - (void) sx; - if (vsel_active) { - if (y >= vsel_sy && y <= vsel_ey) - return true; - } + auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool { if (!sel_active) return false; if (y < sel_sy || y > sel_ey) @@ -146,9 +141,48 @@ TerminalRenderer::Draw(Editor &ed) }; int written = 0; if (li < lines.size()) { - std::string line = static_cast(lines[li]); - src_i = 0; - render_col = 0; + std::string line = static_cast(lines[li]); + const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey; + const std::size_t vsel_spot_src = vsel_on_line + ? std::min(buf->Curx(), line.size()) + : 0; + const bool vsel_spot_is_eol = vsel_on_line && vsel_spot_src == line.size(); + std::size_t vsel_line_rx = 0; + if (vsel_spot_is_eol) { + // Compute the rendered (column) width of the line so we can highlight a + // single cell at EOL when the spot falls beyond the last character. + std::size_t rc = 0; + std::size_t si = 0; + while (si < line.size()) { + wchar_t wch = L' '; + int wch_len = 1; + std::mbstate_t state = std::mbstate_t(); + size_t res = std::mbrtowc(&wch, &line[si], line.size() - si, &state); + if (res == (size_t) -1 || res == (size_t) -2) { + wch = static_cast(line[si]); + wch_len = 1; + } else if (res == 0) { + wch = L'\0'; + wch_len = 1; + } else { + wch_len = static_cast(res); + } + if (wch == L'\t') { + constexpr std::size_t tab_width = 8; + const std::size_t next_tab = tab_width - (rc % tab_width); + rc += next_tab; + } else { + int w = wcwidth(wch); + if (w < 0) + w = 1; + rc += static_cast(w); + } + si += static_cast(wch_len); + } + vsel_line_rx = rc; + } + src_i = 0; + render_col = 0; // Syntax highlighting: fetch per-line spans (sanitized copy) std::vector sane_spans; if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()-> @@ -247,7 +281,11 @@ TerminalRenderer::Draw(Editor &ed) } // Now render visible spaces while (next_tab > 0 && written < cols) { - bool in_sel = is_src_in_sel(li, src_i); + bool in_mark = is_src_in_mark_sel(li, src_i); + bool in_vsel = + vsel_on_line && !vsel_spot_is_eol && src_i == + vsel_spot_src; + bool in_sel = in_mark || in_vsel; bool in_hl = search_mode && is_src_in_hl(src_i); bool in_cur = has_current && li == cur_my && src_i >= cur_mx @@ -297,7 +335,16 @@ TerminalRenderer::Draw(Editor &ed) break; } - bool in_sel = from_src && is_src_in_sel(li, src_i); + bool in_mark = from_src && is_src_in_mark_sel(li, src_i); + bool in_vsel = false; + if (vsel_on_line) { + if (from_src) { + in_vsel = !vsel_spot_is_eol && src_i == vsel_spot_src; + } else { + in_vsel = vsel_spot_is_eol && render_col == vsel_line_rx; + } + } + bool in_sel = in_mark || in_vsel; bool in_hl = search_mode && from_src && is_src_in_hl(src_i); bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx && src_i < cur_mend; diff --git a/UndoNode.h b/UndoNode.h index 4d8ea20..5f2e8e9 100644 --- a/UndoNode.h +++ b/UndoNode.h @@ -15,6 +15,7 @@ struct UndoNode { UndoType type{}; int row{}; int col{}; + std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group std::string text; UndoNode *parent = nullptr; // previous state; null means pre-first-edit UndoNode *child = nullptr; // next in current timeline diff --git a/UndoSystem.cc b/UndoSystem.cc index f4a2966..b905c9b 100644 --- a/UndoSystem.cc +++ b/UndoSystem.cc @@ -8,6 +8,25 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree) : buf_(&owner), tree_(tree) {} +std::uint64_t +UndoSystem::BeginGroup() +{ + // Ensure any pending typed run is sealed so the group is a distinct undo step. + commit(); + if (active_group_id_ == 0) + active_group_id_ = next_group_id_++; + return active_group_id_; +} + + +void +UndoSystem::EndGroup() +{ + commit(); + active_group_id_ = 0; +} + + void UndoSystem::Begin(UndoType type) { @@ -64,10 +83,11 @@ UndoSystem::Begin(UndoType type) } // Start a new pending node. - tree_.pending = new UndoNode{}; - tree_.pending->type = type; - tree_.pending->row = row; - tree_.pending->col = col; + tree_.pending = new UndoNode{}; + tree_.pending->type = type; + tree_.pending->row = row; + tree_.pending->col = col; + tree_.pending->group_id = active_group_id_; tree_.pending->text.clear(); tree_.pending->parent = nullptr; tree_.pending->child = nullptr; @@ -158,8 +178,12 @@ UndoSystem::undo() if (!tree_.current) return; debug_log("undo"); - apply(tree_.current, -1); - tree_.current = tree_.current->parent; + const std::uint64_t gid = tree_.current->group_id; + do { + UndoNode *node = tree_.current; + apply(node, -1); + tree_.current = node->parent; + } while (gid != 0 && tree_.current && tree_.current->group_id == gid); update_dirty_flag(); } @@ -195,8 +219,16 @@ UndoSystem::redo(int branch_index) } debug_log("redo"); - apply(*head, +1); - tree_.current = *head; + UndoNode *node = *head; + const std::uint64_t gid = node->group_id; + apply(node, +1); + tree_.current = node; + while (gid != 0 && tree_.current && tree_.current->child + && tree_.current->child->group_id == gid) { + UndoNode *child = tree_.current->child; + apply(child, +1); + tree_.current = child; + } update_dirty_flag(); } @@ -226,9 +258,11 @@ UndoSystem::clear() { discard_pending(); free_node(tree_.root); - tree_.root = nullptr; - tree_.current = nullptr; - tree_.saved = nullptr; + tree_.root = nullptr; + tree_.current = nullptr; + tree_.saved = nullptr; + active_group_id_ = 0; + next_group_id_ = 1; update_dirty_flag(); } diff --git a/UndoSystem.h b/UndoSystem.h index beaabd5..dba874e 100644 --- a/UndoSystem.h +++ b/UndoSystem.h @@ -12,6 +12,12 @@ class UndoSystem { public: explicit UndoSystem(Buffer &owner, UndoTree &tree); + // Begin an atomic group: subsequent committed nodes with the same group_id will be + // undone/redone as a single step. Returns the active group id. + std::uint64_t BeginGroup(); + + void EndGroup(); + void Begin(UndoType type); void Append(char ch); @@ -66,6 +72,9 @@ private: PendingAppendMode pending_mode_ = PendingAppendMode::Append; + std::uint64_t active_group_id_ = 0; + std::uint64_t next_group_id_ = 1; + Buffer *buf_; UndoTree &tree_; }; \ No newline at end of file diff --git a/docs/plans/swap-files.md b/docs/plans/swap-files.md index 1d70c12..679849a 100644 --- a/docs/plans/swap-files.md +++ b/docs/plans/swap-files.md @@ -12,11 +12,14 @@ Goals Model overview -------------- -Per open buffer, maintain a sidecar swap journal next to the file: +Per open buffer, maintain a swap journal in a per-user state directory: -- Path: `..kte.swp` in the same directory as the file (for - unnamed/unsaved buffers, use a per‑session temp dir like - `$TMPDIR/kte/` with a random UUID). +- Path: `$XDG_STATE_HOME/kte/swap/.swp` (or + `~/.local/state/kte/swap/...`) + where `` is the file path with separators replaced (e.g. + `/home/kyle/tmp/test.txt` → `home!kyle!tmp!test.txt.swp`). + Unnamed/unsaved + buffers use a unique `unnamed--.swp` name. - Format: append‑only journal of editing operations with periodic checkpoints. - Crash safety: only append, fsync as per policy; checkpoint via @@ -84,7 +87,7 @@ Recovery flow On opening a file: -1. Detect swap sidecar `..kte.swp`. +1. Detect swap journal `$XDG_STATE_HOME/kte/swap/.swp`. 2. Validate header, iterate records verifying CRCs. 3. Compare recorded original file identity against actual file; if mismatch, warn user but allow recovery (content wins). @@ -98,7 +101,7 @@ Stability & corruption mitigation --------------------------------- - Append‑only with per‑record CRC32 guards against torn writes. -- Atomic checkpoint rotation: write `..kte.swp.tmp`, fsync, +- Atomic checkpoint rotation: write `.swp.tmp`, fsync, then rename over old `.swp`. - Size caps: rotate or compact when `.swp` exceeds a threshold (e.g., 64–128 MB). Compaction creates a fresh file with a single checkpoint. @@ -117,8 +120,8 @@ Security considerations Interoperability & UX --------------------- -- Use a distinctive extension `.kte.swp` to avoid conflicts with other - editors. +- Use a distinctive directory (`$XDG_STATE_HOME/kte/swap`) to avoid + conflicts with other editors’ `.swp` conventions. - Status bar indicator when swap is active; commands to purge/compact. - On save: do not delete swap immediately; keep until the buffer is clean and idle for a short grace period (allows undo of accidental diff --git a/tests/test_buffer_rows.cc b/tests/test_buffer_rows.cc new file mode 100644 index 0000000..a877e43 --- /dev/null +++ b/tests/test_buffer_rows.cc @@ -0,0 +1,142 @@ +#include "Test.h" +#include "Buffer.h" +#include +#include +#include +#include + + +static std::vector +split_lines_preserve_trailing_empty(const std::string &s) +{ + std::vector out; + std::size_t start = 0; + for (std::size_t i = 0; i <= s.size(); i++) { + if (i == s.size() || s[i] == '\n') { + out.push_back(s.substr(start, i - start)); + start = i + 1; + } + } + if (out.empty()) + out.push_back(std::string()); + return out; +} + + +static std::vector +line_starts_for(const std::string &s) +{ + std::vector starts; + starts.push_back(0); + for (std::size_t i = 0; i < s.size(); i++) { + if (s[i] == '\n') + starts.push_back(i + 1); + } + return starts; +} + + +static std::size_t +ref_linecol_to_offset(const std::string &s, std::size_t row, std::size_t col) +{ + auto starts = line_starts_for(s); + if (starts.empty()) + return 0; + if (row >= starts.size()) + return s.size(); + std::size_t start = starts[row]; + std::size_t end = (row + 1 < starts.size()) ? starts[row + 1] : s.size(); + if (end > start && s[end - 1] == '\n') + end -= 1; // clamp before trailing newline + return start + std::min(col, end - start); +} + + +static void +check_buffer_matches_model(const Buffer &b, const std::string &model) +{ + auto expected_lines = split_lines_preserve_trailing_empty(model); + const auto &rows = b.Rows(); + ASSERT_EQ(rows.size(), expected_lines.size()); + ASSERT_EQ(b.Nrows(), rows.size()); + + auto starts = line_starts_for(model); + ASSERT_EQ(starts.size(), expected_lines.size()); + + std::string via_views; + for (std::size_t i = 0; i < rows.size(); i++) { + ASSERT_EQ(std::string(rows[i]), expected_lines[i]); + ASSERT_EQ(b.GetLineString(i), expected_lines[i]); + + std::size_t exp_start = starts[i]; + std::size_t exp_end = (i + 1 < starts.size()) ? starts[i + 1] : model.size(); + auto r = b.GetLineRange(i); + ASSERT_EQ(r.first, exp_start); + ASSERT_EQ(r.second, exp_end); + + auto v = b.GetLineView(i); + ASSERT_EQ(std::string(v), model.substr(exp_start, exp_end - exp_start)); + via_views.append(v.data(), v.size()); + } + ASSERT_EQ(via_views, model); +} + + +TEST (Buffer_RowsCache_MultiLineEdits_StayConsistent) +{ + Buffer b; + std::string model; + + check_buffer_matches_model(b, model); + + // Insert text and newlines in a few different ways. + b.insert_text(0, 0, std::string("abc")); + model.insert(0, "abc"); + check_buffer_matches_model(b, model); + + b.split_line(0, 1); // a\nbc + model.insert(ref_linecol_to_offset(model, 0, 1), "\n"); + check_buffer_matches_model(b, model); + + b.insert_text(1, 2, std::string("X")); // a\nbcX + model.insert(ref_linecol_to_offset(model, 1, 2), "X"); + check_buffer_matches_model(b, model); + + b.join_lines(0); // abcX + { + std::size_t off = ref_linecol_to_offset(model, 0, std::numeric_limits::max()); + if (off < model.size() && model[off] == '\n') + model.erase(off, 1); + } + check_buffer_matches_model(b, model); + + // Insert a multi-line segment in one shot. + b.insert_text(0, 2, std::string("\n123\nxyz")); + model.insert(ref_linecol_to_offset(model, 0, 2), "\n123\nxyz"); + check_buffer_matches_model(b, model); + + // Delete spanning across a newline. + b.delete_text(0, 1, 5); + { + std::size_t start = ref_linecol_to_offset(model, 0, 1); + std::size_t actual = std::min(5, model.size() - start); + model.erase(start, actual); + } + check_buffer_matches_model(b, model); + + // Insert/delete whole rows. + b.insert_row(1, std::string_view("ROW")); + model.insert(ref_linecol_to_offset(model, 1, 0), "ROW\n"); + check_buffer_matches_model(b, model); + + b.delete_row(1); + { + auto starts = line_starts_for(model); + if (1 < (int) starts.size()) { + std::size_t start = starts[1]; + std::size_t end = (2 < starts.size()) ? starts[2] : model.size(); + model.erase(start, end - start); + } + } + check_buffer_matches_model(b, model); +} \ No newline at end of file diff --git a/tests/test_command_semantics.cc b/tests/test_command_semantics.cc new file mode 100644 index 0000000..27d155b --- /dev/null +++ b/tests/test_command_semantics.cc @@ -0,0 +1,91 @@ +#include "Test.h" + +#include "TestHarness.h" + +using ktet::TestHarness; + + +TEST (CommandSemantics_KillToEOL_KillChain_And_Yank) +{ + TestHarness h; + Editor &ed = h.EditorRef(); + Buffer &b = h.Buf(); + + b.insert_text(0, 0, std::string("abc\ndef")); + b.SetCursor(1, 0); // a|bc + + ed.KillRingClear(); + ed.SetKillChain(false); + + ASSERT_TRUE(h.Exec(CommandId::KillToEOL)); + ASSERT_EQ(h.Text(), std::string("a\ndef")); + ASSERT_EQ(ed.KillRingHead(), std::string("bc")); + + // At EOL, KillToEOL kills the newline (join). + ASSERT_TRUE(h.Exec(CommandId::KillToEOL)); + ASSERT_EQ(h.Text(), std::string("adef")); + ASSERT_EQ(ed.KillRingHead(), std::string("bc\n")); + + // Yank pastes the kill ring head and breaks the kill chain. + ASSERT_TRUE(h.Exec(CommandId::Yank)); + ASSERT_EQ(h.Text(), std::string("abc\ndef")); + ASSERT_EQ(ed.KillRingHead(), std::string("bc\n")); + ASSERT_EQ(ed.KillChain(), false); +} + + +TEST (CommandSemantics_ToggleMark_JumpToMark) +{ + TestHarness h; + Buffer &b = h.Buf(); + + b.insert_text(0, 0, std::string("hello")); + b.SetCursor(2, 0); + ASSERT_EQ(b.MarkSet(), false); + + ASSERT_TRUE(h.Exec(CommandId::ToggleMark)); + ASSERT_EQ(b.MarkSet(), true); + ASSERT_EQ(b.MarkCurx(), (std::size_t) 2); + ASSERT_EQ(b.MarkCury(), (std::size_t) 0); + + b.SetCursor(4, 0); + ASSERT_TRUE(h.Exec(CommandId::JumpToMark)); + ASSERT_EQ(b.Curx(), (std::size_t) 2); + ASSERT_EQ(b.Cury(), (std::size_t) 0); + // Jump-to-mark swaps: mark becomes previous cursor. + ASSERT_EQ(b.MarkSet(), true); + ASSERT_EQ(b.MarkCurx(), (std::size_t) 4); + ASSERT_EQ(b.MarkCury(), (std::size_t) 0); +} + + +TEST (CommandSemantics_CopyRegion_And_KillRegion) +{ + TestHarness h; + Editor &ed = h.EditorRef(); + Buffer &b = h.Buf(); + + b.insert_text(0, 0, std::string("hello world")); + b.SetCursor(0, 0); + + ed.KillRingClear(); + ed.SetKillChain(false); + + // Copy "hello" (region [0,5)). + ASSERT_TRUE(h.Exec(CommandId::ToggleMark)); + b.SetCursor(5, 0); + ASSERT_TRUE(h.Exec(CommandId::CopyRegion)); + ASSERT_EQ(ed.KillRingHead(), std::string("hello")); + ASSERT_EQ(b.MarkSet(), false); + ASSERT_EQ(h.Text(), std::string("hello world")); + + // Kill "world" (region [6,11)). + ed.SetKillChain(false); + b.SetCursor(6, 0); + ASSERT_TRUE(h.Exec(CommandId::ToggleMark)); + b.SetCursor(11, 0); + ASSERT_TRUE(h.Exec(CommandId::KillRegion)); + ASSERT_EQ(ed.KillRingHead(), std::string("world")); + ASSERT_EQ(b.MarkSet(), false); + ASSERT_EQ(h.Text(), std::string("hello ")); +} \ No newline at end of file diff --git a/tests/test_daily_workflows.cc b/tests/test_daily_workflows.cc new file mode 100644 index 0000000..ada2910 --- /dev/null +++ b/tests/test_daily_workflows.cc @@ -0,0 +1,170 @@ +#include "Test.h" + +#include "Command.h" +#include "Editor.h" + +#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce + +#include +#include +#include +#include + + +static void +write_file_bytes(const std::string &path, const std::string &bytes) +{ + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(bytes.data(), (std::streamsize) bytes.size()); +} + + +static std::string +read_file_bytes(const std::string &path) +{ + std::ifstream in(path, std::ios::binary); + return std::string((std::istreambuf_iterator(in)), std::istreambuf_iterator()); +} + + +static std::string +buffer_bytes_via_views(const Buffer &b) +{ + const auto &rows = b.Rows(); + std::string out; + for (std::size_t i = 0; i < rows.size(); i++) { + auto v = b.GetLineView(i); + out.append(v.data(), v.size()); + } + return out; +} + + +TEST (DailyWorkflow_OpenEditSave_Transcript) +{ + ktet::InstallDefaultCommandsOnce(); + + const std::string path = "./.kte_ut_daily_open_edit_save.txt"; + std::remove(path.c_str()); + write_file_bytes(path, "one\n"); + const std::string npath = std::filesystem::canonical(path).string(); + + Editor ed; + ed.SetDimensions(24, 80); + // Seed an empty buffer so OpenFile can reuse it. + { + Buffer scratch; + ed.AddBuffer(std::move(scratch)); + } + + std::string err; + ASSERT_TRUE(ed.OpenFile(path, err)); + ASSERT_TRUE(ed.CurrentBuffer() != nullptr); + ASSERT_EQ(ed.CurrentBuffer()->Filename(), npath); + + // Append two new lines via commands (no UI). + ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd)); + ASSERT_TRUE(Execute(ed, CommandId::InsertText, "two")); + ASSERT_TRUE(Execute(ed, CommandId::Newline)); + ASSERT_TRUE(Execute(ed, CommandId::InsertText, "three")); + + ASSERT_TRUE(Execute(ed, CommandId::Save)); + ASSERT_TRUE(ed.CurrentBuffer() != nullptr); + ASSERT_EQ(read_file_bytes(npath), buffer_bytes_via_views(*ed.CurrentBuffer())); + + std::remove(path.c_str()); + std::remove(npath.c_str()); +} + + +TEST (DailyWorkflow_MultiBufferSwitchClose_Transcript) +{ + ktet::InstallDefaultCommandsOnce(); + + const std::string p1 = "./.kte_ut_daily_buf_1.txt"; + const std::string p2 = "./.kte_ut_daily_buf_2.txt"; + std::remove(p1.c_str()); + std::remove(p2.c_str()); + write_file_bytes(p1, "aaa\n"); + write_file_bytes(p2, "bbb\n"); + const std::string np1 = std::filesystem::canonical(p1).string(); + const std::string np2 = std::filesystem::canonical(p2).string(); + + Editor ed; + ed.SetDimensions(24, 80); + { + Buffer scratch; + ed.AddBuffer(std::move(scratch)); + } + + std::string err; + ASSERT_TRUE(ed.OpenFile(p1, err)); + ASSERT_TRUE(ed.OpenFile(p2, err)); + ASSERT_EQ(ed.BufferCount(), (std::size_t) 2); + ASSERT_TRUE(ed.CurrentBuffer() != nullptr); + ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2); + + // Switch back and forth. + ASSERT_TRUE(Execute(ed, CommandId::BufferPrev)); + ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1); + ASSERT_TRUE(Execute(ed, CommandId::BufferNext)); + ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2); + + // Close current buffer (p2); ensure we land on p1. + ASSERT_TRUE(Execute(ed, CommandId::BufferClose)); + ASSERT_EQ(ed.BufferCount(), (std::size_t) 1); + ASSERT_TRUE(ed.CurrentBuffer() != nullptr); + ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1); + + std::remove(p1.c_str()); + std::remove(p2.c_str()); + std::remove(np1.c_str()); + std::remove(np2.c_str()); +} + + +TEST (DailyWorkflow_CrashRecovery_SwapReplay_Transcript) +{ + ktet::InstallDefaultCommandsOnce(); + + const std::string path = "./.kte_ut_daily_swap_recover.txt"; + std::remove(path.c_str()); + write_file_bytes(path, "base\nline2\n"); + + Editor ed; + ed.SetDimensions(24, 80); + { + Buffer scratch; + ed.AddBuffer(std::move(scratch)); + } + + std::string err; + ASSERT_TRUE(ed.OpenFile(path, err)); + Buffer *buf = ed.CurrentBuffer(); + ASSERT_TRUE(buf != nullptr); + + // Make unsaved edits through command execution. + ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart)); + ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X")); + ASSERT_TRUE(Execute(ed, CommandId::MoveDown)); + ASSERT_TRUE(Execute(ed, CommandId::MoveHome)); + ASSERT_TRUE(Execute(ed, CommandId::InsertText, "ZZ")); + ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd)); + ASSERT_TRUE(Execute(ed, CommandId::InsertText, "TAIL")); + + // Ensure journal is durable and capture expected bytes. + ed.Swap()->Flush(buf); + const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(*buf); + const std::string expected = buffer_bytes_via_views(*buf); + + // "Crash": reopen from disk (original file content) into a fresh Buffer and replay. + Buffer recovered; + ASSERT_TRUE(recovered.OpenFromFile(path, err)); + ASSERT_TRUE(kte::SwapManager::ReplayFile(recovered, swap_path, err)); + ASSERT_EQ(buffer_bytes_via_views(recovered), expected); + + // Cleanup. + ed.Swap()->Detach(buf); + std::remove(path.c_str()); + std::remove(swap_path.c_str()); +} \ No newline at end of file diff --git a/tests/test_kkeymap.cc b/tests/test_kkeymap.cc new file mode 100644 index 0000000..ec2d385 --- /dev/null +++ b/tests/test_kkeymap.cc @@ -0,0 +1,84 @@ +#include "Test.h" + +#include "KKeymap.h" + +#include + + +TEST (KKeymap_KPrefix_CanonicalChords) +{ + CommandId id{}; + + // From docs/ke.md (K-commands) + ASSERT_TRUE(KLookupKCommand('s', false, id)); + ASSERT_EQ(id, CommandId::Save); + ASSERT_TRUE(KLookupKCommand('s', true, id)); // C-k C-s + ASSERT_EQ(id, CommandId::Save); + + ASSERT_TRUE(KLookupKCommand('d', false, id)); + ASSERT_EQ(id, CommandId::KillToEOL); + ASSERT_TRUE(KLookupKCommand('d', true, id)); // C-k C-d + ASSERT_EQ(id, CommandId::KillLine); + + ASSERT_TRUE(KLookupKCommand(' ', false, id)); // C-k SPACE + ASSERT_EQ(id, CommandId::ToggleMark); + + ASSERT_TRUE(KLookupKCommand('j', false, id)); + ASSERT_EQ(id, CommandId::JumpToMark); + + ASSERT_TRUE(KLookupKCommand('f', false, id)); + ASSERT_EQ(id, CommandId::FlushKillRing); + + ASSERT_TRUE(KLookupKCommand('y', false, id)); + ASSERT_EQ(id, CommandId::Yank); + + // Unknown should not map + ASSERT_EQ(KLookupKCommand('Z', false, id), false); +} + + +TEST (KKeymap_CtrlChords_CanonicalChords) +{ + CommandId id{}; + + // From docs/ke.md (other keybindings) + ASSERT_TRUE(KLookupCtrlCommand('n', id)); + ASSERT_EQ(id, CommandId::MoveDown); + ASSERT_TRUE(KLookupCtrlCommand('p', id)); + ASSERT_EQ(id, CommandId::MoveUp); + ASSERT_TRUE(KLookupCtrlCommand('f', id)); + ASSERT_EQ(id, CommandId::MoveRight); + ASSERT_TRUE(KLookupCtrlCommand('b', id)); + ASSERT_EQ(id, CommandId::MoveLeft); + + ASSERT_TRUE(KLookupCtrlCommand('w', id)); + ASSERT_EQ(id, CommandId::KillRegion); + ASSERT_TRUE(KLookupCtrlCommand('y', id)); + ASSERT_EQ(id, CommandId::Yank); + + ASSERT_EQ(KLookupCtrlCommand('z', id), false); +} + + +TEST (KKeymap_EscChords_CanonicalChords) +{ + CommandId id{}; + + // From docs/ke.md (ESC bindings) + ASSERT_TRUE(KLookupEscCommand('b', id)); + ASSERT_EQ(id, CommandId::WordPrev); + ASSERT_TRUE(KLookupEscCommand('f', id)); + ASSERT_EQ(id, CommandId::WordNext); + ASSERT_TRUE(KLookupEscCommand('d', id)); + ASSERT_EQ(id, CommandId::DeleteWordNext); + ASSERT_TRUE(KLookupEscCommand('q', id)); + ASSERT_EQ(id, CommandId::ReflowParagraph); + ASSERT_TRUE(KLookupEscCommand('w', id)); + ASSERT_EQ(id, CommandId::CopyRegion); + + // ESC BACKSPACE + ASSERT_TRUE(KLookupEscCommand(KEY_BACKSPACE, id)); + ASSERT_EQ(id, CommandId::DeleteWordPrev); + + ASSERT_EQ(KLookupEscCommand('z', id), false); +} \ No newline at end of file diff --git a/tests/test_piece_table.cc b/tests/test_piece_table.cc index edcd0c1..286311a 100644 --- a/tests/test_piece_table.cc +++ b/tests/test_piece_table.cc @@ -1,49 +1,181 @@ #include "Test.h" #include "PieceTable.h" +#include +#include +#include #include +#include -TEST(PieceTable_Insert_Delete_LineCount) { - PieceTable pt; - // start empty - ASSERT_EQ(pt.Size(), (std::size_t)0); - ASSERT_EQ(pt.LineCount(), (std::size_t)1); // empty buffer has 1 logical line - // Insert some text with newlines - const char *t = "abc\n123\nxyz"; // last line without trailing NL - pt.Insert(0, t, 11); - ASSERT_EQ(pt.Size(), (std::size_t)11); - ASSERT_EQ(pt.LineCount(), (std::size_t)3); - - // Check get line - ASSERT_EQ(pt.GetLine(0), std::string("abc")); - ASSERT_EQ(pt.GetLine(1), std::string("123")); - ASSERT_EQ(pt.GetLine(2), std::string("xyz")); - - // Delete middle line entirely including its trailing NL - auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2 - pt.Delete(r.first, r.second - r.first); - ASSERT_EQ(pt.LineCount(), (std::size_t)2); - ASSERT_EQ(pt.GetLine(0), std::string("abc")); - ASSERT_EQ(pt.GetLine(1), std::string("xyz")); +static std::vector +LineStartsFor(const std::string &s) +{ + std::vector starts; + starts.push_back(0); + for (std::size_t i = 0; i < s.size(); i++) { + if (s[i] == '\n') + starts.push_back(i + 1); + } + return starts; } -TEST(PieceTable_LineCol_Conversions) { - PieceTable pt; - std::string s = "hello\nworld\n"; // two lines with trailing NL - pt.Insert(0, s.data(), s.size()); - // Byte offsets of starts - auto off0 = pt.LineColToByteOffset(0, 0); - auto off1 = pt.LineColToByteOffset(1, 0); - auto off2 = pt.LineColToByteOffset(2, 0); // EOF - ASSERT_EQ(off0, (std::size_t)0); - ASSERT_EQ(off1, (std::size_t)6); // "hello\n" - ASSERT_EQ(off2, pt.Size()); - - auto lc0 = pt.ByteOffsetToLineCol(0); - auto lc1 = pt.ByteOffsetToLineCol(6); - ASSERT_EQ(lc0.first, (std::size_t)0); - ASSERT_EQ(lc0.second, (std::size_t)0); - ASSERT_EQ(lc1.first, (std::size_t)1); - ASSERT_EQ(lc1.second, (std::size_t)0); +static std::string +LineContentFor(const std::string &s, std::size_t line_num) +{ + auto starts = LineStartsFor(s); + if (starts.empty() || line_num >= starts.size()) + return std::string(); + std::size_t start = starts[line_num]; + std::size_t end = (line_num + 1 < starts.size()) ? starts[line_num + 1] : s.size(); + if (end > start && s[end - 1] == '\n') + end -= 1; + return s.substr(start, end - start); } + + +TEST (PieceTable_Insert_Delete_LineCount) +{ + PieceTable pt; + // start empty + ASSERT_EQ(pt.Size(), (std::size_t) 0); + ASSERT_EQ(pt.LineCount(), (std::size_t) 1); // empty buffer has 1 logical line + + // Insert some text with newlines + const char *t = "abc\n123\nxyz"; // last line without trailing NL + pt.Insert(0, t, 11); + ASSERT_EQ(pt.Size(), (std::size_t) 11); + ASSERT_EQ(pt.LineCount(), (std::size_t) 3); + + // Check get line + ASSERT_EQ(pt.GetLine(0), std::string("abc")); + ASSERT_EQ(pt.GetLine(1), std::string("123")); + ASSERT_EQ(pt.GetLine(2), std::string("xyz")); + + // Delete middle line entirely including its trailing NL + auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2 + pt.Delete(r.first, r.second - r.first); + ASSERT_EQ(pt.LineCount(), (std::size_t) 2); + ASSERT_EQ(pt.GetLine(0), std::string("abc")); + ASSERT_EQ(pt.GetLine(1), std::string("xyz")); +} + + +TEST (PieceTable_LineCol_Conversions) +{ + PieceTable pt; + std::string s = "hello\nworld\n"; // two lines with trailing NL + pt.Insert(0, s.data(), s.size()); + + // Byte offsets of starts + auto off0 = pt.LineColToByteOffset(0, 0); + auto off1 = pt.LineColToByteOffset(1, 0); + auto off2 = pt.LineColToByteOffset(2, 0); // EOF + ASSERT_EQ(off0, (std::size_t) 0); + ASSERT_EQ(off1, (std::size_t) 6); // "hello\n" + ASSERT_EQ(off2, pt.Size()); + + auto lc0 = pt.ByteOffsetToLineCol(0); + auto lc1 = pt.ByteOffsetToLineCol(6); + ASSERT_EQ(lc0.first, (std::size_t) 0); + ASSERT_EQ(lc0.second, (std::size_t) 0); + ASSERT_EQ(lc1.first, (std::size_t) 1); + ASSERT_EQ(lc1.second, (std::size_t) 0); +} + + +TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic) +{ + PieceTable pt; + std::string model; + + std::mt19937 rng(0xC0FFEEu); + const std::vector corpus = { + "a", + "b", + "c", + "xyz", + "123", + "\n", + "!\n", + "foo\nbar", + "end\n", + }; + + auto check_invariants = [&](const char *where) { + (void) where; + ASSERT_EQ(pt.Size(), model.size()); + ASSERT_EQ(pt.GetRange(0, pt.Size()), model); + + auto starts = LineStartsFor(model); + ASSERT_EQ(pt.LineCount(), starts.size()); + + // Spot-check a few line ranges and contents. + std::size_t last = starts.empty() ? (std::size_t) 0 : (starts.size() - 1); + std::size_t mid = (starts.size() > 2) ? (std::size_t) 1 : last; + const std::array probe_lines = {(std::size_t) 0, last, mid}; + for (auto line: probe_lines) { + if (starts.empty()) + break; + if (line >= starts.size()) + continue; + std::size_t exp_start = starts[line]; + std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size(); + auto r = pt.GetLineRange(line); + ASSERT_EQ(r.first, exp_start); + ASSERT_EQ(r.second, exp_end); + ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line)); + } + + // Round-trips for a few offsets. + const std::vector probe_offsets = { + 0, + model.size() / 2, + model.size(), + }; + for (auto off: probe_offsets) { + auto lc = pt.ByteOffsetToLineCol(off); + auto back = pt.LineColToByteOffset(lc.first, lc.second); + ASSERT_EQ(back, off); + } + }; + + check_invariants("initial"); + + for (int step = 0; step < 250; step++) { + bool do_insert = model.empty() || ((rng() % 3u) != 0u); // bias toward insert + if (do_insert) { + const std::string &ins = corpus[rng() % corpus.size()]; + std::size_t pos = model.empty() ? 0 : (rng() % (model.size() + 1)); + pt.Insert(pos, ins.data(), ins.size()); + model.insert(pos, ins); + } else { + std::size_t pos = rng() % model.size(); + std::size_t max = std::min(8, model.size() - pos); + std::size_t len = 1 + (rng() % max); + pt.Delete(pos, len); + model.erase(pos, len); + } + + // Also validate GetRange on a small random window when non-empty. + if (!model.empty()) { + std::size_t off = rng() % model.size(); + std::size_t max = std::min(16, model.size() - off); + std::size_t len = 1 + (rng() % max); + ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len)); + } + + check_invariants("step"); + } + + // Full line-by-line range verification at the end. + auto starts = LineStartsFor(model); + for (std::size_t line = 0; line < starts.size(); line++) { + std::size_t exp_start = starts[line]; + std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size(); + auto r = pt.GetLineRange(line); + ASSERT_EQ(r.first, exp_start); + ASSERT_EQ(r.second, exp_end); + ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line)); + } +} \ No newline at end of file diff --git a/tests/test_search_replace_flow.cc b/tests/test_search_replace_flow.cc new file mode 100644 index 0000000..cb5ac5e --- /dev/null +++ b/tests/test_search_replace_flow.cc @@ -0,0 +1,129 @@ +#include "Test.h" + +#include "tests/TestHarness.h" + +using ktet::TestHarness; + +// These tests intentionally drive the prompt-based search/replace UI headlessly +// via `Execute(Editor&, CommandId, ...)` to lock down behavior without ncurses. + +TEST (SearchFlow_FindStart_Success_LeavesCursorOnMatch_And_ClearsSearchState) +{ + TestHarness h; + Editor &ed = h.EditorRef(); + Buffer &b = h.Buf(); + + b.insert_text(0, 0, "abc def abc"); + b.SetCursor(0, 0); + b.SetOffsets(0, 0); + + // Keep a mark set to ensure search doesn't clobber it. + b.SetMark(0, 0); + ASSERT_TRUE(b.MarkSet()); + + ASSERT_TRUE(h.Exec(CommandId::FindStart)); + ASSERT_TRUE(ed.PromptActive()); + ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::Search); + ASSERT_TRUE(ed.SearchActive()); + + // Typing into the prompt uses InsertText and should jump to the first match. + ASSERT_TRUE(h.Exec(CommandId::InsertText, "def")); + ASSERT_EQ(b.Cury(), (std::size_t) 0); + ASSERT_EQ(b.Curx(), (std::size_t) 4); + + // Enter (Newline) accepts the prompt and ends incremental search. + ASSERT_TRUE(h.Exec(CommandId::Newline)); + ASSERT_TRUE(!ed.PromptActive()); + ASSERT_TRUE(!ed.SearchActive()); + ASSERT_TRUE(b.MarkSet()); +} + + +TEST (SearchFlow_FindStart_NotFound_RestoresOrigin_And_ClearsSearchState) +{ + TestHarness h; + Editor &ed = h.EditorRef(); + Buffer &b = h.Buf(); + + b.insert_text(0, 0, "hello world\nsecond line\n"); + b.SetCursor(3, 0); + b.SetOffsets(1, 2); + + const std::size_t ox = b.Curx(); + const std::size_t oy = b.Cury(); + const std::size_t orow = b.Rowoffs(); + const std::size_t ocol = b.Coloffs(); + + ASSERT_TRUE(h.Exec(CommandId::FindStart)); + ASSERT_TRUE(ed.PromptActive()); + ASSERT_TRUE(ed.SearchActive()); + + // Not-found should restore cursor/viewport to the saved origin while still in prompt. + ASSERT_TRUE(h.Exec(CommandId::InsertText, "zzzz")); + ASSERT_EQ(b.Curx(), ox); + ASSERT_EQ(b.Cury(), oy); + ASSERT_EQ(b.Rowoffs(), orow); + ASSERT_EQ(b.Coloffs(), ocol); + + ASSERT_TRUE(h.Exec(CommandId::Newline)); + ASSERT_TRUE(!ed.PromptActive()); + ASSERT_TRUE(!ed.SearchActive()); +} + + +TEST (SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState) +{ + TestHarness h; + Editor &ed = h.EditorRef(); + Buffer &b = h.Buf(); + + b.insert_text(0, 0, "abc abc\n"); + b.SetCursor(0, 0); + + const std::string before = h.Text(); + + ASSERT_TRUE(h.Exec(CommandId::SearchReplace)); + ASSERT_TRUE(ed.PromptActive()); + ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind); + + // Accept empty find -> proceed to ReplaceWith. + ASSERT_TRUE(h.Exec(CommandId::Newline)); + ASSERT_TRUE(ed.PromptActive()); + ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith); + + // Provide replacement and accept -> should cancel due to empty find. + ASSERT_TRUE(h.Exec(CommandId::InsertText, "X")); + ASSERT_TRUE(h.Exec(CommandId::Newline)); + + ASSERT_TRUE(!ed.PromptActive()); + ASSERT_TRUE(!ed.SearchActive()); + ASSERT_EQ(h.Text(), before); +} + + +TEST (SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter) +{ + TestHarness h; + Editor &ed = h.EditorRef(); + Buffer &b = h.Buf(); + + b.insert_text(0, 0, "abc\ndef\n"); + b.SetCursor(1, 0); + b.SetOffsets(0, 0); + + const std::size_t ox = b.Curx(); + const std::size_t oy = b.Cury(); + + ASSERT_TRUE(h.Exec(CommandId::RegexFindStart)); + ASSERT_TRUE(ed.PromptActive()); + ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::RegexSearch); + + // Invalid regex should not crash; cursor should remain at origin due to no matches. + ASSERT_TRUE(h.Exec(CommandId::InsertText, "(")); + ASSERT_EQ(b.Curx(), ox); + ASSERT_EQ(b.Cury(), oy); + + ASSERT_TRUE(h.Exec(CommandId::Newline)); + ASSERT_TRUE(!ed.PromptActive()); + ASSERT_TRUE(!ed.SearchActive()); +} \ No newline at end of file diff --git a/tests/test_swap_recorder.cc b/tests/test_swap_recorder.cc new file mode 100644 index 0000000..e134755 --- /dev/null +++ b/tests/test_swap_recorder.cc @@ -0,0 +1,104 @@ +#include "Test.h" + +#include "Buffer.h" +#include "SwapRecorder.h" + +#include +#include + +namespace { +struct SwapEvent { + enum class Type { + Insert, + Delete, + }; + + Type type; + int row; + int col; + std::string bytes; + std::size_t len = 0; +}; + +class FakeSwapRecorder final : public kte::SwapRecorder { +public: + std::vector events; + + + void OnInsert(int row, int col, std::string_view bytes) override + { + SwapEvent e; + e.type = SwapEvent::Type::Insert; + e.row = row; + e.col = col; + e.bytes = std::string(bytes); + e.len = 0; + events.push_back(std::move(e)); + } + + + void OnDelete(int row, int col, std::size_t len) override + { + SwapEvent e; + e.type = SwapEvent::Type::Delete; + e.row = row; + e.col = col; + e.len = len; + events.push_back(std::move(e)); + } +}; +} // namespace + + +TEST (SwapRecorder_InsertABC) +{ + Buffer b; + FakeSwapRecorder rec; + b.SetSwapRecorder(&rec); + + b.insert_text(0, 0, std::string_view("abc")); + + ASSERT_EQ(rec.events.size(), (std::size_t) 1); + ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert); + ASSERT_EQ(rec.events[0].row, 0); + ASSERT_EQ(rec.events[0].col, 0); + ASSERT_EQ(rec.events[0].bytes, std::string("abc")); +} + + +TEST (SwapRecorder_InsertNewline) +{ + Buffer b; + FakeSwapRecorder rec; + b.SetSwapRecorder(&rec); + + b.split_line(0, 0); + + ASSERT_EQ(rec.events.size(), (std::size_t) 1); + ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert); + ASSERT_EQ(rec.events[0].row, 0); + ASSERT_EQ(rec.events[0].col, 0); + ASSERT_EQ(rec.events[0].bytes, std::string("\n")); +} + + +TEST (SwapRecorder_DeleteSpanningNewline) +{ + Buffer b; + // Prepare content without a recorder (should be no-op) + b.insert_text(0, 0, std::string_view("ab")); + b.split_line(0, 2); + b.insert_text(1, 0, std::string_view("cd")); + + FakeSwapRecorder rec; + b.SetSwapRecorder(&rec); + + // Delete "b\n c" (3 bytes) starting at row 0, col 1. + b.delete_text(0, 1, 3); + + ASSERT_EQ(rec.events.size(), (std::size_t) 1); + ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Delete); + ASSERT_EQ(rec.events[0].row, 0); + ASSERT_EQ(rec.events[0].col, 1); + ASSERT_EQ(rec.events[0].len, (std::size_t) 3); +} \ No newline at end of file diff --git a/tests/test_swap_replay.cc b/tests/test_swap_replay.cc new file mode 100644 index 0000000..8e6e5fc --- /dev/null +++ b/tests/test_swap_replay.cc @@ -0,0 +1,114 @@ +#include "Test.h" + +#include "Buffer.h" +#include "Swap.h" + +#include +#include +#include + + +static void +write_file_bytes(const std::string &path, const std::string &bytes) +{ + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(bytes.data(), (std::streamsize) bytes.size()); +} + + +static std::string +read_file_bytes(const std::string &path) +{ + std::ifstream in(path, std::ios::binary); + return std::string((std::istreambuf_iterator(in)), std::istreambuf_iterator()); +} + + +static std::string +buffer_bytes_via_views(const Buffer &b) +{ + const auto &rows = b.Rows(); + std::string out; + for (std::size_t i = 0; i < rows.size(); i++) { + auto v = b.GetLineView(i); + out.append(v.data(), v.size()); + } + return out; +} + + +TEST (SwapReplay_RecordFlushReopenReplay_ExactBytesMatch) +{ + const std::string path = "./.kte_ut_swap_replay_1.txt"; + std::remove(path.c_str()); + write_file_bytes(path, "base\nline2\n"); + + Buffer b; + std::string err; + ASSERT_TRUE(b.OpenFromFile(path, err)); + + kte::SwapManager sm; + sm.Attach(&b); + b.SetSwapRecorder(sm.RecorderFor(&b)); + + // Edits (no save): swap should capture these. + b.insert_text(0, 0, std::string("X")); // Xbase\nline2\n + b.delete_text(1, 1, 2); // delete "in" from "line2" + b.split_line(0, 3); // Xba\nse... + b.join_lines(0); // join back + b.insert_text(1, 0, std::string("ZZ")); // insert at start of line2 + b.delete_text(0, 0, 1); // delete leading X + + sm.Flush(&b); + const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b); + const std::string expected = buffer_bytes_via_views(b); + + // Close journal before replaying (for determinism) + b.SetSwapRecorder(nullptr); + sm.Detach(&b); + + Buffer b2; + ASSERT_TRUE(b2.OpenFromFile(path, err)); + ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err)); + ASSERT_EQ(buffer_bytes_via_views(b2), expected); + + std::remove(path.c_str()); + std::remove(swap_path.c_str()); +} + + +TEST (SwapReplay_TruncatedLog_FailsSafely) +{ + const std::string path = "./.kte_ut_swap_replay_2.txt"; + std::remove(path.c_str()); + write_file_bytes(path, "hello\n"); + + Buffer b; + std::string err; + ASSERT_TRUE(b.OpenFromFile(path, err)); + + kte::SwapManager sm; + sm.Attach(&b); + b.SetSwapRecorder(sm.RecorderFor(&b)); + b.insert_text(0, 0, std::string("X")); + sm.Flush(&b); + const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b); + b.SetSwapRecorder(nullptr); + sm.Detach(&b); + + const std::string bytes = read_file_bytes(swap_path); + ASSERT_TRUE(bytes.size() > 70); // header + at least one record + + const std::string trunc_path = swap_path + ".trunc"; + write_file_bytes(trunc_path, bytes.substr(0, bytes.size() - 1)); + + Buffer b2; + ASSERT_TRUE(b2.OpenFromFile(path, err)); + std::string rerr; + ASSERT_EQ(kte::SwapManager::ReplayFile(b2, trunc_path, rerr), false); + ASSERT_EQ(rerr.empty(), false); + + std::remove(path.c_str()); + std::remove(swap_path.c_str()); + std::remove(trunc_path.c_str()); +} \ No newline at end of file diff --git a/tests/test_swap_writer.cc b/tests/test_swap_writer.cc new file mode 100644 index 0000000..2bb6c59 --- /dev/null +++ b/tests/test_swap_writer.cc @@ -0,0 +1,236 @@ +#include "Test.h" + +#include "Buffer.h" +#include "Swap.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace { +std::vector +read_all_bytes(const std::string &path) +{ + std::ifstream in(path, std::ios::binary); + return std::vector((std::istreambuf_iterator(in)), std::istreambuf_iterator()); +} + + +std::uint32_t +read_le32(const std::uint8_t *p) +{ + return (std::uint32_t) p[0] | ((std::uint32_t) p[1] << 8) | ((std::uint32_t) p[2] << 16) | + ((std::uint32_t) p[3] << 24); +} + + +std::uint64_t +read_le64(const std::uint8_t *p) +{ + std::uint64_t v = 0; + for (int i = 7; i >= 0; --i) { + v = (v << 8) | p[i]; + } + return v; +} + + +std::uint32_t +crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0) +{ + static std::uint32_t table[256]; + static bool inited = false; + if (!inited) { + for (std::uint32_t i = 0; i < 256; ++i) { + std::uint32_t c = i; + for (int j = 0; j < 8; ++j) + c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); + table[i] = c; + } + inited = true; + } + std::uint32_t c = ~seed; + for (std::size_t i = 0; i < len; ++i) + c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8); + return ~c; +} +} // namespace + + +TEST (SwapWriter_Header_Records_And_CRC) +{ + const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() / + (std::string("kte_ut_xdg_state_") + std::to_string((int) ::getpid())); + std::filesystem::remove_all(xdg_root); + + const char *old_xdg = std::getenv("XDG_STATE_HOME"); + setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1); + + const std::string path = (xdg_root / "work" / "kte_ut_swap_writer.txt").string(); + std::filesystem::create_directories((xdg_root / "work")); + + // Clean up from prior runs + std::remove(path.c_str()); + + // Ensure file exists so buffer is file-backed + { + std::ofstream out(path, std::ios::binary); + out << ""; + } + + Buffer b; + std::string err; + ASSERT_TRUE(b.OpenFromFile(path, err)); + ASSERT_TRUE(err.empty()); + ASSERT_TRUE(b.IsFileBacked()); + + kte::SwapManager sm; + sm.Attach(&b); + b.SetSwapRecorder(sm.RecorderFor(&b)); + const std::string swp = kte::SwapManager::ComputeSwapPathForTests(b); + std::remove(swp.c_str()); + + // Emit one INS and one DEL + b.insert_text(0, 0, std::string_view("abc")); + b.delete_text(0, 1, 1); + + // Ensure all records are written before reading + sm.Flush(&b); + sm.Detach(&b); + b.SetSwapRecorder(nullptr); + + ASSERT_TRUE(std::filesystem::exists(swp)); + + // Verify permissions 0600 + struct stat st{}; + ASSERT_TRUE(::stat(swp.c_str(), &st) == 0); + ASSERT_EQ((st.st_mode & 0777), 0600); + + const std::vector bytes = read_all_bytes(swp); + ASSERT_TRUE(bytes.size() >= 64); + + // Header + static const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'}; + for (int i = 0; i < 8; ++i) + ASSERT_EQ(bytes[(std::size_t) i], magic[i]); + ASSERT_EQ(read_le32(bytes.data() + 8), (std::uint32_t) 1); + // flags currently 0 + ASSERT_EQ(read_le32(bytes.data() + 12), (std::uint32_t) 0); + ASSERT_TRUE(read_le64(bytes.data() + 16) != 0); + + // Records + std::vector types; + std::size_t off = 64; + while (off < bytes.size()) { + ASSERT_TRUE(bytes.size() - off >= 8); // at least header+crc + const std::uint8_t type = bytes[off + 0]; + const std::uint32_t len = (std::uint32_t) bytes[off + 1] | ((std::uint32_t) bytes[off + 2] << 8) | + ((std::uint32_t) bytes[off + 3] << 16); + const std::size_t payload_off = off + 4; + const std::size_t crc_off = payload_off + len; + ASSERT_TRUE(crc_off + 4 <= bytes.size()); + + const std::uint32_t got_crc = read_le32(bytes.data() + crc_off); + std::uint32_t c = 0; + c = crc32(bytes.data() + off, 4, c); + c = crc32(bytes.data() + payload_off, len, c); + ASSERT_EQ(got_crc, c); + + types.push_back(type); + off = crc_off + 4; + } + + ASSERT_EQ(types.size(), (std::size_t) 2); + ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::INS); + ASSERT_EQ(types[1], (std::uint8_t) kte::SwapRecType::DEL); + + std::remove(path.c_str()); + std::remove(swp.c_str()); + if (old_xdg) { + setenv("XDG_STATE_HOME", old_xdg, 1); + } else { + unsetenv("XDG_STATE_HOME"); + } + std::filesystem::remove_all(xdg_root); +} + + +TEST (SwapWriter_NoStomp_SameBasename) +{ + const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() / + (std::string("kte_ut_xdg_state_nostomp_") + std::to_string( + (int) ::getpid())); + std::filesystem::remove_all(xdg_root); + std::filesystem::create_directories(xdg_root); + + const char *old_xdg = std::getenv("XDG_STATE_HOME"); + setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1); + + const std::filesystem::path d1 = xdg_root / "p1"; + const std::filesystem::path d2 = xdg_root / "p2"; + std::filesystem::create_directories(d1); + std::filesystem::create_directories(d2); + const std::filesystem::path f1 = d1 / "same.txt"; + const std::filesystem::path f2 = d2 / "same.txt"; + + { + std::ofstream out(f1.string(), std::ios::binary); + out << ""; + } + { + std::ofstream out(f2.string(), std::ios::binary); + out << ""; + } + + Buffer b1; + Buffer b2; + std::string err; + ASSERT_TRUE(b1.OpenFromFile(f1.string(), err)); + ASSERT_TRUE(err.empty()); + ASSERT_TRUE(b2.OpenFromFile(f2.string(), err)); + ASSERT_TRUE(err.empty()); + + const std::string swp1 = kte::SwapManager::ComputeSwapPathForTests(b1); + const std::string swp2 = kte::SwapManager::ComputeSwapPathForTests(b2); + ASSERT_TRUE(swp1 != swp2); + + // Actually write to both to ensure one doesn't clobber the other. + kte::SwapManager sm; + sm.Attach(&b1); + sm.Attach(&b2); + b1.SetSwapRecorder(sm.RecorderFor(&b1)); + b2.SetSwapRecorder(sm.RecorderFor(&b2)); + + b1.insert_text(0, 0, std::string_view("one")); + b2.insert_text(0, 0, std::string_view("two")); + sm.Flush(); + + ASSERT_TRUE(std::filesystem::exists(swp1)); + ASSERT_TRUE(std::filesystem::exists(swp2)); + ASSERT_TRUE(std::filesystem::file_size(swp1) >= 64); + ASSERT_TRUE(std::filesystem::file_size(swp2) >= 64); + + sm.Detach(&b1); + sm.Detach(&b2); + b1.SetSwapRecorder(nullptr); + b2.SetSwapRecorder(nullptr); + + std::remove(swp1.c_str()); + std::remove(swp2.c_str()); + std::remove(f1.string().c_str()); + std::remove(f2.string().c_str()); + if (old_xdg) { + setenv("XDG_STATE_HOME", old_xdg, 1); + } else { + unsetenv("XDG_STATE_HOME"); + } + std::filesystem::remove_all(xdg_root); +} \ No newline at end of file diff --git a/tests/test_visual_line_mode.cc b/tests/test_visual_line_mode.cc index f41856f..74f3782 100644 --- a/tests/test_visual_line_mode.cc +++ b/tests/test_visual_line_mode.cc @@ -65,6 +65,49 @@ TEST (VisualLineMode_BroadcastInsert) } +TEST (VisualLineMode_BroadcastInsert_UndoRedo) +{ + InstallDefaultCommands(); + + Editor ed; + ed.SetDimensions(24, 80); + + Buffer b; + b.insert_text(0, 0, "foo\nfoo\nfoo\n"); + b.SetCursor(1, 0); // fo|o + ed.AddBuffer(std::move(b)); + + ASSERT_TRUE(ed.CurrentBuffer() != nullptr); + + ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle"))); + ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2)); + + // Broadcast insert to all selected lines. + ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X"))); + { + const std::string got = dump_buf(*ed.CurrentBuffer()); + const std::string exp = "fXoo\nfXoo\nfXoo\n\n"; + ASSERT_TRUE(got == exp); + } + + // Undo should restore all affected lines in a single step. + ASSERT_TRUE(Execute(ed, std::string("undo"))); + { + const std::string got = dump_buf(*ed.CurrentBuffer()); + const std::string exp = "foo\nfoo\nfoo\n\n"; + ASSERT_TRUE(got == exp); + } + + // Redo should re-apply the whole insert. + ASSERT_TRUE(Execute(ed, std::string("redo"))); + { + const std::string got = dump_buf(*ed.CurrentBuffer()); + const std::string exp = "fXoo\nfXoo\nfXoo\n\n"; + ASSERT_TRUE(got == exp); + } +} + + TEST (VisualLineMode_BroadcastBackspace) { InstallDefaultCommands(); @@ -92,6 +135,46 @@ TEST (VisualLineMode_BroadcastBackspace) } +TEST (VisualLineMode_BroadcastBackspace_UndoRedo) +{ + InstallDefaultCommands(); + + Editor ed; + ed.SetDimensions(24, 80); + + Buffer b; + b.insert_text(0, 0, "abcd\nabcd\nabcd\n"); + b.SetCursor(2, 0); // ab|cd + ed.AddBuffer(std::move(b)); + + ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle"))); + ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2)); + + ASSERT_TRUE(Execute(ed, std::string("backspace"))); + { + const std::string got = dump_buf(*ed.CurrentBuffer()); + const std::string exp = "acd\nacd\nacd\n\n"; + ASSERT_TRUE(got == exp); + } + + // Undo should restore all affected lines. + ASSERT_TRUE(Execute(ed, std::string("undo"))); + { + const std::string got = dump_buf(*ed.CurrentBuffer()); + const std::string exp = "abcd\nabcd\nabcd\n\n"; + ASSERT_TRUE(got == exp); + } + + // Redo should re-apply. + ASSERT_TRUE(Execute(ed, std::string("redo"))); + { + const std::string got = dump_buf(*ed.CurrentBuffer()); + const std::string exp = "acd\nacd\nacd\n\n"; + ASSERT_TRUE(got == exp); + } +} + + TEST (VisualLineMode_CancelWithCtrlG) { InstallDefaultCommands(); @@ -155,4 +238,95 @@ TEST (Yank_ClearsMarkAndVisualLine) ASSERT_TRUE(!buf->MarkSet()); ASSERT_TRUE(!buf->VisualLineActive()); +} + + +TEST (VisualLineMode_Yank_BroadcastsToBOL_AndUndo) +{ + InstallDefaultCommands(); + + Editor ed; + ed.SetDimensions(24, 80); + + Buffer b; + b.insert_text(0, 0, "aa\nbb\ncc\n"); + b.SetCursor(1, 0); // a|a + ed.AddBuffer(std::move(b)); + + ASSERT_TRUE(ed.CurrentBuffer() != nullptr); + + // Enter visual-line mode and extend selection to 3 lines. + ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle"))); + ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2)); + ASSERT_TRUE(ed.CurrentBuffer()->VisualLineActive()); + + ed.KillRingClear(); + ed.KillRingPush("X"); + + // Yank in visual-line mode should paste at BOL on every affected line. + ASSERT_TRUE(Execute(ed, std::string("yank"))); + { + const std::string got = dump_buf(*ed.CurrentBuffer()); + // Note: buffers that end with a trailing '\n' have an extra empty row. + const std::string exp = "Xaa\nXbb\nXcc\n\n"; + if (got != exp) { + std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n"; + std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n"; + } + ASSERT_TRUE(got == exp); + } + + // Undo should restore all affected lines in a single step. + ASSERT_TRUE(Execute(ed, std::string("undo"))); + { + const std::string got = dump_buf(*ed.CurrentBuffer()); + const std::string exp = "aa\nbb\ncc\n\n"; + if (got != exp) { + std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n"; + std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n"; + } + ASSERT_TRUE(got == exp); + } + + // Redo should re-apply the whole yank. + ASSERT_TRUE(Execute(ed, std::string("redo"))); + { + const std::string got = dump_buf(*ed.CurrentBuffer()); + const std::string exp = "Xaa\nXbb\nXcc\n\n"; + ASSERT_TRUE(got == exp); + } +} + + +TEST (VisualLineMode_Highlight_IsPerLineCursorSpot) +{ + Buffer b; + // Note: buffers that end with a trailing '\n' have an extra empty row. + b.insert_text(0, 0, "abcd\nx\nhi\n"); + // Place primary cursor on line 0 at column 3 (abc|d). + b.SetCursor(3, 0); + + // Select lines 0..2 in visual-line mode. + b.VisualLineStart(); + b.VisualLineSetActiveY(2); + ASSERT_TRUE(b.VisualLineActive()); + ASSERT_TRUE(b.VisualLineStartY() == 0); + ASSERT_TRUE(b.VisualLineEndY() == 2); + + // Line 0: "abcd" (len=4) => spot is 3 + ASSERT_TRUE(b.VisualLineSpotSelected(0, 3)); + ASSERT_TRUE(!b.VisualLineSpotSelected(0, 0)); + ASSERT_TRUE(!b.VisualLineSpotSelected(0, 2)); + ASSERT_TRUE(!b.VisualLineSpotSelected(0, 4)); + + // Line 1: "x" (len=1) => spot clamps to EOL (1) + ASSERT_TRUE(b.VisualLineSpotSelected(1, 1)); + ASSERT_TRUE(!b.VisualLineSpotSelected(1, 0)); + + // Line 2: "hi" (len=2) => spot clamps to EOL (2) + ASSERT_TRUE(b.VisualLineSpotSelected(2, 2)); + ASSERT_TRUE(!b.VisualLineSpotSelected(2, 0)); + + // Outside the selected line range should never be highlighted. + ASSERT_TRUE(!b.VisualLineSpotSelected(3, 0)); } \ No newline at end of file