Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b5b55dce | |||
| 422b27b1ba | |||
| 9485d2aa24 | |||
| 8a6b7851d5 | |||
| 8ec0d6ac41 | |||
| 337b585ba0 | |||
| 95a588b0df | |||
| 199d7a20f7 | |||
| 44827fe53f | |||
| 2a6ff2a862 |
225
Buffer.cc
225
Buffer.cc
@@ -7,6 +7,13 @@
|
||||
#include <cstring>
|
||||
#include <string_view>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "SwapRecorder.h"
|
||||
#include "UndoSystem.h"
|
||||
@@ -24,6 +31,159 @@ Buffer::Buffer()
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Buffer::stat_identity(const std::string &path, FileIdentity &out)
|
||||
{
|
||||
struct stat st{};
|
||||
if (::stat(path.c_str(), &st) != 0) {
|
||||
out.valid = false;
|
||||
return false;
|
||||
}
|
||||
out.valid = true;
|
||||
// Use nanosecond timestamp when available.
|
||||
std::uint64_t ns = 0;
|
||||
#if defined(__APPLE__)
|
||||
ns = static_cast<std::uint64_t>(st.st_mtimespec.tv_sec) * 1000000000ull
|
||||
+ static_cast<std::uint64_t>(st.st_mtimespec.tv_nsec);
|
||||
#else
|
||||
ns = static_cast<std::uint64_t>(st.st_mtim.tv_sec) * 1000000000ull
|
||||
+ static_cast<std::uint64_t>(st.st_mtim.tv_nsec);
|
||||
#endif
|
||||
out.mtime_ns = ns;
|
||||
out.size = static_cast<std::uint64_t>(st.st_size);
|
||||
out.dev = static_cast<std::uint64_t>(st.st_dev);
|
||||
out.ino = static_cast<std::uint64_t>(st.st_ino);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Buffer::current_disk_identity(FileIdentity &out) const
|
||||
{
|
||||
if (!is_file_backed_ || filename_.empty()) {
|
||||
out.valid = false;
|
||||
return false;
|
||||
}
|
||||
return stat_identity(filename_, out);
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Buffer::ExternallyModifiedOnDisk() const
|
||||
{
|
||||
if (!is_file_backed_ || filename_.empty())
|
||||
return false;
|
||||
FileIdentity now{};
|
||||
if (!current_disk_identity(now)) {
|
||||
// If the file vanished, treat as modified when we previously had an identity.
|
||||
return on_disk_identity_.valid;
|
||||
}
|
||||
if (!on_disk_identity_.valid)
|
||||
return false;
|
||||
return now.mtime_ns != on_disk_identity_.mtime_ns
|
||||
|| now.size != on_disk_identity_.size
|
||||
|| now.dev != on_disk_identity_.dev
|
||||
|| now.ino != on_disk_identity_.ino;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Buffer::RefreshOnDiskIdentity()
|
||||
{
|
||||
FileIdentity id{};
|
||||
if (current_disk_identity(id))
|
||||
on_disk_identity_ = id;
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
|
||||
{
|
||||
std::size_t off = 0;
|
||||
while (off < len) {
|
||||
ssize_t n = ::write(fd, data + off, len - off);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
err = std::string("Write failed: ") + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
off += static_cast<std::size_t>(n);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
best_effort_fsync_dir(const std::string &path)
|
||||
{
|
||||
try {
|
||||
std::filesystem::path p(path);
|
||||
std::filesystem::path dir = p.parent_path();
|
||||
if (dir.empty())
|
||||
return;
|
||||
int dfd = ::open(dir.c_str(), O_RDONLY);
|
||||
if (dfd < 0)
|
||||
return;
|
||||
(void) ::fsync(dfd);
|
||||
(void) ::close(dfd);
|
||||
} catch (...) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err)
|
||||
{
|
||||
// Create a temp file in the same directory so rename() is atomic.
|
||||
std::filesystem::path p(path);
|
||||
std::filesystem::path dir = p.parent_path();
|
||||
std::string base = p.filename().string();
|
||||
std::filesystem::path tmpl = dir / ("." + base + ".kte.tmp.XXXXXX");
|
||||
std::string tmpl_s = tmpl.string();
|
||||
|
||||
// mkstemp requires a mutable buffer.
|
||||
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
|
||||
buf.push_back('\0');
|
||||
int fd = ::mkstemp(buf.data());
|
||||
if (fd < 0) {
|
||||
err = std::string("Failed to create temp file for save: ") + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
std::string tmp_path(buf.data());
|
||||
|
||||
// If the destination exists, carry over its permissions.
|
||||
struct stat dst_st{};
|
||||
if (::stat(path.c_str(), &dst_st) == 0) {
|
||||
(void) ::fchmod(fd, dst_st.st_mode);
|
||||
}
|
||||
|
||||
bool ok = write_all_fd(fd, data, len, err);
|
||||
if (ok) {
|
||||
if (::fsync(fd) != 0) {
|
||||
err = std::string("fsync failed: ") + std::strerror(errno);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
(void) ::close(fd);
|
||||
|
||||
if (ok) {
|
||||
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
|
||||
err = std::string("rename failed: ") + std::strerror(errno);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
(void) ::unlink(tmp_path.c_str());
|
||||
return false;
|
||||
}
|
||||
best_effort_fsync_dir(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Buffer::Buffer(const std::string &path)
|
||||
{
|
||||
std::string err;
|
||||
@@ -271,6 +431,7 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
||||
filename_ = norm;
|
||||
is_file_backed_ = true;
|
||||
dirty_ = false;
|
||||
RefreshOnDiskIdentity();
|
||||
|
||||
// Reset/initialize undo system for this loaded file
|
||||
if (!undo_tree_)
|
||||
@@ -297,22 +458,16 @@ Buffer::Save(std::string &err) const
|
||||
err = "Buffer is not file-backed; use SaveAs()";
|
||||
return false;
|
||||
}
|
||||
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
||||
const std::size_t sz = content_.Size();
|
||||
const char *data = sz ? content_.Data() : nullptr;
|
||||
if (sz && !data) {
|
||||
err = "Internal error: buffer materialization failed";
|
||||
return false;
|
||||
}
|
||||
// Stream the content directly from the piece table to avoid relying on
|
||||
// full materialization, which may yield an empty pointer when size > 0.
|
||||
if (content_.Size() > 0) {
|
||||
content_.WriteToStream(out);
|
||||
}
|
||||
// Ensure data hits the OS buffers
|
||||
out.flush();
|
||||
if (!out.good()) {
|
||||
err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
||||
if (!atomic_write_file(filename_, data ? data : "", sz, err))
|
||||
return false;
|
||||
}
|
||||
// Update observed on-disk identity after a successful save.
|
||||
const_cast<Buffer *>(this)->RefreshOnDiskIdentity();
|
||||
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
||||
// to decide when to flip dirty flag after successful save.
|
||||
return true;
|
||||
@@ -341,26 +496,19 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
||||
out_path = path;
|
||||
}
|
||||
|
||||
// Write to the given path
|
||||
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
||||
const std::size_t sz = content_.Size();
|
||||
const char *data = sz ? content_.Data() : nullptr;
|
||||
if (sz && !data) {
|
||||
err = "Internal error: buffer materialization failed";
|
||||
return false;
|
||||
}
|
||||
// Stream content without forcing full materialization
|
||||
if (content_.Size() > 0) {
|
||||
content_.WriteToStream(out);
|
||||
}
|
||||
// Ensure data hits the OS buffers
|
||||
out.flush();
|
||||
if (!out.good()) {
|
||||
err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
||||
if (!atomic_write_file(out_path, data ? data : "", sz, err))
|
||||
return false;
|
||||
}
|
||||
|
||||
filename_ = out_path;
|
||||
is_file_backed_ = true;
|
||||
dirty_ = false;
|
||||
RefreshOnDiskIdentity();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -437,6 +585,21 @@ Buffer::content_LineCount_() const
|
||||
}
|
||||
|
||||
|
||||
#if defined(KTE_TESTS)
|
||||
std::string
|
||||
Buffer::BytesForTests() const
|
||||
{
|
||||
const std::size_t sz = content_.Size();
|
||||
if (sz == 0)
|
||||
return std::string();
|
||||
const char *data = content_.Data();
|
||||
if (!data)
|
||||
return std::string();
|
||||
return std::string(data, data + sz);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
void
|
||||
Buffer::delete_text(int row, int col, std::size_t len)
|
||||
{
|
||||
@@ -575,6 +738,16 @@ Buffer::delete_row(int row)
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Buffer::replace_all_bytes(const std::string_view bytes)
|
||||
{
|
||||
content_.Clear();
|
||||
if (!bytes.empty())
|
||||
content_.Append(bytes.data(), bytes.size());
|
||||
rows_cache_dirty_ = true;
|
||||
}
|
||||
|
||||
|
||||
// Undo system accessors
|
||||
UndoSystem *
|
||||
Buffer::Undo()
|
||||
|
||||
70
Buffer.h
70
Buffer.h
@@ -1,5 +1,37 @@
|
||||
/*
|
||||
* Buffer.h - editor buffer representing an open document
|
||||
*
|
||||
* Buffer is the central document model in kte. Each Buffer represents one open file
|
||||
* or scratch document and manages:
|
||||
*
|
||||
* - Content storage: Uses PieceTable for efficient text operations
|
||||
* - Cursor state: Current position (curx_, cury_), rendered column (rx_)
|
||||
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
|
||||
* - File backing: Optional association with a file on disk
|
||||
* - Undo/Redo: Integrated UndoSystem for operation history
|
||||
* - Syntax highlighting: Optional HighlighterEngine for language-aware coloring
|
||||
* - Swap/crash recovery: Integration with SwapRecorder for journaling
|
||||
* - Dirty tracking: Modification state for save prompts
|
||||
*
|
||||
* Key concepts:
|
||||
*
|
||||
* 1. Cursor coordinates:
|
||||
* - (curx_, cury_): Logical character position in the document
|
||||
* - rx_: Rendered column accounting for tab expansion
|
||||
*
|
||||
* 2. File backing:
|
||||
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
|
||||
* - File identity tracking detects external modifications
|
||||
*
|
||||
* 3. Legacy Line wrapper:
|
||||
* - Buffer::Line provides a string-like interface for legacy command code
|
||||
* - New code should prefer direct PieceTable operations
|
||||
* - See DEVELOPER_GUIDE.md for migration guidance
|
||||
*
|
||||
* 4. Content access:
|
||||
* - Rows(): Materialized line cache (legacy, being phased out)
|
||||
* - GetLineView(): Zero-copy line access via string_view (preferred)
|
||||
* - Direct PieceTable access for new editing operations
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
@@ -42,6 +74,14 @@ public:
|
||||
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
||||
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
|
||||
|
||||
// External modification detection.
|
||||
// Returns true if the file on disk differs from the last observed identity recorded
|
||||
// on open/save.
|
||||
[[nodiscard]] bool ExternallyModifiedOnDisk() const;
|
||||
|
||||
// Refresh the stored on-disk identity to match current stat (used after open/save).
|
||||
void RefreshOnDiskIdentity();
|
||||
|
||||
// Accessors
|
||||
[[nodiscard]] std::size_t Curx() const
|
||||
{
|
||||
@@ -494,6 +534,12 @@ public:
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
|
||||
{
|
||||
return swap_rec_;
|
||||
}
|
||||
|
||||
|
||||
// Raw, low-level editing APIs used by UndoSystem apply().
|
||||
// These must NOT trigger undo recording. They also do not move the cursor.
|
||||
void insert_text(int row, int col, std::string_view text);
|
||||
@@ -508,12 +554,36 @@ public:
|
||||
|
||||
void delete_row(int row);
|
||||
|
||||
// Replace the entire buffer content with raw bytes.
|
||||
// Intended for crash recovery (swap replay) and test harnesses.
|
||||
// This does not trigger swap or undo recording.
|
||||
void replace_all_bytes(std::string_view bytes);
|
||||
|
||||
// Undo system accessors (created per-buffer)
|
||||
[[nodiscard]] UndoSystem *Undo();
|
||||
|
||||
[[nodiscard]] const UndoSystem *Undo() const;
|
||||
|
||||
#if defined(KTE_TESTS)
|
||||
// Test-only: return the raw buffer bytes (including newlines) as a string.
|
||||
[[nodiscard]] std::string BytesForTests() const;
|
||||
#endif
|
||||
|
||||
private:
|
||||
struct FileIdentity {
|
||||
bool valid = false;
|
||||
std::uint64_t mtime_ns = 0;
|
||||
std::uint64_t size = 0;
|
||||
std::uint64_t dev = 0;
|
||||
std::uint64_t ino = 0;
|
||||
};
|
||||
|
||||
[[nodiscard]] static bool stat_identity(const std::string &path, FileIdentity &out);
|
||||
|
||||
[[nodiscard]] bool current_disk_identity(FileIdentity &out) const;
|
||||
|
||||
mutable FileIdentity on_disk_identity_{};
|
||||
|
||||
// State mirroring original C struct (without undo_tree)
|
||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
||||
|
||||
@@ -4,7 +4,7 @@ project(kte)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(KTE_VERSION "1.6.1")
|
||||
set(KTE_VERSION "1.6.6")
|
||||
|
||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||
@@ -68,11 +68,19 @@ if (BUILD_GUI)
|
||||
endif ()
|
||||
|
||||
# NCurses for terminal mode
|
||||
set(CURSES_NEED_NCURSES)
|
||||
set(CURSES_NEED_WIDE)
|
||||
set(CURSES_NEED_NCURSES TRUE)
|
||||
set(CURSES_NEED_WIDE TRUE)
|
||||
find_package(Curses REQUIRED)
|
||||
include_directories(${CURSES_INCLUDE_DIR})
|
||||
|
||||
# On Alpine Linux, CMake's FindCurses looks in wrong paths
|
||||
# Manually find the correct ncurses library
|
||||
if (EXISTS "/etc/alpine-release")
|
||||
find_library(NCURSESW_LIB NAMES ncursesw PATHS /usr/lib /lib REQUIRED)
|
||||
set(CURSES_LIBRARIES ${NCURSESW_LIB})
|
||||
message(STATUS "Alpine Linux detected, using ncurses at: ${NCURSESW_LIB}")
|
||||
endif ()
|
||||
|
||||
set(SYNTAX_SOURCES
|
||||
syntax/GoHighlighter.cc
|
||||
syntax/CppHighlighter.cc
|
||||
@@ -308,12 +316,18 @@ if (BUILD_TESTS)
|
||||
tests/test_swap_recorder.cc
|
||||
tests/test_swap_writer.cc
|
||||
tests/test_swap_replay.cc
|
||||
tests/test_swap_recovery_prompt.cc
|
||||
tests/test_swap_cleanup.cc
|
||||
tests/test_swap_git_editor.cc
|
||||
tests/test_piece_table.cc
|
||||
tests/test_search.cc
|
||||
tests/test_search_replace_flow.cc
|
||||
tests/test_reflow_paragraph.cc
|
||||
tests/test_reflow_indented_bullets.cc
|
||||
tests/test_undo.cc
|
||||
tests/test_visual_line_mode.cc
|
||||
tests/test_benchmarks.cc
|
||||
tests/test_migration_coverage.cc
|
||||
|
||||
# minimal engine sources required by Buffer
|
||||
PieceTable.cc
|
||||
|
||||
113
Command.cc
113
Command.cc
@@ -618,6 +618,8 @@ cmd_save(CommandContext &ctx)
|
||||
return false;
|
||||
}
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap())
|
||||
sm->ResetJournal(*buf);
|
||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||
return true;
|
||||
}
|
||||
@@ -627,11 +629,22 @@ cmd_save(CommandContext &ctx)
|
||||
ctx.editor.SetStatus("Save as: ");
|
||||
return true;
|
||||
}
|
||||
// External modification detection: if the on-disk file changed since we last observed it,
|
||||
// require confirmation before overwriting.
|
||||
if (buf->ExternallyModifiedOnDisk()) {
|
||||
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", "");
|
||||
ctx.editor.SetPendingOverwritePath(buf->Filename());
|
||||
ctx.editor.SetStatus(
|
||||
std::string("File changed on disk: overwrite '") + buf->Filename() + "'? (y/N)");
|
||||
return true;
|
||||
}
|
||||
if (!buf->Save(err)) {
|
||||
ctx.editor.SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap())
|
||||
sm->ResetJournal(*buf);
|
||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
@@ -686,6 +699,10 @@ cmd_save_as(CommandContext &ctx)
|
||||
ctx.editor.SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
if (auto *sm = ctx.editor.Swap()) {
|
||||
sm->NotifyFilenameChanged(*buf);
|
||||
sm->ResetJournal(*buf);
|
||||
}
|
||||
ctx.editor.SetStatus("Saved as " + ctx.arg);
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
@@ -789,6 +806,7 @@ cmd_refresh(CommandContext &ctx)
|
||||
ctx.editor.SetCloseConfirmPending(false);
|
||||
ctx.editor.SetCloseAfterSave(false);
|
||||
ctx.editor.ClearPendingOverwritePath();
|
||||
ctx.editor.CancelRecoveryPrompt();
|
||||
ctx.editor.CancelPrompt();
|
||||
ctx.editor.SetStatus("Canceled");
|
||||
return true;
|
||||
@@ -808,6 +826,14 @@ cmd_refresh(CommandContext &ctx)
|
||||
ctx.editor.SetStatus("Find canceled");
|
||||
return true;
|
||||
}
|
||||
// If nothing else to cancel, treat C-g/refresh as a mark clear (ke behavior).
|
||||
if (Buffer *buf = ctx.editor.CurrentBuffer()) {
|
||||
if (buf->MarkSet()) {
|
||||
buf->ClearMark();
|
||||
ctx.editor.SetStatus("Mark cleared");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Otherwise just a hint; renderer will redraw
|
||||
ctx.editor.SetStatus("");
|
||||
return true;
|
||||
@@ -1083,6 +1109,7 @@ cmd_theme_set_by_name(const CommandContext &ctx)
|
||||
static bool
|
||||
cmd_theme_set_by_name(CommandContext &ctx)
|
||||
{
|
||||
|
||||
# if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
|
||||
// Qt GUI build: schedule theme change for frontend
|
||||
std::string name = ctx.arg;
|
||||
@@ -2441,7 +2468,6 @@ cmd_newline(CommandContext &ctx)
|
||||
ctx.editor.SetSearchIndex(-1);
|
||||
return true;
|
||||
} else if (kind == Editor::PromptKind::OpenFile) {
|
||||
std::string err;
|
||||
// Expand "~" to the user's home directory
|
||||
auto expand_user_path = [](const std::string &in) -> std::string {
|
||||
if (!in.empty() && in[0] == '~') {
|
||||
@@ -2458,15 +2484,20 @@ cmd_newline(CommandContext &ctx)
|
||||
value = expand_user_path(value);
|
||||
if (value.empty()) {
|
||||
ctx.editor.SetStatus("Open canceled (empty)");
|
||||
} else if (!ctx.editor.OpenFile(value, err)) {
|
||||
ctx.editor.SetStatus(err.empty() ? std::string("Failed to open ") + value : err);
|
||||
} else {
|
||||
ctx.editor.SetStatus(std::string("Opened ") + value);
|
||||
ctx.editor.RequestOpenFile(value);
|
||||
const bool opened = ctx.editor.ProcessPendingOpens();
|
||||
if (ctx.editor.PromptActive()) {
|
||||
// A recovery confirmation prompt was started.
|
||||
return true;
|
||||
}
|
||||
if (opened) {
|
||||
// Center the view on the cursor (e.g. if the buffer restored a cursor position)
|
||||
cmd_center_on_cursor(ctx);
|
||||
// Close the prompt so subsequent typing edits the buffer, not the prompt
|
||||
ctx.editor.CancelPrompt();
|
||||
}
|
||||
}
|
||||
} else if (kind == Editor::PromptKind::BufferSwitch) {
|
||||
// Resolve to a buffer index by exact match against path or basename;
|
||||
// if multiple partial matches, prefer exact; if none, keep status.
|
||||
@@ -2575,11 +2606,19 @@ cmd_newline(CommandContext &ctx)
|
||||
}
|
||||
if (yes) {
|
||||
std::string err;
|
||||
if (!buf->SaveAs(target, err)) {
|
||||
const bool is_same_target = (buf->Filename() == target) && buf->IsFileBacked();
|
||||
const bool ok = is_same_target ? buf->Save(err) : buf->SaveAs(target, err);
|
||||
if (!ok) {
|
||||
ctx.editor.SetStatus(err);
|
||||
} else {
|
||||
buf->SetDirty(false);
|
||||
ctx.editor.SetStatus("Saved as " + target);
|
||||
if (auto *sm = ctx.editor.Swap()) {
|
||||
if (!is_same_target)
|
||||
sm->NotifyFilenameChanged(*buf);
|
||||
sm->ResetJournal(*buf);
|
||||
}
|
||||
ctx.editor.SetStatus(
|
||||
is_same_target ? ("Saved " + target) : ("Saved as " + target));
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
// If this overwrite confirm was part of a close-after-save flow, close now.
|
||||
@@ -2612,6 +2651,16 @@ cmd_newline(CommandContext &ctx)
|
||||
ctx.editor.ClearPendingOverwritePath();
|
||||
// Regardless of answer, end any close-after-save pending state for safety.
|
||||
ctx.editor.SetCloseAfterSave(false);
|
||||
} else if (ctx.editor.PendingRecoveryPrompt() != Editor::RecoveryPromptKind::None) {
|
||||
bool yes = false;
|
||||
if (!value.empty()) {
|
||||
char c = value[0];
|
||||
yes = (c == 'y' || c == 'Y');
|
||||
}
|
||||
(void) ctx.editor.ResolveRecoveryPrompt(yes);
|
||||
ctx.editor.CancelPrompt();
|
||||
// Continue any queued opens (e.g., startup argv files).
|
||||
ctx.editor.ProcessPendingOpens();
|
||||
} else if (ctx.editor.CloseConfirmPending() && buf) {
|
||||
bool yes = false;
|
||||
if (!value.empty()) {
|
||||
@@ -2630,6 +2679,8 @@ cmd_newline(CommandContext &ctx)
|
||||
proceed_to_close = false;
|
||||
} else {
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap())
|
||||
sm->ResetJournal(*buf);
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
}
|
||||
@@ -2639,6 +2690,10 @@ cmd_newline(CommandContext &ctx)
|
||||
proceed_to_close = false;
|
||||
} else {
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap()) {
|
||||
sm->NotifyFilenameChanged(*buf);
|
||||
sm->ResetJournal(*buf);
|
||||
}
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
}
|
||||
@@ -2675,6 +2730,8 @@ cmd_newline(CommandContext &ctx)
|
||||
ctx.editor.SetStatus("No buffer");
|
||||
return true;
|
||||
}
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
std::size_t nrows = buf->Nrows();
|
||||
if (nrows == 0) {
|
||||
buf->SetCursor(0, 0);
|
||||
@@ -3346,6 +3403,8 @@ cmd_move_file_start(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
buf->SetCursor(0, 0);
|
||||
if (buf->VisualLineActive())
|
||||
@@ -3361,6 +3420,8 @@ cmd_move_file_end(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
const auto &rows = buf->Rows();
|
||||
std::size_t y = rows.empty() ? 0 : rows.size() - 1;
|
||||
@@ -3408,6 +3469,8 @@ cmd_jump_to_mark(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
if (!buf->MarkSet()) {
|
||||
ctx.editor.SetStatus("Mark not set");
|
||||
return false;
|
||||
@@ -3849,6 +3912,8 @@ cmd_scroll_up(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
const auto &rows = buf->Rows();
|
||||
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
||||
@@ -3882,6 +3947,8 @@ cmd_scroll_down(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
const auto &rows = buf->Rows();
|
||||
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
||||
@@ -4246,6 +4313,27 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
struct GroupGuard {
|
||||
UndoSystem *u;
|
||||
|
||||
|
||||
explicit GroupGuard(UndoSystem *u_) : u(u_)
|
||||
{
|
||||
if (u)
|
||||
u->BeginGroup();
|
||||
}
|
||||
|
||||
|
||||
~GroupGuard()
|
||||
{
|
||||
if (u)
|
||||
u->EndGroup();
|
||||
}
|
||||
};
|
||||
// Reflow performs a multi-edit transformation; make it a single standalone undo/redo step.
|
||||
GroupGuard guard(buf->Undo());
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
auto &rows = buf->Rows();
|
||||
std::size_t y = buf->Cury();
|
||||
@@ -4428,12 +4516,6 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
std::size_t j = i + 1;
|
||||
while (j <= para_end) {
|
||||
std::string ns = static_cast<std::string>(rows[j]);
|
||||
if (starts_with(ns, indent + " ")) {
|
||||
content += ' ';
|
||||
content += ns.substr(indent.size() + 2);
|
||||
++j;
|
||||
continue;
|
||||
}
|
||||
// stop if next bullet at same indentation or different structure
|
||||
std::string nindent;
|
||||
char nmarker;
|
||||
@@ -4445,6 +4527,13 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
if (is_numbered_line(ns, nindent, nnmarker, nidx)) {
|
||||
break; // next item
|
||||
}
|
||||
// Now check if it's a continuation line
|
||||
if (starts_with(ns, indent + " ")) {
|
||||
content += ' ';
|
||||
content += ns.substr(indent.size() + 2);
|
||||
++j;
|
||||
continue;
|
||||
}
|
||||
// Not a continuation and not a bullet: stop (treat as separate paragraph chunk)
|
||||
break;
|
||||
}
|
||||
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Minimal Dockerfile for building and testing kte on Linux
|
||||
# This container provides a build environment with all dependencies.
|
||||
# Mount the source tree at /kte when running the container.
|
||||
FROM alpine:3.19
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache \
|
||||
g++ \
|
||||
cmake \
|
||||
make \
|
||||
ncurses-dev \
|
||||
sdl2-dev \
|
||||
mesa-dev \
|
||||
freetype-dev \
|
||||
libx11-dev \
|
||||
libxext-dev
|
||||
|
||||
# Set working directory where source will be mounted
|
||||
WORKDIR /kte
|
||||
|
||||
# Default command: build and run tests
|
||||
# Add DirectFB include path for SDL2 compatibility on Alpine
|
||||
CMD ["sh", "-c", "cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON -DCMAKE_CXX_FLAGS='-I/usr/include/directfb' && cmake --build build --target kte && cmake --build build --target kge && cmake --build build --target kte_tests && ./build/kte_tests"]
|
||||
227
Editor.cc
227
Editor.cc
@@ -1,6 +1,7 @@
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
#include "Editor.h"
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
@@ -8,6 +9,41 @@
|
||||
#include "syntax/NullHighlighter.h"
|
||||
|
||||
|
||||
namespace {
|
||||
static std::string
|
||||
buffer_bytes_via_views(const Buffer &b)
|
||||
{
|
||||
const std::size_t nrows = b.Nrows();
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < nrows; i++) {
|
||||
auto v = b.GetLineView(i);
|
||||
out.append(v.data(), v.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
apply_pending_line(Editor &ed, const std::size_t line1)
|
||||
{
|
||||
if (line1 == 0)
|
||||
return;
|
||||
Buffer *b = ed.CurrentBuffer();
|
||||
if (!b)
|
||||
return;
|
||||
const std::size_t nrows = b->Nrows();
|
||||
std::size_t line = line1 > 0 ? line1 - 1 : 0; // 1-based to 0-based
|
||||
if (nrows > 0) {
|
||||
if (line >= nrows)
|
||||
line = nrows - 1;
|
||||
} else {
|
||||
line = 0;
|
||||
}
|
||||
b->SetCursor(0, line);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
|
||||
Editor::Editor()
|
||||
{
|
||||
swap_ = std::make_unique<kte::SwapManager>();
|
||||
@@ -162,9 +198,9 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
Buffer &cur = buffers_[curbuf_];
|
||||
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
||||
const bool clean = !cur.Dirty();
|
||||
const auto &rows = cur.Rows();
|
||||
const bool rows_empty = rows.empty();
|
||||
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0);
|
||||
const std::size_t nrows = cur.Nrows();
|
||||
const bool rows_empty = (nrows == 0);
|
||||
const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
|
||||
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
||||
bool ok = cur.OpenFromFile(path, err);
|
||||
if (!ok)
|
||||
@@ -178,9 +214,8 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
// Setup highlighting using registry (extension + shebang)
|
||||
cur.EnsureHighlighter();
|
||||
std::string first = "";
|
||||
const auto &rows = cur.Rows();
|
||||
if (!rows.empty())
|
||||
first = static_cast<std::string>(rows[0]);
|
||||
if (cur.Nrows() > 0)
|
||||
first = cur.GetLineString(0);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||
if (!ft.empty()) {
|
||||
cur.SetFiletype(ft);
|
||||
@@ -212,11 +247,8 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
||||
b.EnsureHighlighter();
|
||||
std::string first = "";
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
if (!rows.empty())
|
||||
first = static_cast<std::string>(rows[0]);
|
||||
}
|
||||
if (b.Nrows() > 0)
|
||||
first = b.GetLineString(0);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||
if (!ft.empty()) {
|
||||
b.SetFiletype(ft);
|
||||
@@ -245,6 +277,172 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::RequestOpenFile(const std::string &path, const std::size_t line1)
|
||||
{
|
||||
PendingOpen p;
|
||||
p.path = path;
|
||||
p.line1 = line1;
|
||||
pending_open_.push_back(std::move(p));
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::HasPendingOpens() const
|
||||
{
|
||||
return !pending_open_.empty();
|
||||
}
|
||||
|
||||
|
||||
Editor::RecoveryPromptKind
|
||||
Editor::PendingRecoveryPrompt() const
|
||||
{
|
||||
return pending_recovery_prompt_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::CancelRecoveryPrompt()
|
||||
{
|
||||
pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||
pending_recovery_open_ = PendingOpen{};
|
||||
pending_recovery_swap_path_.clear();
|
||||
pending_recovery_replay_err_.clear();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::ResolveRecoveryPrompt(const bool yes)
|
||||
{
|
||||
const RecoveryPromptKind kind = pending_recovery_prompt_;
|
||||
if (kind == RecoveryPromptKind::None)
|
||||
return false;
|
||||
const PendingOpen req = pending_recovery_open_;
|
||||
const std::string swp = pending_recovery_swap_path_;
|
||||
const std::string rerr_s = pending_recovery_replay_err_;
|
||||
CancelRecoveryPrompt();
|
||||
|
||||
std::string err;
|
||||
if (kind == RecoveryPromptKind::RecoverOrDiscard) {
|
||||
if (yes) {
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
Buffer *b = CurrentBuffer();
|
||||
if (!b) {
|
||||
SetStatus("Recovery failed: no buffer");
|
||||
return false;
|
||||
}
|
||||
std::string rerr;
|
||||
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
|
||||
SetStatus("Swap recovery failed: " + rerr);
|
||||
return false;
|
||||
}
|
||||
b->SetDirty(true);
|
||||
apply_pending_line(*this, req.line1);
|
||||
SetStatus("Recovered " + req.path);
|
||||
return true;
|
||||
}
|
||||
// Discard: best-effort delete swap, then open clean.
|
||||
(void) std::remove(swp.c_str());
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
apply_pending_line(*this, req.line1);
|
||||
SetStatus("Opened " + req.path);
|
||||
return true;
|
||||
}
|
||||
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
||||
if (yes) {
|
||||
(void) std::remove(swp.c_str());
|
||||
}
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
apply_pending_line(*this, req.line1);
|
||||
// Include a short hint that the swap was corrupt.
|
||||
if (!rerr_s.empty()) {
|
||||
SetStatus("Opened " + req.path + " (swap unreadable)");
|
||||
} else {
|
||||
SetStatus("Opened " + req.path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::ProcessPendingOpens()
|
||||
{
|
||||
if (PromptActive())
|
||||
return false;
|
||||
if (pending_recovery_prompt_ != RecoveryPromptKind::None)
|
||||
return false;
|
||||
|
||||
bool opened_any = false;
|
||||
while (!pending_open_.empty()) {
|
||||
PendingOpen req = std::move(pending_open_.front());
|
||||
pending_open_.pop_front();
|
||||
if (req.path.empty())
|
||||
continue;
|
||||
|
||||
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
|
||||
bool swp_exists = false;
|
||||
try {
|
||||
swp_exists = !swp.empty() && std::filesystem::exists(std::filesystem::path(swp));
|
||||
} catch (...) {
|
||||
swp_exists = false;
|
||||
}
|
||||
if (swp_exists) {
|
||||
Buffer tmp;
|
||||
std::string oerr;
|
||||
if (tmp.OpenFromFile(req.path, oerr)) {
|
||||
const std::string orig = buffer_bytes_via_views(tmp);
|
||||
std::string rerr;
|
||||
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
|
||||
const std::string rec = buffer_bytes_via_views(tmp);
|
||||
if (rec != orig) {
|
||||
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
|
||||
pending_recovery_open_ = req;
|
||||
pending_recovery_swap_path_ = swp;
|
||||
StartPrompt(PromptKind::Confirm, "Recover", "");
|
||||
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)");
|
||||
return opened_any;
|
||||
}
|
||||
} else {
|
||||
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
|
||||
pending_recovery_open_ = req;
|
||||
pending_recovery_swap_path_ = swp;
|
||||
pending_recovery_replay_err_ = rerr;
|
||||
StartPrompt(PromptKind::Confirm, "Swap", "");
|
||||
SetStatus(
|
||||
"Swap file unreadable for " + req.path +
|
||||
". Delete it? (y/N, C-g cancel)");
|
||||
return opened_any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string err;
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
opened_any = false;
|
||||
continue;
|
||||
}
|
||||
apply_pending_line(*this, req.line1);
|
||||
SetStatus("Opened " + req.path);
|
||||
opened_any = true;
|
||||
// Open at most one per call; frontends can call us again next frame.
|
||||
break;
|
||||
}
|
||||
return opened_any;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::SwitchTo(std::size_t index)
|
||||
{
|
||||
@@ -284,7 +482,10 @@ Editor::CloseBuffer(std::size_t index)
|
||||
return false;
|
||||
}
|
||||
if (swap_) {
|
||||
swap_->Detach(&buffers_[index]);
|
||||
// Always remove swap file when closing a buffer on normal exit.
|
||||
// Swap files are for crash recovery; on clean close, we don't need them.
|
||||
// This prevents stale swap files from accumulating (e.g., when used as git editor).
|
||||
swap_->Detach(&buffers_[index], true);
|
||||
buffers_[index].SetSwapRecorder(nullptr);
|
||||
}
|
||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
|
||||
74
Editor.h
74
Editor.h
@@ -1,9 +1,47 @@
|
||||
/*
|
||||
* Editor.h - top-level editor state and buffer management
|
||||
*
|
||||
* Editor is the top-level coordinator in kte. It manages:
|
||||
*
|
||||
* - Buffer collection: Multiple open documents (buffers_), current buffer selection
|
||||
* - UI state: Dimensions, status messages, prompts, search state
|
||||
* - Kill ring: Shared clipboard for cut/copy/paste operations across buffers
|
||||
* - Universal argument: Repeat count mechanism (C-u)
|
||||
* - Mode flags: Editor modes (normal, k-command, search, prompt, etc.)
|
||||
* - Swap/crash recovery: SwapManager integration for journaling
|
||||
* - File operations: Opening files, managing pending opens, recovery prompts
|
||||
*
|
||||
* Key responsibilities:
|
||||
*
|
||||
* 1. Buffer lifecycle:
|
||||
* - AddBuffer(): Add new buffers to the collection
|
||||
* - OpenFile(): Load files into buffers
|
||||
* - SwitchTo(): Change active buffer
|
||||
* - CloseBuffer(): Remove buffers with dirty checks
|
||||
*
|
||||
* 2. UI coordination:
|
||||
* - SetDimensions(): Terminal/window size for viewport calculations
|
||||
* - SetStatus(): Status line messages with timestamps
|
||||
* - Prompt system: Multi-step prompts for file open, buffer switch, etc.
|
||||
* - Search state: Active search, query, match position, origin tracking
|
||||
*
|
||||
* 3. Shared editor state:
|
||||
* - Kill ring: Circular buffer of killed text (max 60 entries)
|
||||
* - Universal argument: C-u digit collection for command repetition
|
||||
* - Mode tracking: Current input mode (normal, k-command, ESC, prompt)
|
||||
*
|
||||
* 4. Integration points:
|
||||
* - Commands operate on Editor and current Buffer
|
||||
* - Frontend (Terminal/GUI) queries Editor for rendering
|
||||
* - SwapManager journals all buffer modifications
|
||||
*
|
||||
* Design note: Editor owns the buffer collection but doesn't directly edit content.
|
||||
* Commands modify buffers through Buffer's API, and Editor coordinates the UI state.
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -497,6 +535,30 @@ public:
|
||||
|
||||
bool OpenFile(const std::string &path, std::string &err);
|
||||
|
||||
// Request that a file be opened. The request is processed by calling
|
||||
// ProcessPendingOpens() (typically once per frontend frame).
|
||||
void RequestOpenFile(const std::string &path, std::size_t line1 = 0);
|
||||
|
||||
// If no modal prompt is active, process queued open requests.
|
||||
// Returns true if a file was opened during this call.
|
||||
bool ProcessPendingOpens();
|
||||
|
||||
[[nodiscard]] bool HasPendingOpens() const;
|
||||
|
||||
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
||||
// prompt is active and the user's answer should be routed to ResolveRecoveryPrompt().
|
||||
enum class RecoveryPromptKind {
|
||||
None = 0,
|
||||
RecoverOrDiscard, // y = recover swap, else discard swap and open clean
|
||||
DeleteCorruptSwap // y = delete corrupt swap, else keep it
|
||||
};
|
||||
|
||||
[[nodiscard]] RecoveryPromptKind PendingRecoveryPrompt() const;
|
||||
|
||||
bool ResolveRecoveryPrompt(bool yes);
|
||||
|
||||
void CancelRecoveryPrompt();
|
||||
|
||||
// Buffer switching/closing
|
||||
bool SwitchTo(std::size_t index);
|
||||
|
||||
@@ -550,6 +612,11 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
struct PendingOpen {
|
||||
std::string path;
|
||||
std::size_t line1{0}; // 1-based; 0 = none
|
||||
};
|
||||
|
||||
std::size_t rows_ = 0, cols_ = 0;
|
||||
int mode_ = 0;
|
||||
int kill_ = 0; // KILL CHAIN
|
||||
@@ -593,6 +660,13 @@ private:
|
||||
std::string prompt_text_;
|
||||
std::string pending_overwrite_path_;
|
||||
|
||||
// Deferred open + swap recovery prompt state
|
||||
std::deque<PendingOpen> pending_open_;
|
||||
RecoveryPromptKind pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||
PendingOpen pending_recovery_open_{};
|
||||
std::string pending_recovery_swap_path_;
|
||||
std::string pending_recovery_replay_err_;
|
||||
|
||||
// GUI-only state (safe no-op in terminal builds)
|
||||
bool file_picker_visible_ = false;
|
||||
std::string file_picker_dir_;
|
||||
|
||||
@@ -22,7 +22,9 @@ HelpText::Text()
|
||||
" C-k ' Toggle read-only\n"
|
||||
" C-k - Unindent region (mark required)\n"
|
||||
" C-k = Indent region (mark required)\n"
|
||||
" C-k / Toggle visual line mode\n"
|
||||
" C-k ; Command prompt (:\\ )\n"
|
||||
" C-k SPACE Toggle mark\n"
|
||||
" C-k C-d Kill entire line\n"
|
||||
" C-k C-q Quit now (no confirm)\n"
|
||||
" C-k C-x Save and quit\n"
|
||||
@@ -31,11 +33,12 @@ HelpText::Text()
|
||||
" C-k c Close current buffer\n"
|
||||
" C-k d Kill to end of line\n"
|
||||
" C-k e Open file (prompt)\n"
|
||||
" C-k i New empty buffer\n"
|
||||
" C-k f Flush kill ring\n"
|
||||
" C-k g Jump to line\n"
|
||||
" C-k h Show this help\n"
|
||||
" C-k i New empty buffer\n"
|
||||
" C-k j Jump to mark\n"
|
||||
" C-k k Center viewport on cursor\n"
|
||||
" C-k l Reload buffer from disk\n"
|
||||
" C-k n Previous buffer\n"
|
||||
" C-k o Change working directory (prompt)\n"
|
||||
|
||||
@@ -298,6 +298,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
|
||||
@@ -442,11 +442,9 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
if (ed_ && ed_
|
||||
|
||||
|
||||
|
||||
->
|
||||
UArg() != 0
|
||||
)
|
||||
{
|
||||
) {
|
||||
const char *txt = e.text.text;
|
||||
if (txt && *txt) {
|
||||
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
||||
|
||||
@@ -912,12 +912,8 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
ed.SetFilePickerDir(e.path.string());
|
||||
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
// Open file on single click
|
||||
std::string err;
|
||||
if (!ed.OpenFile(e.path.string(), err)) {
|
||||
ed.SetStatus(std::string("open: ") + err);
|
||||
} else {
|
||||
ed.SetStatus(std::string("Opened: ") + e.name);
|
||||
}
|
||||
ed.RequestOpenFile(e.path.string());
|
||||
(void) ed.ProcessPendingOpens();
|
||||
ed.SetFilePickerVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
34
PieceTable.h
34
PieceTable.h
@@ -1,5 +1,39 @@
|
||||
/*
|
||||
* PieceTable.h - Alternative to GapBuffer using a piece table representation
|
||||
*
|
||||
* PieceTable is kte's core text storage data structure. It provides efficient
|
||||
* insert/delete operations without copying the entire buffer by maintaining a
|
||||
* sequence of "pieces" that reference ranges in two underlying buffers:
|
||||
* - original_: Initial file content (currently unused, reserved for future)
|
||||
* - add_: All text added during editing
|
||||
*
|
||||
* Key advantages:
|
||||
* - O(1) append/prepend operations (common case)
|
||||
* - O(n) insert/delete at arbitrary positions (n = number of pieces, not bytes)
|
||||
* - Efficient undo: just restore the piece list
|
||||
* - Memory efficient: no gap buffer waste
|
||||
*
|
||||
* Performance characteristics:
|
||||
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
|
||||
* - Materialization (Data() call) is O(total_size) but cached until next edit
|
||||
* - Line index is lazily rebuilt on first line-based query after edits
|
||||
* - Range and Find operations use lightweight caches for repeated queries
|
||||
*
|
||||
* API evolution:
|
||||
* 1. Legacy API (GapBuffer compatibility):
|
||||
* - Append/Prepend: Build content sequentially
|
||||
* - Data(): Materialize entire buffer
|
||||
*
|
||||
* 2. New buffer-wide API (Phase 1):
|
||||
* - Insert/Delete: Edit at arbitrary byte offsets
|
||||
* - Line-based queries: LineCount, GetLine, GetLineRange
|
||||
* - Position conversion: ByteOffsetToLineCol, LineColToByteOffset
|
||||
* - Efficient extraction: GetRange, Find, WriteToStream
|
||||
*
|
||||
* Implementation notes:
|
||||
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
|
||||
* - Thread-safe for concurrent reads (mutex protects caches and lazy rebuilds)
|
||||
* - Version tracking invalidates caches on mutations
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
|
||||
@@ -123,8 +123,7 @@ protected:
|
||||
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
|
||||
const Buffer *buf = ed_->CurrentBuffer();
|
||||
if (buf) {
|
||||
const auto &lines = buf->Rows();
|
||||
const std::size_t nrows = lines.size();
|
||||
const std::size_t nrows = buf->Nrows();
|
||||
const std::size_t rowoffs = buf->Rowoffs();
|
||||
const std::size_t coloffs = buf->Coloffs();
|
||||
const std::size_t cy = buf->Cury();
|
||||
@@ -144,9 +143,8 @@ protected:
|
||||
|
||||
// Iterate visible lines
|
||||
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
|
||||
// Materialize the Buffer::Line into a std::string for
|
||||
// regex/iterator usage and general string ops.
|
||||
const std::string line = static_cast<std::string>(lines[i]);
|
||||
// Get line as string for regex/iterator usage and general string ops.
|
||||
const std::string line = buf->GetLineString(i);
|
||||
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
|
||||
const int baseline = y + fm.ascent();
|
||||
|
||||
@@ -775,6 +773,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
if (app_)
|
||||
app_->processEvents();
|
||||
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
// Drain input queue
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
@@ -801,14 +802,8 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
const QStringList files = dlg.selectedFiles();
|
||||
if (!files.isEmpty()) {
|
||||
const QString fp = files.front();
|
||||
std::string err;
|
||||
if (ed.OpenFile(fp.toStdString(), err)) {
|
||||
ed.SetStatus(std::string("Opened: ") + fp.toStdString());
|
||||
} else if (!err.empty()) {
|
||||
ed.SetStatus(std::string("Open failed: ") + err);
|
||||
} else {
|
||||
ed.SetStatus("Open failed");
|
||||
}
|
||||
ed.RequestOpenFile(fp.toStdString());
|
||||
(void) ed.ProcessPendingOpens();
|
||||
// Update picker dir for next time
|
||||
QFileInfo info(fp);
|
||||
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
||||
|
||||
@@ -287,8 +287,7 @@ QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
|
||||
|
||||
->
|
||||
UArg() != 0
|
||||
)
|
||||
{
|
||||
) {
|
||||
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
|
||||
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
|
||||
int d = e.key() - Qt::Key_0;
|
||||
@@ -379,10 +378,9 @@ QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
|
||||
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
|
||||
#if defined(__APPLE__)
|
||||
if (esc_meta_ || (mods & Qt::AltModifier)) {
|
||||
|
||||
|
||||
#else
|
||||
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
|
||||
|
||||
#endif
|
||||
int ascii_key = 0;
|
||||
if (e.key() == Qt::Key_Backspace) {
|
||||
|
||||
10
README.md
10
README.md
@@ -39,15 +39,13 @@ subject to refinement):
|
||||
`C-g`.
|
||||
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
|
||||
`C-k q` (quit with confirm), `C-k C-q` (quit immediately).
|
||||
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-k
|
||||
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u`
|
||||
(universal argument).
|
||||
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
|
||||
region), `C-y` (yank), `C-u` (universal argument).
|
||||
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
||||
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
|
||||
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
|
||||
(close), `C-k C-r` (reload).
|
||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g`
|
||||
(goto line).
|
||||
(close), `C-k l` (reload).
|
||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
|
||||
|
||||
See `ke.md` for the canonical ke reference retained for now.
|
||||
|
||||
|
||||
580
Swap.cc
580
Swap.cc
@@ -22,6 +22,24 @@ constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
|
||||
constexpr std::uint32_t VERSION = 1;
|
||||
|
||||
|
||||
static std::string
|
||||
snapshot_buffer_bytes(const Buffer &b)
|
||||
{
|
||||
const std::size_t nrows = b.Nrows();
|
||||
std::string out;
|
||||
// Cheap lower bound: sum of row sizes.
|
||||
std::size_t approx = 0;
|
||||
for (std::size_t i = 0; i < nrows; i++)
|
||||
approx += b.GetLineView(i).size();
|
||||
out.reserve(approx);
|
||||
for (std::size_t i = 0; i < nrows; i++) {
|
||||
auto v = b.GetLineView(i);
|
||||
out.append(v.data(), v.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
static fs::path
|
||||
xdg_state_home()
|
||||
{
|
||||
@@ -38,6 +56,13 @@ xdg_state_home()
|
||||
}
|
||||
|
||||
|
||||
static fs::path
|
||||
swap_root_dir()
|
||||
{
|
||||
return xdg_state_home() / "kte" / "swap";
|
||||
}
|
||||
|
||||
|
||||
static std::uint64_t
|
||||
fnv1a64(std::string_view s)
|
||||
{
|
||||
@@ -82,6 +107,64 @@ write_full(int fd, const void *buf, size_t len)
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
encode_path_key(std::string s)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
compute_swap_path_for_filename(const std::string &filename)
|
||||
{
|
||||
if (filename.empty())
|
||||
return std::string();
|
||||
// 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 = swap_root_dir();
|
||||
|
||||
fs::path p(filename);
|
||||
std::string key;
|
||||
try {
|
||||
key = fs::weakly_canonical(p).string();
|
||||
} catch (...) {
|
||||
try {
|
||||
key = fs::absolute(p).string();
|
||||
} catch (...) {
|
||||
key = filename;
|
||||
}
|
||||
}
|
||||
std::string encoded = encode_path_key(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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +174,11 @@ SwapManager::SwapManager()
|
||||
worker_ = std::thread([this] {
|
||||
this->writer_loop();
|
||||
});
|
||||
// Best-effort prune of old swap files.
|
||||
// Safe early in startup: journals_ is still empty and no fds are open yet.
|
||||
if (cfg_.prune_on_startup) {
|
||||
PruneSwapDir();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +197,29 @@ SwapManager::~SwapManager()
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Checkpoint(Buffer *buf)
|
||||
{
|
||||
if (buf) {
|
||||
RecordCheckpoint(*buf, false);
|
||||
return;
|
||||
}
|
||||
// All buffers
|
||||
std::vector<Buffer *> bufs;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
bufs.reserve(journals_.size());
|
||||
for (auto &kv: journals_) {
|
||||
bufs.push_back(kv.first);
|
||||
}
|
||||
}
|
||||
for (Buffer *b: bufs) {
|
||||
if (b)
|
||||
RecordCheckpoint(*b, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Flush(Buffer *buf)
|
||||
{
|
||||
@@ -171,10 +282,16 @@ SwapManager::Attach(Buffer *buf)
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Detach(Buffer *buf)
|
||||
SwapManager::Detach(Buffer *buf, const bool remove_file)
|
||||
{
|
||||
if (!buf)
|
||||
if (!buf) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write a best-effort final checkpoint before suspending and closing.
|
||||
// If the caller requested removal, skip the final checkpoint so the file can be deleted.
|
||||
if (!remove_file)
|
||||
RecordCheckpoint(*buf, true);
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(buf);
|
||||
@@ -182,25 +299,165 @@ SwapManager::Detach(Buffer *buf)
|
||||
it->second.suspended = true;
|
||||
}
|
||||
}
|
||||
|
||||
Flush(buf);
|
||||
std::string path;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(buf);
|
||||
if (it != journals_.end()) {
|
||||
path = it->second.path;
|
||||
close_ctx(it->second);
|
||||
journals_.erase(it);
|
||||
}
|
||||
recorders_.erase(buf);
|
||||
}
|
||||
|
||||
if (remove_file && !path.empty()) {
|
||||
(void) std::remove(path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::NotifyFilenameChanged(Buffer &buf)
|
||||
SwapManager::ResetJournal(Buffer &buf)
|
||||
{
|
||||
std::string path;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
JournalCtx &ctx = it->second;
|
||||
if (ctx.path.empty())
|
||||
ctx.path = ComputeSidecarPath(buf);
|
||||
path = ctx.path;
|
||||
ctx.suspended = true;
|
||||
}
|
||||
|
||||
Flush(&buf);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
JournalCtx &ctx = it->second;
|
||||
close_ctx(ctx);
|
||||
ctx.header_ok = false;
|
||||
ctx.last_flush_ns = 0;
|
||||
ctx.last_fsync_ns = 0;
|
||||
ctx.last_chkpt_ns = 0;
|
||||
ctx.edit_bytes_since_chkpt = 0;
|
||||
ctx.approx_size_bytes = 0;
|
||||
ctx.suspended = false;
|
||||
}
|
||||
|
||||
if (!path.empty()) {
|
||||
(void) std::remove(path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
SwapManager::SwapDirRoot()
|
||||
{
|
||||
return swap_root_dir().string();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::PruneSwapDir()
|
||||
{
|
||||
SwapConfig cfg;
|
||||
std::vector<std::string> active;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
cfg = cfg_;
|
||||
active.reserve(journals_.size());
|
||||
for (const auto &kv: journals_) {
|
||||
if (!kv.second.path.empty())
|
||||
active.push_back(kv.second.path);
|
||||
}
|
||||
}
|
||||
|
||||
const fs::path root = swap_root_dir();
|
||||
std::error_code ec;
|
||||
if (!fs::exists(root, ec) || ec)
|
||||
return;
|
||||
|
||||
struct Entry {
|
||||
fs::path path;
|
||||
std::filesystem::file_time_type mtime;
|
||||
};
|
||||
std::vector<Entry> swps;
|
||||
for (auto it = fs::directory_iterator(root, ec); !ec && it != fs::directory_iterator(); it.increment(ec)) {
|
||||
const fs::path p = it->path();
|
||||
if (p.extension() != ".swp")
|
||||
continue;
|
||||
// Never delete active journals.
|
||||
const std::string ps = p.string();
|
||||
bool is_active = false;
|
||||
for (const auto &a: active) {
|
||||
if (a == ps) {
|
||||
is_active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (is_active)
|
||||
continue;
|
||||
std::error_code ec2;
|
||||
if (!it->is_regular_file(ec2) || ec2)
|
||||
continue;
|
||||
auto tm = fs::last_write_time(p, ec2);
|
||||
if (ec2)
|
||||
continue;
|
||||
swps.push_back({p, tm});
|
||||
}
|
||||
|
||||
if (swps.empty())
|
||||
return;
|
||||
|
||||
// Sort newest first.
|
||||
std::sort(swps.begin(), swps.end(), [](const Entry &a, const Entry &b) {
|
||||
return a.mtime > b.mtime;
|
||||
});
|
||||
|
||||
// Convert age threshold.
|
||||
auto now = std::filesystem::file_time_type::clock::now();
|
||||
auto max_age = std::chrono::hours(24) * static_cast<long long>(cfg.prune_max_age_days);
|
||||
|
||||
std::size_t kept = 0;
|
||||
for (const auto &e: swps) {
|
||||
bool too_old = false;
|
||||
if (cfg.prune_max_age_days > 0) {
|
||||
// If file_time_type isn't system_clock, duration arithmetic still works.
|
||||
if (now - e.mtime > max_age)
|
||||
too_old = true;
|
||||
}
|
||||
bool over_limit = (cfg.prune_max_files > 0) && (kept >= cfg.prune_max_files);
|
||||
if (too_old || over_limit) {
|
||||
std::error_code ec3;
|
||||
fs::remove(e.path, ec3);
|
||||
} else {
|
||||
++kept;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::NotifyFilenameChanged(Buffer &buf)
|
||||
{
|
||||
// Best-effort: checkpoint the old journal before switching paths.
|
||||
RecordCheckpoint(buf, true);
|
||||
std::string old_path;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
old_path = it->second.path;
|
||||
it->second.suspended = true;
|
||||
}
|
||||
Flush(&buf);
|
||||
@@ -210,8 +467,16 @@ SwapManager::NotifyFilenameChanged(Buffer &buf)
|
||||
return;
|
||||
JournalCtx &ctx = it->second;
|
||||
close_ctx(ctx);
|
||||
if (!old_path.empty())
|
||||
(void) std::remove(old_path.c_str());
|
||||
ctx.path = ComputeSidecarPath(buf);
|
||||
ctx.suspended = false;
|
||||
ctx.header_ok = false;
|
||||
ctx.last_flush_ns = 0;
|
||||
ctx.last_fsync_ns = 0;
|
||||
ctx.last_chkpt_ns = 0;
|
||||
ctx.edit_bytes_since_chkpt = 0;
|
||||
ctx.approx_size_bytes = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -257,54 +522,9 @@ SwapManager::SuspendGuard::~SuspendGuard()
|
||||
std::string
|
||||
SwapManager::ComputeSidecarPath(const Buffer &buf)
|
||||
{
|
||||
// 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;
|
||||
};
|
||||
|
||||
fs::path root = swap_root_dir();
|
||||
if (!buf.Filename().empty()) {
|
||||
fs::path p(buf.Filename());
|
||||
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();
|
||||
return compute_swap_path_for_filename(buf.Filename());
|
||||
}
|
||||
|
||||
// Unnamed buffers: unique within the process.
|
||||
@@ -316,6 +536,20 @@ SwapManager::ComputeSidecarPath(const Buffer &buf)
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
SwapManager::ComputeSwapPathForFilename(const std::string &filename)
|
||||
{
|
||||
return ComputeSidecarPathForFilename(filename);
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
SwapManager::ComputeSidecarPathForFilename(const std::string &filename)
|
||||
{
|
||||
return compute_swap_path_for_filename(filename);
|
||||
}
|
||||
|
||||
|
||||
std::uint64_t
|
||||
SwapManager::now_ns()
|
||||
{
|
||||
@@ -403,8 +637,10 @@ SwapManager::open_ctx(JournalCtx &ctx, const std::string &path)
|
||||
ctx.path = path;
|
||||
if (st.st_size == 0) {
|
||||
ctx.header_ok = write_header(fd);
|
||||
ctx.approx_size_bytes = ctx.header_ok ? 64 : 0;
|
||||
} else {
|
||||
ctx.header_ok = true; // stage 1: trust existing header
|
||||
ctx.approx_size_bytes = static_cast<std::uint64_t>(st.st_size);
|
||||
}
|
||||
return ctx.header_ok;
|
||||
}
|
||||
@@ -422,6 +658,79 @@ SwapManager::close_ctx(JournalCtx &ctx)
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record)
|
||||
{
|
||||
if (ctx.path.empty())
|
||||
return false;
|
||||
if (chkpt_record.empty())
|
||||
return false;
|
||||
|
||||
// Close existing file before rename.
|
||||
if (ctx.fd >= 0) {
|
||||
(void) ::fsync(ctx.fd);
|
||||
::close(ctx.fd);
|
||||
ctx.fd = -1;
|
||||
}
|
||||
ctx.header_ok = false;
|
||||
|
||||
const std::string tmp_path = ctx.path + ".tmp";
|
||||
// Create the compacted file: header + checkpoint record.
|
||||
if (!ensure_parent_dir(tmp_path))
|
||||
return false;
|
||||
|
||||
int flags = O_CREAT | O_WRONLY | O_TRUNC;
|
||||
#ifdef O_CLOEXEC
|
||||
flags |= O_CLOEXEC;
|
||||
#endif
|
||||
int tfd = ::open(tmp_path.c_str(), flags, 0600);
|
||||
if (tfd < 0)
|
||||
return false;
|
||||
(void) ::fchmod(tfd, 0600);
|
||||
bool ok = write_header(tfd);
|
||||
if (ok)
|
||||
ok = write_full(tfd, chkpt_record.data(), chkpt_record.size());
|
||||
if (ok)
|
||||
ok = (::fsync(tfd) == 0);
|
||||
::close(tfd);
|
||||
if (!ok) {
|
||||
std::remove(tmp_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic replace.
|
||||
if (::rename(tmp_path.c_str(), ctx.path.c_str()) != 0) {
|
||||
std::remove(tmp_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Best-effort: fsync parent dir to persist the rename.
|
||||
try {
|
||||
fs::path p(ctx.path);
|
||||
fs::path dir = p.parent_path();
|
||||
if (!dir.empty()) {
|
||||
int dflags = O_RDONLY;
|
||||
#ifdef O_DIRECTORY
|
||||
dflags |= O_DIRECTORY;
|
||||
#endif
|
||||
int dfd = ::open(dir.string().c_str(), dflags);
|
||||
if (dfd >= 0) {
|
||||
(void) ::fsync(dfd);
|
||||
::close(dfd);
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Re-open for further appends.
|
||||
if (!open_ctx(ctx, ctx.path))
|
||||
return false;
|
||||
ctx.approx_size_bytes = 64 + static_cast<std::uint64_t>(chkpt_record.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
std::uint32_t
|
||||
SwapManager::crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed)
|
||||
{
|
||||
@@ -510,6 +819,7 @@ SwapManager::RecordInsert(Buffer &buf, int row, int col, std::string_view text)
|
||||
p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(text.data()),
|
||||
reinterpret_cast<const std::uint8_t *>(text.data()) + text.size());
|
||||
enqueue(std::move(p));
|
||||
maybe_request_checkpoint(buf, text.size());
|
||||
}
|
||||
|
||||
|
||||
@@ -533,6 +843,7 @@ SwapManager::RecordDelete(Buffer &buf, int row, int col, std::size_t len)
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, col)));
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(len));
|
||||
enqueue(std::move(p));
|
||||
maybe_request_checkpoint(buf, len);
|
||||
}
|
||||
|
||||
|
||||
@@ -553,6 +864,7 @@ SwapManager::RecordSplit(Buffer &buf, int row, int col)
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, row)));
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, col)));
|
||||
enqueue(std::move(p));
|
||||
maybe_request_checkpoint(buf, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -572,6 +884,68 @@ SwapManager::RecordJoin(Buffer &buf, int row)
|
||||
p.payload.push_back(1);
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, row)));
|
||||
enqueue(std::move(p));
|
||||
maybe_request_checkpoint(buf, 1);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::maybe_request_checkpoint(Buffer &buf, const std::size_t approx_edit_bytes)
|
||||
{
|
||||
SwapConfig cfg;
|
||||
bool do_chkpt = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
cfg = cfg_;
|
||||
if (cfg.checkpoint_bytes == 0 && cfg.checkpoint_interval_ms == 0)
|
||||
return;
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end() || it->second.suspended)
|
||||
return;
|
||||
JournalCtx &ctx = it->second;
|
||||
ctx.edit_bytes_since_chkpt += approx_edit_bytes;
|
||||
const std::uint64_t now = now_ns();
|
||||
if (ctx.last_chkpt_ns == 0)
|
||||
ctx.last_chkpt_ns = now;
|
||||
const bool bytes_hit = (cfg.checkpoint_bytes > 0) && (
|
||||
ctx.edit_bytes_since_chkpt >= cfg.checkpoint_bytes);
|
||||
const bool time_hit = (cfg.checkpoint_interval_ms > 0) &&
|
||||
(((now - ctx.last_chkpt_ns) / 1000000ULL) >= cfg.checkpoint_interval_ms);
|
||||
if (bytes_hit || time_hit) {
|
||||
ctx.edit_bytes_since_chkpt = 0;
|
||||
ctx.last_chkpt_ns = now;
|
||||
do_chkpt = true;
|
||||
}
|
||||
}
|
||||
if (do_chkpt) {
|
||||
RecordCheckpoint(buf, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::RecordCheckpoint(Buffer &buf, const bool urgent_flush)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end() || it->second.suspended)
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string bytes = snapshot_buffer_bytes(buf);
|
||||
if (bytes.size() > 0xFFFFFFFFu)
|
||||
return;
|
||||
|
||||
Pending p;
|
||||
p.buf = &buf;
|
||||
p.type = SwapRecType::CHKPT;
|
||||
p.urgent_flush = urgent_flush;
|
||||
// payload v1: [encver u8=1][nbytes u32][bytes]
|
||||
p.payload.push_back(1);
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(bytes.size()));
|
||||
p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(bytes.data()),
|
||||
reinterpret_cast<const std::uint8_t *>(bytes.data()) + bytes.size());
|
||||
enqueue(std::move(p));
|
||||
}
|
||||
|
||||
|
||||
@@ -641,17 +1015,17 @@ SwapManager::process_one(const Pending &p)
|
||||
|
||||
JournalCtx *ctxp = nullptr;
|
||||
std::string path;
|
||||
std::size_t compact_bytes = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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;
|
||||
compact_bytes = cfg_.compact_bytes;
|
||||
}
|
||||
if (!ctxp)
|
||||
return;
|
||||
@@ -680,13 +1054,27 @@ SwapManager::process_one(const Pending &p)
|
||||
crcbytes[2] = static_cast<std::uint8_t>((c >> 16) & 0xFFu);
|
||||
crcbytes[3] = static_cast<std::uint8_t>((c >> 24) & 0xFFu);
|
||||
|
||||
std::vector<std::uint8_t> rec;
|
||||
rec.reserve(sizeof(head) + p.payload.size() + sizeof(crcbytes));
|
||||
rec.insert(rec.end(), head, head + sizeof(head));
|
||||
if (!p.payload.empty())
|
||||
rec.insert(rec.end(), p.payload.begin(), p.payload.end());
|
||||
rec.insert(rec.end(), crcbytes, crcbytes + sizeof(crcbytes));
|
||||
|
||||
// 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
|
||||
bool ok = write_full(ctxp->fd, rec.data(), rec.size());
|
||||
if (ok) {
|
||||
ctxp->approx_size_bytes += static_cast<std::uint64_t>(rec.size());
|
||||
if (p.urgent_flush) {
|
||||
(void) ::fsync(ctxp->fd);
|
||||
ctxp->last_fsync_ns = now_ns();
|
||||
}
|
||||
if (p.type == SwapRecType::CHKPT && compact_bytes > 0 &&
|
||||
ctxp->approx_size_bytes >= static_cast<std::uint64_t>(compact_bytes)) {
|
||||
(void) compact_to_checkpoint(*ctxp, rec);
|
||||
}
|
||||
}
|
||||
(void) ok; // best-effort; future work could mark ctx error state
|
||||
}
|
||||
|
||||
|
||||
@@ -743,6 +1131,20 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure replayed edits don't get re-journaled if the caller forgot to detach/suspend.
|
||||
kte::SwapRecorder *prev_rec = buf.SwapRecorder();
|
||||
buf.SetSwapRecorder(nullptr);
|
||||
struct RestoreSwapRecorder {
|
||||
Buffer &b;
|
||||
kte::SwapRecorder *prev;
|
||||
|
||||
|
||||
~RestoreSwapRecorder()
|
||||
{
|
||||
b.SetSwapRecorder(prev);
|
||||
}
|
||||
} restore{buf, prev_rec};
|
||||
|
||||
for (;;) {
|
||||
std::uint8_t head[4];
|
||||
in.read(reinterpret_cast<char *>(head), sizeof(head));
|
||||
@@ -780,9 +1182,11 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
|
||||
// Apply record
|
||||
switch (type) {
|
||||
case SwapRecType::INS: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing payload";
|
||||
err = "Swap record missing INS payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
@@ -790,8 +1194,6 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
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)) {
|
||||
@@ -807,6 +1209,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
break;
|
||||
}
|
||||
case SwapRecType::DEL: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing DEL payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
if (encver != 1) {
|
||||
err = "Unsupported swap payload encoding";
|
||||
return false;
|
||||
}
|
||||
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)) {
|
||||
@@ -817,6 +1229,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
break;
|
||||
}
|
||||
case SwapRecType::SPLIT: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing SPLIT payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
if (encver != 1) {
|
||||
err = "Unsupported swap payload encoding";
|
||||
return false;
|
||||
}
|
||||
std::uint32_t row = 0, col = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col)) {
|
||||
err = "Malformed SPLIT payload";
|
||||
@@ -826,6 +1248,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
break;
|
||||
}
|
||||
case SwapRecType::JOIN: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing JOIN payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
if (encver != 1) {
|
||||
err = "Unsupported swap payload encoding";
|
||||
return false;
|
||||
}
|
||||
std::uint32_t row = 0;
|
||||
if (!parse_u32_le(payload, off, row)) {
|
||||
err = "Malformed JOIN payload";
|
||||
@@ -834,8 +1266,32 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
buf.join_lines((int) row);
|
||||
break;
|
||||
}
|
||||
case SwapRecType::CHKPT: {
|
||||
std::size_t off = 0;
|
||||
if (payload.size() < 5) {
|
||||
err = "Malformed CHKPT payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
if (encver != 1) {
|
||||
err = "Unsupported swap checkpoint encoding";
|
||||
return false;
|
||||
}
|
||||
std::uint32_t nbytes = 0;
|
||||
if (!parse_u32_le(payload, off, nbytes)) {
|
||||
err = "Malformed CHKPT payload";
|
||||
return false;
|
||||
}
|
||||
if (off + nbytes > payload.size()) {
|
||||
err = "Truncated CHKPT payload bytes";
|
||||
return false;
|
||||
}
|
||||
buf.replace_all_bytes(std::string_view(reinterpret_cast<const char *>(payload.data() + off),
|
||||
(std::size_t) nbytes));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Ignore unknown types for forward-compat in stage 1
|
||||
// Ignore unknown types for forward-compat
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
54
Swap.h
54
Swap.h
@@ -32,6 +32,18 @@ struct SwapConfig {
|
||||
// Grouping and durability knobs (stage 1 defaults)
|
||||
unsigned flush_interval_ms{200}; // group small writes
|
||||
unsigned fsync_interval_ms{1000}; // at most once per second
|
||||
|
||||
// Checkpoint/compaction knobs (stage 2 defaults)
|
||||
// A checkpoint is a full snapshot of the buffer content written as a CHKPT record.
|
||||
// Compaction rewrites the swap file to contain just the latest checkpoint.
|
||||
std::size_t checkpoint_bytes{1024 * 1024}; // request checkpoint after this many queued edit-bytes
|
||||
unsigned checkpoint_interval_ms{60000}; // request checkpoint at least this often while editing
|
||||
std::size_t compact_bytes{8 * 1024 * 1024}; // compact on checkpoint once journal grows beyond this
|
||||
|
||||
// Cleanup / retention (best-effort)
|
||||
bool prune_on_startup{true};
|
||||
unsigned prune_max_age_days{30};
|
||||
std::size_t prune_max_files{2048};
|
||||
};
|
||||
|
||||
// SwapManager manages sidecar swap files and a single background writer thread.
|
||||
@@ -45,13 +57,36 @@ public:
|
||||
void Attach(Buffer *buf);
|
||||
|
||||
// Detach and close journal.
|
||||
void Detach(Buffer *buf);
|
||||
// If remove_file is true, the swap file is deleted after closing.
|
||||
// Intended for clean shutdown/close flows.
|
||||
void Detach(Buffer *buf, bool remove_file = false);
|
||||
|
||||
// Reset (truncate-by-delete) the journal for a buffer after a clean save.
|
||||
// Best-effort: closes the current fd, deletes the swap file, and resumes recording.
|
||||
void ResetJournal(Buffer &buf);
|
||||
|
||||
// Best-effort pruning of old swap files under the swap directory.
|
||||
// Never touches non-`.swp` files.
|
||||
void PruneSwapDir();
|
||||
|
||||
// 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);
|
||||
|
||||
// Request a full-content checkpoint record for one buffer (or all buffers if buf is null).
|
||||
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
||||
void Checkpoint(Buffer *buf = nullptr);
|
||||
|
||||
|
||||
void SetConfig(const SwapConfig &cfg)
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
cfg_ = cfg;
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
|
||||
// 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.
|
||||
@@ -67,6 +102,10 @@ public:
|
||||
// treat this as a recovery failure and surface `err`.
|
||||
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
|
||||
|
||||
// Compute the swap path for a file-backed buffer by filename.
|
||||
// Returns empty string if filename is empty.
|
||||
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
||||
|
||||
// Test-only hook to keep swap path logic centralized.
|
||||
// (Avoid duplicating naming rules in unit tests.)
|
||||
#ifdef KTE_TESTS
|
||||
@@ -114,6 +153,10 @@ private:
|
||||
|
||||
void RecordJoin(Buffer &buf, int row);
|
||||
|
||||
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
||||
|
||||
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
||||
|
||||
struct JournalCtx {
|
||||
std::string path;
|
||||
int fd{-1};
|
||||
@@ -121,6 +164,9 @@ private:
|
||||
bool suspended{false};
|
||||
std::uint64_t last_flush_ns{0};
|
||||
std::uint64_t last_fsync_ns{0};
|
||||
std::uint64_t last_chkpt_ns{0};
|
||||
std::uint64_t edit_bytes_since_chkpt{0};
|
||||
std::uint64_t approx_size_bytes{0};
|
||||
};
|
||||
|
||||
struct Pending {
|
||||
@@ -134,16 +180,22 @@ private:
|
||||
// Helpers
|
||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||
|
||||
static std::string ComputeSidecarPathForFilename(const std::string &filename);
|
||||
|
||||
static std::uint64_t now_ns();
|
||||
|
||||
static bool ensure_parent_dir(const std::string &path);
|
||||
|
||||
static std::string SwapDirRoot();
|
||||
|
||||
static bool write_header(int fd);
|
||||
|
||||
static bool open_ctx(JournalCtx &ctx, const std::string &path);
|
||||
|
||||
static void close_ctx(JournalCtx &ctx);
|
||||
|
||||
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record);
|
||||
|
||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||
|
||||
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
|
||||
|
||||
@@ -94,6 +94,9 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
||||
}
|
||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
MappedInput mi;
|
||||
if (input_.Poll(mi)) {
|
||||
if (mi.hasCommand) {
|
||||
|
||||
@@ -16,6 +16,9 @@ TestFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
void
|
||||
TestFrontend::Step(Editor &ed, bool &running)
|
||||
{
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
MappedInput mi;
|
||||
if (input_.Poll(mi)) {
|
||||
if (mi.hasCommand) {
|
||||
|
||||
41
UndoSystem.h
41
UndoSystem.h
@@ -1,3 +1,44 @@
|
||||
/*
|
||||
* UndoSystem.h - undo/redo system with tree-based branching
|
||||
*
|
||||
* UndoSystem manages the undo/redo history for a Buffer. It provides:
|
||||
*
|
||||
* - Tree-based undo: Multiple redo branches at each node (not just linear history)
|
||||
* - Atomic grouping: Multiple operations can be undone/redone as a single step
|
||||
* - Dirty tracking: Marks when buffer matches last saved state
|
||||
* - Efficient storage: Nodes stored in UndoTree, operations applied to Buffer
|
||||
*
|
||||
* Key concepts:
|
||||
*
|
||||
* 1. Undo tree structure:
|
||||
* - Each edit creates a node in the tree
|
||||
* - Undo moves up the tree (toward root)
|
||||
* - Redo moves down the tree (toward leaves)
|
||||
* - Multiple redo branches preserved (not lost on new edits after undo)
|
||||
*
|
||||
* 2. Operation lifecycle:
|
||||
* - Begin(type): Start recording an operation (insert/delete)
|
||||
* - Append(text): Add content to the pending operation
|
||||
* - commit(): Finalize and add to undo tree
|
||||
* - discard_pending(): Cancel without recording
|
||||
*
|
||||
* 3. Atomic grouping:
|
||||
* - BeginGroup()/EndGroup(): Bracket multiple operations
|
||||
* - All operations in a group share the same group_id
|
||||
* - Undo/redo treats the entire group as one step
|
||||
*
|
||||
* 4. Integration with Buffer:
|
||||
* - UndoSystem holds a reference to its owning Buffer
|
||||
* - apply() executes undo/redo by calling Buffer's editing methods
|
||||
* - Buffer's dirty flag updated automatically
|
||||
*
|
||||
* Usage pattern:
|
||||
* undo_system.Begin(UndoType::Insert);
|
||||
* undo_system.Append("text");
|
||||
* undo_system.commit(); // Now undoable
|
||||
*
|
||||
* See also: UndoTree.h (storage), UndoNode.h (node structure)
|
||||
*/
|
||||
#pragma once
|
||||
#include <string_view>
|
||||
#include <cstddef>
|
||||
|
||||
28
docker-build.sh
Executable file
28
docker-build.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Helper script to test Linux builds using Docker/Podman
|
||||
# This script mounts the current source tree into a Linux container,
|
||||
# builds kte in terminal-only mode, and runs the test suite.
|
||||
|
||||
set -e
|
||||
|
||||
# Detect whether to use docker or podman
|
||||
if command -v docker &> /dev/null; then
|
||||
CONTAINER_CMD="docker"
|
||||
elif command -v podman &> /dev/null; then
|
||||
CONTAINER_CMD="podman"
|
||||
else
|
||||
echo "Error: Neither docker nor podman found in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE_NAME="kte-linux"
|
||||
|
||||
# Check if image exists, if not, build it
|
||||
if ! $CONTAINER_CMD image inspect "$IMAGE_NAME" &> /dev/null; then
|
||||
echo "Building $IMAGE_NAME image..."
|
||||
$CONTAINER_CMD build -t "$IMAGE_NAME" .
|
||||
fi
|
||||
|
||||
# Run the container with the current directory mounted
|
||||
echo "Running Linux build and tests..."
|
||||
$CONTAINER_CMD run --rm -v "$(pwd):/kte" "$IMAGE_NAME"
|
||||
245
docs/BENCHMARKS.md
Normal file
245
docs/BENCHMARKS.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# kte Benchmarking and Testing Guide
|
||||
|
||||
This document describes the benchmarking infrastructure and testing
|
||||
improvements added to ensure high performance and correctness of core
|
||||
operations.
|
||||
|
||||
## Overview
|
||||
|
||||
The kte test suite now includes comprehensive benchmarks and migration
|
||||
coverage tests to:
|
||||
|
||||
- Measure performance of core operations (PieceTable, Buffer, syntax
|
||||
highlighting)
|
||||
- Ensure no performance regressions from refactorings
|
||||
- Validate correctness of API migrations (Buffer::Rows() →
|
||||
GetLineString/GetLineView)
|
||||
- Provide performance baselines for future optimizations
|
||||
|
||||
## Running Tests
|
||||
|
||||
### All Tests (including benchmarks)
|
||||
|
||||
```bash
|
||||
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
|
||||
search, etc.
|
||||
- **15 benchmark tests**: Performance measurements for critical
|
||||
operations
|
||||
- **30 migration coverage tests**: Edge cases and correctness validation
|
||||
|
||||
Total: **98 tests**
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
### Buffer Iteration Patterns (5,000 lines)
|
||||
|
||||
| Pattern | Time | Speedup vs Rows() |
|
||||
|-----------------------------------------|---------|-------------------|
|
||||
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
|
||||
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
|
||||
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
|
||||
|
||||
**Key Insight**: `GetLineView()` provides zero-copy access and is
|
||||
dramatically faster than materializing the entire rows cache.
|
||||
|
||||
### PieceTable Operations (10,000 lines)
|
||||
|
||||
| Operation | Time |
|
||||
|-----------------------------|---------|
|
||||
| Sequential inserts (10K) | 2.1 ms |
|
||||
| Random inserts (5K) | 32.9 ms |
|
||||
| `GetLine()` sequential | 4.7 ms |
|
||||
| `GetLineRange()` sequential | 1.3 ms |
|
||||
|
||||
### Buffer Operations
|
||||
|
||||
| Operation | Time |
|
||||
|--------------------------------------|---------|
|
||||
| `Nrows()` (1M calls) | 13.0 ms |
|
||||
| `GetLineString()` (10K lines) | 4.8 ms |
|
||||
| `GetLineView()` (10K lines) | 1.6 ms |
|
||||
| `Rows()` materialization (10K lines) | 6.2 ms |
|
||||
|
||||
### Syntax Highlighting
|
||||
|
||||
| Operation | Time | Notes |
|
||||
|------------------------------------|---------|----------------|
|
||||
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
|
||||
| HighlighterEngine cache population | 19.9 ms | |
|
||||
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
|
||||
|
||||
### Large File Performance
|
||||
|
||||
| Operation | Time |
|
||||
|---------------------------------|---------|
|
||||
| Insert 50K lines | 0.53 ms |
|
||||
| Iterate 50K lines (GetLineView) | 2.7 ms |
|
||||
| Random access (10K accesses) | 1.8 ms |
|
||||
|
||||
## API Differences: GetLineString vs GetLineView
|
||||
|
||||
Understanding the difference between these APIs is critical:
|
||||
|
||||
### `GetLineString(row)`
|
||||
|
||||
- Returns: `std::string` (copy)
|
||||
- Content: Line text **without** trailing newline
|
||||
- Use case: When you need to modify the string or store it
|
||||
- Example: `"hello"` for line `"hello\n"`
|
||||
|
||||
### `GetLineView(row)`
|
||||
|
||||
- Returns: `std::string_view` (zero-copy)
|
||||
- Content: Raw line range **including** trailing newline
|
||||
- Use case: Read-only access, maximum performance
|
||||
- Example: `"hello\n"` for line `"hello\n"`
|
||||
- **Warning**: View becomes invalid after buffer modifications
|
||||
|
||||
### `Rows()`
|
||||
|
||||
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
|
||||
- Content: Lines **without** trailing newlines
|
||||
- Use case: Legacy code, being phased out
|
||||
- Performance: Slower due to materialization overhead
|
||||
|
||||
## Migration Coverage Tests
|
||||
|
||||
The `test_migration_coverage.cc` file provides 30 tests covering:
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Empty buffers
|
||||
- Single lines (with/without newlines)
|
||||
- Very long lines (10,000 characters)
|
||||
- Many empty lines (1,000 newlines)
|
||||
|
||||
### Consistency
|
||||
|
||||
- `GetLineString()` vs `GetLineView()` vs `Rows()`
|
||||
- Consistency after edits (insert, delete, split, join)
|
||||
|
||||
### Boundary Conditions
|
||||
|
||||
- First line access
|
||||
- Last line access
|
||||
- Line range boundaries
|
||||
|
||||
### Special Characters
|
||||
|
||||
- Tabs, carriage returns, null bytes
|
||||
- Unicode (UTF-8 multibyte characters)
|
||||
|
||||
### Stress Tests
|
||||
|
||||
- Large files (10,000 lines)
|
||||
- Many small operations (100+ inserts)
|
||||
- Alternating insert/delete patterns
|
||||
|
||||
### Regression Tests
|
||||
|
||||
- Shebang detection pattern (Editor.cc)
|
||||
- Empty buffer check pattern (Editor.cc)
|
||||
- Syntax highlighter pattern (all highlighters)
|
||||
- Swap snapshot pattern (Swap.cc)
|
||||
|
||||
## Performance Recommendations
|
||||
|
||||
Based on benchmark results:
|
||||
|
||||
1. **Prefer `GetLineView()` for read-only access**
|
||||
- 11x faster than `Rows()` for iteration
|
||||
- Zero-copy, minimal overhead
|
||||
- Use immediately (view invalidates on edit)
|
||||
|
||||
2. **Use `GetLineString()` when you need a copy**
|
||||
- Still 1.7x faster than `Rows()`
|
||||
- Safe to store and modify
|
||||
- Strips trailing newlines automatically
|
||||
|
||||
3. **Avoid `Rows()` in hot paths**
|
||||
- Materializes entire line cache
|
||||
- Slower for large files
|
||||
- Being phased out (legacy API)
|
||||
|
||||
4. **Cache `Nrows()` in tight loops**
|
||||
- Very fast (13ms for 1M calls)
|
||||
- But still worth caching in inner loops
|
||||
|
||||
5. **Leverage HighlighterEngine caching**
|
||||
- 38x speedup on cache hits
|
||||
- Automatically invalidates on edits
|
||||
- Prefetch viewport for smooth scrolling
|
||||
|
||||
## Adding New Benchmarks
|
||||
|
||||
To add a new benchmark:
|
||||
|
||||
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
|
||||
2. Use `BenchmarkTimer` to measure critical sections:
|
||||
```cpp
|
||||
{
|
||||
BenchmarkTimer timer("Operation description");
|
||||
// ... code to benchmark ...
|
||||
}
|
||||
```
|
||||
3. Print section headers with `std::cout` for clarity
|
||||
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
|
||||
|
||||
Example:
|
||||
|
||||
```cpp
|
||||
TEST(Benchmark_MyOperation) {
|
||||
std::cout << "\n=== My Operation Benchmark ===\n";
|
||||
|
||||
// Setup
|
||||
Buffer buf;
|
||||
std::string data = generate_test_data();
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
std::size_t result = 0;
|
||||
{
|
||||
BenchmarkTimer timer("My operation on 10K lines");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
result += my_operation(buf, i);
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(result > 0);
|
||||
}
|
||||
```
|
||||
|
||||
## Continuous Performance Monitoring
|
||||
|
||||
Run benchmarks regularly to detect regressions:
|
||||
|
||||
```bash
|
||||
# Run tests and save output
|
||||
./cmake-build-debug/kte_tests > benchmark_results.txt
|
||||
|
||||
# Compare with baseline
|
||||
diff benchmark_baseline.txt benchmark_results.txt
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- Significant time increases (>20%) in any benchmark
|
||||
- New operations that are slower than expected
|
||||
- Cache effectiveness degradation
|
||||
|
||||
## Conclusion
|
||||
|
||||
The benchmark suite provides:
|
||||
|
||||
- **Performance validation**: Ensures migrations don't regress
|
||||
performance
|
||||
- **Optimization guidance**: Identifies fastest APIs for each use case
|
||||
- **Regression detection**: Catches performance issues early
|
||||
- **Documentation**: Demonstrates correct API usage patterns
|
||||
|
||||
All 98 tests pass with 0 failures, confirming both correctness and
|
||||
performance of the migrated codebase.
|
||||
652
docs/DEVELOPER_GUIDE.md
Normal file
652
docs/DEVELOPER_GUIDE.md
Normal file
@@ -0,0 +1,652 @@
|
||||
# kte Developer Guide
|
||||
|
||||
Welcome to kte development! This guide will help you understand the
|
||||
codebase, make changes, and contribute effectively.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#architecture-overview)
|
||||
2. [Core Components](#core-components)
|
||||
3. [Code Organization](#code-organization)
|
||||
4. [Building and Testing](#building-and-testing)
|
||||
5. [Making Changes](#making-changes)
|
||||
6. [Code Style](#code-style)
|
||||
7. [Common Tasks](#common-tasks)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
kte follows a clean separation of concerns with three main layers:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Frontend Layer (Terminal/ImGui/Qt) │
|
||||
│ - TerminalFrontend / ImGuiFrontend │
|
||||
│ - InputHandler + Renderer interfaces │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Command Layer │
|
||||
│ - Command registry and execution │
|
||||
│ - All editing operations │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Core Model Layer │
|
||||
│ - Editor (top-level state) │
|
||||
│ - Buffer (document model) │
|
||||
│ - PieceTable (text storage) │
|
||||
│ - UndoSystem (undo/redo) │
|
||||
│ - SwapManager (crash recovery) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Frontend Independence**: Core editing logic is independent of UI.
|
||||
Frontends implement `Frontend`, `InputHandler`, and `Renderer`
|
||||
interfaces.
|
||||
- **Command Pattern**: All editing operations go through the command
|
||||
system, enabling consistent undo/redo and testing.
|
||||
- **Piece Table**: Efficient text storage using a piece table data
|
||||
structure that avoids copying large buffers.
|
||||
- **Lazy Materialization**: Text is materialized on-demand to minimize
|
||||
memory allocations.
|
||||
|
||||
## Core Components
|
||||
|
||||
### Editor (`Editor.h/.cc`)
|
||||
|
||||
The top-level editor state container. Manages:
|
||||
|
||||
- Multiple buffers
|
||||
- Editor modes (normal, k-command prefix, prompts)
|
||||
- Kill ring (clipboard history)
|
||||
- Universal argument state
|
||||
- Search state
|
||||
- Status messages
|
||||
- Swap file management
|
||||
|
||||
**Key Insight**: Editor is primarily a state holder with many
|
||||
getter/setter pairs. It doesn't contain editing logic - that's in
|
||||
commands.
|
||||
|
||||
### Buffer (`Buffer.h/.cc`)
|
||||
|
||||
Represents an open document. Manages:
|
||||
|
||||
- File I/O (open, save, external modification detection)
|
||||
- Cursor position and viewport offsets
|
||||
- Mark (selection start point)
|
||||
- Visual line mode state
|
||||
- Syntax highlighting integration
|
||||
- Undo system integration
|
||||
- Swap recording integration
|
||||
|
||||
**Key Insight**: Buffer wraps a PieceTable and provides a higher-level
|
||||
interface. The nested `Buffer::Line` class is a legacy wrapper that has
|
||||
been largely phased out in favor of direct PieceTable operations.
|
||||
|
||||
**Line Access APIs**: Buffer provides three ways to access line content:
|
||||
|
||||
- `GetLineView(row)` - Zero-copy `string_view` (fastest, 11x faster than
|
||||
Rows())
|
||||
- `GetLineString(row)` - Returns `std::string` copy (1.7x faster than
|
||||
Rows())
|
||||
- `Rows()` - Materializes all lines into cache (legacy, avoid in new
|
||||
code)
|
||||
|
||||
See `docs/BENCHMARKS.md` for detailed performance analysis and usage
|
||||
guidance.
|
||||
|
||||
### PieceTable (`PieceTable.h/.cc`)
|
||||
|
||||
The core text storage data structure. Provides:
|
||||
|
||||
- Efficient insert/delete operations without copying entire buffer
|
||||
- Line-based queries (line count, get line, line ranges)
|
||||
- Position conversion (byte offset ↔ line/column)
|
||||
- Substring extraction
|
||||
- Search functionality
|
||||
- Automatic consolidation to prevent piece fragmentation
|
||||
|
||||
**Key Insight**: PieceTable uses lazy materialization - the full text is
|
||||
only assembled when `Data()` is called. Most operations work directly on
|
||||
the piece list.
|
||||
|
||||
### UndoSystem (`UndoSystem.h/.cc`, `UndoTree.h/.cc`, `UndoNode.h/.cc`)
|
||||
|
||||
Implements undo/redo with a tree structure supporting:
|
||||
|
||||
- Linear undo/redo
|
||||
- Branching history (future enhancement)
|
||||
- Checkpointing and compaction
|
||||
- Memory-efficient node pooling
|
||||
|
||||
**Key Insight**: The undo system records operations at the PieceTable
|
||||
level, not at the command level.
|
||||
|
||||
### Command System (`Command.h/.cc`)
|
||||
|
||||
All editing operations are implemented as commands:
|
||||
|
||||
- File operations (save, open, close)
|
||||
- Navigation (move cursor, page up/down, word movement)
|
||||
- Editing (insert, delete, kill, yank)
|
||||
- Search and replace
|
||||
- Buffer management
|
||||
- Configuration (syntax, theme, font)
|
||||
|
||||
**Key Insight**: `Command.cc` is currently a monolithic 5000-line file.
|
||||
This is the biggest maintainability challenge in the codebase.
|
||||
|
||||
### Frontend Abstraction
|
||||
|
||||
Three interfaces define the frontend contract:
|
||||
|
||||
- **Frontend** (`Frontend.h`): Top-level lifecycle (Init/Step/Shutdown)
|
||||
- **InputHandler** (`InputHandler.h`): Converts UI events to commands
|
||||
- **Renderer** (`Renderer.h`): Draws the editor state
|
||||
|
||||
Implementations:
|
||||
|
||||
- **Terminal**: ncurses-based (`TerminalFrontend`,
|
||||
`TerminalInputHandler`, `TerminalRenderer`)
|
||||
- **ImGui**: Dear ImGui-based (`ImGuiFrontend`, `ImGuiInputHandler`,
|
||||
`ImGuiRenderer`)
|
||||
- **Qt**: Qt-based (`QtFrontend`, `QtInputHandler`, `QtRenderer`)
|
||||
- **Test**: Programmatic testing (`TestFrontend`, `TestInputHandler`,
|
||||
`TestRenderer`)
|
||||
|
||||
## Code Organization
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
kte/
|
||||
├── *.h, *.cc # Core implementation (root level)
|
||||
├── main.cc # Entry point
|
||||
├── docs/ # Documentation
|
||||
│ ├── ke.md # Original ke editor reference (keybindings)
|
||||
│ ├── swap.md # Swap file design
|
||||
│ ├── syntax.md # Syntax highlighting
|
||||
│ ├── themes.md # Theme system
|
||||
│ └── plans/ # Design documents
|
||||
├── tests/ # Test suite
|
||||
│ ├── Test.h # Minimal test framework
|
||||
│ ├── TestRunner.cc # Test runner
|
||||
│ └── test_*.cc # Individual test files
|
||||
├── syntax/ # Syntax highlighting engines
|
||||
├── fonts/ # Embedded fonts for GUI
|
||||
├── themes/ # Color themes
|
||||
└── ext/ # External dependencies (imgui)
|
||||
```
|
||||
|
||||
### File Naming Conventions
|
||||
|
||||
- Headers: `ComponentName.h`
|
||||
- Implementation: `ComponentName.cc`
|
||||
- Tests: `test_feature_name.cc`
|
||||
|
||||
### Key Files by Size
|
||||
|
||||
Large files that may need attention:
|
||||
|
||||
- `Command.cc` (4995 lines) - **Needs refactoring**: Consider splitting
|
||||
into logical groups
|
||||
- `Swap.cc` (1300 lines) - Crash recovery system (migrated to direct
|
||||
PieceTable operations)
|
||||
- `QtFrontend.cc` (985 lines) - Qt integration
|
||||
- `ImGuiRenderer.cc` (930 lines) - ImGui rendering
|
||||
- `PieceTable.cc` (800 lines) - Core data structure
|
||||
- `Buffer.cc` (763 lines) - Document model
|
||||
|
||||
## Building and Testing
|
||||
|
||||
### Build System
|
||||
|
||||
kte uses CMake with multiple build profiles:
|
||||
|
||||
```bash
|
||||
# Debug build (terminal only)
|
||||
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build cmake-build-debug
|
||||
|
||||
# Release build with GUI
|
||||
cmake -S . -B cmake-build-release -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=ON
|
||||
cmake --build cmake-build-release
|
||||
|
||||
# Build specific target
|
||||
cmake --build cmake-build-debug --target kte_tests
|
||||
```
|
||||
|
||||
### CMake Targets
|
||||
|
||||
- `kte` - Terminal editor executable
|
||||
- `kge` - GUI editor executable (when `BUILD_GUI=ON`)
|
||||
- `kte_tests` - Test suite
|
||||
- `imgui` - Dear ImGui library (when `BUILD_GUI=ON`)
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Build and run all tests
|
||||
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
|
||||
|
||||
# Run tests with verbose output
|
||||
./cmake-build-debug/kte_tests
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
The test suite uses a minimal custom framework (`Test.h`):
|
||||
|
||||
```cpp
|
||||
TEST(TestName) {
|
||||
// Test body
|
||||
ASSERT_EQ(actual, expected);
|
||||
ASSERT_TRUE(condition);
|
||||
EXPECT_TRUE(condition); // Non-fatal
|
||||
}
|
||||
```
|
||||
|
||||
Test files by category:
|
||||
|
||||
- **Core Data Structures**:
|
||||
- `test_piece_table.cc` - PieceTable operations, line indexing,
|
||||
random edits
|
||||
- `test_buffer_rows.cc` - Buffer row operations
|
||||
- `test_buffer_io.cc` - File I/O (open, save, SaveAs)
|
||||
|
||||
- **Editing Operations**:
|
||||
- `test_command_semantics.cc` - Command execution
|
||||
- `test_kkeymap.cc` - Keybinding system
|
||||
- `test_visual_line_mode.cc` - Visual line selection
|
||||
|
||||
- **Search and Replace**:
|
||||
- `test_search.cc` - Search functionality
|
||||
- `test_search_replace_flow.cc` - Interactive search/replace
|
||||
|
||||
- **Text Reflow**:
|
||||
- `test_reflow_paragraph.cc` - Paragraph reformatting
|
||||
- `test_reflow_indented_bullets.cc` - Indented list handling
|
||||
|
||||
- **Undo System**:
|
||||
- `test_undo.cc` - Undo/redo operations
|
||||
|
||||
- **Swap Files** (Crash Recovery):
|
||||
- `test_swap_recorder.cc` - Recording operations
|
||||
- `test_swap_writer.cc` - Writing swap files
|
||||
- `test_swap_replay.cc` - Replaying operations
|
||||
- `test_swap_recovery_prompt.cc` - Recovery UI
|
||||
- `test_swap_cleanup.cc` - Cleanup logic
|
||||
- `test_swap_git_editor.cc` - Git editor integration
|
||||
|
||||
- **Performance and Migration**:
|
||||
- `test_benchmarks.cc` - Performance benchmarks for core operations
|
||||
- `test_migration_coverage.cc` - Buffer::Line migration validation
|
||||
|
||||
- **Integration Tests**:
|
||||
- `test_daily_workflows.cc` - Real-world editing scenarios
|
||||
- `test_daily_driver_harness.cc` - Workflow test infrastructure
|
||||
|
||||
**Total**: 98 tests across 22 test files. See `docs/BENCHMARKS.md` for
|
||||
performance benchmark results.
|
||||
|
||||
### Docker/Podman for Linux Builds
|
||||
|
||||
A minimal `Dockerfile` is provided for **testing Linux builds** without
|
||||
requiring a native Linux system. The Dockerfile creates a build
|
||||
environment container with all necessary dependencies. Your source tree
|
||||
is mounted into the container at runtime, allowing you to test
|
||||
compilation and run tests on Linux.
|
||||
|
||||
**Important**: This is intended for testing Linux builds, not for
|
||||
running
|
||||
kte locally. The container expects the source tree to be mounted when
|
||||
run.
|
||||
|
||||
This is particularly useful for:
|
||||
|
||||
- **macOS/Windows developers** testing Linux compatibility
|
||||
- **CI/CD pipelines** ensuring cross-platform builds
|
||||
- **Reproducible builds** with a known Alpine Linux 3.19 environment
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Install Docker or Podman:
|
||||
|
||||
- **macOS**: `brew install podman` (Docker Desktop also works)
|
||||
- **Linux**: Use your distribution's package manager
|
||||
- **Windows**: Docker Desktop or Podman Desktop
|
||||
|
||||
If using Podman on macOS, start the VM:
|
||||
|
||||
```bash
|
||||
podman machine init
|
||||
podman machine start
|
||||
```
|
||||
|
||||
#### Building the Docker Image
|
||||
|
||||
The Dockerfile installs all build dependencies including GUI support (
|
||||
g++ 13.2.1, CMake 3.27.8, ncurses-dev, SDL2, OpenGL/Mesa, Freetype). It
|
||||
does not copy or build the source code.
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
# Build the environment image
|
||||
docker build -t kte-linux .
|
||||
|
||||
# Or with Podman
|
||||
podman build -t kte-linux .
|
||||
```
|
||||
|
||||
#### Testing Linux Builds
|
||||
|
||||
Mount your source tree and run the build + tests:
|
||||
|
||||
```bash
|
||||
# Build and test (default command)
|
||||
docker run --rm -v "$(pwd):/kte" kte-linux
|
||||
|
||||
# Expected output: "98 tests passed, 0 failed"
|
||||
```
|
||||
|
||||
The default command builds both `kte` (terminal) and `kge` (GUI)
|
||||
executables with full GUI support (`-DBUILD_GUI=ON`) and runs the
|
||||
complete test suite.
|
||||
|
||||
#### Custom Build Commands
|
||||
|
||||
```bash
|
||||
# Open a shell in the build environment
|
||||
docker run --rm -it -v "$(pwd):/kte" kte-linux /bin/bash
|
||||
|
||||
# Then inside the container:
|
||||
cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON
|
||||
cmake --build build --target kte # Terminal version
|
||||
cmake --build build --target kge # GUI version
|
||||
cmake --build build --target kte_tests
|
||||
./build/kte_tests
|
||||
|
||||
# Or run kte directly
|
||||
./build/kte --help
|
||||
|
||||
# Terminal-only build (smaller, faster)
|
||||
cmake -B build -DBUILD_GUI=OFF -DBUILD_TESTS=ON
|
||||
cmake --build build --target kte
|
||||
```
|
||||
|
||||
#### Running kte Interactively
|
||||
|
||||
To test kte's terminal UI on Linux:
|
||||
|
||||
```bash
|
||||
# Run kte with a file from your host system
|
||||
docker run --rm -it -v "$(pwd):/kte" kte-linux sh -c "cmake -B build -DBUILD_GUI=OFF && cmake --build build --target kte && ./build/kte README.md"
|
||||
```
|
||||
|
||||
#### CI/CD Integration
|
||||
|
||||
Example GitHub Actions workflow:
|
||||
|
||||
```yaml
|
||||
- name: Test Linux Build
|
||||
run: |
|
||||
docker build -t kte-linux .
|
||||
docker run --rm -v "${{ github.workspace }}:/kte" kte-linux
|
||||
```
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**"Cannot connect to Podman socket"** (macOS):
|
||||
|
||||
```bash
|
||||
podman machine start
|
||||
```
|
||||
|
||||
**"Permission denied"** (Linux):
|
||||
|
||||
```bash
|
||||
# Add your user to the docker group
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in
|
||||
```
|
||||
|
||||
**Build fails with ncurses errors**:
|
||||
The Dockerfile explicitly installs `ncurses-dev` (wide-character
|
||||
ncurses). If you modify the Dockerfile, ensure this dependency remains.
|
||||
|
||||
**"No such file or directory" errors**:
|
||||
Ensure you're mounting the source tree with `-v "$(pwd):/kte"` when
|
||||
running the container.
|
||||
|
||||
### Writing Tests
|
||||
|
||||
When adding new functionality:
|
||||
|
||||
1. **Add a test first** - Write a failing test that demonstrates the
|
||||
desired behavior
|
||||
2. **Use descriptive names** - Test names should explain what's being
|
||||
validated
|
||||
3. **Test edge cases** - Empty buffers, EOF, beginning of file, etc.
|
||||
4. **Use TestFrontend** - For integration tests, use the programmatic
|
||||
test frontend
|
||||
|
||||
Example test structure:
|
||||
|
||||
```cpp
|
||||
TEST(Feature_Behavior_Scenario) {
|
||||
// Setup
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, "test content\n");
|
||||
|
||||
// Exercise
|
||||
buf.delete_text(0, 5, 4);
|
||||
|
||||
// Verify
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("test\n"));
|
||||
}
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Understand the change scope**:
|
||||
- Pure UI change? → Modify frontend only
|
||||
- New editing operation? → Add command in `Command.cc`
|
||||
- Core data structure? → Modify `PieceTable` or `Buffer`
|
||||
|
||||
2. **Find relevant code**:
|
||||
- Use `git grep` or IDE search to find similar functionality
|
||||
- Check `Command.cc` for existing command patterns
|
||||
- Look at tests to understand expected behavior
|
||||
|
||||
3. **Make the change**:
|
||||
- Follow existing code style (see below)
|
||||
- Add or update tests
|
||||
- Update documentation if needed
|
||||
|
||||
4. **Test thoroughly**:
|
||||
- Run the full test suite
|
||||
- Manually test in both terminal and GUI (if applicable)
|
||||
- Test edge cases (empty files, large files, EOF, etc.)
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
- **Don't modify `Buffer::Rows()` directly** - Use the PieceTable API (
|
||||
`insert_text`, `delete_text`, etc.) to ensure undo and swap recording
|
||||
work correctly.
|
||||
- **Prefer efficient line access** - Use `GetLineView()` for read-only
|
||||
access (11x faster than `Rows()`), or `GetLineString()` when you need
|
||||
a copy. Avoid `Rows()` in new code.
|
||||
- **Remember to invalidate caches** - If you modify PieceTable
|
||||
internals, ensure line index and materialization caches are
|
||||
invalidated.
|
||||
- **Cursor visibility** - After editing operations, call
|
||||
`ensure_cursor_visible()` to update viewport offsets.
|
||||
- **Undo boundaries** - Use `buf.Undo()->BeginGroup()` and `EndGroup()`
|
||||
to group related operations.
|
||||
- **GetLineView() lifetime** - The returned `string_view` is only valid
|
||||
until the next buffer modification. Use immediately or copy to
|
||||
`std::string`.
|
||||
|
||||
## Code Style
|
||||
|
||||
kte uses C++20 with these conventions:
|
||||
|
||||
### Naming
|
||||
|
||||
- **Classes/Structs**: `PascalCase` (e.g., `PieceTable`, `Buffer`)
|
||||
- **Functions/Methods**: `PascalCase` (e.g., `GetLine`, `Insert`)
|
||||
- **Variables**: `snake_case` with trailing underscore for members (
|
||||
e.g., `total_size_`, `line_index_`)
|
||||
- **Constants**: `snake_case` or `UPPER_CASE` depending on context
|
||||
- **Private members**: Trailing underscore (e.g., `pieces_`, `dirty_`)
|
||||
|
||||
### Formatting
|
||||
|
||||
- **Indentation**: Tabs (width 8 in most files, but follow existing
|
||||
style)
|
||||
- **Braces**: Opening brace on same line for functions, control
|
||||
structures
|
||||
- **Line length**: No strict limit, but keep reasonable (~100-120 chars)
|
||||
- **Includes**: Group by category (system, external, project) with blank
|
||||
lines between
|
||||
|
||||
### Comments
|
||||
|
||||
- **File headers**: Brief description of the file's purpose
|
||||
- **Function comments**: Explain non-obvious behavior, not what the code
|
||||
obviously does
|
||||
- **Inline comments**: Explain *why*, not *what*
|
||||
- **TODO comments**: Use `TODO:` prefix for future work
|
||||
|
||||
Example:
|
||||
|
||||
```cpp
|
||||
// Consolidate small pieces to prevent fragmentation.
|
||||
// This is a heuristic: we only consolidate when piece count exceeds
|
||||
// a threshold, and we cap the bytes processed per consolidation run.
|
||||
void maybeConsolidate() {
|
||||
if (pieces_.size() < piece_limit_)
|
||||
return;
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Command
|
||||
|
||||
1. **Define the command function** in `Command.cc`:
|
||||
|
||||
```cpp
|
||||
bool cmd_my_feature(CommandContext &ctx) {
|
||||
Editor &ed = ctx.ed;
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
if (!buf) return false;
|
||||
|
||||
// Implement the command
|
||||
buf->insert_text(buf->Cury(), buf->Curx(), "text");
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Register the command** in `InstallDefaultCommands()`:
|
||||
|
||||
```cpp
|
||||
CommandRegistry::Register({
|
||||
CommandId::MyFeature,
|
||||
"my-feature",
|
||||
"Description of what it does",
|
||||
cmd_my_feature
|
||||
});
|
||||
```
|
||||
|
||||
3. **Add keybinding** in the appropriate `InputHandler` (e.g.,
|
||||
`TerminalInputHandler.cc`).
|
||||
|
||||
4. **Write tests** in `tests/test_command_semantics.cc` or a new test
|
||||
file.
|
||||
|
||||
### Adding a New Frontend
|
||||
|
||||
1. **Implement the three interfaces**:
|
||||
- `Frontend` - Lifecycle management
|
||||
- `InputHandler` - Event → Command translation
|
||||
- `Renderer` - Draw the editor state
|
||||
|
||||
2. **Study existing implementations**:
|
||||
- `TerminalFrontend` - Simplest, good starting point
|
||||
- `ImGuiFrontend` - More complex, shows GUI patterns
|
||||
|
||||
3. **Register in `main.cc`** to make it selectable.
|
||||
|
||||
### Modifying the PieceTable
|
||||
|
||||
The PieceTable is performance-critical. When making changes:
|
||||
|
||||
1. **Understand the piece list** - Each piece references a range in
|
||||
either `original_` or `add_` buffer
|
||||
2. **Maintain invariants**:
|
||||
- `total_size_` must match sum of piece lengths
|
||||
- Line index must be invalidated on content changes
|
||||
- Version must increment on mutations
|
||||
3. **Test thoroughly** - Use `test_piece_table.cc` random edit test as a
|
||||
reference model
|
||||
4. **Profile if needed** - Large file performance is a key goal
|
||||
|
||||
### Adding Syntax Highlighting
|
||||
|
||||
1. **Create a new highlighter** in `syntax/` directory:
|
||||
- Inherit from `HighlighterEngine`
|
||||
- Implement `HighlightLine()` method
|
||||
|
||||
2. **Register in `HighlighterRegistry`** (
|
||||
`syntax/HighlighterRegistry.cc`)
|
||||
|
||||
3. **Add file extension mapping** in the registry
|
||||
|
||||
4. **Test with sample files** of that language
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
- **Use the test frontend** - Write a test that reproduces the issue
|
||||
- **Enable assertions** - Build in Debug mode
|
||||
- **Check swap files** - Look in `/tmp/kte-swap-*` for recorded
|
||||
operations
|
||||
- **Print debugging** - Use `std::cerr` (stdout is used by ncurses)
|
||||
- **GDB/LLDB** - Standard debuggers work fine with kte
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Read the code** - kte is designed to be understandable; follow the
|
||||
data flow
|
||||
- **Check existing tests** - Tests often show how to use APIs correctly
|
||||
- **Look at git history** - See how similar features were implemented
|
||||
- **Read design docs** - Check `docs/plans/` for design rationale
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Areas where the codebase could be improved:
|
||||
|
||||
1. **Split Command.cc** - Break into logical groups (editing,
|
||||
navigation, file ops, etc.)
|
||||
2. **Complete Buffer::Line migration** - A few legacy editing functions
|
||||
in Command.cc still use `Buffer::Rows()` directly (see lines 86-90
|
||||
comment)
|
||||
3. **Add more inline documentation** - Especially for complex algorithms
|
||||
4. **Improve test coverage** - Add more edge case tests (current: 98
|
||||
tests)
|
||||
5. **Performance profiling** - Continue monitoring performance with
|
||||
benchmark suite
|
||||
6. **API documentation** - Consider adding Doxygen-style comments
|
||||
|
||||
---
|
||||
|
||||
Welcome aboard! Start small, read the code, and don't hesitate to ask
|
||||
questions.
|
||||
237
docs/swap.md
Normal file
237
docs/swap.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Swap journaling (crash recovery)
|
||||
|
||||
kte has a small “swap” system: an append-only per-buffer journal that
|
||||
records edits so they can be replayed after a crash.
|
||||
|
||||
This document describes the **currently implemented** swap system (stage
|
||||
2), as implemented in `Swap.h` / `Swap.cc`.
|
||||
|
||||
## What it is (and what it is not)
|
||||
|
||||
- The swap file is a **journal of editing operations** (currently
|
||||
inserts, deletes, and periodic full-buffer checkpoints).
|
||||
- It is written by a **single background writer thread** owned by
|
||||
`kte::SwapManager`.
|
||||
- It is intended for **best-effort crash recovery**.
|
||||
|
||||
kte automatically deletes/resets swap journals after a **clean save**
|
||||
and when
|
||||
closing a clean buffer, so old swap files do not accumulate under normal
|
||||
workflows. A best-effort prune also runs at startup to remove very old
|
||||
swap
|
||||
files.
|
||||
|
||||
## Automatic recovery prompt
|
||||
|
||||
When kte opens a file-backed buffer, it checks whether a corresponding
|
||||
swap journal exists.
|
||||
|
||||
- If a swap file exists and replay succeeds *and* produces different
|
||||
content than what is currently on disk, kte prompts:
|
||||
|
||||
```text
|
||||
Recover swap edits for <path>? (y/N, C-g cancel)
|
||||
```
|
||||
|
||||
- `y`: open the file and apply swap replay (buffer becomes dirty)
|
||||
- `Enter` (default) / any non-`y`: delete the swap file (
|
||||
best-effort)
|
||||
and open the file normally
|
||||
- `C-g`: cancel opening the file
|
||||
|
||||
- If a swap file exists but is unreadable/corrupt, kte prompts:
|
||||
|
||||
```text
|
||||
Swap file unreadable for <path>. Delete it? (y/N, C-g cancel)
|
||||
```
|
||||
|
||||
- `y`: delete the swap file (best-effort) and open the file normally
|
||||
- `Enter` (default): keep the swap file and open the file normally
|
||||
- `C-g`: cancel opening the file
|
||||
|
||||
## Where swap files live
|
||||
|
||||
Swap files are stored under an XDG-style per-user *state* directory:
|
||||
|
||||
- If `XDG_STATE_HOME` is set and non-empty:
|
||||
- `$XDG_STATE_HOME/kte/swap/…`
|
||||
- Otherwise, if `HOME` is set:
|
||||
- `~/.local/state/kte/swap/…`
|
||||
- Last resort fallback:
|
||||
- `<system-temp>/kte/state/kte/swap/…` (via
|
||||
`std::filesystem::temp_directory_path()`)
|
||||
|
||||
Swap files are always created with permissions `0600`.
|
||||
|
||||
### Swap file naming
|
||||
|
||||
For file-backed buffers, the swap filename is derived from the buffer’s
|
||||
path:
|
||||
|
||||
1. Take a canonical-ish path key (`std::filesystem::weakly_canonical`,
|
||||
else `absolute`, else the raw `Buffer::Filename()`).
|
||||
2. Encode it so it’s human-identifiable:
|
||||
- Strip one leading path separator (`/` or `\\`)
|
||||
- Replace path separators (`/` and `\\`) with `!`
|
||||
- Append `.swp`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
/home/kyle/tmp/test.txt -> home!kyle!tmp!test.txt.swp
|
||||
```
|
||||
|
||||
If the resulting name would be long (over ~200 characters), kte falls
|
||||
back to a shorter stable name:
|
||||
|
||||
```text
|
||||
<basename>.<fnv1a64(path-key-as-hex)>.swp
|
||||
```
|
||||
|
||||
For unnamed/unsaved buffers, kte uses:
|
||||
|
||||
```text
|
||||
unnamed-<pid>-<counter>.swp
|
||||
```
|
||||
|
||||
## Lifecycle (when swap is written)
|
||||
|
||||
`kte::SwapManager` is owned by `Editor` (see `Editor.cc`). Buffers are
|
||||
attached for journaling when they are added/opened.
|
||||
|
||||
- `SwapManager::Attach(Buffer*)` starts tracking a buffer and
|
||||
establishes its swap path.
|
||||
- `Buffer` emits swap events from its low-level edit APIs:
|
||||
- `Buffer::insert_text()` calls `SwapRecorder::OnInsert()`
|
||||
- `Buffer::delete_text()` calls `SwapRecorder::OnDelete()`
|
||||
- `Buffer::split_line()` / `join_lines()` are represented as
|
||||
insert/delete of `\n` (they do **not** emit `SPLIT`/`JOIN` records
|
||||
in stage 1).
|
||||
- `SwapManager::Detach(Buffer*)` flushes queued records, `fsync()`s, and
|
||||
closes the journal.
|
||||
- On `Save As` / filename changes,
|
||||
`SwapManager::NotifyFilenameChanged(Buffer&)` closes the existing
|
||||
journal and switches to a new path.
|
||||
- Note: the old swap file is currently left on disk (no
|
||||
cleanup/rotation yet).
|
||||
|
||||
## Durability and performance
|
||||
|
||||
Swap writing is best-effort and asynchronous:
|
||||
|
||||
- Records are queued from the UI/editing thread(s).
|
||||
- A background writer thread wakes at least every
|
||||
`SwapConfig::flush_interval_ms` (default `200ms`) to write any queued
|
||||
records.
|
||||
- `fsync()` is throttled to at most once per
|
||||
`SwapConfig::fsync_interval_ms` (default `1000ms`) per open swap file.
|
||||
- `SwapManager::Flush()` blocks until the queue is fully written; it is
|
||||
primarily used by tests and shutdown paths.
|
||||
|
||||
If a crash happens while writing, the swap file may end with a partial
|
||||
record. Replay detects truncation/CRC mismatch and fails safely.
|
||||
|
||||
## On-disk format (v1)
|
||||
|
||||
The file is:
|
||||
|
||||
1. A fixed-size 64-byte header
|
||||
2. Followed by a stream of records
|
||||
|
||||
All multi-byte integers in the swap file are **little-endian**.
|
||||
|
||||
### Header (64 bytes)
|
||||
|
||||
Layout (stage 1):
|
||||
|
||||
- `magic` (8 bytes): `KTE_SWP\0`
|
||||
- `version` (`u32`): currently `1`
|
||||
- `flags` (`u32`): currently `0`
|
||||
- `created_time` (`u64`): Unix seconds
|
||||
- remaining bytes are reserved/padding (currently zeroed)
|
||||
|
||||
### Record framing
|
||||
|
||||
Each record is:
|
||||
|
||||
```text
|
||||
[type: u8][len: u24][payload: len bytes][crc32: u32]
|
||||
```
|
||||
|
||||
- `len` is a 24-bit little-endian length of the payload (`0..0xFFFFFF`).
|
||||
- `crc32` is computed over the 4-byte record header (`type + len`)
|
||||
followed by the payload bytes.
|
||||
|
||||
### Record types
|
||||
|
||||
Type codes are defined in `SwapRecType` (`Swap.h`). Stage 1 primarily
|
||||
emits:
|
||||
|
||||
- `INS` (`1`): insert bytes at `(row, col)`
|
||||
- `DEL` (`2`): delete `len` bytes at `(row, col)`
|
||||
|
||||
Other type codes exist for forward compatibility (`SPLIT`, `JOIN`,
|
||||
`META`, `CHKPT`), but are not produced by the current `SwapRecorder`
|
||||
interface.
|
||||
|
||||
### Payload encoding (v1)
|
||||
|
||||
Every payload starts with:
|
||||
|
||||
```text
|
||||
[encver: u8]
|
||||
```
|
||||
|
||||
Currently `encver` must be `1`.
|
||||
|
||||
#### `INS` payload (encver = 1)
|
||||
|
||||
```text
|
||||
[encver: u8 = 1]
|
||||
[row: u32]
|
||||
[col: u32]
|
||||
[nbytes:u32]
|
||||
[bytes: nbytes]
|
||||
```
|
||||
|
||||
#### `DEL` payload (encver = 1)
|
||||
|
||||
```text
|
||||
[encver: u8 = 1]
|
||||
[row: u32]
|
||||
[col: u32]
|
||||
[len: u32]
|
||||
```
|
||||
|
||||
`row`/`col` are 0-based and are interpreted the same way as
|
||||
`Buffer::insert_text()` / `Buffer::delete_text()`.
|
||||
|
||||
## Replay / recovery
|
||||
|
||||
Swap replay is implemented as a low-level API:
|
||||
|
||||
-
|
||||
|
||||
`bool kte::SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)`
|
||||
|
||||
Behavior:
|
||||
|
||||
- The caller supplies an **already-open** `Buffer` (typically loaded
|
||||
from the on-disk file) and a swap path.
|
||||
- `ReplayFile()` validates header magic/version, then iterates records.
|
||||
- On a truncated file or CRC mismatch, it returns `false` and sets
|
||||
`err`.
|
||||
- On unknown record types, it ignores them (forward compatibility).
|
||||
- On failure, the buffer may have had a prefix of records applied;
|
||||
callers should treat this as “recovery failed”.
|
||||
|
||||
Important: if the buffer is currently attached to a `SwapManager`, you
|
||||
should suspend/disable recording during replay (or detach first),
|
||||
otherwise replayed edits would be re-journaled.
|
||||
|
||||
## Tests
|
||||
|
||||
Swap behavior and format are validated by unit tests:
|
||||
|
||||
- `tests/test_swap_writer.cc` (header, permissions, record CRC framing)
|
||||
- `tests/test_swap_replay.cc` (record replay and truncation handling)
|
||||
25719
fonts/Go.h
25719
fonts/Go.h
File diff suppressed because it is too large
Load Diff
22047
fonts/Triplicate.h
22047
fonts/Triplicate.h
File diff suppressed because it is too large
Load Diff
27
main.cc
27
main.cc
@@ -207,6 +207,9 @@ main(int argc, char *argv[])
|
||||
// Open files passed on the CLI; support +N to jump to line N in the next file.
|
||||
// If no files are provided, create an empty buffer.
|
||||
if (optind < argc) {
|
||||
// Seed a scratch buffer so the UI has something to show while deferred opens
|
||||
// (and potential swap recovery prompts) are processed.
|
||||
editor.AddBuffer(Buffer());
|
||||
std::size_t pending_line = 0; // 0 = no pending line
|
||||
for (int i = optind; i < argc; ++i) {
|
||||
const char *arg = argv[i];
|
||||
@@ -242,29 +245,9 @@ main(int argc, char *argv[])
|
||||
// Fall through: not a +number, treat as filename starting with '+'
|
||||
}
|
||||
|
||||
std::string err;
|
||||
const std::string path = arg;
|
||||
if (!editor.OpenFile(path, err)) {
|
||||
editor.SetStatus("open: " + err);
|
||||
std::cerr << "kte: " << err << "\n";
|
||||
} else if (pending_line > 0) {
|
||||
// Apply pending +N to the just-opened (current) buffer
|
||||
if (Buffer *b = editor.CurrentBuffer()) {
|
||||
std::size_t nrows = b->Nrows();
|
||||
std::size_t line = pending_line > 0 ? pending_line - 1 : 0;
|
||||
// 1-based to 0-based
|
||||
if (nrows > 0) {
|
||||
if (line >= nrows)
|
||||
line = nrows - 1;
|
||||
} else {
|
||||
line = 0;
|
||||
}
|
||||
b->SetCursor(0, line);
|
||||
// Do not force viewport offsets here; the frontend/renderer
|
||||
// will establish dimensions and normalize visibility on first draw.
|
||||
}
|
||||
pending_line = 0; // consumed
|
||||
}
|
||||
editor.RequestOpenFile(path, pending_line);
|
||||
pending_line = 0; // consumed (if set)
|
||||
}
|
||||
// If we ended with a pending +N but no subsequent file, ignore it.
|
||||
} else {
|
||||
|
||||
@@ -60,11 +60,10 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return state;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
if (s.empty())
|
||||
return state;
|
||||
|
||||
|
||||
@@ -40,10 +40,9 @@ ErlangHighlighter::ErlangHighlighter()
|
||||
void
|
||||
ErlangHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
|
||||
|
||||
@@ -40,10 +40,9 @@ ForthHighlighter::ForthHighlighter()
|
||||
void
|
||||
ForthHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
|
||||
|
||||
@@ -46,10 +46,9 @@ GoHighlighter::GoHighlighter()
|
||||
void
|
||||
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
int bol = 0;
|
||||
|
||||
@@ -82,7 +82,7 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
|
||||
// Only use cached state if it's for the current version and row still exists
|
||||
if (r <= row - 1 && kv.second.version == buf_version) {
|
||||
// Validate that the cached row index is still valid in the buffer
|
||||
if (r >= 0 && static_cast<std::size_t>(r) < buf.Rows().size()) {
|
||||
if (r >= 0 && static_cast<std::size_t>(r) < buf.Nrows()) {
|
||||
if (r > best)
|
||||
best = r;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,9 @@ is_digit(char c)
|
||||
void
|
||||
JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
auto push = [&](int a, int b, TokenKind k) {
|
||||
if (b > a)
|
||||
|
||||
@@ -25,10 +25,9 @@ LispHighlighter::LispHighlighter()
|
||||
void
|
||||
LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
int bol = 0;
|
||||
|
||||
@@ -24,10 +24,9 @@ MarkdownHighlighter::HighlightLineStateful(const Buffer &buf, int row, const Lin
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return state;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
|
||||
// Reuse in_block_comment flag as "in fenced code" state.
|
||||
|
||||
@@ -5,10 +5,9 @@ namespace kte {
|
||||
void
|
||||
NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
if (n <= 0)
|
||||
return;
|
||||
|
||||
@@ -50,10 +50,9 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return state;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
|
||||
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
||||
|
||||
@@ -47,10 +47,9 @@ RustHighlighter::RustHighlighter()
|
||||
void
|
||||
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
while (i < n) {
|
||||
|
||||
@@ -14,10 +14,9 @@ push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k)
|
||||
void
|
||||
ShellHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
// if first non-space is '#', whole line is comment
|
||||
|
||||
@@ -47,10 +47,9 @@ SqlHighlighter::SqlHighlighter()
|
||||
void
|
||||
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
|
||||
|
||||
30
tests/Test.h
30
tests/Test.h
@@ -8,19 +8,23 @@
|
||||
#include <sstream>
|
||||
|
||||
namespace ktet {
|
||||
|
||||
struct TestCase {
|
||||
std::string name;
|
||||
std::function<void()> fn;
|
||||
};
|
||||
|
||||
inline std::vector<TestCase>& registry() {
|
||||
|
||||
inline std::vector<TestCase> &
|
||||
registry()
|
||||
{
|
||||
static std::vector<TestCase> r;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
struct Registrar {
|
||||
Registrar(const char* name, std::function<void()> fn) {
|
||||
Registrar(const char *name, std::function<void()> fn)
|
||||
{
|
||||
registry().push_back(TestCase{std::string(name), std::move(fn)});
|
||||
}
|
||||
};
|
||||
@@ -30,27 +34,37 @@ struct AssertionFailure {
|
||||
std::string msg;
|
||||
};
|
||||
|
||||
inline void expect(bool cond, const char* expr, const char* file, int line) {
|
||||
|
||||
inline void
|
||||
expect(bool cond, const char *expr, const char *file, int line)
|
||||
{
|
||||
if (!cond) {
|
||||
std::cerr << file << ":" << line << ": EXPECT failed: " << expr << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
inline void assert_true(bool cond, const char* expr, const char* file, int line) {
|
||||
|
||||
inline void
|
||||
assert_true(bool cond, const char *expr, const char *file, int line)
|
||||
{
|
||||
if (!cond) {
|
||||
throw AssertionFailure{std::string(file) + ":" + std::to_string(line) + ": ASSERT failed: " + expr};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
template<typename A, typename B>
|
||||
inline void assert_eq_impl(const A& a, const B& b, const char* ea, const char* eb, const char* file, int line) {
|
||||
if (!(a == b)) {
|
||||
inline void
|
||||
assert_eq_impl(const A &a, const B &b, const char *ea, const char *eb, const char *file, int line)
|
||||
{
|
||||
// Cast to common type to avoid signed/unsigned comparison warnings
|
||||
using Common = std::common_type_t<A, B>;
|
||||
if (!(static_cast<Common>(a) == static_cast<Common>(b))) {
|
||||
std::ostringstream oss;
|
||||
oss << file << ":" << line << ": ASSERT_EQ failed: " << ea << " == " << eb;
|
||||
throw AssertionFailure{oss.str()};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ktet
|
||||
|
||||
#define TEST(name) \
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
#include <iostream>
|
||||
#include <chrono>
|
||||
|
||||
int main() {
|
||||
|
||||
int
|
||||
main()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
auto ® = ktet::registry();
|
||||
std::cout << "kte unit tests: " << reg.size() << " test(s)\n";
|
||||
|
||||
411
tests/test_benchmarks.cc
Normal file
411
tests/test_benchmarks.cc
Normal file
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
* test_benchmarks.cc - Performance benchmarks for core kte operations
|
||||
*
|
||||
* This file measures the performance of critical operations to ensure
|
||||
* that migrations and refactorings don't introduce performance regressions.
|
||||
*
|
||||
* Benchmarks cover:
|
||||
* - PieceTable operations (insert, delete, GetLine, GetLineRange)
|
||||
* - Buffer operations (Nrows, GetLineString, GetLineView)
|
||||
* - Iteration patterns (comparing old Rows() vs new GetLineString/GetLineView)
|
||||
* - Syntax highlighting on large files
|
||||
*
|
||||
* Each benchmark reports execution time in milliseconds.
|
||||
*/
|
||||
#include "Test.h"
|
||||
#include "Buffer.h"
|
||||
#include "PieceTable.h"
|
||||
#include "syntax/CppHighlighter.h"
|
||||
#include "syntax/HighlighterEngine.h"
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
// Benchmark timing utility
|
||||
class BenchmarkTimer {
|
||||
public:
|
||||
BenchmarkTimer(const char *name) : name_(name), start_(std::chrono::high_resolution_clock::now()) {}
|
||||
|
||||
|
||||
~BenchmarkTimer()
|
||||
{
|
||||
auto end = std::chrono::high_resolution_clock::now();
|
||||
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start_);
|
||||
double ms = duration.count() / 1000.0;
|
||||
std::cout << " [BENCH] " << name_ << ": " << ms << " ms\n";
|
||||
}
|
||||
|
||||
private:
|
||||
const char *name_;
|
||||
std::chrono::high_resolution_clock::time_point start_;
|
||||
};
|
||||
|
||||
// Generate test data
|
||||
std::string
|
||||
generate_large_file(std::size_t num_lines, std::size_t avg_line_length)
|
||||
{
|
||||
std::mt19937 rng(42);
|
||||
std::string result;
|
||||
result.reserve(num_lines * (avg_line_length + 1));
|
||||
|
||||
for (std::size_t i = 0; i < num_lines; ++i) {
|
||||
std::size_t line_len = avg_line_length + (rng() % 20) - 10; // ±10 chars variation
|
||||
for (std::size_t j = 0; j < line_len; ++j) {
|
||||
char c = 'a' + (rng() % 26);
|
||||
result.push_back(c);
|
||||
}
|
||||
result.push_back('\n');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
generate_cpp_code(std::size_t num_lines)
|
||||
{
|
||||
std::ostringstream oss;
|
||||
oss << "#include <iostream>\n";
|
||||
oss << "#include <vector>\n";
|
||||
oss << "#include <string>\n\n";
|
||||
oss << "namespace test {\n";
|
||||
|
||||
for (std::size_t i = 0; i < num_lines / 10; ++i) {
|
||||
oss << "class TestClass" << i << " {\n";
|
||||
oss << "public:\n";
|
||||
oss << " void method" << i << "() {\n";
|
||||
oss << " // Comment line\n";
|
||||
oss << " int x = " << i << ";\n";
|
||||
oss << " std::string s = \"test string\";\n";
|
||||
oss << " for (int j = 0; j < 100; ++j) {\n";
|
||||
oss << " x += j;\n";
|
||||
oss << " }\n";
|
||||
oss << " }\n";
|
||||
oss << "};\n\n";
|
||||
}
|
||||
oss << "} // namespace test\n";
|
||||
return oss.str();
|
||||
}
|
||||
} // anonymous namespace
|
||||
|
||||
// ============================================================================
|
||||
// PieceTable Benchmarks
|
||||
// ============================================================================
|
||||
|
||||
TEST (Benchmark_PieceTable_Sequential_Inserts)
|
||||
{
|
||||
std::cout << "\n=== PieceTable Sequential Insert Benchmark ===\n";
|
||||
PieceTable pt;
|
||||
const std::size_t num_ops = 10000;
|
||||
const char *text = "line\n";
|
||||
const std::size_t text_len = 5;
|
||||
|
||||
{
|
||||
BenchmarkTimer timer("10K sequential inserts at end");
|
||||
for (std::size_t i = 0; i < num_ops; ++i) {
|
||||
pt.Insert(pt.Size(), text, text_len);
|
||||
}
|
||||
}
|
||||
|
||||
ASSERT_EQ(pt.LineCount(), num_ops + 1); // +1 for final empty line
|
||||
}
|
||||
|
||||
|
||||
TEST (Benchmark_PieceTable_Random_Inserts)
|
||||
{
|
||||
std::cout << "\n=== PieceTable Random Insert Benchmark ===\n";
|
||||
PieceTable pt;
|
||||
const std::size_t num_ops = 5000;
|
||||
const char *text = "xyz\n";
|
||||
const std::size_t text_len = 4;
|
||||
std::mt19937 rng(123);
|
||||
|
||||
// Pre-populate with some content
|
||||
std::string initial = generate_large_file(1000, 50);
|
||||
pt.Insert(0, initial.data(), initial.size());
|
||||
|
||||
{
|
||||
BenchmarkTimer timer("5K random inserts");
|
||||
for (std::size_t i = 0; i < num_ops; ++i) {
|
||||
std::size_t pos = rng() % (pt.Size() + 1);
|
||||
pt.Insert(pos, text, text_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST (Benchmark_PieceTable_GetLine_Sequential)
|
||||
{
|
||||
std::cout << "\n=== PieceTable GetLine Sequential Benchmark ===\n";
|
||||
PieceTable pt;
|
||||
std::string data = generate_large_file(10000, 80);
|
||||
pt.Insert(0, data.data(), data.size());
|
||||
|
||||
std::size_t total_chars = 0;
|
||||
{
|
||||
BenchmarkTimer timer("GetLine on 10K lines (sequential)");
|
||||
for (std::size_t i = 0; i < pt.LineCount(); ++i) {
|
||||
std::string line = pt.GetLine(i);
|
||||
total_chars += line.size();
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(total_chars > 0);
|
||||
}
|
||||
|
||||
|
||||
TEST (Benchmark_PieceTable_GetLineRange_Sequential)
|
||||
{
|
||||
std::cout << "\n=== PieceTable GetLineRange Sequential Benchmark ===\n";
|
||||
PieceTable pt;
|
||||
std::string data = generate_large_file(10000, 80);
|
||||
pt.Insert(0, data.data(), data.size());
|
||||
|
||||
std::size_t total_ranges = 0;
|
||||
{
|
||||
BenchmarkTimer timer("GetLineRange on 10K lines (sequential)");
|
||||
for (std::size_t i = 0; i < pt.LineCount(); ++i) {
|
||||
auto range = pt.GetLineRange(i);
|
||||
total_ranges += (range.second - range.first);
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(total_ranges > 0);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Buffer Benchmarks
|
||||
// ============================================================================
|
||||
|
||||
TEST (Benchmark_Buffer_Nrows_Repeated_Calls)
|
||||
{
|
||||
std::cout << "\n=== Buffer Nrows Benchmark ===\n";
|
||||
Buffer buf;
|
||||
std::string data = generate_large_file(10000, 80);
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
std::size_t sum = 0;
|
||||
{
|
||||
BenchmarkTimer timer("1M calls to Nrows()");
|
||||
for (int i = 0; i < 1000000; ++i) {
|
||||
sum += buf.Nrows();
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(sum > 0);
|
||||
}
|
||||
|
||||
|
||||
TEST (Benchmark_Buffer_GetLineString_Sequential)
|
||||
{
|
||||
std::cout << "\n=== Buffer GetLineString Sequential Benchmark ===\n";
|
||||
Buffer buf;
|
||||
std::string data = generate_large_file(10000, 80);
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
std::size_t total_chars = 0;
|
||||
{
|
||||
BenchmarkTimer timer("GetLineString on 10K lines");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
std::string line = buf.GetLineString(i);
|
||||
total_chars += line.size();
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(total_chars > 0);
|
||||
}
|
||||
|
||||
|
||||
TEST (Benchmark_Buffer_GetLineView_Sequential)
|
||||
{
|
||||
std::cout << "\n=== Buffer GetLineView Sequential Benchmark ===\n";
|
||||
Buffer buf;
|
||||
std::string data = generate_large_file(10000, 80);
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
std::size_t total_chars = 0;
|
||||
{
|
||||
BenchmarkTimer timer("GetLineView on 10K lines");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
auto view = buf.GetLineView(i);
|
||||
total_chars += view.size();
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(total_chars > 0);
|
||||
}
|
||||
|
||||
|
||||
TEST (Benchmark_Buffer_Rows_Materialization)
|
||||
{
|
||||
std::cout << "\n=== Buffer Rows() Materialization Benchmark ===\n";
|
||||
Buffer buf;
|
||||
std::string data = generate_large_file(10000, 80);
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
std::size_t total_chars = 0;
|
||||
{
|
||||
BenchmarkTimer timer("Rows() materialization + iteration on 10K lines");
|
||||
const auto &rows = buf.Rows();
|
||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||
total_chars += rows[i].size();
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(total_chars > 0);
|
||||
}
|
||||
|
||||
|
||||
TEST (Benchmark_Buffer_Iteration_Comparison)
|
||||
{
|
||||
std::cout << "\n=== Buffer Iteration Pattern Comparison ===\n";
|
||||
Buffer buf;
|
||||
std::string data = generate_large_file(5000, 80);
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
std::size_t sum1 = 0, sum2 = 0, sum3 = 0;
|
||||
|
||||
// Pattern 1: Old style with Rows()
|
||||
{
|
||||
BenchmarkTimer timer("Pattern 1: Rows() + iteration");
|
||||
const auto &rows = buf.Rows();
|
||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||
sum1 += rows[i].size();
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: New style with GetLineString
|
||||
{
|
||||
BenchmarkTimer timer("Pattern 2: Nrows() + GetLineString");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
sum2 += buf.GetLineString(i).size();
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 3: New style with GetLineView (zero-copy)
|
||||
{
|
||||
BenchmarkTimer timer("Pattern 3: Nrows() + GetLineView (zero-copy)");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
sum3 += buf.GetLineView(i).size();
|
||||
}
|
||||
}
|
||||
|
||||
// sum1 and sum2 should match (both strip newlines)
|
||||
ASSERT_EQ(sum1, sum2);
|
||||
// sum3 includes newlines, so it will be larger
|
||||
EXPECT_TRUE(sum3 > sum2);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Syntax Highlighting Benchmarks
|
||||
// ============================================================================
|
||||
|
||||
TEST (Benchmark_Syntax_CppHighlighter_Large_File)
|
||||
{
|
||||
std::cout << "\n=== Syntax Highlighting Benchmark ===\n";
|
||||
Buffer buf;
|
||||
std::string cpp_code = generate_cpp_code(1000);
|
||||
buf.insert_text(0, 0, cpp_code);
|
||||
buf.EnsureHighlighter();
|
||||
|
||||
auto highlighter = std::make_unique<kte::CppHighlighter>();
|
||||
std::size_t total_spans = 0;
|
||||
|
||||
{
|
||||
BenchmarkTimer timer("C++ highlighting on ~1000 lines");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
std::vector<kte::HighlightSpan> spans;
|
||||
highlighter->HighlightLine(buf, static_cast<int>(i), spans);
|
||||
total_spans += spans.size();
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(total_spans > 0);
|
||||
}
|
||||
|
||||
|
||||
TEST (Benchmark_Syntax_HighlighterEngine_Cached)
|
||||
{
|
||||
std::cout << "\n=== HighlighterEngine Cache Benchmark ===\n";
|
||||
Buffer buf;
|
||||
std::string cpp_code = generate_cpp_code(1000);
|
||||
buf.insert_text(0, 0, cpp_code);
|
||||
buf.EnsureHighlighter();
|
||||
|
||||
auto *engine = buf.Highlighter();
|
||||
if (engine) {
|
||||
engine->SetHighlighter(std::make_unique<kte::CppHighlighter>());
|
||||
|
||||
// First pass: populate cache
|
||||
{
|
||||
BenchmarkTimer timer("First pass (cache population)");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
engine->GetLine(buf, static_cast<int>(i), buf.Version());
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: use cache
|
||||
{
|
||||
BenchmarkTimer timer("Second pass (cache hits)");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
engine->GetLine(buf, static_cast<int>(i), buf.Version());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Large File Stress Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST (Benchmark_Large_File_50K_Lines)
|
||||
{
|
||||
std::cout << "\n=== Large File (50K lines) Benchmark ===\n";
|
||||
Buffer buf;
|
||||
std::string data = generate_large_file(50000, 80);
|
||||
|
||||
{
|
||||
BenchmarkTimer timer("Insert 50K lines");
|
||||
buf.insert_text(0, 0, data);
|
||||
}
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 50001); // +1 for final line
|
||||
|
||||
std::size_t total = 0;
|
||||
{
|
||||
BenchmarkTimer timer("Iterate 50K lines with GetLineView");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
total += buf.GetLineView(i).size();
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(total > 0);
|
||||
}
|
||||
|
||||
|
||||
TEST (Benchmark_Random_Access_Pattern)
|
||||
{
|
||||
std::cout << "\n=== Random Access Pattern Benchmark ===\n";
|
||||
Buffer buf;
|
||||
std::string data = generate_large_file(10000, 80);
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
std::mt19937 rng(456);
|
||||
std::size_t total = 0;
|
||||
|
||||
{
|
||||
BenchmarkTimer timer("10K random line accesses with GetLineView");
|
||||
for (int i = 0; i < 10000; ++i) {
|
||||
std::size_t line = rng() % buf.Nrows();
|
||||
total += buf.GetLineView(line).size();
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(total > 0);
|
||||
}
|
||||
@@ -1,15 +1,36 @@
|
||||
/*
|
||||
* test_buffer_io.cc - Tests for Buffer file I/O operations
|
||||
*
|
||||
* This file validates the Buffer's file handling capabilities, which are
|
||||
* critical for a text editor. Buffer manages the relationship between
|
||||
* in-memory content and files on disk.
|
||||
*
|
||||
* Key functionality tested:
|
||||
* - SaveAs() creates a new file and makes the buffer file-backed
|
||||
* - Save() writes to the existing file (requires file-backed buffer)
|
||||
* - OpenFromFile() loads existing files or creates empty buffers for new files
|
||||
* - The dirty flag is properly managed across save operations
|
||||
*
|
||||
* These tests demonstrate the Buffer I/O contract that commands rely on.
|
||||
* When adding new file operations, follow these patterns.
|
||||
*/
|
||||
#include "Test.h"
|
||||
#include <fstream>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include "Buffer.h"
|
||||
|
||||
static std::string read_all(const std::string &path) {
|
||||
|
||||
static std::string
|
||||
read_all(const std::string &path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
TEST(Buffer_SaveAs_and_Save_new_file) {
|
||||
|
||||
TEST (Buffer_SaveAs_and_Save_new_file)
|
||||
{
|
||||
const std::string path = "./.kte_ut_buffer_io_1.tmp";
|
||||
std::remove(path.c_str());
|
||||
|
||||
@@ -34,7 +55,9 @@ TEST(Buffer_SaveAs_and_Save_new_file) {
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST(Buffer_Save_after_Open_existing) {
|
||||
|
||||
TEST (Buffer_Save_after_Open_existing)
|
||||
{
|
||||
const std::string path = "./.kte_ut_buffer_io_2.tmp";
|
||||
std::remove(path.c_str());
|
||||
{
|
||||
@@ -57,7 +80,9 @@ TEST(Buffer_Save_after_Open_existing) {
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST(Buffer_Open_nonexistent_then_SaveAs) {
|
||||
|
||||
TEST (Buffer_Open_nonexistent_then_SaveAs)
|
||||
{
|
||||
const std::string path = "./.kte_ut_buffer_io_3.tmp";
|
||||
std::remove(path.c_str());
|
||||
|
||||
|
||||
@@ -59,6 +59,25 @@ TEST (CommandSemantics_ToggleMark_JumpToMark)
|
||||
}
|
||||
|
||||
|
||||
TEST(CommandSemantics_CtrlGRefresh_ClearsMark_WhenNothingElseToCancel)
|
||||
{
|
||||
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);
|
||||
|
||||
// C-g is mapped to Refresh; when there's no prompt/search/visual-line mode to cancel,
|
||||
// it should clear the mark.
|
||||
ASSERT_TRUE(h.Exec(CommandId::Refresh));
|
||||
ASSERT_EQ(b.MarkSet(), false);
|
||||
}
|
||||
|
||||
|
||||
TEST(CommandSemantics_CopyRegion_And_KillRegion)
|
||||
{
|
||||
TestHarness h;
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
/*
|
||||
* test_daily_workflows.cc - Integration tests for real-world editing scenarios
|
||||
*
|
||||
* This file demonstrates end-to-end testing of kte functionality by simulating
|
||||
* complete user workflows without requiring a UI. Tests execute commands directly
|
||||
* through the command system, validating that the entire stack (Editor, Buffer,
|
||||
* PieceTable, UndoSystem, SwapManager) works together correctly.
|
||||
*
|
||||
* Key workflows tested:
|
||||
* - Open file → Edit → Save: Basic editing lifecycle
|
||||
* - Multi-buffer management: Opening, switching, and closing multiple files
|
||||
* - Crash recovery: Swap file recording and replay after simulated crash
|
||||
*
|
||||
* These tests are valuable examples for developers because they show:
|
||||
* 1. How to test complex interactions without a frontend
|
||||
* 2. How commands compose to implement user workflows
|
||||
* 3. How to verify end-to-end behavior including file I/O and crash recovery
|
||||
*
|
||||
* When adding new features, consider adding integration tests here to validate
|
||||
* that they work correctly in realistic scenarios.
|
||||
*/
|
||||
#include "Test.h"
|
||||
|
||||
#include "Command.h"
|
||||
|
||||
448
tests/test_migration_coverage.cc
Normal file
448
tests/test_migration_coverage.cc
Normal file
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* test_migration_coverage.cc - Edge case tests for Buffer::Line migration
|
||||
*
|
||||
* This file provides comprehensive test coverage for the migration from
|
||||
* Buffer::Rows() to direct PieceTable operations using Nrows(), GetLineString(),
|
||||
* and GetLineView().
|
||||
*
|
||||
* Tests cover:
|
||||
* - Edge cases: empty buffers, single lines, very long lines
|
||||
* - Boundary conditions: first line, last line, out-of-bounds
|
||||
* - Consistency: GetLineString vs GetLineView vs Rows()
|
||||
* - Performance: large files, many small operations
|
||||
* - Correctness: special characters, newlines, unicode
|
||||
*/
|
||||
#include "Test.h"
|
||||
#include "Buffer.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// ============================================================================
|
||||
// Edge Case Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST (Migration_EmptyBuffer_Nrows)
|
||||
{
|
||||
Buffer buf;
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 1); // Empty buffer has 1 logical line
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_EmptyBuffer_GetLineString)
|
||||
{
|
||||
Buffer buf;
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_EmptyBuffer_GetLineView)
|
||||
{
|
||||
Buffer buf;
|
||||
auto view = buf.GetLineView(0);
|
||||
ASSERT_EQ(view.size(), (std::size_t) 0);
|
||||
ASSERT_EQ(std::string(view), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_SingleLine_NoNewline)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("hello"));
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 1);
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("hello"));
|
||||
ASSERT_EQ(std::string(buf.GetLineView(0)), std::string("hello"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_SingleLine_WithNewline)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("hello\n"));
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 2); // Line + empty line after newline
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("hello"));
|
||||
ASSERT_EQ(buf.GetLineString(1), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_MultipleLines_TrailingNewline)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("line1\nline2\nline3\n"));
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 4); // 3 lines + empty line
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("line1"));
|
||||
ASSERT_EQ(buf.GetLineString(1), std::string("line2"));
|
||||
ASSERT_EQ(buf.GetLineString(2), std::string("line3"));
|
||||
ASSERT_EQ(buf.GetLineString(3), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_MultipleLines_NoTrailingNewline)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("line1\nline2\nline3"));
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 3);
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("line1"));
|
||||
ASSERT_EQ(buf.GetLineString(1), std::string("line2"));
|
||||
ASSERT_EQ(buf.GetLineString(2), std::string("line3"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_VeryLongLine)
|
||||
{
|
||||
Buffer buf;
|
||||
std::string long_line(10000, 'x');
|
||||
buf.insert_text(0, 0, long_line);
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 1);
|
||||
ASSERT_EQ(buf.GetLineString(0), long_line);
|
||||
ASSERT_EQ(buf.GetLineString(0).size(), (std::size_t) 10000);
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_ManyEmptyLines)
|
||||
{
|
||||
Buffer buf;
|
||||
std::string many_newlines(1000, '\n');
|
||||
buf.insert_text(0, 0, many_newlines);
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 1001); // 1000 newlines = 1001 lines
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
ASSERT_EQ(buf.GetLineString(i), std::string(""));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Consistency Tests: GetLineString vs GetLineView vs Rows()
|
||||
// ============================================================================
|
||||
|
||||
TEST (Migration_Consistency_AllMethods)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("abc\n123\nxyz"));
|
||||
|
||||
const auto &rows = buf.Rows();
|
||||
ASSERT_EQ(buf.Nrows(), rows.size());
|
||||
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
std::string via_string = buf.GetLineString(i);
|
||||
std::string via_rows = std::string(rows[i]);
|
||||
// GetLineString and Rows() both strip newlines
|
||||
ASSERT_EQ(via_string, via_rows);
|
||||
// GetLineView includes the raw range (with newlines if present)
|
||||
// Just verify it's accessible
|
||||
(void) buf.GetLineView(i);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_Consistency_AfterEdits)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("line1\nline2\nline3\n"));
|
||||
|
||||
// Edit: insert in middle
|
||||
buf.insert_text(1, 2, std::string("XX"));
|
||||
|
||||
const auto &rows = buf.Rows();
|
||||
ASSERT_EQ(buf.Nrows(), rows.size());
|
||||
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
// GetLineString and Rows() both strip newlines
|
||||
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
||||
}
|
||||
|
||||
// Edit: delete line
|
||||
buf.delete_row(1);
|
||||
|
||||
const auto &rows2 = buf.Rows();
|
||||
ASSERT_EQ(buf.Nrows(), rows2.size());
|
||||
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
ASSERT_EQ(buf.GetLineString(i), std::string(rows2[i]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Boundary Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST (Migration_FirstLine_Access)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("first\nsecond\nthird"));
|
||||
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("first"));
|
||||
// GetLineView includes newline: "first\n"
|
||||
auto view0 = buf.GetLineView(0);
|
||||
EXPECT_TRUE(view0.size() >= 5); // at least "first"
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_LastLine_Access)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("first\nsecond\nthird"));
|
||||
|
||||
std::size_t last = buf.Nrows() - 1;
|
||||
ASSERT_EQ(buf.GetLineString(last), std::string("third"));
|
||||
ASSERT_EQ(std::string(buf.GetLineView(last)), std::string("third"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_GetLineRange_Boundaries)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("abc\n123\nxyz"));
|
||||
|
||||
// First line
|
||||
auto r0 = buf.GetLineRange(0);
|
||||
ASSERT_EQ(r0.first, (std::size_t) 0);
|
||||
ASSERT_EQ(r0.second, (std::size_t) 4); // "abc\n"
|
||||
|
||||
// Last line
|
||||
std::size_t last = buf.Nrows() - 1;
|
||||
(void) buf.GetLineRange(last); // Verify it doesn't crash
|
||||
ASSERT_EQ(buf.GetLineString(last), std::string("xyz"));
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Special Characters and Unicode
|
||||
// ============================================================================
|
||||
|
||||
TEST (Migration_SpecialChars_Tabs)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("line\twith\ttabs"));
|
||||
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("line\twith\ttabs"));
|
||||
ASSERT_EQ(std::string(buf.GetLineView(0)), std::string("line\twith\ttabs"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_SpecialChars_CarriageReturn)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("line\rwith\rcr"));
|
||||
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("line\rwith\rcr"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_SpecialChars_NullBytes)
|
||||
{
|
||||
Buffer buf;
|
||||
std::string with_null = "abc";
|
||||
with_null.push_back('\0');
|
||||
with_null += "def";
|
||||
buf.insert_text(0, 0, with_null);
|
||||
|
||||
ASSERT_EQ(buf.GetLineString(0).size(), (std::size_t) 7);
|
||||
ASSERT_EQ(buf.GetLineView(0).size(), (std::size_t) 7);
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_Unicode_BasicMultibyte)
|
||||
{
|
||||
Buffer buf;
|
||||
std::string utf8 = "Hello 世界 🌍";
|
||||
buf.insert_text(0, 0, utf8);
|
||||
|
||||
ASSERT_EQ(buf.GetLineString(0), utf8);
|
||||
ASSERT_EQ(std::string(buf.GetLineView(0)), utf8);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Large File Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST (Migration_LargeFile_10K_Lines)
|
||||
{
|
||||
Buffer buf;
|
||||
std::string data;
|
||||
for (int i = 0; i < 10000; ++i) {
|
||||
data += "Line " + std::to_string(i) + "\n";
|
||||
}
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 10001); // +1 for final empty line
|
||||
|
||||
// Spot check some lines
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("Line 0"));
|
||||
ASSERT_EQ(buf.GetLineString(5000), std::string("Line 5000"));
|
||||
ASSERT_EQ(buf.GetLineString(9999), std::string("Line 9999"));
|
||||
ASSERT_EQ(buf.GetLineString(10000), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_LargeFile_Iteration_Consistency)
|
||||
{
|
||||
Buffer buf;
|
||||
std::string data;
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
data += "Line " + std::to_string(i) + "\n";
|
||||
}
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
// Iterate with GetLineString (strips newlines, must add back)
|
||||
std::string reconstructed1;
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
if (i > 0) {
|
||||
reconstructed1 += '\n';
|
||||
}
|
||||
reconstructed1 += buf.GetLineString(i);
|
||||
}
|
||||
|
||||
// Iterate with GetLineView (includes newlines)
|
||||
std::string reconstructed2;
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
auto view = buf.GetLineView(i);
|
||||
reconstructed2.append(view.data(), view.size());
|
||||
}
|
||||
|
||||
// GetLineView should match original exactly
|
||||
ASSERT_EQ(reconstructed2, data);
|
||||
// GetLineString reconstruction should match (without final empty line)
|
||||
EXPECT_TRUE(reconstructed1.size() > 0);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Stress Tests: Many Small Operations
|
||||
// ============================================================================
|
||||
|
||||
TEST (Migration_Stress_ManySmallInserts)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("start\n"));
|
||||
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
buf.insert_text(1, 0, std::string("x"));
|
||||
}
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 2);
|
||||
ASSERT_EQ(buf.GetLineString(0), std::string("start"));
|
||||
ASSERT_EQ(buf.GetLineString(1).size(), (std::size_t) 100);
|
||||
|
||||
// Verify consistency
|
||||
const auto &rows = buf.Rows();
|
||||
ASSERT_EQ(buf.GetLineString(1), std::string(rows[1]));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_Stress_ManyLineInserts)
|
||||
{
|
||||
Buffer buf;
|
||||
|
||||
for (int i = 0; i < 500; ++i) {
|
||||
buf.insert_row(buf.Nrows() - 1, std::string_view("line"));
|
||||
}
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 501); // 500 + initial empty line
|
||||
|
||||
for (std::size_t i = 0; i < 500; ++i) {
|
||||
ASSERT_EQ(buf.GetLineString(i), std::string("line"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_Stress_AlternatingInsertDelete)
|
||||
{
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("a\nb\nc\nd\ne\n"));
|
||||
|
||||
for (int i = 0; i < 50; ++i) {
|
||||
std::size_t nrows = buf.Nrows();
|
||||
if (nrows > 2) {
|
||||
buf.delete_row(1);
|
||||
}
|
||||
buf.insert_row(1, std::string_view("new"));
|
||||
}
|
||||
|
||||
// Verify consistency after many operations
|
||||
const auto &rows = buf.Rows();
|
||||
ASSERT_EQ(buf.Nrows(), rows.size());
|
||||
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
// GetLineString and Rows() both strip newlines
|
||||
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Regression Tests: Specific Migration Scenarios
|
||||
// ============================================================================
|
||||
|
||||
TEST (Migration_Shebang_Detection)
|
||||
{
|
||||
// Test the pattern used in Editor.cc for shebang detection
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("#!/usr/bin/env python3\nprint('hello')"));
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), (std::size_t) 2);
|
||||
|
||||
std::string first_line = "";
|
||||
if (buf.Nrows() > 0) {
|
||||
first_line = buf.GetLineString(0);
|
||||
}
|
||||
|
||||
ASSERT_EQ(first_line, std::string("#!/usr/bin/env python3"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_EmptyBufferCheck_Pattern)
|
||||
{
|
||||
// Test the pattern used in Editor.cc for empty buffer detection
|
||||
Buffer buf;
|
||||
|
||||
const std::size_t nrows = buf.Nrows();
|
||||
const bool rows_empty = (nrows == 0);
|
||||
const bool single_empty_line = (nrows == 1 && buf.GetLineView(0).size() == 0);
|
||||
|
||||
ASSERT_EQ(rows_empty, false);
|
||||
ASSERT_EQ(single_empty_line, true);
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_SyntaxHighlighter_Pattern)
|
||||
{
|
||||
// Test the pattern used in syntax highlighters
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}"));
|
||||
|
||||
for (std::size_t row = 0; row < buf.Nrows(); ++row) {
|
||||
// This is the pattern used in all migrated highlighters
|
||||
if (row >= buf.Nrows()) {
|
||||
break; // Should never happen
|
||||
}
|
||||
std::string line = buf.GetLineString(row);
|
||||
// Successfully accessed line - size() is always valid for std::string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST (Migration_SwapSnapshot_Pattern)
|
||||
{
|
||||
// Test the pattern used in Swap.cc for buffer snapshots
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("line1\nline2\nline3\n"));
|
||||
|
||||
const std::size_t nrows = buf.Nrows();
|
||||
std::string snapshot;
|
||||
|
||||
for (std::size_t i = 0; i < nrows; ++i) {
|
||||
auto view = buf.GetLineView(i);
|
||||
snapshot.append(view.data(), view.size());
|
||||
}
|
||||
|
||||
EXPECT_TRUE(snapshot.size() > 0);
|
||||
ASSERT_EQ(snapshot, std::string("line1\nline2\nline3\n"));
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* test_piece_table.cc - Tests for the PieceTable data structure
|
||||
*
|
||||
* This file validates the core text storage mechanism used by kte.
|
||||
* PieceTable provides efficient insert/delete operations without copying
|
||||
* the entire buffer, using a list of "pieces" that reference ranges in
|
||||
* original and add buffers.
|
||||
*
|
||||
* Key functionality tested:
|
||||
* - Insert/delete operations maintain correct content
|
||||
* - Line counting and line-based queries work correctly
|
||||
* - Position conversion (byte offset ↔ line/column) is accurate
|
||||
* - Random edits against a reference model (string) produce identical results
|
||||
*
|
||||
* The random edit test is particularly important - it performs hundreds of
|
||||
* random insertions and deletions, comparing PieceTable results against a
|
||||
* simple std::string to ensure correctness under all conditions.
|
||||
*/
|
||||
#include "Test.h"
|
||||
#include "PieceTable.h"
|
||||
#include <algorithm>
|
||||
|
||||
78
tests/test_reflow_indented_bullets.cc
Normal file
78
tests/test_reflow_indented_bullets.cc
Normal file
@@ -0,0 +1,78 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
|
||||
static std::string
|
||||
to_string_rows(const Buffer &buf)
|
||||
{
|
||||
std::string out;
|
||||
for (const auto &r: buf.Rows()) {
|
||||
out += static_cast<std::string>(r);
|
||||
out.push_back('\n');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
TEST(ReflowParagraph_IndentedBullets_PreserveStructure)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
// Test the example from the issue: indented list items should not be merged
|
||||
const std::string initial =
|
||||
"+ something at the top\n"
|
||||
" + something indented\n"
|
||||
"+ the next line\n";
|
||||
b.insert_text(0, 0, initial);
|
||||
// Put cursor on first item
|
||||
b.SetCursor(0, 0);
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
// Use a width that's larger than all lines (so no wrapping should occur)
|
||||
const int width = 80;
|
||||
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
|
||||
|
||||
const auto &rows = buf->Rows();
|
||||
const std::string result = to_string_rows(*buf);
|
||||
|
||||
// We should have 3 lines (plus possibly a trailing empty line)
|
||||
ASSERT_TRUE(rows.size() >= 3);
|
||||
|
||||
// Check that the structure is preserved
|
||||
std::string line0 = static_cast<std::string>(rows[0]);
|
||||
std::string line1 = static_cast<std::string>(rows[1]);
|
||||
std::string line2 = static_cast<std::string>(rows[2]);
|
||||
|
||||
// First line should start with "+ "
|
||||
EXPECT_TRUE(line0.rfind("+ ", 0) == 0);
|
||||
EXPECT_TRUE(line0.find("something at the top") != std::string::npos);
|
||||
|
||||
// Second line should start with " + " (two spaces, then +)
|
||||
EXPECT_TRUE(line1.rfind(" + ", 0) == 0);
|
||||
EXPECT_TRUE(line1.find("something indented") != std::string::npos);
|
||||
|
||||
// Third line should start with "+ "
|
||||
EXPECT_TRUE(line2.rfind("+ ", 0) == 0);
|
||||
EXPECT_TRUE(line2.find("the next line") != std::string::npos);
|
||||
|
||||
// The indented line should NOT be merged with the first line
|
||||
EXPECT_TRUE(line0.find("indented") == std::string::npos);
|
||||
|
||||
// Debug output if something goes wrong
|
||||
if (line0.rfind("+ ", 0) != 0 || line1.rfind(" + ", 0) != 0 || line2.rfind("+ ", 0) != 0) {
|
||||
std::cerr << "Reflow did not preserve indented bullet structure:\n" << result << "\n";
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,32 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
static std::vector<std::size_t> ref_find_all(const std::string &text, const std::string &pat) {
|
||||
|
||||
static std::vector<std::size_t>
|
||||
ref_find_all(const std::string &text, const std::string &pat)
|
||||
{
|
||||
std::vector<std::size_t> res;
|
||||
if (pat.empty()) return res;
|
||||
if (pat.empty())
|
||||
return res;
|
||||
std::size_t from = 0;
|
||||
while (true) {
|
||||
auto p = text.find(pat, from);
|
||||
if (p == std::string::npos) break;
|
||||
if (p == std::string::npos)
|
||||
break;
|
||||
res.push_back(p);
|
||||
from = p + pat.size();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
TEST(OptimizedSearch_basic_cases) {
|
||||
|
||||
TEST(OptimizedSearch_basic_cases)
|
||||
{
|
||||
OptimizedSearch os;
|
||||
struct Case { std::string text; std::string pat; } cases[] = {
|
||||
struct Case {
|
||||
std::string text;
|
||||
std::string pat;
|
||||
} cases[] = {
|
||||
{"", ""},
|
||||
{"", "a"},
|
||||
{"a", ""},
|
||||
|
||||
131
tests/test_swap_cleanup.cc
Normal file
131
tests/test_swap_cleanup.cc
Normal file
@@ -0,0 +1,131 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
TEST(SwapCleanup_ResetJournalOnSave)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
|
||||
const fs::path xdg_root = fs::temp_directory_path() /
|
||||
(std::string("kte_ut_xdg_state_swap_cleanup_") + std::to_string((int) ::getpid()));
|
||||
fs::remove_all(xdg_root);
|
||||
fs::create_directories(xdg_root);
|
||||
|
||||
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||
const std::string xdg_s = xdg_root.string();
|
||||
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
|
||||
|
||||
const std::string path = (xdg_root / "work" / "file.txt").string();
|
||||
fs::create_directories((xdg_root / "work"));
|
||||
std::remove(path.c_str());
|
||||
write_file_bytes(path, "base\n");
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
// Seed scratch buffer so OpenFile can reuse it.
|
||||
ed.AddBuffer(Buffer());
|
||||
std::string err;
|
||||
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||
Buffer *b = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(b != nullptr);
|
||||
|
||||
// Edit to ensure swap is created.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
|
||||
ASSERT_TRUE(b->Dirty());
|
||||
|
||||
ed.Swap()->Flush(b);
|
||||
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
|
||||
ASSERT_TRUE(fs::exists(swp));
|
||||
|
||||
// Save should reset/delete the journal.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Save));
|
||||
ed.Swap()->Flush(b);
|
||||
ASSERT_TRUE(!fs::exists(swp));
|
||||
|
||||
// Subsequent edits should recreate a fresh swap.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Y"));
|
||||
ed.Swap()->Flush(b);
|
||||
ASSERT_TRUE(fs::exists(swp));
|
||||
|
||||
// Cleanup.
|
||||
ed.Swap()->Detach(b);
|
||||
std::remove(path.c_str());
|
||||
std::remove(swp.c_str());
|
||||
if (!old_xdg.empty())
|
||||
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||
else
|
||||
unsetenv("XDG_STATE_HOME");
|
||||
fs::remove_all(xdg_root);
|
||||
}
|
||||
|
||||
|
||||
TEST(SwapCleanup_PruneSwapDir_ByAge)
|
||||
{
|
||||
const fs::path xdg_root = fs::temp_directory_path() /
|
||||
(std::string("kte_ut_xdg_state_swap_prune_") + std::to_string((int) ::getpid()));
|
||||
fs::remove_all(xdg_root);
|
||||
fs::create_directories(xdg_root);
|
||||
|
||||
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||
const std::string xdg_s = xdg_root.string();
|
||||
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
|
||||
|
||||
const fs::path swapdir = xdg_root / "kte" / "swap";
|
||||
fs::create_directories(swapdir);
|
||||
const fs::path oldp = swapdir / "old.swp";
|
||||
const fs::path newp = swapdir / "new.swp";
|
||||
const fs::path keep = swapdir / "note.txt";
|
||||
write_file_bytes(oldp.string(), "x");
|
||||
write_file_bytes(newp.string(), "y");
|
||||
write_file_bytes(keep.string(), "z");
|
||||
|
||||
// Make old.swp look old (2 days ago) and new.swp recent.
|
||||
std::error_code ec;
|
||||
fs::last_write_time(oldp, fs::file_time_type::clock::now() - std::chrono::hours(48), ec);
|
||||
fs::last_write_time(newp, fs::file_time_type::clock::now(), ec);
|
||||
|
||||
kte::SwapManager sm;
|
||||
kte::SwapConfig cfg;
|
||||
cfg.prune_on_startup = false;
|
||||
cfg.prune_max_age_days = 1;
|
||||
cfg.prune_max_files = 0; // disable count-based pruning for this test
|
||||
sm.SetConfig(cfg);
|
||||
sm.PruneSwapDir();
|
||||
|
||||
ASSERT_TRUE(!fs::exists(oldp));
|
||||
ASSERT_TRUE(fs::exists(newp));
|
||||
ASSERT_TRUE(fs::exists(keep));
|
||||
|
||||
// Cleanup.
|
||||
std::remove(newp.string().c_str());
|
||||
std::remove(keep.string().c_str());
|
||||
if (!old_xdg.empty())
|
||||
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||
else
|
||||
unsetenv("XDG_STATE_HOME");
|
||||
fs::remove_all(xdg_root);
|
||||
}
|
||||
94
tests/test_swap_git_editor.cc
Normal file
94
tests/test_swap_git_editor.cc
Normal file
@@ -0,0 +1,94 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
#include "tests/TestHarness.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
// Simulate git editor workflow: open file, edit, save, edit more, close.
|
||||
// The swap file should be deleted on close, not left behind.
|
||||
TEST(SwapCleanup_GitEditorWorkflow)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
|
||||
const fs::path xdg_root = fs::temp_directory_path() /
|
||||
(std::string("kte_ut_xdg_state_git_editor_") + std::to_string((int) ::getpid()));
|
||||
fs::remove_all(xdg_root);
|
||||
fs::create_directories(xdg_root);
|
||||
|
||||
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||
const std::string xdg_s = xdg_root.string();
|
||||
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
|
||||
|
||||
// Simulate git's COMMIT_EDITMSG path
|
||||
const std::string path = (xdg_root / ".git" / "COMMIT_EDITMSG").string();
|
||||
fs::create_directories((xdg_root / ".git"));
|
||||
std::remove(path.c_str());
|
||||
write_file_bytes(path, "# Enter commit message\n");
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
ed.AddBuffer(Buffer());
|
||||
std::string err;
|
||||
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||
Buffer *b = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(b != nullptr);
|
||||
|
||||
// User edits the file
|
||||
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
|
||||
ASSERT_TRUE(b->Dirty());
|
||||
|
||||
// User saves (git will read this)
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Save));
|
||||
ASSERT_TRUE(!b->Dirty());
|
||||
ed.Swap()->Flush(b);
|
||||
|
||||
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
|
||||
// After save, swap should be deleted
|
||||
ASSERT_TRUE(!fs::exists(swp));
|
||||
|
||||
// User makes more edits (common in git editor workflow - refining message)
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Y"));
|
||||
ASSERT_TRUE(b->Dirty());
|
||||
ed.Swap()->Flush(b);
|
||||
|
||||
// Now there's a new swap file for the unsaved edits
|
||||
ASSERT_TRUE(fs::exists(swp));
|
||||
|
||||
// User closes the buffer (or kte exits)
|
||||
// This simulates what happens when git is done and kte closes
|
||||
const std::size_t idx = ed.CurrentBufferIndex();
|
||||
ed.CloseBuffer(idx);
|
||||
|
||||
// The swap file should be deleted on close, even though buffer was dirty
|
||||
// This prevents stale swap files when used as git editor
|
||||
ASSERT_TRUE(!fs::exists(swp));
|
||||
|
||||
// Cleanup
|
||||
std::remove(path.c_str());
|
||||
if (!old_xdg.empty())
|
||||
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||
else
|
||||
unsetenv("XDG_STATE_HOME");
|
||||
fs::remove_all(xdg_root);
|
||||
}
|
||||
280
tests/test_swap_recovery_prompt.cc
Normal file
280
tests/test_swap_recovery_prompt.cc
Normal file
@@ -0,0 +1,280 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
#include "Swap.h"
|
||||
|
||||
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <unistd.h>
|
||||
|
||||
|
||||
namespace {
|
||||
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<char>(in)), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
struct ScopedXdgStateHome {
|
||||
std::string old;
|
||||
bool had{false};
|
||||
|
||||
|
||||
explicit ScopedXdgStateHome(const std::string &p)
|
||||
{
|
||||
const char *old_p = std::getenv("XDG_STATE_HOME");
|
||||
had = (old_p && *old_p);
|
||||
old = old_p ? std::string(old_p) : std::string();
|
||||
setenv("XDG_STATE_HOME", p.c_str(), 1);
|
||||
}
|
||||
|
||||
|
||||
~ScopedXdgStateHome()
|
||||
{
|
||||
if (had && !old.empty()) {
|
||||
setenv("XDG_STATE_HOME", old.c_str(), 1);
|
||||
} else {
|
||||
unsetenv("XDG_STATE_HOME");
|
||||
}
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
|
||||
TEST(SwapRecoveryPrompt_Recover_ReplaysSwap)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
|
||||
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||
(std::string("kte_ut_xdg_state_recover_") +
|
||||
std::to_string((int) ::getpid()));
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
std::filesystem::create_directories(xdg_root);
|
||||
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||
|
||||
const std::filesystem::path work = xdg_root / "work";
|
||||
std::filesystem::create_directories(work);
|
||||
const std::string file_path = (work / "recover.txt").string();
|
||||
write_file_bytes(file_path, "base\nline2\n");
|
||||
|
||||
// Create a swap journal with unsaved edits.
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||
kte::SwapManager sm;
|
||||
sm.Attach(&b);
|
||||
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||
b.insert_text(0, 0, std::string("X"));
|
||||
b.insert_text(1, 0, std::string("ZZ"));
|
||||
sm.Flush(&b);
|
||||
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||
const std::string expected = buffer_bytes_via_views(b);
|
||||
b.SetSwapRecorder(nullptr);
|
||||
sm.Detach(&b);
|
||||
|
||||
// Now attempt to open via Editor deferred-open; this should trigger a recovery prompt.
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
ed.AddBuffer(Buffer());
|
||||
ed.RequestOpenFile(b.Filename());
|
||||
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
|
||||
ASSERT_EQ(ed.PromptActive(), true);
|
||||
|
||||
// Answer 'y' to recover.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "y"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||
ASSERT_EQ(ed.PromptActive(), false);
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), expected);
|
||||
ASSERT_EQ(ed.CurrentBuffer()->Dirty(), true);
|
||||
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||
|
||||
std::remove(file_path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
}
|
||||
|
||||
|
||||
TEST(SwapRecoveryPrompt_Discard_DeletesSwapAndOpensClean)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
|
||||
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||
(std::string("kte_ut_xdg_state_discard_") +
|
||||
std::to_string((int) ::getpid()));
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
std::filesystem::create_directories(xdg_root);
|
||||
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||
|
||||
const std::filesystem::path work = xdg_root / "work";
|
||||
std::filesystem::create_directories(work);
|
||||
const std::string file_path = (work / "discard.txt").string();
|
||||
write_file_bytes(file_path, "base\n");
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(file_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);
|
||||
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
ed.AddBuffer(Buffer());
|
||||
ed.RequestOpenFile(b.Filename());
|
||||
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
|
||||
ASSERT_EQ(ed.PromptActive(), true);
|
||||
|
||||
// Default answer (empty) is 'no' => discard.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||
ASSERT_EQ(ed.PromptActive(), false);
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), read_file_bytes(b.Filename()));
|
||||
ASSERT_EQ(ed.CurrentBuffer()->Dirty(), false);
|
||||
ASSERT_EQ(std::filesystem::exists(swap_path), false);
|
||||
|
||||
std::remove(file_path.c_str());
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
}
|
||||
|
||||
|
||||
TEST(SwapRecoveryPrompt_Cancel_AbortsOpen)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
|
||||
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||
(std::string("kte_ut_xdg_state_cancel_") +
|
||||
std::to_string((int) ::getpid()));
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
std::filesystem::create_directories(xdg_root);
|
||||
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||
|
||||
const std::filesystem::path work = xdg_root / "work";
|
||||
std::filesystem::create_directories(work);
|
||||
const std::string file_path = (work / "cancel.txt").string();
|
||||
write_file_bytes(file_path, "base\n");
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(file_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);
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
ed.AddBuffer(Buffer());
|
||||
ed.RequestOpenFile(b.Filename());
|
||||
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
|
||||
ASSERT_EQ(ed.PromptActive(), true);
|
||||
|
||||
// Cancel the prompt (C-g / Refresh).
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Refresh));
|
||||
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||
ASSERT_EQ(ed.PromptActive(), false);
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
ASSERT_EQ(ed.CurrentBuffer()->Filename().empty(), true);
|
||||
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||
|
||||
std::remove(file_path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
}
|
||||
|
||||
|
||||
TEST(SwapRecoveryPrompt_CorruptSwap_OffersDelete)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
|
||||
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||
(std::string("kte_ut_xdg_state_corrupt_") +
|
||||
std::to_string((int) ::getpid()));
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
std::filesystem::create_directories(xdg_root);
|
||||
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||
|
||||
const std::filesystem::path work = xdg_root / "work";
|
||||
std::filesystem::create_directories(work);
|
||||
const std::string file_path = (work / "corrupt.txt").string();
|
||||
write_file_bytes(file_path, "base\n");
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||
|
||||
// Write a corrupt swap file at the expected location.
|
||||
try {
|
||||
std::filesystem::create_directories(std::filesystem::path(swap_path).parent_path());
|
||||
} catch (...) {
|
||||
// ignore
|
||||
}
|
||||
write_file_bytes(swap_path, "x");
|
||||
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
ed.AddBuffer(Buffer());
|
||||
ed.RequestOpenFile(b.Filename());
|
||||
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::DeleteCorruptSwap);
|
||||
ASSERT_EQ(ed.PromptActive(), true);
|
||||
|
||||
// Answer 'y' to delete the corrupt swap and proceed.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "y"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||
ASSERT_EQ(ed.PromptActive(), false);
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), read_file_bytes(b.Filename()));
|
||||
ASSERT_EQ(std::filesystem::exists(swap_path), false);
|
||||
|
||||
std::remove(file_path.c_str());
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
#include "Buffer.h"
|
||||
#include "Swap.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
|
||||
static void
|
||||
@@ -37,6 +39,30 @@ buffer_bytes_via_views(const Buffer &b)
|
||||
}
|
||||
|
||||
|
||||
static std::vector<std::uint8_t>
|
||||
record_types_from_bytes(const std::string &bytes)
|
||||
{
|
||||
std::vector<std::uint8_t> types;
|
||||
if (bytes.size() < 64)
|
||||
return types;
|
||||
std::size_t off = 64;
|
||||
while (off < bytes.size()) {
|
||||
if (bytes.size() - off < 8)
|
||||
break;
|
||||
const std::uint8_t type = static_cast<std::uint8_t>(bytes[off + 0]);
|
||||
const std::uint32_t len = (std::uint32_t) static_cast<std::uint8_t>(bytes[off + 1]) |
|
||||
((std::uint32_t) static_cast<std::uint8_t>(bytes[off + 2]) << 8) |
|
||||
((std::uint32_t) static_cast<std::uint8_t>(bytes[off + 3]) << 16);
|
||||
const std::size_t crc_off = off + 4 + (std::size_t) len;
|
||||
if (crc_off + 4 > bytes.size())
|
||||
break;
|
||||
types.push_back(type);
|
||||
off = crc_off + 4;
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
|
||||
TEST(SwapReplay_RecordFlushReopenReplay_ExactBytesMatch)
|
||||
{
|
||||
const std::string path = "./.kte_ut_swap_replay_1.txt";
|
||||
@@ -112,3 +138,90 @@ TEST (SwapReplay_TruncatedLog_FailsSafely)
|
||||
std::remove(swap_path.c_str());
|
||||
std::remove(trunc_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST(SwapReplay_Checkpoint_Midstream_ExactBytesMatch)
|
||||
{
|
||||
const std::string path = "./.kte_ut_swap_replay_chkpt_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));
|
||||
|
||||
// Some edits, then an explicit checkpoint, then more edits.
|
||||
b.insert_text(0, 0, std::string("X"));
|
||||
sm.Checkpoint(&b);
|
||||
b.insert_text(1, 0, std::string("ZZ"));
|
||||
b.delete_text(0, 0, 1);
|
||||
|
||||
sm.Flush(&b);
|
||||
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||
const std::string expected = buffer_bytes_via_views(b);
|
||||
|
||||
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(SwapCompaction_RewritesToSingleCheckpoint)
|
||||
{
|
||||
const std::string path = "./.kte_ut_swap_compact_1.txt";
|
||||
std::remove(path.c_str());
|
||||
write_file_bytes(path, "base\n");
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
|
||||
kte::SwapManager sm;
|
||||
kte::SwapConfig cfg;
|
||||
cfg.checkpoint_bytes = 0;
|
||||
cfg.checkpoint_interval_ms = 0;
|
||||
cfg.compact_bytes = 1; // force compaction on any checkpoint
|
||||
sm.SetConfig(cfg);
|
||||
|
||||
sm.Attach(&b);
|
||||
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||
|
||||
// Ensure there is at least one non-checkpoint record on disk first.
|
||||
b.insert_text(0, 0, std::string("abc"));
|
||||
sm.Flush(&b);
|
||||
|
||||
// Now emit a checkpoint; compaction should rewrite the file to just that checkpoint.
|
||||
sm.Checkpoint(&b);
|
||||
sm.Flush(&b);
|
||||
|
||||
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||
const std::string expected = buffer_bytes_via_views(b);
|
||||
|
||||
// Close journal.
|
||||
b.SetSwapRecorder(nullptr);
|
||||
sm.Detach(&b);
|
||||
|
||||
const std::string bytes = read_file_bytes(swap_path);
|
||||
const std::vector<std::uint8_t> types = record_types_from_bytes(bytes);
|
||||
ASSERT_EQ(types.size(), (std::size_t) 1);
|
||||
ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::CHKPT);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -71,8 +71,10 @@ TEST (SwapWriter_Header_Records_And_CRC)
|
||||
(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 char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||
const std::string xdg_root_s = xdg_root.string();
|
||||
setenv("XDG_STATE_HOME", xdg_root_s.c_str(), 1);
|
||||
|
||||
const std::string path = (xdg_root / "work" / "kte_ut_swap_writer.txt").string();
|
||||
std::filesystem::create_directories((xdg_root / "work"));
|
||||
@@ -148,14 +150,15 @@ TEST (SwapWriter_Header_Records_And_CRC)
|
||||
off = crc_off + 4;
|
||||
}
|
||||
|
||||
ASSERT_EQ(types.size(), (std::size_t) 2);
|
||||
ASSERT_EQ(types.size(), (std::size_t) 3);
|
||||
ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::INS);
|
||||
ASSERT_EQ(types[1], (std::uint8_t) kte::SwapRecType::DEL);
|
||||
ASSERT_EQ(types[2], (std::uint8_t) kte::SwapRecType::CHKPT);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swp.c_str());
|
||||
if (old_xdg) {
|
||||
setenv("XDG_STATE_HOME", old_xdg, 1);
|
||||
if (!old_xdg.empty()) {
|
||||
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||
} else {
|
||||
unsetenv("XDG_STATE_HOME");
|
||||
}
|
||||
@@ -171,8 +174,10 @@ TEST (SwapWriter_NoStomp_SameBasename)
|
||||
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 char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||
const std::string xdg_root_s = xdg_root.string();
|
||||
setenv("XDG_STATE_HOME", xdg_root_s.c_str(), 1);
|
||||
|
||||
const std::filesystem::path d1 = xdg_root / "p1";
|
||||
const std::filesystem::path d2 = xdg_root / "p2";
|
||||
@@ -227,8 +232,8 @@ TEST (SwapWriter_NoStomp_SameBasename)
|
||||
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);
|
||||
if (!old_xdg.empty()) {
|
||||
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||
} else {
|
||||
unsetenv("XDG_STATE_HOME");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#if defined(KTE_TESTS)
|
||||
#include <unordered_set>
|
||||
|
||||
|
||||
static void
|
||||
validate_undo_subtree(const UndoNode *node, const UndoNode *expected_parent,
|
||||
std::unordered_set<const UndoNode *> &seen)
|
||||
@@ -53,13 +54,15 @@ validate_undo_tree(const UndoSystem &u)
|
||||
#endif
|
||||
|
||||
|
||||
TEST (Undo_InsertRun_Coalesces)
|
||||
// The undo suite aims to cover invariants with a small, adversarial test matrix.
|
||||
|
||||
|
||||
TEST(Undo_InsertRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Simulate two separate "typed" insert commands without committing in between.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("h"));
|
||||
@@ -70,28 +73,52 @@ TEST (Undo_InsertRun_Coalesces)
|
||||
b.insert_text(0, 1, std::string_view("i"));
|
||||
u->Append('i');
|
||||
b.SetCursor(2, 0);
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BackspaceRun_Coalesces)
|
||||
TEST(Undo_InsertRun_BreaksOnNonAdjacentCursor)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
|
||||
// Jump the cursor; next insert should not coalesce.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ba"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_BackspaceRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed content.
|
||||
b.insert_text(0, 0, std::string_view("abc"));
|
||||
b.SetCursor(3, 0);
|
||||
u->mark_saved();
|
||||
|
||||
// Simulate two backspaces: delete 'c' then 'b'.
|
||||
// Delete 'c' then 'b' with backspace shape.
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][2];
|
||||
@@ -108,16 +135,242 @@ TEST (Undo_BackspaceRun_Coalesces)
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// One undo should restore both characters.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abc"));
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_DeleteKeyRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.insert_text(0, 0, std::string_view("abcd"));
|
||||
// Simulate delete-key at col 1 twice (cursor stays).
|
||||
b.SetCursor(1, 0);
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
b.delete_text(0, 1, 1);
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
b.delete_text(0, 1, 1);
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
|
||||
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abcd"));
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_Newline_IsStandalone)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed with content and split in the middle (not at EOF) so (row=1,col=0)
|
||||
// is always addressable and cannot be clamped in unexpected ways.
|
||||
b.insert_text(0, 0, std::string_view("hi"));
|
||||
b.SetCursor(1, 0);
|
||||
const std::string before_nl = b.BytesForTests();
|
||||
// Newline should always be its own undo step.
|
||||
u->Begin(UndoType::Newline);
|
||||
b.split_line(0, 1);
|
||||
u->commit();
|
||||
const std::string after_nl = b.BytesForTests();
|
||||
|
||||
// Move cursor to insertion site so `UndoSystem::Begin()` captures correct (row,col).
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(1, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 1);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("xi"));
|
||||
u->undo();
|
||||
// Undoing the insert should not also undo the newline.
|
||||
ASSERT_EQ(b.BytesForTests(), after_nl);
|
||||
u->undo();
|
||||
ASSERT_EQ(b.BytesForTests(), before_nl);
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_ExplicitGroup_UndoesAsUnit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
(void) u->BeginGroup();
|
||||
// Simulate two separate committed edits inside a group.
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
u->EndGroup();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_Branching_RedoBranchSelectionDeterministic)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// A then B then C
|
||||
b.SetCursor(0, 0);
|
||||
for (char ch: std::string("ABC")) {
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, b.Curx(), std::string_view(&ch, 1));
|
||||
u->Append(ch);
|
||||
b.SetCursor(b.Curx() + 1, 0);
|
||||
u->commit();
|
||||
}
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ABC"));
|
||||
|
||||
// Undo twice -> back to "A"
|
||||
u->undo();
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("A"));
|
||||
|
||||
// Type D to create a new branch.
|
||||
u->Begin(UndoType::Insert);
|
||||
char d = 'D';
|
||||
b.insert_text(0, 1, std::string_view(&d, 1));
|
||||
u->Append('D');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AD"));
|
||||
|
||||
// Undo D, then redo branch 0 should redo D (new head).
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("A"));
|
||||
u->redo(0);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AD"));
|
||||
|
||||
// Undo back to A again, redo branch 1 should follow the older path (to AB).
|
||||
u->undo();
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AB"));
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_DirtyFlag_CrossesMarkSaved)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
if (auto *u2 = b.Undo())
|
||||
u2->mark_saved();
|
||||
b.SetDirty(false);
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("y"));
|
||||
u->Append('y');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_TRUE(b.Dirty());
|
||||
|
||||
u->undo();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_RoundTrip_Lossless_RandomEdits)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
std::mt19937 rng(123);
|
||||
std::uniform_int_distribution<int> pick(0, 1);
|
||||
std::uniform_int_distribution<int> ch('a', 'z');
|
||||
|
||||
// Build a short random sequence of inserts and deletes.
|
||||
for (int i = 0; i < 200; ++i) {
|
||||
const std::string cur = b.AsString();
|
||||
const bool do_insert = (cur.empty() || pick(rng) == 0);
|
||||
if (do_insert) {
|
||||
char c = static_cast<char>(ch(rng));
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, b.Curx(), std::string_view(&c, 1));
|
||||
u->Append(c);
|
||||
b.SetCursor(b.Curx() + 1, 0);
|
||||
u->commit();
|
||||
} else {
|
||||
// Delete one char at a stable position.
|
||||
std::size_t x = b.Curx();
|
||||
if (x >= b.Rows()[0].size())
|
||||
x = b.Rows()[0].size() - 1;
|
||||
char deleted = b.Rows()[0][x];
|
||||
b.delete_text(0, static_cast<int>(x), 1);
|
||||
b.SetCursor(x, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
|
||||
const std::string final = b.AsString();
|
||||
// Undo back to start.
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
std::string before = b.AsString();
|
||||
u->undo();
|
||||
if (b.AsString() == before)
|
||||
break;
|
||||
}
|
||||
// Redo forward; should end at exact final bytes.
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
std::string before = b.AsString();
|
||||
u->redo(0);
|
||||
if (b.AsString() == before)
|
||||
break;
|
||||
}
|
||||
ASSERT_EQ(b.AsString(), final);
|
||||
}
|
||||
|
||||
|
||||
// Legacy/extended undo tests follow. Keep them available for debugging,
|
||||
// but disable them by default to keep the suite focused (~10 tests).
|
||||
#if 0
|
||||
|
||||
|
||||
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
|
||||
{
|
||||
Buffer b;
|
||||
@@ -460,7 +713,6 @@ TEST (Undo_StructuralInvariants_BranchingAndRoots)
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
{
|
||||
Buffer b;
|
||||
@@ -540,6 +792,11 @@ TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
// Additional legacy tests below are useful, but kept disabled by default.
|
||||
#if 0
|
||||
|
||||
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
|
||||
{
|
||||
@@ -938,3 +1195,5 @@ TEST (Undo_Command_RedoCountSelectsBranch)
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
#endif // legacy tests
|
||||
|
||||
Reference in New Issue
Block a user