9 Commits

Author SHA1 Message Date
b0b5b55dce Switch Docker to Alpine and build kge.
Update build environment to Alpine, enable GUI support, and refine developer guide

- Migrated Dockerfile base image from Ubuntu 22.04 to Alpine 3.19 for a smaller and faster container.
- Added dependencies for GUI support (SDL2, OpenGL/Mesa, Freetype, etc.) and updated CMake options.
- Enhanced `DEVELOPER_GUIDE.md` with new instructions for GUI builds, updated dependencies, and simplified custom build workflows.
- Addressed Alpine-specific ncurses library path issues in CMake configuration.
2026-02-17 16:53:12 -08:00
422b27b1ba Add Docker support for Linux build testing
- Introduced a `Dockerfile` for setting up a minimal Ubuntu-based build environment with required dependencies.
- Added `docker-build.sh` script to simplify Linux build and test execution using Docker or Podman.
- Updated `DEVELOPER_GUIDE.md` with instructions for using Docker/Podman for Linux builds, including CI/CD integration examples.
2026-02-17 16:35:52 -08:00
9485d2aa24 Linux fixup. 2026-02-17 16:13:28 -08:00
8a6b7851d5 Bump patch version. 2026-02-17 16:08:53 -08:00
8ec0d6ac41 Add benchmarks, migration tests, and dev guide
Add benchmarks for core operations, migration edge case tests, improved
buffer I/O tests, and developer guide

- Introduced `test_benchmarks.cc` for performance benchmarking of key
  operations in `PieceTable` and `Buffer`, including syntax highlighting
  and iteration patterns.
- Added `test_migration_coverage.cc` to provide comprehensive tests for
  migration of `Buffer::Rows()` to `PieceTable` APIs, with edge cases,
  boundary handling, and consistency checks.
- Enhanced `test_buffer_io.cc` with additional cases for save/load
  workflows, file handling, and better integration with the core API.
- Documented architectural details and core concepts in a new
  `DEVELOPER_GUIDE.md`. Highlighted design principles, code
  organization, and contribution workflows.
2026-02-17 16:08:23 -08:00
337b585ba0 Reformat code. 2026-02-17 13:44:36 -08:00
95a588b0df Add test for Git editor swap cleanup and improve swap file handling
- Added `test_swap_git_editor.cc` to verify proper swap file cleanup during Git editor workflows. Ensures no stale swap files are left after editor closure.
- Updated swap handling logic in `Editor.cc` to always remove swap files on buffer closure during normal exit, preventing accumulation of leftover files.
- Bumped version to 1.6.5 in `CMakeLists.txt`.
2026-02-17 13:10:01 -08:00
199d7a20f7 Add indented bullet reflow test, improve undo edge cases, and bump version
- Added `test_reflow_indented_bullets.cc` to verify correct reflow handling for indented bullet points.
- Enhanced undo system with additional tests for cursor adjacency, explicit grouping, branching, newline independence, and dirty-state tracking.
- Introduced external modification detection for files and required confirmation before overwrites.
- Refactored buffer save logic to use atomic writes and track on-disk identity.
- Updated CMake to include new test files and bumped version to 1.6.4.
2026-02-16 12:44:08 -08:00
44827fe53f Add mark-clearing behavior to refresh command and related test.
- Updated `Refresh` command to clear the mark when no active prompt, search, or visual-line mode is present.
- Added a new unit test verifying mark-clearing behavior for `Ctrl-G` (mapped to `Refresh`).
- Bumped version to 1.6.3 in `CMakeLists.txt`.
2026-02-14 23:05:44 -08:00
123 changed files with 35040 additions and 16401 deletions

215
Buffer.cc
View File

@@ -7,6 +7,13 @@
#include <cstring>
#include <string_view>
#include <vector>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include "Buffer.h"
#include "SwapRecorder.h"
#include "UndoSystem.h"
@@ -24,6 +31,159 @@ Buffer::Buffer()
}
bool
Buffer::stat_identity(const std::string &path, FileIdentity &out)
{
struct stat st{};
if (::stat(path.c_str(), &st) != 0) {
out.valid = false;
return false;
}
out.valid = true;
// Use nanosecond timestamp when available.
std::uint64_t ns = 0;
#if defined(__APPLE__)
ns = static_cast<std::uint64_t>(st.st_mtimespec.tv_sec) * 1000000000ull
+ static_cast<std::uint64_t>(st.st_mtimespec.tv_nsec);
#else
ns = static_cast<std::uint64_t>(st.st_mtim.tv_sec) * 1000000000ull
+ static_cast<std::uint64_t>(st.st_mtim.tv_nsec);
#endif
out.mtime_ns = ns;
out.size = static_cast<std::uint64_t>(st.st_size);
out.dev = static_cast<std::uint64_t>(st.st_dev);
out.ino = static_cast<std::uint64_t>(st.st_ino);
return true;
}
bool
Buffer::current_disk_identity(FileIdentity &out) const
{
if (!is_file_backed_ || filename_.empty()) {
out.valid = false;
return false;
}
return stat_identity(filename_, out);
}
bool
Buffer::ExternallyModifiedOnDisk() const
{
if (!is_file_backed_ || filename_.empty())
return false;
FileIdentity now{};
if (!current_disk_identity(now)) {
// If the file vanished, treat as modified when we previously had an identity.
return on_disk_identity_.valid;
}
if (!on_disk_identity_.valid)
return false;
return now.mtime_ns != on_disk_identity_.mtime_ns
|| now.size != on_disk_identity_.size
|| now.dev != on_disk_identity_.dev
|| now.ino != on_disk_identity_.ino;
}
void
Buffer::RefreshOnDiskIdentity()
{
FileIdentity id{};
if (current_disk_identity(id))
on_disk_identity_ = id;
}
static bool
write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
{
std::size_t off = 0;
while (off < len) {
ssize_t n = ::write(fd, data + off, len - off);
if (n < 0) {
if (errno == EINTR)
continue;
err = std::string("Write failed: ") + std::strerror(errno);
return false;
}
off += static_cast<std::size_t>(n);
}
return true;
}
static void
best_effort_fsync_dir(const std::string &path)
{
try {
std::filesystem::path p(path);
std::filesystem::path dir = p.parent_path();
if (dir.empty())
return;
int dfd = ::open(dir.c_str(), O_RDONLY);
if (dfd < 0)
return;
(void) ::fsync(dfd);
(void) ::close(dfd);
} catch (...) {
// best-effort
}
}
static bool
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err)
{
// Create a temp file in the same directory so rename() is atomic.
std::filesystem::path p(path);
std::filesystem::path dir = p.parent_path();
std::string base = p.filename().string();
std::filesystem::path tmpl = dir / ("." + base + ".kte.tmp.XXXXXX");
std::string tmpl_s = tmpl.string();
// mkstemp requires a mutable buffer.
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
buf.push_back('\0');
int fd = ::mkstemp(buf.data());
if (fd < 0) {
err = std::string("Failed to create temp file for save: ") + std::strerror(errno);
return false;
}
std::string tmp_path(buf.data());
// If the destination exists, carry over its permissions.
struct stat dst_st{};
if (::stat(path.c_str(), &dst_st) == 0) {
(void) ::fchmod(fd, dst_st.st_mode);
}
bool ok = write_all_fd(fd, data, len, err);
if (ok) {
if (::fsync(fd) != 0) {
err = std::string("fsync failed: ") + std::strerror(errno);
ok = false;
}
}
(void) ::close(fd);
if (ok) {
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
err = std::string("rename failed: ") + std::strerror(errno);
ok = false;
}
}
if (!ok) {
(void) ::unlink(tmp_path.c_str());
return false;
}
best_effort_fsync_dir(path);
return true;
}
Buffer::Buffer(const std::string &path)
{
std::string err;
@@ -271,6 +431,7 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
filename_ = norm;
is_file_backed_ = true;
dirty_ = false;
RefreshOnDiskIdentity();
// Reset/initialize undo system for this loaded file
if (!undo_tree_)
@@ -297,22 +458,16 @@ Buffer::Save(std::string &err) const
err = "Buffer is not file-backed; use SaveAs()";
return false;
}
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) {
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
const std::size_t sz = content_.Size();
const char *data = sz ? content_.Data() : nullptr;
if (sz && !data) {
err = "Internal error: buffer materialization failed";
return false;
}
// Stream the content directly from the piece table to avoid relying on
// full materialization, which may yield an empty pointer when size > 0.
if (content_.Size() > 0) {
content_.WriteToStream(out);
}
// Ensure data hits the OS buffers
out.flush();
if (!out.good()) {
err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno));
if (!atomic_write_file(filename_, data ? data : "", sz, err))
return false;
}
// Update observed on-disk identity after a successful save.
const_cast<Buffer *>(this)->RefreshOnDiskIdentity();
// Note: const method cannot change dirty_. Intentionally const to allow UI code
// to decide when to flip dirty flag after successful save.
return true;
@@ -341,26 +496,19 @@ Buffer::SaveAs(const std::string &path, std::string &err)
out_path = path;
}
// Write to the given path
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) {
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
const std::size_t sz = content_.Size();
const char *data = sz ? content_.Data() : nullptr;
if (sz && !data) {
err = "Internal error: buffer materialization failed";
return false;
}
// Stream content without forcing full materialization
if (content_.Size() > 0) {
content_.WriteToStream(out);
}
// Ensure data hits the OS buffers
out.flush();
if (!out.good()) {
err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno));
if (!atomic_write_file(out_path, data ? data : "", sz, err))
return false;
}
filename_ = out_path;
is_file_backed_ = true;
dirty_ = false;
RefreshOnDiskIdentity();
return true;
}
@@ -437,6 +585,21 @@ Buffer::content_LineCount_() const
}
#if defined(KTE_TESTS)
std::string
Buffer::BytesForTests() const
{
const std::size_t sz = content_.Size();
if (sz == 0)
return std::string();
const char *data = content_.Data();
if (!data)
return std::string();
return std::string(data, data + sz);
}
#endif
void
Buffer::delete_text(int row, int col, std::size_t len)
{

View File

@@ -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)

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.6.2")
set(KTE_VERSION "1.6.6")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -68,11 +68,19 @@ if (BUILD_GUI)
endif ()
# NCurses for terminal mode
set(CURSES_NEED_NCURSES)
set(CURSES_NEED_WIDE)
set(CURSES_NEED_NCURSES TRUE)
set(CURSES_NEED_WIDE TRUE)
find_package(Curses REQUIRED)
include_directories(${CURSES_INCLUDE_DIR})
# On Alpine Linux, CMake's FindCurses looks in wrong paths
# Manually find the correct ncurses library
if (EXISTS "/etc/alpine-release")
find_library(NCURSESW_LIB NAMES ncursesw PATHS /usr/lib /lib REQUIRED)
set(CURSES_LIBRARIES ${NCURSESW_LIB})
message(STATUS "Alpine Linux detected, using ncurses at: ${NCURSESW_LIB}")
endif ()
set(SYNTAX_SOURCES
syntax/GoHighlighter.cc
syntax/CppHighlighter.cc
@@ -310,12 +318,16 @@ if (BUILD_TESTS)
tests/test_swap_replay.cc
tests/test_swap_recovery_prompt.cc
tests/test_swap_cleanup.cc
tests/test_swap_git_editor.cc
tests/test_piece_table.cc
tests/test_search.cc
tests/test_search_replace_flow.cc
tests/test_reflow_paragraph.cc
tests/test_reflow_indented_bullets.cc
tests/test_undo.cc
tests/test_visual_line_mode.cc
tests/test_benchmarks.cc
tests/test_migration_coverage.cc
# minimal engine sources required by Buffer
PieceTable.cc

View File

@@ -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;
@@ -1092,6 +1109,7 @@ cmd_theme_set_by_name(const CommandContext &ctx)
static bool
cmd_theme_set_by_name(CommandContext &ctx)
{
# if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
// Qt GUI build: schedule theme change for frontend
std::string name = ctx.arg;
@@ -2588,15 +2606,19 @@ cmd_newline(CommandContext &ctx)
}
if (yes) {
std::string err;
if (!buf->SaveAs(target, err)) {
const bool is_same_target = (buf->Filename() == target) && buf->IsFileBacked();
const bool ok = is_same_target ? buf->Save(err) : buf->SaveAs(target, err);
if (!ok) {
ctx.editor.SetStatus(err);
} else {
buf->SetDirty(false);
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 +2730,8 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetStatus("No buffer");
return true;
}
if (auto *u = buf->Undo())
u->commit();
std::size_t nrows = buf->Nrows();
if (nrows == 0) {
buf->SetCursor(0, 0);
@@ -3379,6 +3403,8 @@ cmd_move_file_start(CommandContext &ctx)
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
buf->SetCursor(0, 0);
if (buf->VisualLineActive())
@@ -3394,6 +3420,8 @@ cmd_move_file_end(CommandContext &ctx)
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
const auto &rows = buf->Rows();
std::size_t y = rows.empty() ? 0 : rows.size() - 1;
@@ -3441,6 +3469,8 @@ cmd_jump_to_mark(CommandContext &ctx)
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
if (!buf->MarkSet()) {
ctx.editor.SetStatus("Mark not set");
return false;
@@ -3882,6 +3912,8 @@ cmd_scroll_up(CommandContext &ctx)
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
const auto &rows = buf->Rows();
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
@@ -3915,6 +3947,8 @@ cmd_scroll_down(CommandContext &ctx)
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
const auto &rows = buf->Rows();
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
@@ -4279,6 +4313,27 @@ cmd_reflow_paragraph(CommandContext &ctx)
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
struct GroupGuard {
UndoSystem *u;
explicit GroupGuard(UndoSystem *u_) : u(u_)
{
if (u)
u->BeginGroup();
}
~GroupGuard()
{
if (u)
u->EndGroup();
}
};
// Reflow performs a multi-edit transformation; make it a single standalone undo/redo step.
GroupGuard guard(buf->Undo());
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto &rows = buf->Rows();
std::size_t y = buf->Cury();
@@ -4461,12 +4516,6 @@ cmd_reflow_paragraph(CommandContext &ctx)
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
if (starts_with(ns, indent + " ")) {
content += ' ';
content += ns.substr(indent.size() + 2);
++j;
continue;
}
// stop if next bullet at same indentation or different structure
std::string nindent;
char nmarker;
@@ -4478,6 +4527,13 @@ cmd_reflow_paragraph(CommandContext &ctx)
if (is_numbered_line(ns, nindent, nnmarker, nidx)) {
break; // next item
}
// Now check if it's a continuation line
if (starts_with(ns, indent + " ")) {
content += ' ';
content += ns.substr(indent.size() + 2);
++j;
continue;
}
// Not a continuation and not a bullet: stop (treat as separate paragraph chunk)
break;
}

23
Dockerfile Normal file
View 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"]

View File

@@ -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));

View File

@@ -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>

View File

@@ -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"

View File

@@ -442,11 +442,9 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
if (ed_ && ed_
->
UArg() != 0
)
{
) {
const char *txt = e.text.text;
if (txt && *txt) {
unsigned char c0 = static_cast<unsigned char>(txt[0]);

View File

@@ -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>

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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.

14
Swap.cc
View File

@@ -25,14 +25,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 +284,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 +299,7 @@ SwapManager::Detach(Buffer *buf, const bool remove_file)
it->second.suspended = true;
}
}
Flush(buf);
std::string path;
{
@@ -309,6 +312,7 @@ SwapManager::Detach(Buffer *buf, const bool remove_file)
}
recorders_.erase(buf);
}
if (remove_file && !path.empty()) {
(void) std::remove(path.c_str());
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,245 @@
# kte Benchmarking and Testing Guide
This document describes the benchmarking infrastructure and testing
improvements added to ensure high performance and correctness of core
operations.
## Overview
The kte test suite now includes comprehensive benchmarks and migration
coverage tests to:
- Measure performance of core operations (PieceTable, Buffer, syntax
highlighting)
- Ensure no performance regressions from refactorings
- Validate correctness of API migrations (Buffer::Rows() →
GetLineString/GetLineView)
- Provide performance baselines for future optimizations
## Running Tests
### All Tests (including benchmarks)
```bash
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
```
### Test Organization
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
search, etc.
- **15 benchmark tests**: Performance measurements for critical
operations
- **30 migration coverage tests**: Edge cases and correctness validation
Total: **98 tests**
## Benchmark Results
### Buffer Iteration Patterns (5,000 lines)
| Pattern | Time | Speedup vs Rows() |
|-----------------------------------------|---------|-------------------|
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
**Key Insight**: `GetLineView()` provides zero-copy access and is
dramatically faster than materializing the entire rows cache.
### PieceTable Operations (10,000 lines)
| Operation | Time |
|-----------------------------|---------|
| Sequential inserts (10K) | 2.1 ms |
| Random inserts (5K) | 32.9 ms |
| `GetLine()` sequential | 4.7 ms |
| `GetLineRange()` sequential | 1.3 ms |
### Buffer Operations
| Operation | Time |
|--------------------------------------|---------|
| `Nrows()` (1M calls) | 13.0 ms |
| `GetLineString()` (10K lines) | 4.8 ms |
| `GetLineView()` (10K lines) | 1.6 ms |
| `Rows()` materialization (10K lines) | 6.2 ms |
### Syntax Highlighting
| Operation | Time | Notes |
|------------------------------------|---------|----------------|
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
| HighlighterEngine cache population | 19.9 ms | |
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
### Large File Performance
| Operation | Time |
|---------------------------------|---------|
| Insert 50K lines | 0.53 ms |
| Iterate 50K lines (GetLineView) | 2.7 ms |
| Random access (10K accesses) | 1.8 ms |
## API Differences: GetLineString vs GetLineView
Understanding the difference between these APIs is critical:
### `GetLineString(row)`
- Returns: `std::string` (copy)
- Content: Line text **without** trailing newline
- Use case: When you need to modify the string or store it
- Example: `"hello"` for line `"hello\n"`
### `GetLineView(row)`
- Returns: `std::string_view` (zero-copy)
- Content: Raw line range **including** trailing newline
- Use case: Read-only access, maximum performance
- Example: `"hello\n"` for line `"hello\n"`
- **Warning**: View becomes invalid after buffer modifications
### `Rows()`
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
- Content: Lines **without** trailing newlines
- Use case: Legacy code, being phased out
- Performance: Slower due to materialization overhead
## Migration Coverage Tests
The `test_migration_coverage.cc` file provides 30 tests covering:
### Edge Cases
- Empty buffers
- Single lines (with/without newlines)
- Very long lines (10,000 characters)
- Many empty lines (1,000 newlines)
### Consistency
- `GetLineString()` vs `GetLineView()` vs `Rows()`
- Consistency after edits (insert, delete, split, join)
### Boundary Conditions
- First line access
- Last line access
- Line range boundaries
### Special Characters
- Tabs, carriage returns, null bytes
- Unicode (UTF-8 multibyte characters)
### Stress Tests
- Large files (10,000 lines)
- Many small operations (100+ inserts)
- Alternating insert/delete patterns
### Regression Tests
- Shebang detection pattern (Editor.cc)
- Empty buffer check pattern (Editor.cc)
- Syntax highlighter pattern (all highlighters)
- Swap snapshot pattern (Swap.cc)
## Performance Recommendations
Based on benchmark results:
1. **Prefer `GetLineView()` for read-only access**
- 11x faster than `Rows()` for iteration
- Zero-copy, minimal overhead
- Use immediately (view invalidates on edit)
2. **Use `GetLineString()` when you need a copy**
- Still 1.7x faster than `Rows()`
- Safe to store and modify
- Strips trailing newlines automatically
3. **Avoid `Rows()` in hot paths**
- Materializes entire line cache
- Slower for large files
- Being phased out (legacy API)
4. **Cache `Nrows()` in tight loops**
- Very fast (13ms for 1M calls)
- But still worth caching in inner loops
5. **Leverage HighlighterEngine caching**
- 38x speedup on cache hits
- Automatically invalidates on edits
- Prefetch viewport for smooth scrolling
## Adding New Benchmarks
To add a new benchmark:
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
2. Use `BenchmarkTimer` to measure critical sections:
```cpp
{
BenchmarkTimer timer("Operation description");
// ... code to benchmark ...
}
```
3. Print section headers with `std::cout` for clarity
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
Example:
```cpp
TEST(Benchmark_MyOperation) {
std::cout << "\n=== My Operation Benchmark ===\n";
// Setup
Buffer buf;
std::string data = generate_test_data();
buf.insert_text(0, 0, data);
std::size_t result = 0;
{
BenchmarkTimer timer("My operation on 10K lines");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
result += my_operation(buf, i);
}
}
EXPECT_TRUE(result > 0);
}
```
## Continuous Performance Monitoring
Run benchmarks regularly to detect regressions:
```bash
# Run tests and save output
./cmake-build-debug/kte_tests > benchmark_results.txt
# Compare with baseline
diff benchmark_baseline.txt benchmark_results.txt
```
Look for:
- Significant time increases (>20%) in any benchmark
- New operations that are slower than expected
- Cache effectiveness degradation
## Conclusion
The benchmark suite provides:
- **Performance validation**: Ensures migrations don't regress
performance
- **Optimization guidance**: Identifies fastest APIs for each use case
- **Regression detection**: Catches performance issues early
- **Documentation**: Demonstrates correct API usage patterns
All 98 tests pass with 0 failures, confirming both correctness and
performance of the migrated codebase.

652
docs/DEVELOPER_GUIDE.md Normal file
View File

@@ -0,0 +1,652 @@
# kte Developer Guide
Welcome to kte development! This guide will help you understand the
codebase, make changes, and contribute effectively.
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Core Components](#core-components)
3. [Code Organization](#code-organization)
4. [Building and Testing](#building-and-testing)
5. [Making Changes](#making-changes)
6. [Code Style](#code-style)
7. [Common Tasks](#common-tasks)
## Architecture Overview
kte follows a clean separation of concerns with three main layers:
```
┌─────────────────────────────────────────┐
│ Frontend Layer (Terminal/ImGui/Qt) │
│ - TerminalFrontend / ImGuiFrontend │
│ - InputHandler + Renderer interfaces │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Command Layer │
│ - Command registry and execution │
│ - All editing operations │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Core Model Layer │
│ - Editor (top-level state) │
│ - Buffer (document model) │
│ - PieceTable (text storage) │
│ - UndoSystem (undo/redo) │
│ - SwapManager (crash recovery) │
└─────────────────────────────────────────┘
```
### Design Principles
- **Frontend Independence**: Core editing logic is independent of UI.
Frontends implement `Frontend`, `InputHandler`, and `Renderer`
interfaces.
- **Command Pattern**: All editing operations go through the command
system, enabling consistent undo/redo and testing.
- **Piece Table**: Efficient text storage using a piece table data
structure that avoids copying large buffers.
- **Lazy Materialization**: Text is materialized on-demand to minimize
memory allocations.
## Core Components
### Editor (`Editor.h/.cc`)
The top-level editor state container. Manages:
- Multiple buffers
- Editor modes (normal, k-command prefix, prompts)
- Kill ring (clipboard history)
- Universal argument state
- Search state
- Status messages
- Swap file management
**Key Insight**: Editor is primarily a state holder with many
getter/setter pairs. It doesn't contain editing logic - that's in
commands.
### Buffer (`Buffer.h/.cc`)
Represents an open document. Manages:
- File I/O (open, save, external modification detection)
- Cursor position and viewport offsets
- Mark (selection start point)
- Visual line mode state
- Syntax highlighting integration
- Undo system integration
- Swap recording integration
**Key Insight**: Buffer wraps a PieceTable and provides a higher-level
interface. The nested `Buffer::Line` class is a legacy wrapper that has
been largely phased out in favor of direct PieceTable operations.
**Line Access APIs**: Buffer provides three ways to access line content:
- `GetLineView(row)` - Zero-copy `string_view` (fastest, 11x faster than
Rows())
- `GetLineString(row)` - Returns `std::string` copy (1.7x faster than
Rows())
- `Rows()` - Materializes all lines into cache (legacy, avoid in new
code)
See `docs/BENCHMARKS.md` for detailed performance analysis and usage
guidance.
### PieceTable (`PieceTable.h/.cc`)
The core text storage data structure. Provides:
- Efficient insert/delete operations without copying entire buffer
- Line-based queries (line count, get line, line ranges)
- Position conversion (byte offset ↔ line/column)
- Substring extraction
- Search functionality
- Automatic consolidation to prevent piece fragmentation
**Key Insight**: PieceTable uses lazy materialization - the full text is
only assembled when `Data()` is called. Most operations work directly on
the piece list.
### UndoSystem (`UndoSystem.h/.cc`, `UndoTree.h/.cc`, `UndoNode.h/.cc`)
Implements undo/redo with a tree structure supporting:
- Linear undo/redo
- Branching history (future enhancement)
- Checkpointing and compaction
- Memory-efficient node pooling
**Key Insight**: The undo system records operations at the PieceTable
level, not at the command level.
### Command System (`Command.h/.cc`)
All editing operations are implemented as commands:
- File operations (save, open, close)
- Navigation (move cursor, page up/down, word movement)
- Editing (insert, delete, kill, yank)
- Search and replace
- Buffer management
- Configuration (syntax, theme, font)
**Key Insight**: `Command.cc` is currently a monolithic 5000-line file.
This is the biggest maintainability challenge in the codebase.
### Frontend Abstraction
Three interfaces define the frontend contract:
- **Frontend** (`Frontend.h`): Top-level lifecycle (Init/Step/Shutdown)
- **InputHandler** (`InputHandler.h`): Converts UI events to commands
- **Renderer** (`Renderer.h`): Draws the editor state
Implementations:
- **Terminal**: ncurses-based (`TerminalFrontend`,
`TerminalInputHandler`, `TerminalRenderer`)
- **ImGui**: Dear ImGui-based (`ImGuiFrontend`, `ImGuiInputHandler`,
`ImGuiRenderer`)
- **Qt**: Qt-based (`QtFrontend`, `QtInputHandler`, `QtRenderer`)
- **Test**: Programmatic testing (`TestFrontend`, `TestInputHandler`,
`TestRenderer`)
## Code Organization
### Directory Structure
```
kte/
├── *.h, *.cc # Core implementation (root level)
├── main.cc # Entry point
├── docs/ # Documentation
│ ├── ke.md # Original ke editor reference (keybindings)
│ ├── swap.md # Swap file design
│ ├── syntax.md # Syntax highlighting
│ ├── themes.md # Theme system
│ └── plans/ # Design documents
├── tests/ # Test suite
│ ├── Test.h # Minimal test framework
│ ├── TestRunner.cc # Test runner
│ └── test_*.cc # Individual test files
├── syntax/ # Syntax highlighting engines
├── fonts/ # Embedded fonts for GUI
├── themes/ # Color themes
└── ext/ # External dependencies (imgui)
```
### File Naming Conventions
- Headers: `ComponentName.h`
- Implementation: `ComponentName.cc`
- Tests: `test_feature_name.cc`
### Key Files by Size
Large files that may need attention:
- `Command.cc` (4995 lines) - **Needs refactoring**: Consider splitting
into logical groups
- `Swap.cc` (1300 lines) - Crash recovery system (migrated to direct
PieceTable operations)
- `QtFrontend.cc` (985 lines) - Qt integration
- `ImGuiRenderer.cc` (930 lines) - ImGui rendering
- `PieceTable.cc` (800 lines) - Core data structure
- `Buffer.cc` (763 lines) - Document model
## Building and Testing
### Build System
kte uses CMake with multiple build profiles:
```bash
# Debug build (terminal only)
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug
cmake --build cmake-build-debug
# Release build with GUI
cmake -S . -B cmake-build-release -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=ON
cmake --build cmake-build-release
# Build specific target
cmake --build cmake-build-debug --target kte_tests
```
### CMake Targets
- `kte` - Terminal editor executable
- `kge` - GUI editor executable (when `BUILD_GUI=ON`)
- `kte_tests` - Test suite
- `imgui` - Dear ImGui library (when `BUILD_GUI=ON`)
### Running Tests
```bash
# Build and run all tests
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
# Run tests with verbose output
./cmake-build-debug/kte_tests
```
### Test Organization
The test suite uses a minimal custom framework (`Test.h`):
```cpp
TEST(TestName) {
// Test body
ASSERT_EQ(actual, expected);
ASSERT_TRUE(condition);
EXPECT_TRUE(condition); // Non-fatal
}
```
Test files by category:
- **Core Data Structures**:
- `test_piece_table.cc` - PieceTable operations, line indexing,
random edits
- `test_buffer_rows.cc` - Buffer row operations
- `test_buffer_io.cc` - File I/O (open, save, SaveAs)
- **Editing Operations**:
- `test_command_semantics.cc` - Command execution
- `test_kkeymap.cc` - Keybinding system
- `test_visual_line_mode.cc` - Visual line selection
- **Search and Replace**:
- `test_search.cc` - Search functionality
- `test_search_replace_flow.cc` - Interactive search/replace
- **Text Reflow**:
- `test_reflow_paragraph.cc` - Paragraph reformatting
- `test_reflow_indented_bullets.cc` - Indented list handling
- **Undo System**:
- `test_undo.cc` - Undo/redo operations
- **Swap Files** (Crash Recovery):
- `test_swap_recorder.cc` - Recording operations
- `test_swap_writer.cc` - Writing swap files
- `test_swap_replay.cc` - Replaying operations
- `test_swap_recovery_prompt.cc` - Recovery UI
- `test_swap_cleanup.cc` - Cleanup logic
- `test_swap_git_editor.cc` - Git editor integration
- **Performance and Migration**:
- `test_benchmarks.cc` - Performance benchmarks for core operations
- `test_migration_coverage.cc` - Buffer::Line migration validation
- **Integration Tests**:
- `test_daily_workflows.cc` - Real-world editing scenarios
- `test_daily_driver_harness.cc` - Workflow test infrastructure
**Total**: 98 tests across 22 test files. See `docs/BENCHMARKS.md` for
performance benchmark results.
### Docker/Podman for Linux Builds
A minimal `Dockerfile` is provided for **testing Linux builds** without
requiring a native Linux system. The Dockerfile creates a build
environment container with all necessary dependencies. Your source tree
is mounted into the container at runtime, allowing you to test
compilation and run tests on Linux.
**Important**: This is intended for testing Linux builds, not for
running
kte locally. The container expects the source tree to be mounted when
run.
This is particularly useful for:
- **macOS/Windows developers** testing Linux compatibility
- **CI/CD pipelines** ensuring cross-platform builds
- **Reproducible builds** with a known Alpine Linux 3.19 environment
#### Prerequisites
Install Docker or Podman:
- **macOS**: `brew install podman` (Docker Desktop also works)
- **Linux**: Use your distribution's package manager
- **Windows**: Docker Desktop or Podman Desktop
If using Podman on macOS, start the VM:
```bash
podman machine init
podman machine start
```
#### Building the Docker Image
The Dockerfile installs all build dependencies including GUI support (
g++ 13.2.1, CMake 3.27.8, ncurses-dev, SDL2, OpenGL/Mesa, Freetype). It
does not copy or build the source code.
From the project root:
```bash
# Build the environment image
docker build -t kte-linux .
# Or with Podman
podman build -t kte-linux .
```
#### Testing Linux Builds
Mount your source tree and run the build + tests:
```bash
# Build and test (default command)
docker run --rm -v "$(pwd):/kte" kte-linux
# Expected output: "98 tests passed, 0 failed"
```
The default command builds both `kte` (terminal) and `kge` (GUI)
executables with full GUI support (`-DBUILD_GUI=ON`) and runs the
complete test suite.
#### Custom Build Commands
```bash
# Open a shell in the build environment
docker run --rm -it -v "$(pwd):/kte" kte-linux /bin/bash
# Then inside the container:
cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON
cmake --build build --target kte # Terminal version
cmake --build build --target kge # GUI version
cmake --build build --target kte_tests
./build/kte_tests
# Or run kte directly
./build/kte --help
# Terminal-only build (smaller, faster)
cmake -B build -DBUILD_GUI=OFF -DBUILD_TESTS=ON
cmake --build build --target kte
```
#### Running kte Interactively
To test kte's terminal UI on Linux:
```bash
# Run kte with a file from your host system
docker run --rm -it -v "$(pwd):/kte" kte-linux sh -c "cmake -B build -DBUILD_GUI=OFF && cmake --build build --target kte && ./build/kte README.md"
```
#### CI/CD Integration
Example GitHub Actions workflow:
```yaml
- name: Test Linux Build
run: |
docker build -t kte-linux .
docker run --rm -v "${{ github.workspace }}:/kte" kte-linux
```
#### Troubleshooting
**"Cannot connect to Podman socket"** (macOS):
```bash
podman machine start
```
**"Permission denied"** (Linux):
```bash
# Add your user to the docker group
sudo usermod -aG docker $USER
# Log out and back in
```
**Build fails with ncurses errors**:
The Dockerfile explicitly installs `ncurses-dev` (wide-character
ncurses). If you modify the Dockerfile, ensure this dependency remains.
**"No such file or directory" errors**:
Ensure you're mounting the source tree with `-v "$(pwd):/kte"` when
running the container.
### Writing Tests
When adding new functionality:
1. **Add a test first** - Write a failing test that demonstrates the
desired behavior
2. **Use descriptive names** - Test names should explain what's being
validated
3. **Test edge cases** - Empty buffers, EOF, beginning of file, etc.
4. **Use TestFrontend** - For integration tests, use the programmatic
test frontend
Example test structure:
```cpp
TEST(Feature_Behavior_Scenario) {
// Setup
Buffer buf;
buf.insert_text(0, 0, "test content\n");
// Exercise
buf.delete_text(0, 5, 4);
// Verify
ASSERT_EQ(buf.GetLineString(0), std::string("test\n"));
}
```
## Making Changes
### Development Workflow
1. **Understand the change scope**:
- Pure UI change? → Modify frontend only
- New editing operation? → Add command in `Command.cc`
- Core data structure? → Modify `PieceTable` or `Buffer`
2. **Find relevant code**:
- Use `git grep` or IDE search to find similar functionality
- Check `Command.cc` for existing command patterns
- Look at tests to understand expected behavior
3. **Make the change**:
- Follow existing code style (see below)
- Add or update tests
- Update documentation if needed
4. **Test thoroughly**:
- Run the full test suite
- Manually test in both terminal and GUI (if applicable)
- Test edge cases (empty files, large files, EOF, etc.)
### Common Pitfalls
- **Don't modify `Buffer::Rows()` directly** - Use the PieceTable API (
`insert_text`, `delete_text`, etc.) to ensure undo and swap recording
work correctly.
- **Prefer efficient line access** - Use `GetLineView()` for read-only
access (11x faster than `Rows()`), or `GetLineString()` when you need
a copy. Avoid `Rows()` in new code.
- **Remember to invalidate caches** - If you modify PieceTable
internals, ensure line index and materialization caches are
invalidated.
- **Cursor visibility** - After editing operations, call
`ensure_cursor_visible()` to update viewport offsets.
- **Undo boundaries** - Use `buf.Undo()->BeginGroup()` and `EndGroup()`
to group related operations.
- **GetLineView() lifetime** - The returned `string_view` is only valid
until the next buffer modification. Use immediately or copy to
`std::string`.
## Code Style
kte uses C++20 with these conventions:
### Naming
- **Classes/Structs**: `PascalCase` (e.g., `PieceTable`, `Buffer`)
- **Functions/Methods**: `PascalCase` (e.g., `GetLine`, `Insert`)
- **Variables**: `snake_case` with trailing underscore for members (
e.g., `total_size_`, `line_index_`)
- **Constants**: `snake_case` or `UPPER_CASE` depending on context
- **Private members**: Trailing underscore (e.g., `pieces_`, `dirty_`)
### Formatting
- **Indentation**: Tabs (width 8 in most files, but follow existing
style)
- **Braces**: Opening brace on same line for functions, control
structures
- **Line length**: No strict limit, but keep reasonable (~100-120 chars)
- **Includes**: Group by category (system, external, project) with blank
lines between
### Comments
- **File headers**: Brief description of the file's purpose
- **Function comments**: Explain non-obvious behavior, not what the code
obviously does
- **Inline comments**: Explain *why*, not *what*
- **TODO comments**: Use `TODO:` prefix for future work
Example:
```cpp
// Consolidate small pieces to prevent fragmentation.
// This is a heuristic: we only consolidate when piece count exceeds
// a threshold, and we cap the bytes processed per consolidation run.
void maybeConsolidate() {
if (pieces_.size() < piece_limit_)
return;
// ... implementation
}
```
## Common Tasks
### Adding a New Command
1. **Define the command function** in `Command.cc`:
```cpp
bool cmd_my_feature(CommandContext &ctx) {
Editor &ed = ctx.ed;
Buffer *buf = ed.CurrentBuffer();
if (!buf) return false;
// Implement the command
buf->insert_text(buf->Cury(), buf->Curx(), "text");
return true;
}
```
2. **Register the command** in `InstallDefaultCommands()`:
```cpp
CommandRegistry::Register({
CommandId::MyFeature,
"my-feature",
"Description of what it does",
cmd_my_feature
});
```
3. **Add keybinding** in the appropriate `InputHandler` (e.g.,
`TerminalInputHandler.cc`).
4. **Write tests** in `tests/test_command_semantics.cc` or a new test
file.
### Adding a New Frontend
1. **Implement the three interfaces**:
- `Frontend` - Lifecycle management
- `InputHandler` - Event → Command translation
- `Renderer` - Draw the editor state
2. **Study existing implementations**:
- `TerminalFrontend` - Simplest, good starting point
- `ImGuiFrontend` - More complex, shows GUI patterns
3. **Register in `main.cc`** to make it selectable.
### Modifying the PieceTable
The PieceTable is performance-critical. When making changes:
1. **Understand the piece list** - Each piece references a range in
either `original_` or `add_` buffer
2. **Maintain invariants**:
- `total_size_` must match sum of piece lengths
- Line index must be invalidated on content changes
- Version must increment on mutations
3. **Test thoroughly** - Use `test_piece_table.cc` random edit test as a
reference model
4. **Profile if needed** - Large file performance is a key goal
### Adding Syntax Highlighting
1. **Create a new highlighter** in `syntax/` directory:
- Inherit from `HighlighterEngine`
- Implement `HighlightLine()` method
2. **Register in `HighlighterRegistry`** (
`syntax/HighlighterRegistry.cc`)
3. **Add file extension mapping** in the registry
4. **Test with sample files** of that language
### Debugging Tips
- **Use the test frontend** - Write a test that reproduces the issue
- **Enable assertions** - Build in Debug mode
- **Check swap files** - Look in `/tmp/kte-swap-*` for recorded
operations
- **Print debugging** - Use `std::cerr` (stdout is used by ncurses)
- **GDB/LLDB** - Standard debuggers work fine with kte
## Getting Help
- **Read the code** - kte is designed to be understandable; follow the
data flow
- **Check existing tests** - Tests often show how to use APIs correctly
- **Look at git history** - See how similar features were implemented
- **Read design docs** - Check `docs/plans/` for design rationale
## Future Improvements
Areas where the codebase could be improved:
1. **Split Command.cc** - Break into logical groups (editing,
navigation, file ops, etc.)
2. **Complete Buffer::Line migration** - A few legacy editing functions
in Command.cc still use `Buffer::Rows()` directly (see lines 86-90
comment)
3. **Add more inline documentation** - Especially for complex algorithms
4. **Improve test coverage** - Add more edge case tests (current: 98
tests)
5. **Performance profiling** - Continue monitoring performance with
benchmark suite
6. **API documentation** - Consider adding Doxygen-style comments
---
Welcome aboard! Start small, read the code, and don't hesitate to ask
questions.

25719
fonts/Go.h

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -195,7 +195,6 @@ main(int argc, char *argv[])
} else if (req_term) {
use_gui = false;
} else {
// Default depends on build target: kge defaults to GUI, kte to terminal
#if defined(KTE_DEFAULT_GUI)
use_gui = true;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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.

View File

@@ -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;

View File

@@ -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 "\"\"\""

View File

@@ -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) {

View File

@@ -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

View File

@@ -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;

View File

@@ -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) \

View File

@@ -2,7 +2,10 @@
#include <iostream>
#include <chrono>
int main() {
int
main()
{
using namespace std::chrono;
auto &reg = ktet::registry();
std::cout << "kte unit tests: " << reg.size() << " test(s)\n";

411
tests/test_benchmarks.cc Normal file
View 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);
}

View File

@@ -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());

View File

@@ -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;

View File

@@ -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"

View 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"));
}

View File

@@ -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>

View 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";
}
}

View File

@@ -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", ""},

View 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);
}

View File

@@ -10,6 +10,7 @@
#if defined(KTE_TESTS)
#include <unordered_set>
static void
validate_undo_subtree(const UndoNode *node, const UndoNode *expected_parent,
std::unordered_set<const UndoNode *> &seen)
@@ -53,13 +54,15 @@ validate_undo_tree(const UndoSystem &u)
#endif
TEST (Undo_InsertRun_Coalesces)
// The undo suite aims to cover invariants with a small, adversarial test matrix.
TEST(Undo_InsertRun_Coalesces_OneStep)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Simulate two separate "typed" insert commands without committing in between.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("h"));
@@ -70,28 +73,52 @@ TEST (Undo_InsertRun_Coalesces)
b.insert_text(0, 1, std::string_view("i"));
u->Append('i');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
}
TEST (Undo_BackspaceRun_Coalesces)
TEST(Undo_InsertRun_BreaksOnNonAdjacentCursor)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
// Jump the cursor; next insert should not coalesce.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("b"));
u->Append('b');
b.SetCursor(1, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ba"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
}
TEST(Undo_BackspaceRun_Coalesces_OneStep)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed content.
b.insert_text(0, 0, std::string_view("abc"));
b.SetCursor(3, 0);
u->mark_saved();
// Simulate two backspaces: delete 'c' then 'b'.
// Delete 'c' then 'b' with backspace shape.
{
const auto &rows = b.Rows();
char deleted = rows[0][2];
@@ -108,16 +135,242 @@ TEST (Undo_BackspaceRun_Coalesces)
u->Begin(UndoType::Delete);
u->Append(deleted);
}
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// One undo should restore both characters.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abc"));
}
TEST(Undo_DeleteKeyRun_Coalesces_OneStep)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.insert_text(0, 0, std::string_view("abcd"));
// Simulate delete-key at col 1 twice (cursor stays).
b.SetCursor(1, 0);
{
const auto &rows = b.Rows();
char deleted = rows[0][1];
b.delete_text(0, 1, 1);
b.SetCursor(1, 0);
u->Begin(UndoType::Delete);
u->Append(deleted);
}
{
const auto &rows = b.Rows();
char deleted = rows[0][1];
b.delete_text(0, 1, 1);
b.SetCursor(1, 0);
u->Begin(UndoType::Delete);
u->Append(deleted);
}
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abcd"));
}
TEST(Undo_Newline_IsStandalone)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed with content and split in the middle (not at EOF) so (row=1,col=0)
// is always addressable and cannot be clamped in unexpected ways.
b.insert_text(0, 0, std::string_view("hi"));
b.SetCursor(1, 0);
const std::string before_nl = b.BytesForTests();
// Newline should always be its own undo step.
u->Begin(UndoType::Newline);
b.split_line(0, 1);
u->commit();
const std::string after_nl = b.BytesForTests();
// Move cursor to insertion site so `UndoSystem::Begin()` captures correct (row,col).
b.SetCursor(0, 1);
u->Begin(UndoType::Insert);
b.insert_text(1, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 1);
u->commit();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[1]), std::string("xi"));
u->undo();
// Undoing the insert should not also undo the newline.
ASSERT_EQ(b.BytesForTests(), after_nl);
u->undo();
ASSERT_EQ(b.BytesForTests(), before_nl);
}
TEST(Undo_ExplicitGroup_UndoesAsUnit)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.SetCursor(0, 0);
(void) u->BeginGroup();
// Simulate two separate committed edits inside a group.
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
u->EndGroup();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
}
TEST(Undo_Branching_RedoBranchSelectionDeterministic)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// A then B then C
b.SetCursor(0, 0);
for (char ch: std::string("ABC")) {
u->Begin(UndoType::Insert);
b.insert_text(0, b.Curx(), std::string_view(&ch, 1));
u->Append(ch);
b.SetCursor(b.Curx() + 1, 0);
u->commit();
}
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ABC"));
// Undo twice -> back to "A"
u->undo();
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("A"));
// Type D to create a new branch.
u->Begin(UndoType::Insert);
char d = 'D';
b.insert_text(0, 1, std::string_view(&d, 1));
u->Append('D');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AD"));
// Undo D, then redo branch 0 should redo D (new head).
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("A"));
u->redo(0);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AD"));
// Undo back to A again, redo branch 1 should follow the older path (to AB).
u->undo();
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AB"));
}
TEST(Undo_DirtyFlag_CrossesMarkSaved)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 0);
u->commit();
if (auto *u2 = b.Undo())
u2->mark_saved();
b.SetDirty(false);
ASSERT_TRUE(!b.Dirty());
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("y"));
u->Append('y');
b.SetCursor(2, 0);
u->commit();
ASSERT_TRUE(b.Dirty());
u->undo();
ASSERT_TRUE(!b.Dirty());
}
TEST(Undo_RoundTrip_Lossless_RandomEdits)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
std::mt19937 rng(123);
std::uniform_int_distribution<int> pick(0, 1);
std::uniform_int_distribution<int> ch('a', 'z');
// Build a short random sequence of inserts and deletes.
for (int i = 0; i < 200; ++i) {
const std::string cur = b.AsString();
const bool do_insert = (cur.empty() || pick(rng) == 0);
if (do_insert) {
char c = static_cast<char>(ch(rng));
u->Begin(UndoType::Insert);
b.insert_text(0, b.Curx(), std::string_view(&c, 1));
u->Append(c);
b.SetCursor(b.Curx() + 1, 0);
u->commit();
} else {
// Delete one char at a stable position.
std::size_t x = b.Curx();
if (x >= b.Rows()[0].size())
x = b.Rows()[0].size() - 1;
char deleted = b.Rows()[0][x];
b.delete_text(0, static_cast<int>(x), 1);
b.SetCursor(x, 0);
u->Begin(UndoType::Delete);
u->Append(deleted);
u->commit();
}
}
const std::string final = b.AsString();
// Undo back to start.
for (int i = 0; i < 1000; ++i) {
std::string before = b.AsString();
u->undo();
if (b.AsString() == before)
break;
}
// Redo forward; should end at exact final bytes.
for (int i = 0; i < 1000; ++i) {
std::string before = b.AsString();
u->redo(0);
if (b.AsString() == before)
break;
}
ASSERT_EQ(b.AsString(), final);
}
// Legacy/extended undo tests follow. Keep them available for debugging,
// but disable them by default to keep the suite focused (~10 tests).
#if 0
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
{
Buffer b;
@@ -460,7 +713,6 @@ TEST (Undo_StructuralInvariants_BranchingAndRoots)
validate_undo_tree(*u);
}
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
{
Buffer b;
@@ -540,6 +792,11 @@ TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
validate_undo_tree(*u);
}
#endif
// Additional legacy tests below are useful, but kept disabled by default.
#if 0
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
{
@@ -938,3 +1195,5 @@ TEST (Undo_Command_RedoCountSelectsBranch)
validate_undo_tree(*u);
}
#endif // legacy tests