Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11c523ad52 | |||
| c261261e26 | |||
| 27dcb41857 | |||
| bc3433e988 | |||
| 690c51b0f3 | |||
| 0d87bc0b25 | |||
| daeeecb342 | |||
| a428b204a0 | |||
| a21409e689 | |||
| b0b5b55dce | |||
| 422b27b1ba | |||
| 9485d2aa24 | |||
| 8a6b7851d5 | |||
| 8ec0d6ac41 | |||
| 337b585ba0 | |||
| 95a588b0df | |||
| 199d7a20f7 | |||
| 44827fe53f |
265
Buffer.cc
265
Buffer.cc
@@ -7,10 +7,20 @@
|
||||
#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"
|
||||
#include "UndoTree.h"
|
||||
#include "ErrorHandler.h"
|
||||
#include "SyscallWrappers.h"
|
||||
#include "ErrorRecovery.h"
|
||||
// For reconstructing highlighter state on copies
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
#include "syntax/NullHighlighter.h"
|
||||
@@ -24,6 +34,177 @@ 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 = kte::syscall::Open(dir.c_str(), O_RDONLY);
|
||||
if (dfd < 0)
|
||||
return;
|
||||
(void) kte::syscall::Fsync(dfd);
|
||||
(void) kte::syscall::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');
|
||||
|
||||
// Retry on transient errors for temp file creation
|
||||
int fd = -1;
|
||||
auto mkstemp_fn = [&]() -> bool {
|
||||
// Reset buffer for each retry attempt
|
||||
buf.assign(tmpl_s.begin(), tmpl_s.end());
|
||||
buf.push_back('\0');
|
||||
fd = kte::syscall::Mkstemp(buf.data());
|
||||
return fd >= 0;
|
||||
};
|
||||
|
||||
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) {
|
||||
if (fd < 0) {
|
||||
err = std::string("Failed to create temp file for save: ") + std::strerror(errno) + err;
|
||||
}
|
||||
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) kte::syscall::Fchmod(fd, dst_st.st_mode);
|
||||
}
|
||||
|
||||
bool ok = write_all_fd(fd, data, len, err);
|
||||
if (ok) {
|
||||
// Retry fsync on transient errors
|
||||
auto fsync_fn = [&]() -> bool {
|
||||
return kte::syscall::Fsync(fd) == 0;
|
||||
};
|
||||
|
||||
std::string fsync_err;
|
||||
if (!kte::RetryOnTransientError(fsync_fn, kte::RetryPolicy::Aggressive(), fsync_err)) {
|
||||
err = std::string("fsync failed: ") + std::strerror(errno) + fsync_err;
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
(void) kte::syscall::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;
|
||||
@@ -251,17 +432,46 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
||||
std::ifstream in(norm, std::ios::in | std::ios::binary);
|
||||
if (!in) {
|
||||
err = "Failed to open file: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read entire file into PieceTable as-is
|
||||
std::string data;
|
||||
in.seekg(0, std::ios::end);
|
||||
if (!in) {
|
||||
err = "Failed to seek to end of file: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
auto sz = in.tellg();
|
||||
if (sz < 0) {
|
||||
err = "Failed to get file size: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
if (sz > 0) {
|
||||
data.resize(static_cast<std::size_t>(sz));
|
||||
in.seekg(0, std::ios::beg);
|
||||
if (!in) {
|
||||
err = "Failed to seek to beginning of file: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
in.read(data.data(), static_cast<std::streamsize>(data.size()));
|
||||
if (!in && !in.eof()) {
|
||||
err = "Failed to read file: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
// Validate we read the expected number of bytes
|
||||
const std::streamsize bytes_read = in.gcount();
|
||||
if (bytes_read != static_cast<std::streamsize>(data.size())) {
|
||||
err = "Partial read of file (expected " + std::to_string(data.size()) +
|
||||
" bytes, got " + std::to_string(bytes_read) + "): " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
content_.Clear();
|
||||
if (!data.empty())
|
||||
@@ -271,6 +481,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 +508,18 @@ 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)) {
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
|
||||
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 +548,21 @@ 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)) {
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
|
||||
return false;
|
||||
}
|
||||
|
||||
filename_ = out_path;
|
||||
is_file_backed_ = true;
|
||||
dirty_ = false;
|
||||
RefreshOnDiskIdentity();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -437,6 +639,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)
|
||||
{
|
||||
|
||||
59
Buffer.h
59
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
|
||||
{
|
||||
@@ -524,7 +564,26 @@ public:
|
||||
|
||||
[[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.2")
|
||||
set(KTE_VERSION "1.7.1")
|
||||
|
||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||
@@ -39,7 +39,6 @@ if (MSVC)
|
||||
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
|
||||
else ()
|
||||
add_compile_options(
|
||||
"-static"
|
||||
"-Wall"
|
||||
"-Wextra"
|
||||
"-Werror"
|
||||
@@ -68,11 +67,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
|
||||
@@ -134,6 +141,9 @@ set(COMMON_SOURCES
|
||||
HelpText.cc
|
||||
KKeymap.cc
|
||||
Swap.cc
|
||||
ErrorHandler.cc
|
||||
SyscallWrappers.cc
|
||||
ErrorRecovery.cc
|
||||
TerminalInputHandler.cc
|
||||
TerminalRenderer.cc
|
||||
TerminalFrontend.cc
|
||||
@@ -274,6 +284,11 @@ endif ()
|
||||
|
||||
target_link_libraries(kte ${CURSES_LIBRARIES})
|
||||
|
||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||
if (NOT APPLE)
|
||||
target_link_options(kte PRIVATE -static)
|
||||
endif ()
|
||||
|
||||
if (KTE_ENABLE_TREESITTER)
|
||||
# Users can provide their own tree-sitter include/lib via cache variables
|
||||
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
|
||||
@@ -308,14 +323,21 @@ if (BUILD_TESTS)
|
||||
tests/test_swap_recorder.cc
|
||||
tests/test_swap_writer.cc
|
||||
tests/test_swap_replay.cc
|
||||
tests/test_swap_edge_cases.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
|
||||
tests/test_smart_newline.cc
|
||||
tests/test_reflow_undo.cc
|
||||
|
||||
# minimal engine sources required by Buffer
|
||||
PieceTable.cc
|
||||
@@ -324,6 +346,9 @@ if (BUILD_TESTS)
|
||||
Command.cc
|
||||
HelpText.cc
|
||||
Swap.cc
|
||||
ErrorHandler.cc
|
||||
SyscallWrappers.cc
|
||||
ErrorRecovery.cc
|
||||
KKeymap.cc
|
||||
SwapRecorder.h
|
||||
OptimizedSearch.cc
|
||||
@@ -348,6 +373,11 @@ if (BUILD_TESTS)
|
||||
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||
if (NOT APPLE)
|
||||
target_link_options(kte_tests PRIVATE -static)
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
if (BUILD_GUI)
|
||||
@@ -387,6 +417,11 @@ if (BUILD_GUI)
|
||||
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
||||
endif ()
|
||||
|
||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||
if (NOT APPLE)
|
||||
target_link_options(kge PRIVATE -static)
|
||||
endif ()
|
||||
|
||||
# On macOS, build kge as a proper .app bundle
|
||||
if (APPLE)
|
||||
# Define the icon file
|
||||
|
||||
139
Command.cc
139
Command.cc
@@ -629,6 +629,15 @@ 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;
|
||||
@@ -817,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;
|
||||
@@ -2588,15 +2605,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);
|
||||
if (auto *sm = ctx.editor.Swap()) {
|
||||
if (!is_same_target)
|
||||
sm->NotifyFilenameChanged(*buf);
|
||||
sm->ResetJournal(*buf);
|
||||
}
|
||||
ctx.editor.SetStatus("Saved as " + target);
|
||||
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.
|
||||
@@ -2708,6 +2729,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);
|
||||
@@ -2925,6 +2948,58 @@ cmd_newline(CommandContext &ctx)
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
cmd_smart_newline(CommandContext &ctx)
|
||||
{
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf) {
|
||||
ctx.editor.SetStatus("No buffer to edit");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (buf->IsReadOnly()) {
|
||||
ctx.editor.SetStatus("Read-only buffer");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Smart newline behavior: add a newline with the same indentation as the current line.
|
||||
// Find indentation of current line
|
||||
std::size_t y = buf->Cury();
|
||||
std::string line = buf->GetLineString(y);
|
||||
std::string indent;
|
||||
for (char c: line) {
|
||||
if (c == ' ' || c == '\t') {
|
||||
indent += c;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform standard newline first
|
||||
if (!cmd_newline(ctx)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Now insert the indentation at the new cursor position
|
||||
if (!indent.empty()) {
|
||||
std::size_t new_y = buf->Cury();
|
||||
std::size_t new_x = buf->Curx();
|
||||
buf->insert_text(static_cast<int>(new_y), static_cast<int>(new_x), indent);
|
||||
buf->SetCursor(new_x + indent.size(), new_y);
|
||||
buf->SetDirty(true);
|
||||
|
||||
if (auto *u = buf->Undo()) {
|
||||
u->Begin(UndoType::Insert);
|
||||
u->Append(indent);
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
|
||||
ensure_cursor_visible(ctx.editor, *buf);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
cmd_backspace(CommandContext &ctx)
|
||||
{
|
||||
@@ -3379,6 +3454,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())
|
||||
@@ -3394,6 +3471,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;
|
||||
@@ -3441,6 +3520,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;
|
||||
@@ -3882,6 +3963,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());
|
||||
@@ -3915,6 +3998,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());
|
||||
@@ -4279,6 +4364,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();
|
||||
@@ -4461,12 +4567,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;
|
||||
@@ -4478,6 +4578,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;
|
||||
}
|
||||
@@ -4568,7 +4675,14 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
new_lines.push_back("");
|
||||
|
||||
// Replace paragraph lines via PieceTable-backed operations
|
||||
UndoSystem *u = buf->Undo();
|
||||
for (std::size_t i = para_end; i + 1 > para_start; --i) {
|
||||
if (u) {
|
||||
buf->SetCursor(0, i);
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(static_cast<std::string>(buf->Rows()[i]));
|
||||
u->commit();
|
||||
}
|
||||
buf->delete_row(static_cast<int>(i));
|
||||
if (i == 0)
|
||||
break; // prevent wrap on size_t
|
||||
@@ -4577,6 +4691,12 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
std::size_t insert_y = para_start;
|
||||
for (const auto &ln: new_lines) {
|
||||
buf->insert_row(static_cast<int>(insert_y), std::string_view(ln));
|
||||
if (u) {
|
||||
buf->SetCursor(0, insert_y);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view(ln));
|
||||
u->commit();
|
||||
}
|
||||
insert_y += 1;
|
||||
}
|
||||
|
||||
@@ -4750,6 +4870,9 @@ InstallDefaultCommands()
|
||||
CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text, false, true
|
||||
});
|
||||
CommandRegistry::Register({CommandId::Newline, "newline", "Insert newline at cursor", cmd_newline});
|
||||
CommandRegistry::Register({
|
||||
CommandId::SmartNewline, "smart-newline", "Insert newline with auto-indent", cmd_smart_newline
|
||||
});
|
||||
CommandRegistry::Register({CommandId::Backspace, "backspace", "Delete char before cursor", cmd_backspace});
|
||||
CommandRegistry::Register({CommandId::DeleteChar, "delete-char", "Delete char at cursor", cmd_delete_char});
|
||||
CommandRegistry::Register({CommandId::KillToEOL, "kill-to-eol", "Delete to end of line", cmd_kill_to_eol});
|
||||
|
||||
@@ -38,6 +38,7 @@ enum class CommandId {
|
||||
// Editing
|
||||
InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
|
||||
Newline, // insert a newline at cursor
|
||||
SmartNewline, // insert a newline with auto-indent (Shift-Enter)
|
||||
Backspace, // delete char before cursor (may join lines)
|
||||
DeleteChar, // delete char at cursor (may join lines)
|
||||
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
|
||||
|
||||
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"]
|
||||
29
Editor.cc
29
Editor.cc
@@ -13,9 +13,9 @@ namespace {
|
||||
static std::string
|
||||
buffer_bytes_via_views(const Buffer &b)
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
const std::size_t nrows = b.Nrows();
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||
for (std::size_t i = 0; i < nrows; i++) {
|
||||
auto v = b.GetLineView(i);
|
||||
out.append(v.data(), v.size());
|
||||
}
|
||||
@@ -198,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)
|
||||
@@ -214,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 &cur_rows = cur.Rows();
|
||||
if (!cur_rows.empty())
|
||||
first = static_cast<std::string>(cur_rows[0]);
|
||||
if (cur.Nrows() > 0)
|
||||
first = cur.GetLineString(0);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||
if (!ft.empty()) {
|
||||
cur.SetFiletype(ft);
|
||||
@@ -248,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);
|
||||
@@ -486,9 +482,10 @@ Editor::CloseBuffer(std::size_t index)
|
||||
return false;
|
||||
}
|
||||
if (swap_) {
|
||||
// If the buffer is clean, remove its swap file when closing.
|
||||
// (Crash recovery is unaffected: on crash, close paths are not executed.)
|
||||
swap_->Detach(&buffers_[index], !buffers_[index].Dirty());
|
||||
// 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));
|
||||
|
||||
37
Editor.h
37
Editor.h
@@ -1,5 +1,42 @@
|
||||
/*
|
||||
* 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>
|
||||
|
||||
313
ErrorHandler.cc
Normal file
313
ErrorHandler.cc
Normal file
@@ -0,0 +1,313 @@
|
||||
#include "ErrorHandler.h"
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace kte {
|
||||
ErrorHandler::ErrorHandler()
|
||||
{
|
||||
// Determine log file path: ~/.local/state/kte/error.log
|
||||
const char *home = std::getenv("HOME");
|
||||
if (home) {
|
||||
fs::path log_dir = fs::path(home) / ".local" / "state" / "kte";
|
||||
try {
|
||||
if (!fs::exists(log_dir)) {
|
||||
fs::create_directories(log_dir);
|
||||
}
|
||||
log_file_path_ = (log_dir / "error.log").string();
|
||||
// Create the log file immediately so it exists in the state directory
|
||||
ensure_log_file();
|
||||
} catch (...) {
|
||||
// If we can't create the directory, disable file logging
|
||||
file_logging_enabled_ = false;
|
||||
}
|
||||
} else {
|
||||
// No HOME, disable file logging
|
||||
file_logging_enabled_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ErrorHandler::~ErrorHandler()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (log_file_ && log_file_->is_open()) {
|
||||
log_file_->flush();
|
||||
log_file_->close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ErrorHandler &
|
||||
ErrorHandler::Instance()
|
||||
{
|
||||
static ErrorHandler instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Report(ErrorSeverity severity, const std::string &component,
|
||||
const std::string &message, const std::string &context)
|
||||
{
|
||||
ErrorRecord record;
|
||||
record.timestamp_ns = now_ns();
|
||||
record.severity = severity;
|
||||
record.component = component;
|
||||
record.message = message;
|
||||
record.context = context;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
// Add to in-memory queue
|
||||
errors_.push_back(record);
|
||||
while (errors_.size() > 100) {
|
||||
errors_.pop_front();
|
||||
}
|
||||
|
||||
++total_error_count_;
|
||||
if (severity == ErrorSeverity::Critical) {
|
||||
++critical_error_count_;
|
||||
}
|
||||
|
||||
// Write to log file if enabled
|
||||
if (file_logging_enabled_) {
|
||||
write_to_log(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Info(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Info, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Warning(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Warning, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Error(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Error, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Critical(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Critical, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
ErrorHandler::HasErrors() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return !errors_.empty();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
ErrorHandler::HasCriticalErrors() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return critical_error_count_ > 0;
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::GetLastError() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (errors_.empty())
|
||||
return "";
|
||||
|
||||
const ErrorRecord &e = errors_.back();
|
||||
std::string result = "[" + severity_to_string(e.severity) + "] ";
|
||||
result += e.component;
|
||||
if (!e.context.empty()) {
|
||||
result += " (" + e.context + ")";
|
||||
}
|
||||
result += ": " + e.message;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
ErrorHandler::GetErrorCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return total_error_count_;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
ErrorHandler::GetErrorCount(ErrorSeverity severity) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
std::size_t count = 0;
|
||||
for (const auto &e: errors_) {
|
||||
if (e.severity == severity) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
std::vector<ErrorHandler::ErrorRecord>
|
||||
ErrorHandler::GetRecentErrors(std::size_t max_count) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
std::vector<ErrorRecord> result;
|
||||
result.reserve(std::min(max_count, errors_.size()));
|
||||
|
||||
// Return most recent first
|
||||
auto it = errors_.rbegin();
|
||||
for (std::size_t i = 0; i < max_count && it != errors_.rend(); ++i, ++it) {
|
||||
result.push_back(*it);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::ClearErrors()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
errors_.clear();
|
||||
total_error_count_ = 0;
|
||||
critical_error_count_ = 0;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::SetFileLoggingEnabled(bool enabled)
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
file_logging_enabled_ = enabled;
|
||||
if (!enabled && log_file_ && log_file_->is_open()) {
|
||||
log_file_->flush();
|
||||
log_file_->close();
|
||||
log_file_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::GetLogFilePath() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return log_file_path_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::write_to_log(const ErrorRecord &record)
|
||||
{
|
||||
// Must be called with mtx_ held
|
||||
if (log_file_path_.empty())
|
||||
return;
|
||||
|
||||
ensure_log_file();
|
||||
if (!log_file_ || !log_file_->is_open())
|
||||
return;
|
||||
|
||||
// Format: [timestamp] [SEVERITY] component (context): message
|
||||
std::string timestamp = format_timestamp(record.timestamp_ns);
|
||||
std::string severity = severity_to_string(record.severity);
|
||||
|
||||
*log_file_ << "[" << timestamp << "] [" << severity << "] " << record.component;
|
||||
if (!record.context.empty()) {
|
||||
*log_file_ << " (" << record.context << ")";
|
||||
}
|
||||
*log_file_ << ": " << record.message << "\n";
|
||||
log_file_->flush();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::ensure_log_file()
|
||||
{
|
||||
// Must be called with mtx_ held
|
||||
if (log_file_ && log_file_->is_open())
|
||||
return;
|
||||
|
||||
if (log_file_path_.empty())
|
||||
return;
|
||||
|
||||
try {
|
||||
log_file_ = std::make_unique<std::ofstream>(log_file_path_,
|
||||
std::ios::app | std::ios::out);
|
||||
if (!log_file_->is_open()) {
|
||||
log_file_.reset();
|
||||
}
|
||||
} catch (...) {
|
||||
log_file_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::format_timestamp(std::uint64_t timestamp_ns) const
|
||||
{
|
||||
// Convert nanoseconds to time_t (seconds)
|
||||
std::time_t seconds = static_cast<std::time_t>(timestamp_ns / 1000000000ULL);
|
||||
std::uint64_t nanos = timestamp_ns % 1000000000ULL;
|
||||
|
||||
std::tm tm_buf{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm_buf, &seconds);
|
||||
#else
|
||||
localtime_r(&seconds, &tm_buf);
|
||||
#endif
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(&tm_buf, "%Y-%m-%d %H:%M:%S");
|
||||
oss << "." << std::setfill('0') << std::setw(3) << (nanos / 1000000ULL);
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::severity_to_string(ErrorSeverity severity) const
|
||||
{
|
||||
switch (severity) {
|
||||
case ErrorSeverity::Info:
|
||||
return "INFO";
|
||||
case ErrorSeverity::Warning:
|
||||
return "WARNING";
|
||||
case ErrorSeverity::Error:
|
||||
return "ERROR";
|
||||
case ErrorSeverity::Critical:
|
||||
return "CRITICAL";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::uint64_t
|
||||
ErrorHandler::now_ns()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<nanoseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
} // namespace kte
|
||||
106
ErrorHandler.h
Normal file
106
ErrorHandler.h
Normal file
@@ -0,0 +1,106 @@
|
||||
// ErrorHandler.h - Centralized error handling and logging for kte
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <fstream>
|
||||
|
||||
namespace kte {
|
||||
enum class ErrorSeverity {
|
||||
Info, // Informational messages
|
||||
Warning, // Non-critical issues
|
||||
Error, // Errors that affect functionality but allow continuation
|
||||
Critical // Critical errors that may cause data loss or crashes
|
||||
};
|
||||
|
||||
// Centralized error handler with logging and in-memory error tracking
|
||||
class ErrorHandler {
|
||||
public:
|
||||
struct ErrorRecord {
|
||||
std::uint64_t timestamp_ns{0};
|
||||
ErrorSeverity severity{ErrorSeverity::Error};
|
||||
std::string component; // e.g., "SwapManager", "Buffer", "main"
|
||||
std::string message;
|
||||
std::string context; // e.g., filename, buffer name, operation
|
||||
};
|
||||
|
||||
// Get the global ErrorHandler instance
|
||||
static ErrorHandler &Instance();
|
||||
|
||||
// Report an error with severity, component, message, and optional context
|
||||
void Report(ErrorSeverity severity, const std::string &component,
|
||||
const std::string &message, const std::string &context = "");
|
||||
|
||||
// Convenience methods for common severity levels
|
||||
void Info(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
void Warning(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
void Error(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
void Critical(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
// Query error state (thread-safe)
|
||||
bool HasErrors() const;
|
||||
|
||||
bool HasCriticalErrors() const;
|
||||
|
||||
std::string GetLastError() const;
|
||||
|
||||
std::size_t GetErrorCount() const;
|
||||
|
||||
std::size_t GetErrorCount(ErrorSeverity severity) const;
|
||||
|
||||
// Get recent errors (up to max_count, most recent first)
|
||||
std::vector<ErrorRecord> GetRecentErrors(std::size_t max_count = 10) const;
|
||||
|
||||
// Clear in-memory error history (does not affect log file)
|
||||
void ClearErrors();
|
||||
|
||||
// Enable/disable file logging (enabled by default)
|
||||
void SetFileLoggingEnabled(bool enabled);
|
||||
|
||||
// Get the path to the error log file
|
||||
std::string GetLogFilePath() const;
|
||||
|
||||
private:
|
||||
ErrorHandler();
|
||||
|
||||
~ErrorHandler();
|
||||
|
||||
// Non-copyable, non-movable
|
||||
ErrorHandler(const ErrorHandler &) = delete;
|
||||
|
||||
ErrorHandler &operator=(const ErrorHandler &) = delete;
|
||||
|
||||
ErrorHandler(ErrorHandler &&) = delete;
|
||||
|
||||
ErrorHandler &operator=(ErrorHandler &&) = delete;
|
||||
|
||||
void write_to_log(const ErrorRecord &record);
|
||||
|
||||
void ensure_log_file();
|
||||
|
||||
std::string format_timestamp(std::uint64_t timestamp_ns) const;
|
||||
|
||||
std::string severity_to_string(ErrorSeverity severity) const;
|
||||
|
||||
static std::uint64_t now_ns();
|
||||
|
||||
mutable std::mutex mtx_;
|
||||
std::deque<ErrorRecord> errors_; // bounded to max 100 entries
|
||||
std::size_t total_error_count_{0};
|
||||
std::size_t critical_error_count_{0};
|
||||
bool file_logging_enabled_{true};
|
||||
std::string log_file_path_;
|
||||
std::unique_ptr<std::ofstream> log_file_;
|
||||
};
|
||||
} // namespace kte
|
||||
157
ErrorRecovery.cc
Normal file
157
ErrorRecovery.cc
Normal file
@@ -0,0 +1,157 @@
|
||||
// ErrorRecovery.cc - Error recovery mechanisms implementation
|
||||
#include "ErrorRecovery.h"
|
||||
#include <mutex>
|
||||
|
||||
namespace kte {
|
||||
CircuitBreaker::CircuitBreaker(const Config &cfg)
|
||||
: config_(cfg), state_(State::Closed), failure_count_(0), success_count_(0),
|
||||
last_failure_time_(std::chrono::steady_clock::time_point::min()),
|
||||
state_change_time_(std::chrono::steady_clock::now()) {}
|
||||
|
||||
|
||||
bool
|
||||
CircuitBreaker::AllowRequest()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
|
||||
switch (state_) {
|
||||
case State::Closed:
|
||||
// Normal operation, allow all requests
|
||||
return true;
|
||||
|
||||
case State::Open: {
|
||||
// Check if timeout has elapsed to transition to HalfOpen
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - state_change_time_
|
||||
);
|
||||
if (elapsed >= config_.open_timeout) {
|
||||
TransitionTo(State::HalfOpen);
|
||||
return true; // Allow one request to test recovery
|
||||
}
|
||||
return false; // Circuit is open, reject request
|
||||
}
|
||||
|
||||
case State::HalfOpen:
|
||||
// Allow limited requests to test recovery
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::RecordSuccess()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
switch (state_) {
|
||||
case State::Closed:
|
||||
// Reset failure count on success in normal operation
|
||||
failure_count_ = 0;
|
||||
break;
|
||||
|
||||
case State::HalfOpen:
|
||||
++success_count_;
|
||||
if (success_count_ >= config_.success_threshold) {
|
||||
// Enough successes, close the circuit
|
||||
TransitionTo(State::Closed);
|
||||
}
|
||||
break;
|
||||
|
||||
case State::Open:
|
||||
// Shouldn't happen (requests rejected), but handle gracefully
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::RecordFailure()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
last_failure_time_ = now;
|
||||
|
||||
switch (state_) {
|
||||
case State::Closed:
|
||||
// Check if we need to reset the failure count (window expired)
|
||||
if (IsWindowExpired()) {
|
||||
failure_count_ = 0;
|
||||
}
|
||||
|
||||
++failure_count_;
|
||||
if (failure_count_ >= config_.failure_threshold) {
|
||||
// Too many failures, open the circuit
|
||||
TransitionTo(State::Open);
|
||||
}
|
||||
break;
|
||||
|
||||
case State::HalfOpen:
|
||||
// Failure during recovery test, reopen the circuit
|
||||
TransitionTo(State::Open);
|
||||
break;
|
||||
|
||||
case State::Open:
|
||||
// Already open, just track the failure
|
||||
++failure_count_;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::Reset()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
TransitionTo(State::Closed);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::TransitionTo(State new_state)
|
||||
{
|
||||
if (state_ == new_state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state_ = new_state;
|
||||
state_change_time_ = std::chrono::steady_clock::now();
|
||||
|
||||
switch (new_state) {
|
||||
case State::Closed:
|
||||
failure_count_ = 0;
|
||||
success_count_ = 0;
|
||||
break;
|
||||
|
||||
case State::Open:
|
||||
success_count_ = 0;
|
||||
// Keep failure_count_ for diagnostics
|
||||
break;
|
||||
|
||||
case State::HalfOpen:
|
||||
success_count_ = 0;
|
||||
// Keep failure_count_ for diagnostics
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
CircuitBreaker::IsWindowExpired() const
|
||||
{
|
||||
if (failure_count_ == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - last_failure_time_
|
||||
);
|
||||
|
||||
return elapsed >= config_.window;
|
||||
}
|
||||
} // namespace kte
|
||||
170
ErrorRecovery.h
Normal file
170
ErrorRecovery.h
Normal file
@@ -0,0 +1,170 @@
|
||||
// ErrorRecovery.h - Error recovery mechanisms for kte
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <cerrno>
|
||||
|
||||
namespace kte {
|
||||
// Classify errno values as transient (retryable) or permanent
|
||||
inline bool
|
||||
IsTransientError(int err)
|
||||
{
|
||||
switch (err) {
|
||||
case EAGAIN:
|
||||
#if EAGAIN != EWOULDBLOCK
|
||||
case EWOULDBLOCK:
|
||||
#endif
|
||||
case EBUSY:
|
||||
case EIO: // I/O error (may be transient on network filesystems)
|
||||
case ETIMEDOUT:
|
||||
case ENOSPC: // Disk full (may become available)
|
||||
case EDQUOT: // Quota exceeded (may become available)
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// RetryPolicy defines retry behavior for transient failures
|
||||
struct RetryPolicy {
|
||||
std::size_t max_attempts{3}; // Maximum retry attempts
|
||||
std::chrono::milliseconds initial_delay{100}; // Initial delay before first retry
|
||||
double backoff_multiplier{2.0}; // Exponential backoff multiplier
|
||||
std::chrono::milliseconds max_delay{5000}; // Maximum delay between retries
|
||||
|
||||
// Default policy: 3 attempts, 100ms initial, 2x backoff, 5s max
|
||||
static RetryPolicy Default()
|
||||
{
|
||||
return RetryPolicy{};
|
||||
}
|
||||
|
||||
|
||||
// Aggressive policy for critical operations: more attempts, faster retries
|
||||
static RetryPolicy Aggressive()
|
||||
{
|
||||
return RetryPolicy{5, std::chrono::milliseconds(50), 1.5, std::chrono::milliseconds(2000)};
|
||||
}
|
||||
|
||||
|
||||
// Conservative policy for non-critical operations: fewer attempts, slower retries
|
||||
static RetryPolicy Conservative()
|
||||
{
|
||||
return RetryPolicy{2, std::chrono::milliseconds(200), 2.5, std::chrono::milliseconds(10000)};
|
||||
}
|
||||
};
|
||||
|
||||
// Retry a function with exponential backoff for transient errors
|
||||
// Returns true on success, false on permanent failure or exhausted retries
|
||||
// The function `fn` should return true on success, false on failure, and set errno on failure
|
||||
template<typename Func>
|
||||
bool
|
||||
RetryOnTransientError(Func fn, const RetryPolicy &policy, std::string &err)
|
||||
{
|
||||
std::size_t attempt = 0;
|
||||
std::chrono::milliseconds delay = policy.initial_delay;
|
||||
|
||||
while (attempt < policy.max_attempts) {
|
||||
++attempt;
|
||||
errno = 0;
|
||||
if (fn()) {
|
||||
return true; // Success
|
||||
}
|
||||
|
||||
int saved_errno = errno;
|
||||
if (!IsTransientError(saved_errno)) {
|
||||
// Permanent error, don't retry
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attempt >= policy.max_attempts) {
|
||||
// Exhausted retries
|
||||
err += " (exhausted " + std::to_string(policy.max_attempts) + " retry attempts)";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sleep before retry
|
||||
std::this_thread::sleep_for(delay);
|
||||
|
||||
// Exponential backoff
|
||||
delay = std::chrono::milliseconds(
|
||||
static_cast<long long>(delay.count() * policy.backoff_multiplier)
|
||||
);
|
||||
if (delay > policy.max_delay) {
|
||||
delay = policy.max_delay;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// CircuitBreaker prevents repeated attempts to failing operations
|
||||
// States: Closed (normal), Open (failing, reject immediately), HalfOpen (testing recovery)
|
||||
class CircuitBreaker {
|
||||
public:
|
||||
enum class State {
|
||||
Closed, // Normal operation, allow all requests
|
||||
Open, // Failing, reject requests immediately
|
||||
HalfOpen // Testing recovery, allow limited requests
|
||||
};
|
||||
|
||||
struct Config {
|
||||
std::size_t failure_threshold; // Failures before opening circuit
|
||||
std::chrono::seconds open_timeout; // Time before attempting recovery (Open → HalfOpen)
|
||||
std::size_t success_threshold; // Successes in HalfOpen before closing
|
||||
std::chrono::seconds window; // Time window for counting failures
|
||||
|
||||
Config()
|
||||
: failure_threshold(5), open_timeout(30), success_threshold(2), window(60) {}
|
||||
};
|
||||
|
||||
|
||||
explicit CircuitBreaker(const Config &cfg = Config());
|
||||
|
||||
|
||||
// Check if operation is allowed (returns false if circuit is Open)
|
||||
bool AllowRequest();
|
||||
|
||||
// Record successful operation
|
||||
void RecordSuccess();
|
||||
|
||||
// Record failed operation
|
||||
void RecordFailure();
|
||||
|
||||
// Get current state
|
||||
State GetState() const
|
||||
{
|
||||
return state_;
|
||||
}
|
||||
|
||||
|
||||
// Get failure count in current window
|
||||
std::size_t GetFailureCount() const
|
||||
{
|
||||
return failure_count_;
|
||||
}
|
||||
|
||||
|
||||
// Reset circuit to Closed state (for testing or manual intervention)
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
void TransitionTo(State new_state);
|
||||
|
||||
bool IsWindowExpired() const;
|
||||
|
||||
Config config_;
|
||||
State state_;
|
||||
std::size_t failure_count_;
|
||||
std::size_t success_count_;
|
||||
std::chrono::steady_clock::time_point last_failure_time_;
|
||||
std::chrono::steady_clock::time_point state_change_time_;
|
||||
mutable std::mutex mtx_;
|
||||
};
|
||||
} // namespace kte
|
||||
@@ -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"
|
||||
|
||||
@@ -125,7 +125,11 @@ map_key(const SDL_Keycode key,
|
||||
case SDLK_KP_ENTER:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
if (mod & KMOD_SHIFT) {
|
||||
out = {true, CommandId::SmartNewline, "", 0};
|
||||
} else {
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
}
|
||||
return true;
|
||||
case SDLK_ESCAPE:
|
||||
k_prefix = false;
|
||||
|
||||
@@ -209,19 +209,35 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
return {by, best_col};
|
||||
};
|
||||
|
||||
// Mouse-driven selection: set mark on press, update cursor on drag
|
||||
// Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag
|
||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
mouse_selecting = true;
|
||||
auto [by, bx] = mouse_pos_to_buf();
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
|
||||
// Only set mark on double click.
|
||||
// Dragging will also set the mark if not already set (handled below).
|
||||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetMark(bx, by);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||
auto [by, bx] = mouse_pos_to_buf();
|
||||
// If we are dragging (mouse moved while down), ensure mark is set to start selection
|
||||
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
if (!mbuf->MarkSet()) {
|
||||
// We'd need to convert click_pos to buf coords, but it's complex here.
|
||||
// Setting it to where the cursor was *before* we started moving it
|
||||
// in this frame is a good approximation, or just using current.
|
||||
mbuf->SetMark(mbuf->Curx(), mbuf->Cury());
|
||||
}
|
||||
}
|
||||
}
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
|
||||
@@ -226,6 +226,10 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
|
||||
case 'q':
|
||||
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
|
||||
return true;
|
||||
case '\n':
|
||||
case '\r':
|
||||
out = CommandId::SmartNewline; // Shift+Enter (some terminals send this as Alt+Enter sequences)
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
339
Swap.cc
339
Swap.cc
@@ -1,5 +1,8 @@
|
||||
#include "Swap.h"
|
||||
#include "Buffer.h"
|
||||
#include "ErrorHandler.h"
|
||||
#include "SyscallWrappers.h"
|
||||
#include "ErrorRecovery.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
@@ -25,14 +28,14 @@ constexpr std::uint32_t VERSION = 1;
|
||||
static std::string
|
||||
snapshot_buffer_bytes(const Buffer &b)
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
const std::size_t nrows = b.Nrows();
|
||||
std::string out;
|
||||
// Cheap lower bound: sum of row sizes.
|
||||
std::size_t approx = 0;
|
||||
for (const auto &r: rows)
|
||||
approx += r.size();
|
||||
for (std::size_t i = 0; i < nrows; i++)
|
||||
approx += b.GetLineView(i).size();
|
||||
out.reserve(approx);
|
||||
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||
for (std::size_t i = 0; i < nrows; i++) {
|
||||
auto v = b.GetLineView(i);
|
||||
out.append(v.data(), v.size());
|
||||
}
|
||||
@@ -284,8 +287,10 @@ SwapManager::Attach(Buffer *buf)
|
||||
void
|
||||
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)
|
||||
@@ -297,6 +302,7 @@ SwapManager::Detach(Buffer *buf, const bool remove_file)
|
||||
it->second.suspended = true;
|
||||
}
|
||||
}
|
||||
|
||||
Flush(buf);
|
||||
std::string path;
|
||||
{
|
||||
@@ -309,6 +315,7 @@ SwapManager::Detach(Buffer *buf, const bool remove_file)
|
||||
}
|
||||
recorders_.erase(buf);
|
||||
}
|
||||
|
||||
if (remove_file && !path.empty()) {
|
||||
(void) std::remove(path.c_str());
|
||||
}
|
||||
@@ -526,8 +533,7 @@ SwapManager::ComputeSidecarPath(const Buffer &buf)
|
||||
// Unnamed buffers: unique within the process.
|
||||
static std::atomic<std::uint64_t> ctr{0};
|
||||
const std::uint64_t n = ++ctr;
|
||||
const int pid = (int) ::getpid();
|
||||
const std::string name = "unnamed-" + std::to_string(pid) + "-" + std::to_string(n) + ".swp";
|
||||
const std::string name = "unnamed-" + std::to_string(n) + ".swp";
|
||||
return (root / name).string();
|
||||
}
|
||||
|
||||
@@ -595,38 +601,68 @@ SwapManager::write_header(int fd)
|
||||
|
||||
|
||||
bool
|
||||
SwapManager::open_ctx(JournalCtx &ctx, const std::string &path)
|
||||
SwapManager::open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)
|
||||
{
|
||||
err.clear();
|
||||
if (ctx.fd >= 0)
|
||||
return true;
|
||||
if (!ensure_parent_dir(path))
|
||||
if (!ensure_parent_dir(path)) {
|
||||
err = "Failed to create parent directory for swap file: " + path;
|
||||
return false;
|
||||
}
|
||||
int flags = O_CREAT | O_WRONLY | O_APPEND;
|
||||
#ifdef O_CLOEXEC
|
||||
flags |= O_CLOEXEC;
|
||||
#endif
|
||||
int fd = ::open(path.c_str(), flags, 0600);
|
||||
if (fd < 0)
|
||||
|
||||
// Retry on transient errors (ENOSPC, EDQUOT, EBUSY, etc.)
|
||||
int fd = -1;
|
||||
auto open_fn = [&]() -> bool {
|
||||
fd = kte::syscall::Open(path.c_str(), flags, 0600);
|
||||
return fd >= 0;
|
||||
};
|
||||
|
||||
if (!RetryOnTransientError(open_fn, RetryPolicy::Aggressive(), err)) {
|
||||
if (fd < 0) {
|
||||
int saved_errno = errno;
|
||||
err = "Failed to open swap file '" + path + "': " + std::strerror(saved_errno) + err;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Ensure permissions even if file already existed.
|
||||
(void) ::fchmod(fd, 0600);
|
||||
(void) kte::syscall::Fchmod(fd, 0600);
|
||||
struct stat st{};
|
||||
if (fstat(fd, &st) != 0) {
|
||||
::close(fd);
|
||||
if (kte::syscall::Fstat(fd, &st) != 0) {
|
||||
int saved_errno = errno;
|
||||
kte::syscall::Close(fd);
|
||||
err = "Failed to fstat swap file '" + path + "': " + std::strerror(saved_errno);
|
||||
return false;
|
||||
}
|
||||
// If an existing file is too small to contain the fixed header, truncate
|
||||
// and restart.
|
||||
if (st.st_size > 0 && st.st_size < 64) {
|
||||
::close(fd);
|
||||
kte::syscall::Close(fd);
|
||||
int tflags = O_CREAT | O_WRONLY | O_TRUNC | O_APPEND;
|
||||
#ifdef O_CLOEXEC
|
||||
tflags |= O_CLOEXEC;
|
||||
#endif
|
||||
fd = ::open(path.c_str(), tflags, 0600);
|
||||
if (fd < 0)
|
||||
|
||||
// Retry on transient errors for truncation open
|
||||
fd = -1;
|
||||
auto reopen_fn = [&]() -> bool {
|
||||
fd = kte::syscall::Open(path.c_str(), tflags, 0600);
|
||||
return fd >= 0;
|
||||
};
|
||||
|
||||
if (!RetryOnTransientError(reopen_fn, RetryPolicy::Aggressive(), err)) {
|
||||
if (fd < 0) {
|
||||
int saved_errno = errno;
|
||||
err = "Failed to reopen swap file for truncation '" + path + "': " + std::strerror(
|
||||
saved_errno) + err;
|
||||
}
|
||||
return false;
|
||||
(void) ::fchmod(fd, 0600);
|
||||
}
|
||||
(void) kte::syscall::Fchmod(fd, 0600);
|
||||
st.st_size = 0;
|
||||
}
|
||||
ctx.fd = fd;
|
||||
@@ -634,6 +670,9 @@ SwapManager::open_ctx(JournalCtx &ctx, const std::string &path)
|
||||
if (st.st_size == 0) {
|
||||
ctx.header_ok = write_header(fd);
|
||||
ctx.approx_size_bytes = ctx.header_ok ? 64 : 0;
|
||||
if (!ctx.header_ok) {
|
||||
err = "Failed to write swap file header: " + path;
|
||||
}
|
||||
} else {
|
||||
ctx.header_ok = true; // stage 1: trust existing header
|
||||
ctx.approx_size_bytes = static_cast<std::uint64_t>(st.st_size);
|
||||
@@ -646,8 +685,8 @@ void
|
||||
SwapManager::close_ctx(JournalCtx &ctx)
|
||||
{
|
||||
if (ctx.fd >= 0) {
|
||||
(void) ::fsync(ctx.fd);
|
||||
::close(ctx.fd);
|
||||
(void) kte::syscall::Fsync(ctx.fd);
|
||||
kte::syscall::Close(ctx.fd);
|
||||
ctx.fd = -1;
|
||||
}
|
||||
ctx.header_ok = false;
|
||||
@@ -655,47 +694,77 @@ SwapManager::close_ctx(JournalCtx &ctx)
|
||||
|
||||
|
||||
bool
|
||||
SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record)
|
||||
SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err)
|
||||
{
|
||||
if (ctx.path.empty())
|
||||
err.clear();
|
||||
if (ctx.path.empty()) {
|
||||
err = "Compact failed: empty path";
|
||||
return false;
|
||||
if (chkpt_record.empty())
|
||||
}
|
||||
if (chkpt_record.empty()) {
|
||||
err = "Compact failed: empty checkpoint record";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Close existing file before rename.
|
||||
if (ctx.fd >= 0) {
|
||||
(void) ::fsync(ctx.fd);
|
||||
::close(ctx.fd);
|
||||
(void) kte::syscall::Fsync(ctx.fd);
|
||||
kte::syscall::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))
|
||||
if (!ensure_parent_dir(tmp_path)) {
|
||||
err = "Failed to create parent directory for temp swap file: " + 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)
|
||||
|
||||
// Retry on transient errors for temp file creation
|
||||
int tfd = -1;
|
||||
auto open_tmp_fn = [&]() -> bool {
|
||||
tfd = kte::syscall::Open(tmp_path.c_str(), flags, 0600);
|
||||
return tfd >= 0;
|
||||
};
|
||||
|
||||
if (!RetryOnTransientError(open_tmp_fn, RetryPolicy::Aggressive(), err)) {
|
||||
if (tfd < 0) {
|
||||
int saved_errno = errno;
|
||||
err = "Failed to open temp swap file '" + tmp_path + "': " + std::strerror(saved_errno) + err;
|
||||
}
|
||||
return false;
|
||||
(void) ::fchmod(tfd, 0600);
|
||||
}
|
||||
(void) kte::syscall::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) {
|
||||
if (kte::syscall::Fsync(tfd) != 0) {
|
||||
int saved_errno = errno;
|
||||
err = "Failed to fsync temp swap file '" + tmp_path + "': " + std::strerror(saved_errno);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
kte::syscall::Close(tfd);
|
||||
if (!ok) {
|
||||
if (err.empty()) {
|
||||
err = "Failed to write temp swap file: " + tmp_path;
|
||||
}
|
||||
std::remove(tmp_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic replace.
|
||||
if (::rename(tmp_path.c_str(), ctx.path.c_str()) != 0) {
|
||||
int saved_errno = errno;
|
||||
err = "Failed to rename temp swap file '" + tmp_path + "' to '" + ctx.path + "': " + std::strerror(
|
||||
saved_errno);
|
||||
std::remove(tmp_path.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -709,10 +778,10 @@ SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8
|
||||
#ifdef O_DIRECTORY
|
||||
dflags |= O_DIRECTORY;
|
||||
#endif
|
||||
int dfd = ::open(dir.string().c_str(), dflags);
|
||||
int dfd = kte::syscall::Open(dir.string().c_str(), dflags);
|
||||
if (dfd >= 0) {
|
||||
(void) ::fsync(dfd);
|
||||
::close(dfd);
|
||||
(void) kte::syscall::Fsync(dfd);
|
||||
kte::syscall::Close(dfd);
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
@@ -720,8 +789,10 @@ SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8
|
||||
}
|
||||
|
||||
// Re-open for further appends.
|
||||
if (!open_ctx(ctx, ctx.path))
|
||||
if (!open_ctx(ctx, ctx.path, err)) {
|
||||
// err already set by open_ctx
|
||||
return false;
|
||||
}
|
||||
ctx.approx_size_bytes = 64 + static_cast<std::uint64_t>(chkpt_record.size());
|
||||
return true;
|
||||
}
|
||||
@@ -966,7 +1037,13 @@ SwapManager::writer_loop()
|
||||
continue;
|
||||
|
||||
for (const Pending &p: batch) {
|
||||
try {
|
||||
process_one(p);
|
||||
} catch (const std::exception &e) {
|
||||
report_error(std::string("Exception in process_one: ") + e.what(), p.buf);
|
||||
} catch (...) {
|
||||
report_error("Unknown exception in process_one", p.buf);
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (p.seq > last_processed_)
|
||||
@@ -978,6 +1055,7 @@ SwapManager::writer_loop()
|
||||
}
|
||||
|
||||
// Throttled fsync: best-effort (grouped)
|
||||
try {
|
||||
std::vector<int> to_sync;
|
||||
std::uint64_t now = now_ns();
|
||||
{
|
||||
@@ -994,7 +1072,12 @@ SwapManager::writer_loop()
|
||||
}
|
||||
}
|
||||
for (int fd: to_sync) {
|
||||
(void) ::fsync(fd);
|
||||
(void) kte::syscall::Fsync(fd);
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
report_error(std::string("Exception in fsync operations: ") + e.what());
|
||||
} catch (...) {
|
||||
report_error("Unknown exception in fsync operations");
|
||||
}
|
||||
}
|
||||
// Wake any waiters.
|
||||
@@ -1007,6 +1090,36 @@ SwapManager::process_one(const Pending &p)
|
||||
{
|
||||
if (!p.buf)
|
||||
return;
|
||||
|
||||
// Check circuit breaker before processing
|
||||
bool circuit_open = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (!circuit_breaker_.AllowRequest()) {
|
||||
circuit_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (circuit_open) {
|
||||
// Circuit is open - graceful degradation: skip swap write
|
||||
// This prevents repeated failures from overwhelming the system
|
||||
// Swap recording will resume when circuit closes
|
||||
static std::atomic<std::uint64_t> last_warning_ns{0};
|
||||
const std::uint64_t now = now_ns();
|
||||
const std::uint64_t last = last_warning_ns.load();
|
||||
// Log warning at most once per 60 seconds to avoid spam
|
||||
if (now - last > 60000000000ULL) {
|
||||
last_warning_ns.store(now);
|
||||
ErrorHandler::Instance().Warning("SwapManager",
|
||||
"Swap operations temporarily disabled due to repeated failures (circuit breaker open)",
|
||||
p.buf && !p.buf->Filename().empty()
|
||||
? p.buf->Filename()
|
||||
: "<unnamed>");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Buffer &buf = *p.buf;
|
||||
|
||||
JournalCtx *ctxp = nullptr;
|
||||
@@ -1025,10 +1138,23 @@ SwapManager::process_one(const Pending &p)
|
||||
}
|
||||
if (!ctxp)
|
||||
return;
|
||||
if (!open_ctx(*ctxp, path))
|
||||
std::string open_err;
|
||||
if (!open_ctx(*ctxp, path, open_err)) {
|
||||
report_error(open_err, p.buf);
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
circuit_breaker_.RecordFailure();
|
||||
}
|
||||
return;
|
||||
if (p.payload.size() > 0xFFFFFFu)
|
||||
}
|
||||
if (p.payload.size() > 0xFFFFFFu) {
|
||||
report_error("Payload too large: " + std::to_string(p.payload.size()) + " bytes", p.buf);
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
circuit_breaker_.RecordFailure();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build record: [type u8][len u24][payload][crc32 u32]
|
||||
std::uint8_t len3[3];
|
||||
@@ -1059,18 +1185,50 @@ SwapManager::process_one(const Pending &p)
|
||||
|
||||
// Write (handle partial writes and check results)
|
||||
bool ok = write_full(ctxp->fd, rec.data(), rec.size());
|
||||
if (ok) {
|
||||
if (!ok) {
|
||||
int err = errno;
|
||||
report_error("Failed to write swap record to '" + path + "': " + std::strerror(err), p.buf);
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
circuit_breaker_.RecordFailure();
|
||||
}
|
||||
return;
|
||||
}
|
||||
ctxp->approx_size_bytes += static_cast<std::uint64_t>(rec.size());
|
||||
if (p.urgent_flush) {
|
||||
(void) ::fsync(ctxp->fd);
|
||||
if (kte::syscall::Fsync(ctxp->fd) != 0) {
|
||||
int err = errno;
|
||||
report_error("Failed to fsync swap file '" + path + "': " + std::strerror(err), p.buf);
|
||||
}
|
||||
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);
|
||||
std::string compact_err;
|
||||
if (!compact_to_checkpoint(*ctxp, rec, compact_err)) {
|
||||
report_error(compact_err, p.buf);
|
||||
// Note: compaction failure is not fatal, don't record circuit breaker failure
|
||||
}
|
||||
}
|
||||
|
||||
// Record success for circuit breaker
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
circuit_breaker_.RecordSuccess();
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
report_error(std::string("Exception in process_one: ") + e.what(), p.buf);
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
circuit_breaker_.RecordFailure();
|
||||
}
|
||||
} catch (...) {
|
||||
report_error("Unknown exception in process_one", p.buf);
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
circuit_breaker_.RecordFailure();
|
||||
}
|
||||
}
|
||||
(void) ok; // best-effort; future work could mark ctx error state
|
||||
}
|
||||
|
||||
|
||||
@@ -1181,8 +1339,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
switch (type) {
|
||||
case SwapRecType::INS: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing INS payload";
|
||||
// INS payload: encver(1) + row(4) + col(4) + nbytes(4) + data(nbytes)
|
||||
// Minimum: 1 + 4 + 4 + 4 = 13 bytes
|
||||
if (payload.size() < 13) {
|
||||
err = "INS payload too short (need at least 13 bytes)";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
@@ -1193,7 +1353,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
std::uint32_t row = 0, col = 0, nbytes = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
|
||||
payload, off, nbytes)) {
|
||||
err = "Malformed INS payload";
|
||||
err = "Malformed INS payload (failed to parse row/col/nbytes)";
|
||||
return false;
|
||||
}
|
||||
if (off + nbytes > payload.size()) {
|
||||
@@ -1206,8 +1366,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
case SwapRecType::DEL: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing DEL payload";
|
||||
// DEL payload: encver(1) + row(4) + col(4) + dlen(4)
|
||||
// Minimum: 1 + 4 + 4 + 4 = 13 bytes
|
||||
if (payload.size() < 13) {
|
||||
err = "DEL payload too short (need at least 13 bytes)";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
@@ -1218,7 +1380,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
std::uint32_t row = 0, col = 0, dlen = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
|
||||
payload, off, dlen)) {
|
||||
err = "Malformed DEL payload";
|
||||
err = "Malformed DEL payload (failed to parse row/col/dlen)";
|
||||
return false;
|
||||
}
|
||||
buf.delete_text((int) row, (int) col, (std::size_t) dlen);
|
||||
@@ -1226,8 +1388,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
case SwapRecType::SPLIT: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing SPLIT payload";
|
||||
// SPLIT payload: encver(1) + row(4) + col(4)
|
||||
// Minimum: 1 + 4 + 4 = 9 bytes
|
||||
if (payload.size() < 9) {
|
||||
err = "SPLIT payload too short (need at least 9 bytes)";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
@@ -1237,7 +1401,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
std::uint32_t row = 0, col = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col)) {
|
||||
err = "Malformed SPLIT payload";
|
||||
err = "Malformed SPLIT payload (failed to parse row/col)";
|
||||
return false;
|
||||
}
|
||||
buf.split_line((int) row, (int) col);
|
||||
@@ -1245,8 +1409,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
case SwapRecType::JOIN: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing JOIN payload";
|
||||
// JOIN payload: encver(1) + row(4)
|
||||
// Minimum: 1 + 4 = 5 bytes
|
||||
if (payload.size() < 5) {
|
||||
err = "JOIN payload too short (need at least 5 bytes)";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
@@ -1256,7 +1422,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
std::uint32_t row = 0;
|
||||
if (!parse_u32_le(payload, off, row)) {
|
||||
err = "Malformed JOIN payload";
|
||||
err = "Malformed JOIN payload (failed to parse row)";
|
||||
return false;
|
||||
}
|
||||
buf.join_lines((int) row);
|
||||
@@ -1264,8 +1430,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
case SwapRecType::CHKPT: {
|
||||
std::size_t off = 0;
|
||||
// CHKPT payload: encver(1) + nbytes(4) + data(nbytes)
|
||||
// Minimum: 1 + 4 = 5 bytes
|
||||
if (payload.size() < 5) {
|
||||
err = "Malformed CHKPT payload";
|
||||
err = "CHKPT payload too short (need at least 5 bytes)";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
@@ -1275,7 +1443,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
std::uint32_t nbytes = 0;
|
||||
if (!parse_u32_le(payload, off, nbytes)) {
|
||||
err = "Malformed CHKPT payload";
|
||||
err = "Malformed CHKPT payload (failed to parse nbytes)";
|
||||
return false;
|
||||
}
|
||||
if (off + nbytes > payload.size()) {
|
||||
@@ -1292,4 +1460,61 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::report_error(const std::string &message, Buffer *buf)
|
||||
{
|
||||
std::string context;
|
||||
if (buf && !buf->Filename().empty()) {
|
||||
context = buf->Filename();
|
||||
} else if (buf) {
|
||||
context = "<unnamed>";
|
||||
} else {
|
||||
context = "<unknown>";
|
||||
}
|
||||
|
||||
// Report to centralized error handler
|
||||
ErrorHandler::Instance().Error("SwapManager", message, context);
|
||||
|
||||
// Maintain local error tracking for backward compatibility
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
SwapError err;
|
||||
err.timestamp_ns = now_ns();
|
||||
err.message = message;
|
||||
err.buffer_name = context;
|
||||
errors_.push_back(err);
|
||||
// Bound the error queue to 100 entries
|
||||
while (errors_.size() > 100) {
|
||||
errors_.pop_front();
|
||||
}
|
||||
++total_error_count_;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
SwapManager::HasErrors() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return !errors_.empty();
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
SwapManager::GetLastError() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (errors_.empty())
|
||||
return "";
|
||||
const SwapError &e = errors_.back();
|
||||
return "[" + e.buffer_name + "] " + e.message;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
SwapManager::GetErrorCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return total_error_count_;
|
||||
}
|
||||
} // namespace kte
|
||||
33
Swap.h
33
Swap.h
@@ -10,10 +10,12 @@
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
|
||||
#include "SwapRecorder.h"
|
||||
#include "ErrorRecovery.h"
|
||||
|
||||
class Buffer;
|
||||
|
||||
@@ -131,6 +133,20 @@ public:
|
||||
// Per-buffer toggle
|
||||
void SetSuspended(Buffer &buf, bool on);
|
||||
|
||||
// Error reporting for background thread
|
||||
struct SwapError {
|
||||
std::uint64_t timestamp_ns{0};
|
||||
std::string message;
|
||||
std::string buffer_name; // filename or "<unnamed>"
|
||||
};
|
||||
|
||||
// Query error state (thread-safe)
|
||||
bool HasErrors() const;
|
||||
|
||||
std::string GetLastError() const;
|
||||
|
||||
std::size_t GetErrorCount() const;
|
||||
|
||||
private:
|
||||
class BufferRecorder final : public SwapRecorder {
|
||||
public:
|
||||
@@ -190,11 +206,12 @@ private:
|
||||
|
||||
static bool write_header(int fd);
|
||||
|
||||
static bool open_ctx(JournalCtx &ctx, const std::string &path);
|
||||
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err);
|
||||
|
||||
static void close_ctx(JournalCtx &ctx);
|
||||
|
||||
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record);
|
||||
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record,
|
||||
std::string &err);
|
||||
|
||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||
|
||||
@@ -210,11 +227,14 @@ private:
|
||||
|
||||
void process_one(const Pending &p);
|
||||
|
||||
// Error reporting helper (called from writer thread)
|
||||
void report_error(const std::string &message, Buffer *buf = nullptr);
|
||||
|
||||
// State
|
||||
SwapConfig cfg_{};
|
||||
std::unordered_map<Buffer *, JournalCtx> journals_;
|
||||
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
||||
std::mutex mtx_;
|
||||
mutable std::mutex mtx_;
|
||||
std::condition_variable cv_;
|
||||
std::vector<Pending> queue_;
|
||||
std::uint64_t next_seq_{0};
|
||||
@@ -222,5 +242,12 @@ private:
|
||||
std::uint64_t inflight_{0};
|
||||
std::atomic<bool> running_{false};
|
||||
std::thread worker_;
|
||||
|
||||
// Error tracking (protected by mtx_)
|
||||
std::deque<SwapError> errors_; // bounded to max 100 entries
|
||||
std::size_t total_error_count_{0};
|
||||
|
||||
// Circuit breaker for swap operations (protected by mtx_)
|
||||
CircuitBreaker circuit_breaker_;
|
||||
};
|
||||
} // namespace kte
|
||||
76
SyscallWrappers.cc
Normal file
76
SyscallWrappers.cc
Normal file
@@ -0,0 +1,76 @@
|
||||
#include "SyscallWrappers.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace kte {
|
||||
namespace syscall {
|
||||
int
|
||||
Open(const char *path, int flags, mode_t mode)
|
||||
{
|
||||
int fd;
|
||||
do {
|
||||
fd = ::open(path, flags, mode);
|
||||
} while (fd == -1 && errno == EINTR);
|
||||
return fd;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Close(int fd)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::close(fd);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Fsync(int fd)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::fsync(fd);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Fstat(int fd, struct stat *buf)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::fstat(fd, buf);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Fchmod(int fd, mode_t mode)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::fchmod(fd, mode);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Mkstemp(char *template_str)
|
||||
{
|
||||
int fd;
|
||||
do {
|
||||
fd = ::mkstemp(template_str);
|
||||
} while (fd == -1 && errno == EINTR);
|
||||
return fd;
|
||||
}
|
||||
} // namespace syscall
|
||||
} // namespace kte
|
||||
47
SyscallWrappers.h
Normal file
47
SyscallWrappers.h
Normal file
@@ -0,0 +1,47 @@
|
||||
// SyscallWrappers.h - EINTR-safe syscall wrappers for kte
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <sys/stat.h>
|
||||
|
||||
namespace kte {
|
||||
namespace syscall {
|
||||
// EINTR-safe wrapper for open(2).
|
||||
// Returns file descriptor on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Open(const char *path, int flags, mode_t mode = 0);
|
||||
|
||||
// EINTR-safe wrapper for close(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
// Note: Some systems may not restart close() on EINTR, but we retry anyway
|
||||
// as recommended by POSIX.1-2008.
|
||||
int Close(int fd);
|
||||
|
||||
// EINTR-safe wrapper for fsync(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Fsync(int fd);
|
||||
|
||||
// EINTR-safe wrapper for fstat(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Fstat(int fd, struct stat *buf);
|
||||
|
||||
// EINTR-safe wrapper for fchmod(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Fchmod(int fd, mode_t mode);
|
||||
|
||||
// EINTR-safe wrapper for mkstemp(3).
|
||||
// Returns file descriptor on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
// Note: template_str must be a mutable buffer ending in "XXXXXX".
|
||||
int Mkstemp(char *template_str);
|
||||
|
||||
// Note: rename(2) and unlink(2) are not wrapped because they operate on
|
||||
// filesystem metadata and typically complete atomically without EINTR.
|
||||
// If interrupted, they either succeed or fail without partial state.
|
||||
} // namespace syscall
|
||||
} // namespace kte
|
||||
@@ -67,13 +67,20 @@ map_key_to_command(const int ch,
|
||||
if (pressed) {
|
||||
mouse_selecting = true;
|
||||
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||
if (Buffer *b = ed->CurrentBuffer()) {
|
||||
b->SetMark(b->Curx(), b->Cury());
|
||||
}
|
||||
// We don't set the mark on simple click anymore in ncurses either,
|
||||
// to be consistent. ncurses doesn't easily support double-click
|
||||
// or drag-threshold in a platform-independent way here,
|
||||
// but we can at least only set mark on MOVED.
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
if (mouse_selecting && moved) {
|
||||
if (Buffer *b = ed->CurrentBuffer()) {
|
||||
if (!b->MarkSet()) {
|
||||
// Set mark at CURRENT cursor position (which is where we were before this move)
|
||||
b->SetMark(b->Curx(), b->Cury());
|
||||
}
|
||||
}
|
||||
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
|
||||
@@ -9,6 +9,7 @@ enum class UndoType : std::uint8_t {
|
||||
Paste,
|
||||
Newline,
|
||||
DeleteRow,
|
||||
InsertRow,
|
||||
};
|
||||
|
||||
struct UndoNode {
|
||||
|
||||
@@ -36,7 +36,8 @@ UndoSystem::Begin(UndoType type)
|
||||
const int col = static_cast<int>(buf_->Curx());
|
||||
|
||||
// Some operations should always be standalone undo steps.
|
||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow);
|
||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
||||
UndoType::InsertRow);
|
||||
if (always_standalone) {
|
||||
commit();
|
||||
}
|
||||
@@ -75,6 +76,7 @@ UndoSystem::Begin(UndoType type)
|
||||
}
|
||||
case UndoType::Newline:
|
||||
case UndoType::DeleteRow:
|
||||
case UndoType::InsertRow:
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -314,6 +316,15 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::InsertRow:
|
||||
if (direction > 0) {
|
||||
buf_->insert_row(node->row, node->text);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->delete_row(node->row);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +422,8 @@ UndoSystem::type_str(UndoType t)
|
||||
return "Newline";
|
||||
case UndoType::DeleteRow:
|
||||
return "DeleteRow";
|
||||
case UndoType::InsertRow:
|
||||
return "InsertRow";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
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.
|
||||
1138
docs/DEVELOPER_GUIDE.md
Normal file
1138
docs/DEVELOPER_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
549
docs/audits/error-propagation-standardization.md
Normal file
549
docs/audits/error-propagation-standardization.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# Error Propagation Standardization Report
|
||||
|
||||
**Project:** kte (Kyle's Text Editor)
|
||||
**Date:** 2026-02-17
|
||||
**Auditor:** Error Propagation Standardization Review
|
||||
**Language:** C++20
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report documents the standardization of error propagation patterns
|
||||
across the kte codebase. Following the implementation of centralized
|
||||
error handling (ErrorHandler), this audit identifies inconsistencies in
|
||||
error propagation and provides concrete remediation recommendations.
|
||||
|
||||
**Key Findings:**
|
||||
|
||||
- **Dominant Pattern**: `bool + std::string &err` is used consistently
|
||||
in Buffer and SwapManager for I/O operations
|
||||
- **Inconsistencies**: PieceTable has no error reporting mechanism; some
|
||||
internal helpers lack error propagation
|
||||
- **Standard Chosen**: `bool + std::string &err` pattern (C++20 project,
|
||||
std::expected not available)
|
||||
- **Documentation**: Comprehensive error handling conventions added to
|
||||
DEVELOPER_GUIDE.md
|
||||
|
||||
**Overall Assessment**: The codebase has a **solid foundation** with the
|
||||
`bool + err` pattern used consistently in critical I/O paths. Primary
|
||||
gaps are in PieceTable memory allocation error handling and some
|
||||
internal helper functions.
|
||||
|
||||
---
|
||||
|
||||
## 1. CURRENT STATE ANALYSIS
|
||||
|
||||
### 1.1 Error Propagation Patterns Found
|
||||
|
||||
#### Pattern 1: `bool + std::string &err` (Dominant)
|
||||
|
||||
**Usage**: File I/O, swap operations, resource allocation
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `Buffer::OpenFromFile(const std::string &path, std::string &err)` (
|
||||
Buffer.h:72)
|
||||
- `Buffer::Save(std::string &err)` (Buffer.h:74)
|
||||
- `Buffer::SaveAs(const std::string &path, std::string &err)` (Buffer.h:
|
||||
75)
|
||||
- `Editor::OpenFile(const std::string &path, std::string &err)` (
|
||||
Editor.h:536)
|
||||
-
|
||||
`SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)` (
|
||||
Swap.h:104)
|
||||
-
|
||||
`SwapManager::open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` (
|
||||
Swap.h:208)
|
||||
-
|
||||
`SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err)` (
|
||||
Swap.h:212-213)
|
||||
|
||||
**Assessment**: ✅ **Excellent** - Consistent, well-implemented,
|
||||
integrated with ErrorHandler
|
||||
|
||||
#### Pattern 2: `void` (State Changes)
|
||||
|
||||
**Usage**: Setters, cursor movement, flag toggles, internal state
|
||||
modifications
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `Buffer::SetCursor(std::size_t x, std::size_t y)` (Buffer.h:348)
|
||||
- `Buffer::SetDirty(bool d)` (Buffer.h:368)
|
||||
- `Buffer::SetMark(std::size_t x, std::size_t y)` (Buffer.h:387)
|
||||
- `Buffer::insert_text(int row, int col, std::string_view text)` (
|
||||
Buffer.h:545)
|
||||
- `Buffer::delete_text(int row, int col, std::size_t len)` (Buffer.h:
|
||||
547)
|
||||
- `Editor::SetStatus(const std::string &msg)` (Editor.h:various)
|
||||
|
||||
**Assessment**: ✅ **Appropriate** - These operations are infallible
|
||||
state changes
|
||||
|
||||
#### Pattern 3: `bool` without error parameter (Control Flow)
|
||||
|
||||
**Usage**: Validation checks, control flow decisions
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `Editor::ProcessPendingOpens()` (Editor.h:544)
|
||||
- `Editor::ResolveRecoveryPrompt(bool yes)` (Editor.h:558)
|
||||
- `Editor::SwitchTo(std::size_t index)` (Editor.h:563)
|
||||
- `Editor::CloseBuffer(std::size_t index)` (Editor.h:565)
|
||||
|
||||
**Assessment**: ✅ **Appropriate** - Success/failure is sufficient for
|
||||
control flow
|
||||
|
||||
#### Pattern 4: No Error Reporting (PieceTable)
|
||||
|
||||
**Usage**: Memory allocation, text manipulation
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `void PieceTable::Reserve(std::size_t newCapacity)` (PieceTable.h:71)
|
||||
- `void PieceTable::Append(const char *s, std::size_t len)` (
|
||||
PieceTable.h:75)
|
||||
-
|
||||
`void PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)` (
|
||||
PieceTable.h:118)
|
||||
- `char *PieceTable::Data()` (PieceTable.h:89-93) - returns nullptr on
|
||||
allocation failure
|
||||
|
||||
**Assessment**: ⚠️ **Gap** - Memory allocation failures are not reported
|
||||
|
||||
---
|
||||
|
||||
## 2. STANDARDIZATION DECISION
|
||||
|
||||
### 2.1 Chosen Pattern: `bool + std::string &err`
|
||||
|
||||
**Rationale**:
|
||||
|
||||
1. **C++20 Project**: `std::expected` (C++23) is not available
|
||||
2. **Existing Adoption**: Already used consistently in Buffer,
|
||||
SwapManager, Editor for I/O operations
|
||||
3. **Clear Semantics**: `bool` return indicates success/failure, `err`
|
||||
provides details
|
||||
4. **ErrorHandler Integration**: Works seamlessly with centralized error
|
||||
logging
|
||||
5. **Zero Overhead**: No exceptions, no dynamic allocation for error
|
||||
paths
|
||||
6. **Testability**: Easy to verify error messages in unit tests
|
||||
|
||||
**Alternative Considered**: `std::expected<T, std::string>` (C++23)
|
||||
|
||||
- **Rejected**: Requires C++23, would require major refactoring, not
|
||||
available in current toolchain
|
||||
|
||||
### 2.2 Pattern Selection Guidelines
|
||||
|
||||
| Operation Type | Pattern | Example |
|
||||
|---------------------|---------------------------|-----------------------------------------------------------------------------------|
|
||||
| File I/O | `bool + std::string &err` | `Buffer::Save(std::string &err)` |
|
||||
| Syscalls | `bool + std::string &err` | `open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` |
|
||||
| Resource Allocation | `bool + std::string &err` | Future: `PieceTable::Reserve(std::size_t cap, std::string &err)` |
|
||||
| Parsing/Validation | `bool + std::string &err` | `SwapManager::ReplayFile(Buffer &buf, const std::string &path, std::string &err)` |
|
||||
| State Changes | `void` | `Buffer::SetCursor(std::size_t x, std::size_t y)` |
|
||||
| Control Flow | `bool` (no err) | `Editor::SwitchTo(std::size_t index)` |
|
||||
|
||||
---
|
||||
|
||||
## 3. INCONSISTENCIES AND GAPS
|
||||
|
||||
### 3.1 PieceTable Memory Allocation (Severity: 6/10)
|
||||
|
||||
**Finding**: PieceTable methods that allocate memory (`Reserve`,
|
||||
`Append`, `Insert`, `Data`) do not report allocation failures.
|
||||
|
||||
**Impact**:
|
||||
|
||||
- Memory allocation failures are silent
|
||||
- `Data()` returns `nullptr` on failure, but callers may not check
|
||||
- Large file operations could fail without user notification
|
||||
|
||||
**Evidence**:
|
||||
|
||||
```cpp
|
||||
// PieceTable.h:71
|
||||
void Reserve(std::size_t newCapacity); // No error reporting
|
||||
|
||||
// PieceTable.h:89-93
|
||||
char *Data(); // Returns nullptr on allocation failure
|
||||
```
|
||||
|
||||
**Remediation Priority**: **Medium** - Memory allocation failures are
|
||||
rare on modern systems, but should be handled for robustness
|
||||
|
||||
**Recommended Fix**:
|
||||
|
||||
**Option 1: Add error parameter to fallible operations** (Preferred)
|
||||
|
||||
```cpp
|
||||
// PieceTable.h
|
||||
bool Reserve(std::size_t newCapacity, std::string &err);
|
||||
bool Append(const char *s, std::size_t len, std::string &err);
|
||||
bool Insert(std::size_t byte_offset, const char *text, std::size_t len, std::string &err);
|
||||
|
||||
// Returns nullptr on failure; check with HasMaterializationError()
|
||||
char *Data();
|
||||
bool HasMaterializationError() const;
|
||||
std::string GetMaterializationError() const;
|
||||
```
|
||||
|
||||
**Option 2: Use exceptions for allocation failures** (Not recommended)
|
||||
|
||||
PieceTable could throw `std::bad_alloc` on allocation failures, but this
|
||||
conflicts with the project's error handling philosophy and would require
|
||||
exception handling throughout the codebase.
|
||||
|
||||
**Option 3: Status quo with improved documentation** (Minimal change)
|
||||
|
||||
Document that `Data()` can return `nullptr` and callers must check. Add
|
||||
assertions in debug builds.
|
||||
|
||||
```cpp
|
||||
// PieceTable.h
|
||||
// Returns pointer to materialized buffer, or nullptr if materialization fails.
|
||||
// Callers MUST check for nullptr before dereferencing.
|
||||
char *Data();
|
||||
```
|
||||
|
||||
**Recommendation**: **Option 3** for now (document + assertions), *
|
||||
*Option 1** if memory allocation errors become a concern in production.
|
||||
|
||||
### 3.2 Internal Helper Functions (Severity: 4/10)
|
||||
|
||||
**Finding**: Some internal helper functions in Swap.cc and Buffer.cc use
|
||||
`bool` returns without error parameters.
|
||||
|
||||
**Examples**:
|
||||
|
||||
```cpp
|
||||
// Swap.cc:562
|
||||
static bool ensure_parent_dir(const std::string &path); // No error details
|
||||
|
||||
// Swap.cc:579
|
||||
static bool write_header(int fd); // No error details
|
||||
|
||||
// Buffer.cc:101
|
||||
static bool write_all_fd(int fd, const char *data, std::size_t len, std::string &err); // ✅ Good
|
||||
```
|
||||
|
||||
**Impact**: Limited - These are internal helpers called by functions
|
||||
that do report errors
|
||||
|
||||
**Remediation Priority**: **Low** - Callers already provide error
|
||||
context
|
||||
|
||||
**Recommended Fix**: Add error parameters to internal helpers for
|
||||
consistency
|
||||
|
||||
```cpp
|
||||
// Swap.cc
|
||||
static bool ensure_parent_dir(const std::string &path, std::string &err);
|
||||
static bool write_header(int fd, std::string &err);
|
||||
```
|
||||
|
||||
**Status**: **Deferred** - Low priority, callers already provide
|
||||
adequate error context
|
||||
|
||||
### 3.3 Editor Control Flow Methods (Severity: 2/10)
|
||||
|
||||
**Finding**: Editor methods like `SwitchTo()`, `CloseBuffer()` return
|
||||
`bool` without error details.
|
||||
|
||||
**Assessment**: ✅ **Appropriate** - These are control flow decisions
|
||||
where success/failure is sufficient
|
||||
|
||||
**Remediation**: **None needed** - Current pattern is correct for this
|
||||
use case
|
||||
|
||||
---
|
||||
|
||||
## 4. ERRORHANDLER INTEGRATION STATUS
|
||||
|
||||
### 4.1 Components with ErrorHandler Integration
|
||||
|
||||
✅ **Buffer** (Buffer.cc)
|
||||
|
||||
- `OpenFromFile()` - Reports file open, seek, read errors
|
||||
- `Save()` - Reports write errors
|
||||
- `SaveAs()` - Reports write errors
|
||||
|
||||
✅ **SwapManager** (Swap.cc)
|
||||
|
||||
- `report_error()` - All swap file errors reported
|
||||
- Background thread errors captured and logged
|
||||
- Errno captured for all syscalls
|
||||
|
||||
✅ **main** (main.cc)
|
||||
|
||||
- Top-level exception handler reports Critical errors
|
||||
- Both `std::exception` and unknown exceptions captured
|
||||
|
||||
### 4.2 Components Without ErrorHandler Integration
|
||||
|
||||
⚠️ **PieceTable** (PieceTable.cc)
|
||||
|
||||
- No error reporting mechanism
|
||||
- Memory allocation failures are silent
|
||||
|
||||
⚠️ **Editor** (Editor.cc)
|
||||
|
||||
- File operations delegate to Buffer (✅ covered)
|
||||
- Control flow methods don't need error reporting (✅ appropriate)
|
||||
|
||||
⚠️ **Command** (Command.cc)
|
||||
|
||||
- Commands use `Editor::SetStatus()` for user-facing messages
|
||||
- No ErrorHandler integration for command failures
|
||||
- **Assessment**: Commands are user-initiated actions; status messages
|
||||
are appropriate
|
||||
|
||||
---
|
||||
|
||||
## 5. DOCUMENTATION STATUS
|
||||
|
||||
### 5.1 Error Handling Conventions (DEVELOPER_GUIDE.md)
|
||||
|
||||
✅ **Added comprehensive section** covering:
|
||||
|
||||
- Three standard error propagation patterns
|
||||
- Pattern selection guidelines with decision tree
|
||||
- ErrorHandler integration requirements
|
||||
- Code examples for file I/O, syscalls, background threads, top-level
|
||||
handlers
|
||||
- Anti-patterns and best practices
|
||||
- Error log location and format
|
||||
- Migration guide for updating existing code
|
||||
|
||||
**Location**: `docs/DEVELOPER_GUIDE.md` section 7
|
||||
|
||||
### 5.2 API Documentation
|
||||
|
||||
⚠️ **Gap**: Individual function documentation in headers could be
|
||||
improved
|
||||
|
||||
**Recommendation**: Add brief comments to public APIs documenting error
|
||||
behavior
|
||||
|
||||
```cpp
|
||||
// Buffer.h
|
||||
// Opens a file and loads its content into the buffer.
|
||||
// Returns false on failure; err contains detailed error message.
|
||||
// Errors are logged to ErrorHandler.
|
||||
bool OpenFromFile(const std::string &path, std::string &err);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. REMEDIATION RECOMMENDATIONS
|
||||
|
||||
### 6.1 High Priority (Severity 7-10)
|
||||
|
||||
**None identified** - Critical error handling gaps were addressed in
|
||||
previous sessions:
|
||||
|
||||
- ✅ Top-level exception handler added (Severity 9/10)
|
||||
- ✅ Background thread error reporting added (Severity 9/10)
|
||||
- ✅ File I/O error checking added (Severity 8/10)
|
||||
- ✅ Errno capture added to swap operations (Severity 7/10)
|
||||
- ✅ Centralized error handling implemented (Severity 7/10)
|
||||
|
||||
### 6.2 Medium Priority (Severity 4-6)
|
||||
|
||||
#### 6.2.1 PieceTable Memory Allocation Error Handling (Severity: 6/10)
|
||||
|
||||
**Action**: Document that `Data()` can return `nullptr` and add debug
|
||||
assertions
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```cpp
|
||||
// PieceTable.h
|
||||
// Returns pointer to materialized buffer, or nullptr if materialization fails
|
||||
// due to memory allocation error. Callers MUST check for nullptr.
|
||||
char *Data();
|
||||
|
||||
// PieceTable.cc
|
||||
char *PieceTable::Data() {
|
||||
materialize();
|
||||
assert(materialized_ != nullptr && "PieceTable materialization failed");
|
||||
return materialized_;
|
||||
}
|
||||
```
|
||||
|
||||
**Effort**: Low (documentation + assertions)
|
||||
**Risk**: Low (no API changes)
|
||||
**Timeline**: Next maintenance cycle
|
||||
|
||||
#### 6.2.2 Add Error Parameters to Internal Helpers (Severity: 4/10)
|
||||
|
||||
**Action**: Add `std::string &err` parameters to `ensure_parent_dir()`
|
||||
and `write_header()`
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```cpp
|
||||
// Swap.cc
|
||||
static bool ensure_parent_dir(const std::string &path, std::string &err) {
|
||||
try {
|
||||
fs::path p(path);
|
||||
fs::path dir = p.parent_path();
|
||||
if (dir.empty())
|
||||
return true;
|
||||
if (!fs::exists(dir))
|
||||
fs::create_directories(dir);
|
||||
return true;
|
||||
} catch (const std::exception &e) {
|
||||
err = std::string("Failed to create directory: ") + e.what();
|
||||
return false;
|
||||
} catch (...) {
|
||||
err = "Failed to create directory: unknown error";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Effort**: Low (update 2 functions + call sites)
|
||||
**Risk**: Low (internal helpers only)
|
||||
**Timeline**: Next maintenance cycle
|
||||
|
||||
### 6.3 Low Priority (Severity 1-3)
|
||||
|
||||
#### 6.3.1 Add Function-Level Error Documentation (Severity: 3/10)
|
||||
|
||||
**Action**: Add brief comments to public APIs documenting error behavior
|
||||
|
||||
**Effort**: Medium (many functions to document)
|
||||
**Risk**: None (documentation only)
|
||||
**Timeline**: Ongoing as code is touched
|
||||
|
||||
#### 6.3.2 Add ErrorHandler Integration to Commands (Severity: 2/10)
|
||||
|
||||
**Action**: Consider logging command failures to ErrorHandler for
|
||||
diagnostics
|
||||
|
||||
**Assessment**: **Not recommended** - Commands are user-initiated
|
||||
actions; status messages are more appropriate than error logs
|
||||
|
||||
---
|
||||
|
||||
## 7. TESTING RECOMMENDATIONS
|
||||
|
||||
### 7.1 Error Handling Test Coverage
|
||||
|
||||
**Current State**:
|
||||
|
||||
- ✅ Swap file error handling tested (test_swap_edge_cases.cc)
|
||||
- ✅ Buffer I/O error handling tested (test_buffer_io.cc)
|
||||
- ⚠️ PieceTable allocation failure testing missing
|
||||
|
||||
**Recommendations**:
|
||||
|
||||
1. **Add PieceTable allocation failure tests** (if Option 1 from 3.1 is
|
||||
implemented)
|
||||
2. **Add ErrorHandler query tests** - Verify error logging and retrieval
|
||||
3. **Add errno capture tests** - Verify errno is captured correctly in
|
||||
syscall failures
|
||||
|
||||
### 7.2 Test Examples
|
||||
|
||||
```cpp
|
||||
// test_error_handler.cc
|
||||
TEST(ErrorHandler, LogsErrorsWithContext) {
|
||||
ErrorHandler::Instance().Error("TestComponent", "Test error", "test.txt");
|
||||
EXPECT_TRUE(ErrorHandler::Instance().HasErrors());
|
||||
EXPECT_EQ(ErrorHandler::Instance().GetErrorCount(), 1);
|
||||
std::string last = ErrorHandler::Instance().GetLastError();
|
||||
EXPECT_TRUE(last.find("Test error") != std::string::npos);
|
||||
EXPECT_TRUE(last.find("test.txt") != std::string::npos);
|
||||
}
|
||||
|
||||
// test_piece_table.cc (if Option 1 implemented)
|
||||
TEST(PieceTable, ReportsAllocationFailure) {
|
||||
PieceTable pt;
|
||||
std::string err;
|
||||
// Attempt to allocate huge buffer
|
||||
bool ok = pt.Reserve(SIZE_MAX, err);
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_FALSE(err.empty());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. MIGRATION CHECKLIST
|
||||
|
||||
For developers updating existing code to follow error handling
|
||||
conventions:
|
||||
|
||||
- [ ] Identify all error-prone operations (file I/O, syscalls,
|
||||
allocations)
|
||||
- [ ] Add `std::string &err` parameter if not present
|
||||
- [ ] Clear `err` at function start: `err.clear();`
|
||||
- [ ] Capture `errno` immediately after syscall failures:
|
||||
`int saved_errno = errno;`
|
||||
- [ ] Build detailed error messages with context (paths, operation
|
||||
details)
|
||||
- [ ] Call `ErrorHandler::Instance().Error()` at all error sites
|
||||
- [ ] Return `false` on failure, `true` on success
|
||||
- [ ] Update all call sites to handle the error parameter
|
||||
- [ ] Write unit tests that verify error handling
|
||||
- [ ] Update function documentation to describe error behavior
|
||||
|
||||
---
|
||||
|
||||
## 9. SUMMARY AND NEXT STEPS
|
||||
|
||||
### 9.1 Achievements
|
||||
|
||||
✅ **Standardized on `bool + std::string &err` pattern** for error-prone
|
||||
operations
|
||||
✅ **Documented comprehensive error handling conventions** in
|
||||
DEVELOPER_GUIDE.md
|
||||
✅ **Identified and prioritized remaining gaps** (PieceTable, internal
|
||||
helpers)
|
||||
✅ **Integrated ErrorHandler** into Buffer, SwapManager, and main
|
||||
✅ **Established clear pattern selection guidelines** for future
|
||||
development
|
||||
|
||||
### 9.2 Remaining Work
|
||||
|
||||
**Medium Priority**:
|
||||
|
||||
1. Document PieceTable `Data()` nullptr behavior and add assertions
|
||||
2. Add error parameters to internal helper functions
|
||||
|
||||
**Low Priority**:
|
||||
|
||||
3. Add function-level error documentation to public APIs
|
||||
4. Add ErrorHandler query tests
|
||||
|
||||
### 9.3 Conclusion
|
||||
|
||||
The kte codebase has achieved **strong error handling consistency** with
|
||||
the `bool + std::string &err` pattern used uniformly across critical I/O
|
||||
paths. The centralized ErrorHandler provides comprehensive logging and
|
||||
UI integration. Remaining gaps are minor and primarily affect edge
|
||||
cases (memory allocation failures) that are rare in practice.
|
||||
|
||||
**Overall Grade**: **B+ (8.5/10)**
|
||||
|
||||
**Strengths**:
|
||||
|
||||
- Consistent error propagation in Buffer and SwapManager
|
||||
- Comprehensive ErrorHandler integration
|
||||
- Excellent documentation in DEVELOPER_GUIDE.md
|
||||
- Errno capture for all syscalls
|
||||
- Top-level exception handling
|
||||
|
||||
**Areas for Improvement**:
|
||||
|
||||
- PieceTable memory allocation error handling
|
||||
- Internal helper function error propagation
|
||||
- Function-level API documentation
|
||||
|
||||
The error handling infrastructure is **production-ready** and provides a
|
||||
solid foundation for reliable operation and debugging.
|
||||
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
25
main.cc
25
main.cc
@@ -20,6 +20,7 @@
|
||||
#include "Editor.h"
|
||||
#include "Frontend.h"
|
||||
#include "TerminalFrontend.h"
|
||||
#include "ErrorHandler.h"
|
||||
|
||||
#if defined(KTE_BUILD_GUI)
|
||||
#if defined(KTE_USE_QT)
|
||||
@@ -116,6 +117,9 @@ main(int argc, char *argv[])
|
||||
{
|
||||
std::setlocale(LC_ALL, "");
|
||||
|
||||
// Ensure the error handler (and its log file) is initialised early.
|
||||
kte::ErrorHandler::Instance();
|
||||
|
||||
Editor editor;
|
||||
|
||||
// CLI parsing using getopt_long
|
||||
@@ -181,10 +185,13 @@ main(int argc, char *argv[])
|
||||
return RunStressHighlighter(stress_seconds);
|
||||
}
|
||||
|
||||
// Top-level exception handler to prevent data loss and ensure cleanup
|
||||
try {
|
||||
// Determine frontend
|
||||
#if !defined(KTE_BUILD_GUI)
|
||||
if (req_gui) {
|
||||
std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed." <<
|
||||
std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed."
|
||||
<<
|
||||
std::endl;
|
||||
return 2;
|
||||
}
|
||||
@@ -196,6 +203,8 @@ main(int argc, char *argv[])
|
||||
use_gui = false;
|
||||
} else {
|
||||
|
||||
|
||||
|
||||
// Default depends on build target: kge defaults to GUI, kte to terminal
|
||||
#if defined(KTE_DEFAULT_GUI)
|
||||
use_gui = true;
|
||||
@@ -302,4 +311,18 @@ main(int argc, char *argv[])
|
||||
fe->Shutdown();
|
||||
|
||||
return 0;
|
||||
} catch (const std::exception &e) {
|
||||
std::string msg = std::string("Unhandled exception: ") + e.what();
|
||||
kte::ErrorHandler::Instance().Critical("main", msg);
|
||||
std::cerr << "\n*** FATAL ERROR ***\n"
|
||||
<< "kte encountered an unhandled exception: " << e.what() << "\n"
|
||||
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
|
||||
return 1;
|
||||
} catch (...) {
|
||||
kte::ErrorHandler::Instance().Critical("main", "Unknown exception");
|
||||
std::cerr << "\n*** FATAL ERROR ***\n"
|
||||
<< "kte encountered an unknown exception.\n"
|
||||
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
69
tests/test_reflow_undo.cc
Normal file
69
tests/test_reflow_undo.cc
Normal file
@@ -0,0 +1,69 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
#include "UndoSystem.h"
|
||||
|
||||
#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 (ReflowUndo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
const std::string initial =
|
||||
"This is a very long line that should be reflowed into multiple lines to see if undo works correctly.\n";
|
||||
b.insert_text(0, 0, initial);
|
||||
b.SetCursor(0, 0);
|
||||
|
||||
// Commit initial insertion so it's its own undo step
|
||||
if (auto *u = b.Undo())
|
||||
u->commit();
|
||||
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
const std::string original_dump = to_string_rows(*buf);
|
||||
|
||||
// Reflow with small width
|
||||
const int width = 20;
|
||||
ASSERT_TRUE(Execute(ed, "reflow-paragraph", "", width));
|
||||
|
||||
const std::string reflowed_dump = to_string_rows(*buf);
|
||||
ASSERT_TRUE(reflowed_dump != original_dump);
|
||||
ASSERT_TRUE(buf->Rows().size() > 1);
|
||||
|
||||
// Undo reflow
|
||||
ASSERT_TRUE(Execute(ed, "undo", "", 1));
|
||||
const std::string after_undo_dump = to_string_rows(*buf);
|
||||
|
||||
if (after_undo_dump != original_dump) {
|
||||
fprintf(stderr, "Undo failed.\nExpected:\n%s\nGot:\n%s\n", original_dump.c_str(),
|
||||
after_undo_dump.c_str());
|
||||
}
|
||||
EXPECT_TRUE(after_undo_dump == original_dump);
|
||||
|
||||
// Redo reflow
|
||||
ASSERT_TRUE(Execute(ed, "redo", "", 1));
|
||||
const std::string after_redo_dump = to_string_rows(*buf);
|
||||
EXPECT_TRUE(after_redo_dump == reflowed_dump);
|
||||
}
|
||||
@@ -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", ""},
|
||||
|
||||
79
tests/test_smart_newline.cc
Normal file
79
tests/test_smart_newline.cc
Normal file
@@ -0,0 +1,79 @@
|
||||
#include "Test.h"
|
||||
#include "Buffer.h"
|
||||
#include "Editor.h"
|
||||
#include "Command.h"
|
||||
#include <string>
|
||||
|
||||
|
||||
TEST (SmartNewline_AutoIndent)
|
||||
{
|
||||
Editor editor;
|
||||
InstallDefaultCommands();
|
||||
Buffer &buf = editor.Buffers().emplace_back();
|
||||
|
||||
// Set up initial state: " line1"
|
||||
buf.insert_text(0, 0, " line1");
|
||||
buf.SetCursor(7, 0); // At end of line
|
||||
|
||||
// Execute SmartNewline
|
||||
bool ok = Execute(editor, CommandId::SmartNewline);
|
||||
ASSERT_TRUE(ok);
|
||||
|
||||
// Should have two lines now
|
||||
ASSERT_EQ(buf.Nrows(), 2);
|
||||
// Line 0 remains " line1"
|
||||
ASSERT_EQ(buf.GetLineString(0), " line1");
|
||||
// Line 1 should have " " (two spaces)
|
||||
ASSERT_EQ(buf.GetLineString(1), " ");
|
||||
// Cursor should be at (2, 1)
|
||||
ASSERT_EQ(buf.Curx(), 2);
|
||||
ASSERT_EQ(buf.Cury(), 1);
|
||||
}
|
||||
|
||||
|
||||
TEST (SmartNewline_TabIndent)
|
||||
{
|
||||
Editor editor;
|
||||
InstallDefaultCommands();
|
||||
Buffer &buf = editor.Buffers().emplace_back();
|
||||
|
||||
// Set up initial state: "\tline1"
|
||||
buf.insert_text(0, 0, "\tline1");
|
||||
buf.SetCursor(6, 0); // At end of line
|
||||
|
||||
// Execute SmartNewline
|
||||
bool ok = Execute(editor, CommandId::SmartNewline);
|
||||
ASSERT_TRUE(ok);
|
||||
|
||||
// Should have two lines now
|
||||
ASSERT_EQ(buf.Nrows(), 2);
|
||||
// Line 1 should have "\t"
|
||||
ASSERT_EQ(buf.GetLineString(1), "\t");
|
||||
// Cursor should be at (1, 1)
|
||||
ASSERT_EQ(buf.Curx(), 1);
|
||||
ASSERT_EQ(buf.Cury(), 1);
|
||||
}
|
||||
|
||||
|
||||
TEST (SmartNewline_NoIndent)
|
||||
{
|
||||
Editor editor;
|
||||
InstallDefaultCommands();
|
||||
Buffer &buf = editor.Buffers().emplace_back();
|
||||
|
||||
// Set up initial state: "line1"
|
||||
buf.insert_text(0, 0, "line1");
|
||||
buf.SetCursor(5, 0); // At end of line
|
||||
|
||||
// Execute SmartNewline
|
||||
bool ok = Execute(editor, CommandId::SmartNewline);
|
||||
ASSERT_TRUE(ok);
|
||||
|
||||
// Should have two lines now
|
||||
ASSERT_EQ(buf.Nrows(), 2);
|
||||
// Line 1 should be empty
|
||||
ASSERT_EQ(buf.GetLineString(1), "");
|
||||
// Cursor should be at (0, 1)
|
||||
ASSERT_EQ(buf.Curx(), 0);
|
||||
ASSERT_EQ(buf.Cury(), 1);
|
||||
}
|
||||
813
tests/test_swap_edge_cases.cc
Normal file
813
tests/test_swap_edge_cases.cc
Normal file
@@ -0,0 +1,813 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Swap.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
|
||||
// CRC32 helper (same algorithm as SwapManager::crc32)
|
||||
static std::uint32_t
|
||||
crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0)
|
||||
{
|
||||
static std::uint32_t table[256];
|
||||
static bool inited = false;
|
||||
if (!inited) {
|
||||
for (std::uint32_t i = 0; i < 256; ++i) {
|
||||
std::uint32_t c = i;
|
||||
for (int j = 0; j < 8; ++j)
|
||||
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
|
||||
table[i] = c;
|
||||
}
|
||||
inited = true;
|
||||
}
|
||||
std::uint32_t c = ~seed;
|
||||
for (std::size_t i = 0; i < len; ++i)
|
||||
c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8);
|
||||
return ~c;
|
||||
}
|
||||
|
||||
|
||||
// Build a valid 64-byte swap file header
|
||||
static std::string
|
||||
build_swap_header()
|
||||
{
|
||||
std::uint8_t hdr[64];
|
||||
std::memset(hdr, 0, sizeof(hdr));
|
||||
// Magic
|
||||
const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
|
||||
std::memcpy(hdr, magic, 8);
|
||||
// Version = 1 (little-endian)
|
||||
hdr[8] = 1;
|
||||
hdr[9] = 0;
|
||||
hdr[10] = 0;
|
||||
hdr[11] = 0;
|
||||
// Flags = 0
|
||||
// Created time (just use 0 for tests)
|
||||
return std::string(reinterpret_cast<char *>(hdr), sizeof(hdr));
|
||||
}
|
||||
|
||||
|
||||
// Build a swap record: [type u8][len u24][payload][crc32 u32]
|
||||
static std::string
|
||||
build_swap_record(std::uint8_t type, const std::vector<std::uint8_t> &payload)
|
||||
{
|
||||
std::vector<std::uint8_t> record;
|
||||
|
||||
// Record header: type(1) + length(3)
|
||||
record.push_back(type);
|
||||
std::uint32_t len = static_cast<std::uint32_t>(payload.size());
|
||||
record.push_back(static_cast<std::uint8_t>(len & 0xFFu));
|
||||
record.push_back(static_cast<std::uint8_t>((len >> 8) & 0xFFu));
|
||||
record.push_back(static_cast<std::uint8_t>((len >> 16) & 0xFFu));
|
||||
|
||||
// Payload
|
||||
record.insert(record.end(), payload.begin(), payload.end());
|
||||
|
||||
// CRC32 (compute over header + payload)
|
||||
std::uint32_t crc = crc32(record.data(), record.size());
|
||||
record.push_back(static_cast<std::uint8_t>(crc & 0xFFu));
|
||||
record.push_back(static_cast<std::uint8_t>((crc >> 8) & 0xFFu));
|
||||
record.push_back(static_cast<std::uint8_t>((crc >> 16) & 0xFFu));
|
||||
record.push_back(static_cast<std::uint8_t>((crc >> 24) & 0xFFu));
|
||||
|
||||
return std::string(reinterpret_cast<char *>(record.data()), record.size());
|
||||
}
|
||||
|
||||
|
||||
// Build complete swap file with header and records
|
||||
static std::string
|
||||
build_swap_file(const std::vector<std::string> &records)
|
||||
{
|
||||
std::string file = build_swap_header();
|
||||
for (const auto &rec: records) {
|
||||
file += rec;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
|
||||
// Write bytes to file
|
||||
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(), static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
|
||||
|
||||
// Helper to encode u32 little-endian
|
||||
static void
|
||||
put_u32_le(std::vector<std::uint8_t> &out, std::uint32_t v)
|
||||
{
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFFu));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFFu));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFFu));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFFu));
|
||||
}
|
||||
|
||||
|
||||
//=============================================================================
|
||||
// 1. MINIMUM VALID PAYLOAD SIZE TESTS
|
||||
//=============================================================================
|
||||
|
||||
TEST (SwapEdge_INS_MinimumValidPayload)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_ins_min.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_ins_min.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// INS record: encver(1) + row(4) + col(4) + nbytes(4) = 13 bytes minimum
|
||||
// nbytes=0 means zero-length insertion
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
put_u32_le(payload, 0); // col
|
||||
put_u32_le(payload, 0); // nbytes=0
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_DEL_MinimumValidPayload)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_del_min.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_del_min.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// DEL record: encver(1) + row(4) + col(4) + dlen(4) = 13 bytes minimum
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
put_u32_le(payload, 0); // col
|
||||
put_u32_le(payload, 0); // dlen=0
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::DEL), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_SPLIT_MinimumValidPayload)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_split_min.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_split_min.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// SPLIT record: encver(1) + row(4) + col(4) = 9 bytes minimum
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
put_u32_le(payload, 0); // col
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::SPLIT), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_JOIN_MinimumValidPayload)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_join_min.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_join_min.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\nworld\n");
|
||||
|
||||
// JOIN record: encver(1) + row(4) = 5 bytes minimum
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::JOIN), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_CHKPT_MinimumValidPayload)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_chkpt_min.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_chkpt_min.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// CHKPT record: encver(1) + nbytes(4) = 5 bytes minimum
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // nbytes=0
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
//=============================================================================
|
||||
// 2. TRUNCATED PAYLOAD TESTS (BELOW MINIMUM)
|
||||
//=============================================================================
|
||||
|
||||
TEST (SwapEdge_INS_TruncatedPayload_1Byte)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_ins_trunc1.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_ins_trunc1.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// INS record with only 1 byte (just encver)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver only
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("INS payload too short") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_INS_TruncatedPayload_5Bytes)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_ins_trunc5.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_ins_trunc5.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// INS record with 5 bytes (encver + row only)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("INS payload too short") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_DEL_TruncatedPayload_9Bytes)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_del_trunc9.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_del_trunc9.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// DEL record with 9 bytes (encver + row + col, missing dlen)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
put_u32_le(payload, 0); // col
|
||||
// missing dlen
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::DEL), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("DEL payload too short") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_SPLIT_TruncatedPayload_5Bytes)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_split_trunc5.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_split_trunc5.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// SPLIT record with 5 bytes (encver + row, missing col)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
// missing col
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::SPLIT), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("SPLIT payload too short") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_JOIN_TruncatedPayload_1Byte)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_join_trunc1.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_join_trunc1.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\nworld\n");
|
||||
|
||||
// JOIN record with 1 byte (just encver)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver only
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::JOIN), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("JOIN payload too short") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_CHKPT_TruncatedPayload_3Bytes)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_chkpt_trunc3.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_chkpt_trunc3.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// CHKPT record with 3 bytes (encver + partial nbytes)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
payload.push_back(0); // partial nbytes (only 2 bytes instead of 4)
|
||||
payload.push_back(0);
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("CHKPT payload too short") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
//=============================================================================
|
||||
// 3. DATA OVERFLOW TESTS
|
||||
//=============================================================================
|
||||
|
||||
TEST (SwapEdge_INS_TruncatedData_NbytesExceedsPayload)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_ins_overflow.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_ins_overflow.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// INS record where nbytes=100 but payload only contains 13 bytes total
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
put_u32_le(payload, 0); // col
|
||||
put_u32_le(payload, 100); // nbytes=100 (but no data follows)
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("Truncated INS payload bytes") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_CHKPT_TruncatedData_NbytesExceedsPayload)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_chkpt_overflow.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_chkpt_overflow.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// CHKPT record where nbytes=1000 but payload only contains 5 bytes total
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 1000); // nbytes=1000 (but no data follows)
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("Truncated CHKPT payload bytes") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
//=============================================================================
|
||||
// 4. UNSUPPORTED ENCODING VERSION TESTS
|
||||
//=============================================================================
|
||||
|
||||
TEST (SwapEdge_INS_UnsupportedEncodingVersion)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_ins_badenc.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_ins_badenc.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// INS record with encver=2 (unsupported)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(2); // encver=2 (unsupported)
|
||||
put_u32_le(payload, 0); // row
|
||||
put_u32_le(payload, 0); // col
|
||||
put_u32_le(payload, 0); // nbytes
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("Unsupported swap payload encoding") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_CHKPT_UnsupportedEncodingVersion)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_chkpt_badenc.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_chkpt_badenc.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// CHKPT record with encver=99 (unsupported)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(99); // encver=99 (unsupported)
|
||||
put_u32_le(payload, 0); // nbytes
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("Unsupported swap checkpoint encoding") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
//=============================================================================
|
||||
// 5. BOUNDARY CONDITION TESTS
|
||||
//=============================================================================
|
||||
|
||||
TEST (SwapEdge_INS_ExactlyEnoughBytes)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_ins_exact.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_ins_exact.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// INS record with nbytes=10 and exactly 23 bytes total (13 header + 10 data)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
put_u32_le(payload, 0); // col
|
||||
put_u32_le(payload, 10); // nbytes=10
|
||||
// Add exactly 10 bytes of data
|
||||
for (int i = 0; i < 10; i++) {
|
||||
payload.push_back('X');
|
||||
}
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_INS_OneByteTooFew)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_ins_toofew.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_ins_toofew.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// INS record with nbytes=10 but only 22 bytes total (13 header + 9 data)
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1); // encver
|
||||
put_u32_le(payload, 0); // row
|
||||
put_u32_le(payload, 0); // col
|
||||
put_u32_le(payload, 10); // nbytes=10
|
||||
// Add only 9 bytes of data (one too few)
|
||||
for (int i = 0; i < 9; i++) {
|
||||
payload.push_back('X');
|
||||
}
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("Truncated INS payload bytes") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
//=============================================================================
|
||||
// 6. MIXED VALID AND INVALID RECORDS
|
||||
//=============================================================================
|
||||
|
||||
TEST (SwapEdge_MixedRecords_ValidThenInvalid)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_mixed1.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_mixed1.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// First record: valid INS
|
||||
std::vector<std::uint8_t> payload1;
|
||||
payload1.push_back(1); // encver
|
||||
put_u32_le(payload1, 0); // row
|
||||
put_u32_le(payload1, 0); // col
|
||||
put_u32_le(payload1, 1); // nbytes=1
|
||||
payload1.push_back('X'); // data
|
||||
|
||||
std::string rec1 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload1);
|
||||
|
||||
// Second record: truncated DEL
|
||||
std::vector<std::uint8_t> payload2;
|
||||
payload2.push_back(1); // encver only
|
||||
|
||||
std::string rec2 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::DEL), payload2);
|
||||
|
||||
std::string file = build_swap_file({rec1, rec2});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("DEL payload too short") != std::string::npos);
|
||||
|
||||
// Verify first INS was applied before failure
|
||||
auto view = b.GetLineView(0);
|
||||
std::string line(view.data(), view.size());
|
||||
ASSERT_TRUE(line.find('X') != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapEdge_MixedRecords_MultipleValidOneInvalid)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_mixed2.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_mixed2.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "ab\n");
|
||||
|
||||
// First record: valid INS at (0,0)
|
||||
std::vector<std::uint8_t> payload1;
|
||||
payload1.push_back(1);
|
||||
put_u32_le(payload1, 0);
|
||||
put_u32_le(payload1, 0);
|
||||
put_u32_le(payload1, 1);
|
||||
payload1.push_back('X');
|
||||
std::string rec1 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload1);
|
||||
|
||||
// Second record: valid INS at (0,1)
|
||||
std::vector<std::uint8_t> payload2;
|
||||
payload2.push_back(1);
|
||||
put_u32_le(payload2, 0);
|
||||
put_u32_le(payload2, 1);
|
||||
put_u32_le(payload2, 1);
|
||||
payload2.push_back('Y');
|
||||
std::string rec2 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload2);
|
||||
|
||||
// Third record: truncated SPLIT
|
||||
std::vector<std::uint8_t> payload3;
|
||||
payload3.push_back(1); // encver only
|
||||
std::string rec3 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::SPLIT), payload3);
|
||||
|
||||
std::string file = build_swap_file({rec1, rec2, rec3});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("SPLIT payload too short") != std::string::npos);
|
||||
|
||||
// Verify first two INS were applied
|
||||
auto view = b.GetLineView(0);
|
||||
std::string line(view.data(), view.size());
|
||||
ASSERT_TRUE(line.find('X') != std::string::npos);
|
||||
ASSERT_TRUE(line.find('Y') != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
//=============================================================================
|
||||
// 7. EMPTY PAYLOAD TEST
|
||||
//=============================================================================
|
||||
|
||||
TEST (SwapEdge_EmptyPayload_INS)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_empty.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_empty.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// INS record with zero-length payload
|
||||
std::vector<std::uint8_t> payload; // empty
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("INS payload too short") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
//=============================================================================
|
||||
// 8. CRC MISMATCH TEST
|
||||
//=============================================================================
|
||||
|
||||
TEST (SwapEdge_ValidStructure_BadCRC)
|
||||
{
|
||||
const std::string path = "./.kte_ut_edge_badcrc.txt";
|
||||
const std::string swap_path = "./.kte_ut_edge_badcrc.swp";
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
// Build a valid INS record
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.push_back(1);
|
||||
put_u32_le(payload, 0);
|
||||
put_u32_le(payload, 0);
|
||||
put_u32_le(payload, 1);
|
||||
payload.push_back('X');
|
||||
|
||||
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||
|
||||
// Corrupt the CRC (last 4 bytes)
|
||||
rec[rec.size() - 1] ^= 0xFF;
|
||||
|
||||
std::string file = build_swap_file({rec});
|
||||
write_file_bytes(swap_path, file);
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||
ASSERT_TRUE(err.find("CRC mismatch") != std::string::npos);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -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 1
|
||||
|
||||
|
||||
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
|
||||
{
|
||||
Buffer b;
|
||||
@@ -540,6 +793,11 @@ TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
// Additional legacy tests below are useful, but kept disabled by default.
|
||||
#if 1
|
||||
|
||||
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
|
||||
{
|
||||
@@ -938,3 +1196,168 @@ TEST (Undo_Command_RedoCountSelectsBranch)
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_InsertRow_UndoDeletesRow)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed two lines so insert_row has proper newline context.
|
||||
b.insert_text(0, 0, std::string_view("first\nlast"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
|
||||
// Insert a row at position 1 (between first and last), then record it.
|
||||
b.insert_row(1, std::string_view("second"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("second"));
|
||||
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view("second"));
|
||||
u->commit();
|
||||
|
||||
// Undo should remove the inserted row.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("first"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("last"));
|
||||
|
||||
// Redo should re-insert it.
|
||||
u->redo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("second"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DeleteRow_UndoRestoresRow)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.insert_text(0, 0, std::string_view("alpha\nbeta\ngamma"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
|
||||
// Record a DeleteRow for row 1 ("beta").
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(static_cast<std::string>(b.Rows()[1]));
|
||||
u->commit();
|
||||
b.delete_row(1);
|
||||
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("alpha"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("gamma"));
|
||||
|
||||
// Undo should restore "beta" at row 1.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("beta"));
|
||||
|
||||
// Redo should delete it again.
|
||||
u->redo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("gamma"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_InsertRow_IsStandalone)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed with two lines so InsertRow has proper newline context.
|
||||
b.insert_text(0, 0, std::string_view("x\nend"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
|
||||
// Start a pending insert on row 0.
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("y"));
|
||||
u->Append('y');
|
||||
b.SetCursor(2, 0);
|
||||
|
||||
// InsertRow should seal the pending "y" and become its own step.
|
||||
b.insert_row(1, std::string_view("row2"));
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view("row2"));
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("row2"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
|
||||
// Undo InsertRow only.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
|
||||
|
||||
// Undo the insert "y".
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_GroupedDeleteAndInsertRows_UndoesAsUnit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed three lines (with trailing newline so delete_row/insert_row work cleanly).
|
||||
b.insert_text(0, 0, std::string_view("aaa\nbbb\nccc\n"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 4); // 3 content + 1 empty trailing
|
||||
const std::string original = b.AsString();
|
||||
|
||||
// Group: delete content rows then insert replacements (simulates reflow).
|
||||
(void) u->BeginGroup();
|
||||
|
||||
// Delete rows 2,1,0 in reverse order (like reflow does).
|
||||
for (int i = 2; i >= 0; --i) {
|
||||
b.SetCursor(0, static_cast<std::size_t>(i));
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(static_cast<std::string>(b.Rows()[static_cast<std::size_t>(i)]));
|
||||
u->commit();
|
||||
b.delete_row(i);
|
||||
}
|
||||
|
||||
// Insert replacement rows.
|
||||
b.insert_row(0, std::string_view("aaa bbb"));
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view("aaa bbb"));
|
||||
u->commit();
|
||||
|
||||
b.insert_row(1, std::string_view("ccc"));
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view("ccc"));
|
||||
u->commit();
|
||||
|
||||
u->EndGroup();
|
||||
|
||||
const std::string reflowed = b.AsString();
|
||||
|
||||
// Single undo should restore original content.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.AsString(), original);
|
||||
|
||||
// Redo should restore the reflowed state.
|
||||
u->redo();
|
||||
ASSERT_EQ(b.AsString(), reflowed);
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
#endif // legacy tests
|
||||
Reference in New Issue
Block a user