Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 690c51b0f3 | |||
| 0d87bc0b25 | |||
| daeeecb342 | |||
| a428b204a0 | |||
| a21409e689 | |||
| b0b5b55dce | |||
| 422b27b1ba | |||
| 9485d2aa24 | |||
| 8a6b7851d5 | |||
| 8ec0d6ac41 | |||
| 337b585ba0 | |||
| 95a588b0df | |||
| 199d7a20f7 | |||
| 44827fe53f | |||
| 2a6ff2a862 | |||
| 895e4ccb1e | |||
| 15b350bfaa | |||
| cc8df36bdf | |||
| 1c0f04f076 | |||
| ac0eadc345 | |||
| f3bdced3d4 | |||
| 2551388420 | |||
| d2d155f211 | |||
| 8634eb78f0 | |||
| 6eb240a0c4 | |||
| 4c402f5ef3 | |||
| a8abda4b87 | |||
| 7347556aa2 | |||
| 289e155c98 | |||
| 147a52f3d4 | |||
| dda7541e2f | |||
| 2408f5494c | |||
| 2542690eca | |||
| cc0c187481 | |||
| a8dcfbec58 | |||
| 65705e3354 | |||
| e1f9a9eb6a | |||
| c9f34003f2 | |||
| f450ef825c | |||
| f6f0c11be4 | |||
| 657c9bbc19 | |||
| 3493695165 | |||
| 5f57cf23dc | |||
| 9312550be4 |
1
.idea/codeStyles/codeStyleConfig.xml
generated
1
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +1,6 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<state>
|
<state>
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="sccl" />
|
||||||
</state>
|
</state>
|
||||||
</component>
|
</component>
|
||||||
3
.idea/editor.xml
generated
3
.idea/editor.xml
generated
@@ -19,7 +19,7 @@
|
|||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="HINT" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefinitionsOrder/@EntryIndexedValue" value="HINT" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
|
|||||||
2
.idea/kte.iml
generated
2
.idea/kte.iml
generated
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<module classpath="CMake" type="CPP_MODULE" version="4">
|
<module classpath="CIDR" type="CPP_MODULE" version="4">
|
||||||
<component name="FacetManager">
|
<component name="FacetManager">
|
||||||
<facet type="Python" name="Python facet">
|
<facet type="Python" name="Python facet">
|
||||||
<configuration sdkName="" />
|
<configuration sdkName="" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Project Guidelines
|
# Project Guidelines
|
||||||
|
|
||||||
kte is Kyle's Text Editor — a simple, fast text editor written in C++17.
|
kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
|
||||||
It
|
It
|
||||||
replaces the earlier C implementation, ke (see the ke manual in
|
replaces the earlier C implementation, ke (see the ke manual in
|
||||||
`docs/ke.md`). The
|
`docs/ke.md`). The
|
||||||
@@ -43,7 +43,7 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
|
|||||||
|
|
||||||
## Contributing/Development Notes
|
## Contributing/Development Notes
|
||||||
|
|
||||||
- C++ standard: C++17.
|
- C++ standard: C++20.
|
||||||
- Keep dependencies minimal.
|
- Keep dependencies minimal.
|
||||||
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
||||||
changing
|
changing
|
||||||
@@ -55,3 +55,4 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
|
|||||||
for now).
|
for now).
|
||||||
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
324
Buffer.cc
324
Buffer.cc
@@ -7,9 +7,20 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
|
#include "SwapRecorder.h"
|
||||||
#include "UndoSystem.h"
|
#include "UndoSystem.h"
|
||||||
#include "UndoTree.h"
|
#include "UndoTree.h"
|
||||||
|
#include "ErrorHandler.h"
|
||||||
|
#include "SyscallWrappers.h"
|
||||||
|
#include "ErrorRecovery.h"
|
||||||
// For reconstructing highlighter state on copies
|
// For reconstructing highlighter state on copies
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "syntax/HighlighterRegistry.h"
|
||||||
#include "syntax/NullHighlighter.h"
|
#include "syntax/NullHighlighter.h"
|
||||||
@@ -23,6 +34,177 @@ Buffer::Buffer()
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Buffer::stat_identity(const std::string &path, FileIdentity &out)
|
||||||
|
{
|
||||||
|
struct stat st{};
|
||||||
|
if (::stat(path.c_str(), &st) != 0) {
|
||||||
|
out.valid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out.valid = true;
|
||||||
|
// Use nanosecond timestamp when available.
|
||||||
|
std::uint64_t ns = 0;
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
ns = static_cast<std::uint64_t>(st.st_mtimespec.tv_sec) * 1000000000ull
|
||||||
|
+ static_cast<std::uint64_t>(st.st_mtimespec.tv_nsec);
|
||||||
|
#else
|
||||||
|
ns = static_cast<std::uint64_t>(st.st_mtim.tv_sec) * 1000000000ull
|
||||||
|
+ static_cast<std::uint64_t>(st.st_mtim.tv_nsec);
|
||||||
|
#endif
|
||||||
|
out.mtime_ns = ns;
|
||||||
|
out.size = static_cast<std::uint64_t>(st.st_size);
|
||||||
|
out.dev = static_cast<std::uint64_t>(st.st_dev);
|
||||||
|
out.ino = static_cast<std::uint64_t>(st.st_ino);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Buffer::current_disk_identity(FileIdentity &out) const
|
||||||
|
{
|
||||||
|
if (!is_file_backed_ || filename_.empty()) {
|
||||||
|
out.valid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return stat_identity(filename_, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Buffer::ExternallyModifiedOnDisk() const
|
||||||
|
{
|
||||||
|
if (!is_file_backed_ || filename_.empty())
|
||||||
|
return false;
|
||||||
|
FileIdentity now{};
|
||||||
|
if (!current_disk_identity(now)) {
|
||||||
|
// If the file vanished, treat as modified when we previously had an identity.
|
||||||
|
return on_disk_identity_.valid;
|
||||||
|
}
|
||||||
|
if (!on_disk_identity_.valid)
|
||||||
|
return false;
|
||||||
|
return now.mtime_ns != on_disk_identity_.mtime_ns
|
||||||
|
|| now.size != on_disk_identity_.size
|
||||||
|
|| now.dev != on_disk_identity_.dev
|
||||||
|
|| now.ino != on_disk_identity_.ino;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Buffer::RefreshOnDiskIdentity()
|
||||||
|
{
|
||||||
|
FileIdentity id{};
|
||||||
|
if (current_disk_identity(id))
|
||||||
|
on_disk_identity_ = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
|
||||||
|
{
|
||||||
|
std::size_t off = 0;
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = ::write(fd, data + off, len - off);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR)
|
||||||
|
continue;
|
||||||
|
err = std::string("Write failed: ") + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
off += static_cast<std::size_t>(n);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
best_effort_fsync_dir(const std::string &path)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(path);
|
||||||
|
std::filesystem::path dir = p.parent_path();
|
||||||
|
if (dir.empty())
|
||||||
|
return;
|
||||||
|
int dfd = kte::syscall::Open(dir.c_str(), O_RDONLY);
|
||||||
|
if (dfd < 0)
|
||||||
|
return;
|
||||||
|
(void) kte::syscall::Fsync(dfd);
|
||||||
|
(void) kte::syscall::Close(dfd);
|
||||||
|
} catch (...) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err)
|
||||||
|
{
|
||||||
|
// Create a temp file in the same directory so rename() is atomic.
|
||||||
|
std::filesystem::path p(path);
|
||||||
|
std::filesystem::path dir = p.parent_path();
|
||||||
|
std::string base = p.filename().string();
|
||||||
|
std::filesystem::path tmpl = dir / ("." + base + ".kte.tmp.XXXXXX");
|
||||||
|
std::string tmpl_s = tmpl.string();
|
||||||
|
|
||||||
|
// mkstemp requires a mutable buffer.
|
||||||
|
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
|
||||||
|
buf.push_back('\0');
|
||||||
|
|
||||||
|
// Retry on transient errors for temp file creation
|
||||||
|
int fd = -1;
|
||||||
|
auto mkstemp_fn = [&]() -> bool {
|
||||||
|
// Reset buffer for each retry attempt
|
||||||
|
buf.assign(tmpl_s.begin(), tmpl_s.end());
|
||||||
|
buf.push_back('\0');
|
||||||
|
fd = kte::syscall::Mkstemp(buf.data());
|
||||||
|
return fd >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) {
|
||||||
|
if (fd < 0) {
|
||||||
|
err = std::string("Failed to create temp file for save: ") + std::strerror(errno) + err;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::string tmp_path(buf.data());
|
||||||
|
|
||||||
|
// If the destination exists, carry over its permissions.
|
||||||
|
struct stat dst_st{};
|
||||||
|
if (::stat(path.c_str(), &dst_st) == 0) {
|
||||||
|
(void) kte::syscall::Fchmod(fd, dst_st.st_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = write_all_fd(fd, data, len, err);
|
||||||
|
if (ok) {
|
||||||
|
// Retry fsync on transient errors
|
||||||
|
auto fsync_fn = [&]() -> bool {
|
||||||
|
return kte::syscall::Fsync(fd) == 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string fsync_err;
|
||||||
|
if (!kte::RetryOnTransientError(fsync_fn, kte::RetryPolicy::Aggressive(), fsync_err)) {
|
||||||
|
err = std::string("fsync failed: ") + std::strerror(errno) + fsync_err;
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
|
||||||
|
err = std::string("rename failed: ") + std::strerror(errno);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
(void) ::unlink(tmp_path.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
best_effort_fsync_dir(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Buffer::Buffer(const std::string &path)
|
Buffer::Buffer(const std::string &path)
|
||||||
{
|
{
|
||||||
std::string err;
|
std::string err;
|
||||||
@@ -250,17 +432,46 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|||||||
std::ifstream in(norm, std::ios::in | std::ios::binary);
|
std::ifstream in(norm, std::ios::in | std::ios::binary);
|
||||||
if (!in) {
|
if (!in) {
|
||||||
err = "Failed to open file: " + norm;
|
err = "Failed to open file: " + norm;
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read entire file into PieceTable as-is
|
// Read entire file into PieceTable as-is
|
||||||
std::string data;
|
std::string data;
|
||||||
in.seekg(0, std::ios::end);
|
in.seekg(0, std::ios::end);
|
||||||
|
if (!in) {
|
||||||
|
err = "Failed to seek to end of file: " + norm;
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
auto sz = in.tellg();
|
auto sz = in.tellg();
|
||||||
|
if (sz < 0) {
|
||||||
|
err = "Failed to get file size: " + norm;
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (sz > 0) {
|
if (sz > 0) {
|
||||||
data.resize(static_cast<std::size_t>(sz));
|
data.resize(static_cast<std::size_t>(sz));
|
||||||
in.seekg(0, std::ios::beg);
|
in.seekg(0, std::ios::beg);
|
||||||
|
if (!in) {
|
||||||
|
err = "Failed to seek to beginning of file: " + norm;
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
in.read(data.data(), static_cast<std::streamsize>(data.size()));
|
in.read(data.data(), static_cast<std::streamsize>(data.size()));
|
||||||
|
if (!in && !in.eof()) {
|
||||||
|
err = "Failed to read file: " + norm;
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Validate we read the expected number of bytes
|
||||||
|
const std::streamsize bytes_read = in.gcount();
|
||||||
|
if (bytes_read != static_cast<std::streamsize>(data.size())) {
|
||||||
|
err = "Partial read of file (expected " + std::to_string(data.size()) +
|
||||||
|
" bytes, got " + std::to_string(bytes_read) + "): " + norm;
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
content_.Clear();
|
content_.Clear();
|
||||||
if (!data.empty())
|
if (!data.empty())
|
||||||
@@ -270,6 +481,7 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|||||||
filename_ = norm;
|
filename_ = norm;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
RefreshOnDiskIdentity();
|
||||||
|
|
||||||
// Reset/initialize undo system for this loaded file
|
// Reset/initialize undo system for this loaded file
|
||||||
if (!undo_tree_)
|
if (!undo_tree_)
|
||||||
@@ -296,21 +508,18 @@ Buffer::Save(std::string &err) const
|
|||||||
err = "Buffer is not file-backed; use SaveAs()";
|
err = "Buffer is not file-backed; use SaveAs()";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
|
const std::size_t sz = content_.Size();
|
||||||
if (!out) {
|
const char *data = sz ? content_.Data() : nullptr;
|
||||||
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
if (sz && !data) {
|
||||||
|
err = "Internal error: buffer materialization failed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Write the entire buffer in a single block to minimize I/O calls.
|
if (!atomic_write_file(filename_, data ? data : "", sz, err)) {
|
||||||
const char *data = content_.Data();
|
kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
|
||||||
const auto size = static_cast<std::streamsize>(content_.Size());
|
|
||||||
if (data != nullptr && size > 0) {
|
|
||||||
out.write(data, size);
|
|
||||||
}
|
|
||||||
if (!out.good()) {
|
|
||||||
err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
|
||||||
return false;
|
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
|
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
||||||
// to decide when to flip dirty flag after successful save.
|
// to decide when to flip dirty flag after successful save.
|
||||||
return true;
|
return true;
|
||||||
@@ -339,26 +548,21 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
|||||||
out_path = path;
|
out_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to the given path
|
const std::size_t sz = content_.Size();
|
||||||
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc);
|
const char *data = sz ? content_.Data() : nullptr;
|
||||||
if (!out) {
|
if (sz && !data) {
|
||||||
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
err = "Internal error: buffer materialization failed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Write whole content in a single I/O operation
|
if (!atomic_write_file(out_path, data ? data : "", sz, err)) {
|
||||||
const char *data = content_.Data();
|
kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
|
||||||
const auto size = static_cast<std::streamsize>(content_.Size());
|
|
||||||
if (data != nullptr && size > 0) {
|
|
||||||
out.write(data, size);
|
|
||||||
}
|
|
||||||
if (!out.good()) {
|
|
||||||
err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
filename_ = out_path;
|
filename_ = out_path;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
RefreshOnDiskIdentity();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +593,8 @@ Buffer::insert_text(int row, int col, std::string_view text)
|
|||||||
if (!text.empty()) {
|
if (!text.empty()) {
|
||||||
content_.Insert(off, text.data(), text.size());
|
content_.Insert(off, text.data(), text.size());
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnInsert(row, col, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +617,7 @@ Buffer::GetLineView(std::size_t row) const
|
|||||||
void
|
void
|
||||||
Buffer::ensure_rows_cache() const
|
Buffer::ensure_rows_cache() const
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(buffer_mutex_);
|
||||||
if (!rows_cache_dirty_)
|
if (!rows_cache_dirty_)
|
||||||
return;
|
return;
|
||||||
rows_.clear();
|
rows_.clear();
|
||||||
@@ -432,6 +639,21 @@ Buffer::content_LineCount_() const
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(KTE_TESTS)
|
||||||
|
std::string
|
||||||
|
Buffer::BytesForTests() const
|
||||||
|
{
|
||||||
|
const std::size_t sz = content_.Size();
|
||||||
|
if (sz == 0)
|
||||||
|
return std::string();
|
||||||
|
const char *data = content_.Data();
|
||||||
|
if (!data)
|
||||||
|
return std::string();
|
||||||
|
return std::string(data, data + sz);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
Buffer::delete_text(int row, int col, std::size_t len)
|
Buffer::delete_text(int row, int col, std::size_t len)
|
||||||
{
|
{
|
||||||
@@ -441,6 +663,7 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
row = 0;
|
row = 0;
|
||||||
if (col < 0)
|
if (col < 0)
|
||||||
col = 0;
|
col = 0;
|
||||||
|
|
||||||
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(col));
|
||||||
std::size_t r = static_cast<std::size_t>(row);
|
std::size_t r = static_cast<std::size_t>(row);
|
||||||
@@ -460,16 +683,19 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
break;
|
break;
|
||||||
// Consume newline between lines as one char, if there is a next line
|
// Consume newline between lines as one char, if there is a next line
|
||||||
if (r + 1 < lc) {
|
if (r + 1 < lc) {
|
||||||
if (remaining > 0) {
|
|
||||||
remaining -= 1; // the newline
|
remaining -= 1; // the newline
|
||||||
r += 1;
|
r += 1;
|
||||||
c = 0;
|
c = 0;
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// At last line and still remaining: delete to EOF
|
// At last line and still remaining: delete to EOF
|
||||||
std::size_t total = content_.Size();
|
const std::size_t total = content_.Size();
|
||||||
content_.Delete(start, total - start);
|
const std::size_t actual = (total > start) ? (total - start) : 0;
|
||||||
|
if (actual == 0)
|
||||||
|
return;
|
||||||
|
content_.Delete(start, actual);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, col, actual);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,8 +703,11 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
// Compute end offset at (r,c)
|
// Compute end offset at (r,c)
|
||||||
std::size_t end = content_.LineColToByteOffset(r, c);
|
std::size_t end = content_.LineColToByteOffset(r, c);
|
||||||
if (end > start) {
|
if (end > start) {
|
||||||
content_.Delete(start, end - start);
|
const std::size_t actual = end - start;
|
||||||
|
content_.Delete(start, actual);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, col, actual);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,15 +715,18 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
void
|
void
|
||||||
Buffer::split_line(int row, const int col)
|
Buffer::split_line(int row, const int col)
|
||||||
{
|
{
|
||||||
|
int c = col;
|
||||||
if (row < 0)
|
if (row < 0)
|
||||||
row = 0;
|
row = 0;
|
||||||
if (col < 0)
|
if (c < 0)
|
||||||
row = 0;
|
c = 0;
|
||||||
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(c));
|
||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off, &nl, 1);
|
content_.Insert(off, &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -506,11 +738,14 @@ Buffer::join_lines(int row)
|
|||||||
std::size_t r = static_cast<std::size_t>(row);
|
std::size_t r = static_cast<std::size_t>(row);
|
||||||
if (r + 1 >= content_.LineCount())
|
if (r + 1 >= content_.LineCount())
|
||||||
return;
|
return;
|
||||||
|
const int col = static_cast<int>(content_.GetLine(r).size());
|
||||||
// Delete the newline between line r and r+1
|
// Delete the newline between line r and r+1
|
||||||
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
||||||
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
||||||
content_.Delete(end_of_line, 1);
|
content_.Delete(end_of_line, 1);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, col, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -525,6 +760,12 @@ Buffer::insert_row(int row, const std::string_view text)
|
|||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off + text.size(), &nl, 1);
|
content_.Insert(off + text.size(), &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_) {
|
||||||
|
// Avoid allocation: emit the row text insertion (if any) and the newline insertion.
|
||||||
|
if (!text.empty())
|
||||||
|
swap_rec_->OnInsert(row, 0, text);
|
||||||
|
swap_rec_->OnInsert(row, static_cast<int>(text.size()), std::string_view("\n", 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -539,9 +780,24 @@ Buffer::delete_row(int row)
|
|||||||
auto range = content_.GetLineRange(r); // [start,end)
|
auto range = content_.GetLineRange(r); // [start,end)
|
||||||
// If not last line, ensure we include the separating newline by using end as-is (which points to next line start)
|
// If not last line, ensure we include the separating newline by using end as-is (which points to next line start)
|
||||||
// If last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
|
// If last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
|
||||||
std::size_t start = range.first;
|
const std::size_t start = range.first;
|
||||||
std::size_t end = range.second;
|
const std::size_t end = range.second;
|
||||||
content_.Delete(start, end - start);
|
const std::size_t actual = (end > start) ? (end - start) : 0;
|
||||||
|
if (actual == 0)
|
||||||
|
return;
|
||||||
|
content_.Delete(start, actual);
|
||||||
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, 0, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Buffer::replace_all_bytes(const std::string_view bytes)
|
||||||
|
{
|
||||||
|
content_.Clear();
|
||||||
|
if (!bytes.empty())
|
||||||
|
content_.Append(bytes.data(), bytes.size());
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
Buffer.h
141
Buffer.h
@@ -1,5 +1,37 @@
|
|||||||
/*
|
/*
|
||||||
* Buffer.h - editor buffer representing an open document
|
* 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
|
#pragma once
|
||||||
|
|
||||||
@@ -14,6 +46,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include "syntax/HighlighterEngine.h"
|
#include "syntax/HighlighterEngine.h"
|
||||||
#include "Highlight.h"
|
#include "Highlight.h"
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
// Forward declaration for swap journal integration
|
// Forward declaration for swap journal integration
|
||||||
namespace kte {
|
namespace kte {
|
||||||
@@ -41,6 +74,14 @@ public:
|
|||||||
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
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
|
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
|
// Accessors
|
||||||
[[nodiscard]] std::size_t Curx() const
|
[[nodiscard]] std::size_t Curx() const
|
||||||
{
|
{
|
||||||
@@ -369,6 +410,71 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Visual-line selection support (multicursor/visual mode)
|
||||||
|
void VisualLineClear()
|
||||||
|
{
|
||||||
|
visual_line_active_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void VisualLineStart()
|
||||||
|
{
|
||||||
|
visual_line_active_ = true;
|
||||||
|
visual_line_anchor_y_ = cury_;
|
||||||
|
visual_line_active_y_ = cury_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void VisualLineToggle()
|
||||||
|
{
|
||||||
|
if (visual_line_active_)
|
||||||
|
VisualLineClear();
|
||||||
|
else
|
||||||
|
VisualLineStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool VisualLineActive() const
|
||||||
|
{
|
||||||
|
return visual_line_active_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void VisualLineSetActiveY(std::size_t y)
|
||||||
|
{
|
||||||
|
visual_line_active_y_ = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t VisualLineStartY() const
|
||||||
|
{
|
||||||
|
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t VisualLineEndY() const
|
||||||
|
{
|
||||||
|
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_y_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// In visual-line (multi-cursor) mode, the UI should highlight only the per-line
|
||||||
|
// cursor "spot" (Curx clamped to each line length), not the entire line.
|
||||||
|
[[nodiscard]] bool VisualLineSpotSelected(std::size_t y, std::size_t sx) const
|
||||||
|
{
|
||||||
|
if (!visual_line_active_)
|
||||||
|
return false;
|
||||||
|
if (y < VisualLineStartY() || y > VisualLineEndY())
|
||||||
|
return false;
|
||||||
|
std::string_view ln = GetLineView(y);
|
||||||
|
// `GetLineView()` returns the raw range, which may include a trailing '\n'.
|
||||||
|
if (!ln.empty() && ln.back() == '\n')
|
||||||
|
ln.remove_suffix(1);
|
||||||
|
const std::size_t spot = std::min(Curx(), ln.size());
|
||||||
|
return sx == spot;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::string AsString() const;
|
[[nodiscard]] std::string AsString() const;
|
||||||
|
|
||||||
// Syntax highlighting integration (per-buffer)
|
// Syntax highlighting integration (per-buffer)
|
||||||
@@ -428,6 +534,12 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
|
||||||
|
{
|
||||||
|
return swap_rec_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Raw, low-level editing APIs used by UndoSystem apply().
|
// Raw, low-level editing APIs used by UndoSystem apply().
|
||||||
// These must NOT trigger undo recording. They also do not move the cursor.
|
// These must NOT trigger undo recording. They also do not move the cursor.
|
||||||
void insert_text(int row, int col, std::string_view text);
|
void insert_text(int row, int col, std::string_view text);
|
||||||
@@ -442,12 +554,36 @@ public:
|
|||||||
|
|
||||||
void delete_row(int row);
|
void delete_row(int row);
|
||||||
|
|
||||||
|
// Replace the entire buffer content with raw bytes.
|
||||||
|
// Intended for crash recovery (swap replay) and test harnesses.
|
||||||
|
// This does not trigger swap or undo recording.
|
||||||
|
void replace_all_bytes(std::string_view bytes);
|
||||||
|
|
||||||
// Undo system accessors (created per-buffer)
|
// Undo system accessors (created per-buffer)
|
||||||
[[nodiscard]] UndoSystem *Undo();
|
[[nodiscard]] UndoSystem *Undo();
|
||||||
|
|
||||||
[[nodiscard]] const UndoSystem *Undo() const;
|
[[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:
|
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)
|
// State mirroring original C struct (without undo_tree)
|
||||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
std::size_t rx_ = 0; // render x (tabs expanded)
|
||||||
@@ -470,6 +606,9 @@ private:
|
|||||||
bool read_only_ = false;
|
bool read_only_ = false;
|
||||||
bool mark_set_ = false;
|
bool mark_set_ = false;
|
||||||
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
||||||
|
bool visual_line_active_ = false;
|
||||||
|
std::size_t visual_line_anchor_y_ = 0;
|
||||||
|
std::size_t visual_line_active_y_ = 0;
|
||||||
|
|
||||||
// Per-buffer undo state
|
// Per-buffer undo state
|
||||||
std::unique_ptr<struct UndoTree> undo_tree_;
|
std::unique_ptr<struct UndoTree> undo_tree_;
|
||||||
@@ -482,4 +621,6 @@ private:
|
|||||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||||
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
||||||
kte::SwapRecorder *swap_rec_ = nullptr;
|
kte::SwapRecorder *swap_rec_ = nullptr;
|
||||||
|
|
||||||
|
mutable std::mutex buffer_mutex_;
|
||||||
};
|
};
|
||||||
130
CMakeLists.txt
130
CMakeLists.txt
@@ -4,13 +4,13 @@ project(kte)
|
|||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(KTE_VERSION "1.5.0")
|
set(KTE_VERSION "1.7.0")
|
||||||
|
|
||||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||||
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
||||||
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
|
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
|
||||||
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.")
|
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
||||||
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
||||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
||||||
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
||||||
@@ -39,7 +39,6 @@ if (MSVC)
|
|||||||
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
|
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
|
||||||
else ()
|
else ()
|
||||||
add_compile_options(
|
add_compile_options(
|
||||||
"-static"
|
|
||||||
"-Wall"
|
"-Wall"
|
||||||
"-Wextra"
|
"-Wextra"
|
||||||
"-Werror"
|
"-Werror"
|
||||||
@@ -63,16 +62,24 @@ endif ()
|
|||||||
|
|
||||||
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
||||||
|
|
||||||
if (${BUILD_GUI})
|
if (BUILD_GUI)
|
||||||
include(cmake/imgui.cmake)
|
include(cmake/imgui.cmake)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
# NCurses for terminal mode
|
# NCurses for terminal mode
|
||||||
set(CURSES_NEED_NCURSES)
|
set(CURSES_NEED_NCURSES TRUE)
|
||||||
set(CURSES_NEED_WIDE)
|
set(CURSES_NEED_WIDE TRUE)
|
||||||
find_package(Curses REQUIRED)
|
find_package(Curses REQUIRED)
|
||||||
include_directories(${CURSES_INCLUDE_DIR})
|
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
|
set(SYNTAX_SOURCES
|
||||||
syntax/GoHighlighter.cc
|
syntax/GoHighlighter.cc
|
||||||
syntax/CppHighlighter.cc
|
syntax/CppHighlighter.cc
|
||||||
@@ -134,6 +141,9 @@ set(COMMON_SOURCES
|
|||||||
HelpText.cc
|
HelpText.cc
|
||||||
KKeymap.cc
|
KKeymap.cc
|
||||||
Swap.cc
|
Swap.cc
|
||||||
|
ErrorHandler.cc
|
||||||
|
SyscallWrappers.cc
|
||||||
|
ErrorRecovery.cc
|
||||||
TerminalInputHandler.cc
|
TerminalInputHandler.cc
|
||||||
TerminalRenderer.cc
|
TerminalRenderer.cc
|
||||||
TerminalFrontend.cc
|
TerminalFrontend.cc
|
||||||
@@ -208,6 +218,7 @@ set(FONT_HEADERS
|
|||||||
fonts/Syne.h
|
fonts/Syne.h
|
||||||
fonts/Triplicate.h
|
fonts/Triplicate.h
|
||||||
fonts/Unispace.h
|
fonts/Unispace.h
|
||||||
|
fonts/BerkeleyMono.h
|
||||||
)
|
)
|
||||||
|
|
||||||
set(COMMON_HEADERS
|
set(COMMON_HEADERS
|
||||||
@@ -255,6 +266,7 @@ if (BUILD_GUI)
|
|||||||
ImGuiFrontend.h
|
ImGuiFrontend.h
|
||||||
ImGuiInputHandler.h
|
ImGuiInputHandler.h
|
||||||
ImGuiRenderer.h
|
ImGuiRenderer.h
|
||||||
|
fonts/BerkeleyMono.h
|
||||||
)
|
)
|
||||||
endif ()
|
endif ()
|
||||||
endif ()
|
endif ()
|
||||||
@@ -272,6 +284,11 @@ endif ()
|
|||||||
|
|
||||||
target_link_libraries(kte ${CURSES_LIBRARIES})
|
target_link_libraries(kte ${CURSES_LIBRARIES})
|
||||||
|
|
||||||
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
|
if (NOT APPLE)
|
||||||
|
target_link_options(kte PRIVATE -static)
|
||||||
|
endif ()
|
||||||
|
|
||||||
if (KTE_ENABLE_TREESITTER)
|
if (KTE_ENABLE_TREESITTER)
|
||||||
# Users can provide their own tree-sitter include/lib via cache variables
|
# Users can provide their own tree-sitter include/lib via cache variables
|
||||||
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
|
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
|
||||||
@@ -292,30 +309,76 @@ install(TARGETS kte
|
|||||||
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
||||||
|
|
||||||
if (BUILD_TESTS)
|
if (BUILD_TESTS)
|
||||||
# test_undo executable for testing undo/redo system
|
# Unified unit test runner
|
||||||
add_executable(test_undo
|
add_executable(kte_tests
|
||||||
test_undo.cc
|
tests/TestRunner.cc
|
||||||
${COMMON_SOURCES}
|
tests/Test.h
|
||||||
${COMMON_HEADERS}
|
tests/TestHarness.h
|
||||||
|
tests/test_daily_driver_harness.cc
|
||||||
|
tests/test_daily_workflows.cc
|
||||||
|
tests/test_buffer_io.cc
|
||||||
|
tests/test_buffer_rows.cc
|
||||||
|
tests/test_command_semantics.cc
|
||||||
|
tests/test_kkeymap.cc
|
||||||
|
tests/test_swap_recorder.cc
|
||||||
|
tests/test_swap_writer.cc
|
||||||
|
tests/test_swap_replay.cc
|
||||||
|
tests/test_swap_edge_cases.cc
|
||||||
|
tests/test_swap_recovery_prompt.cc
|
||||||
|
tests/test_swap_cleanup.cc
|
||||||
|
tests/test_swap_git_editor.cc
|
||||||
|
tests/test_piece_table.cc
|
||||||
|
tests/test_search.cc
|
||||||
|
tests/test_search_replace_flow.cc
|
||||||
|
tests/test_reflow_paragraph.cc
|
||||||
|
tests/test_reflow_indented_bullets.cc
|
||||||
|
tests/test_undo.cc
|
||||||
|
tests/test_visual_line_mode.cc
|
||||||
|
tests/test_benchmarks.cc
|
||||||
|
tests/test_migration_coverage.cc
|
||||||
|
|
||||||
|
# minimal engine sources required by Buffer
|
||||||
|
PieceTable.cc
|
||||||
|
Buffer.cc
|
||||||
|
Editor.cc
|
||||||
|
Command.cc
|
||||||
|
HelpText.cc
|
||||||
|
Swap.cc
|
||||||
|
ErrorHandler.cc
|
||||||
|
SyscallWrappers.cc
|
||||||
|
ErrorRecovery.cc
|
||||||
|
KKeymap.cc
|
||||||
|
SwapRecorder.h
|
||||||
|
OptimizedSearch.cc
|
||||||
|
UndoNode.cc
|
||||||
|
UndoTree.cc
|
||||||
|
UndoSystem.cc
|
||||||
|
${SYNTAX_SOURCES}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (KTE_UNDO_DEBUG)
|
# Allow test-only introspection hooks (guarded in headers) without affecting production builds.
|
||||||
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1)
|
target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
|
||||||
endif ()
|
|
||||||
|
|
||||||
|
# Allow tests to include project headers like "Buffer.h"
|
||||||
|
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
target_link_libraries(test_undo ${CURSES_LIBRARIES})
|
# Keep tests free of ncurses/GUI deps
|
||||||
if (KTE_ENABLE_TREESITTER)
|
if (KTE_ENABLE_TREESITTER)
|
||||||
if (TREESITTER_INCLUDE_DIR)
|
if (TREESITTER_INCLUDE_DIR)
|
||||||
target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR})
|
target_include_directories(kte_tests PRIVATE ${TREESITTER_INCLUDE_DIR})
|
||||||
endif ()
|
endif ()
|
||||||
if (TREESITTER_LIBRARY)
|
if (TREESITTER_LIBRARY)
|
||||||
target_link_libraries(test_undo ${TREESITTER_LIBRARY})
|
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
|
||||||
endif ()
|
|
||||||
endif ()
|
endif ()
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
if (${BUILD_GUI})
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
|
if (NOT APPLE)
|
||||||
|
target_link_options(kte_tests PRIVATE -static)
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (BUILD_GUI)
|
||||||
# ImGui::CreateContext();
|
# ImGui::CreateContext();
|
||||||
# ImGuiIO& io = ImGui::GetIO();
|
# ImGuiIO& io = ImGui::GetIO();
|
||||||
|
|
||||||
@@ -352,6 +415,11 @@ if (${BUILD_GUI})
|
|||||||
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
|
if (NOT APPLE)
|
||||||
|
target_link_options(kge PRIVATE -static)
|
||||||
|
endif ()
|
||||||
|
|
||||||
# On macOS, build kge as a proper .app bundle
|
# On macOS, build kge as a proper .app bundle
|
||||||
if (APPLE)
|
if (APPLE)
|
||||||
# Define the icon file
|
# Define the icon file
|
||||||
@@ -370,12 +438,18 @@ if (${BUILD_GUI})
|
|||||||
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
|
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
|
||||||
@ONLY)
|
@ONLY)
|
||||||
|
|
||||||
|
# Ensure proper macOS bundle properties and RPATH so our bundled
|
||||||
|
# frameworks are preferred over system/Homebrew ones.
|
||||||
set_target_properties(kge PROPERTIES
|
set_target_properties(kge PROPERTIES
|
||||||
MACOSX_BUNDLE TRUE
|
MACOSX_BUNDLE TRUE
|
||||||
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
|
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
|
||||||
MACOSX_BUNDLE_BUNDLE_NAME "kge"
|
MACOSX_BUNDLE_BUNDLE_NAME "kge"
|
||||||
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
|
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
|
||||||
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist")
|
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist"
|
||||||
|
# Prefer the app's bundled frameworks at runtime
|
||||||
|
INSTALL_RPATH "@executable_path/../Frameworks"
|
||||||
|
BUILD_WITH_INSTALL_RPATH TRUE
|
||||||
|
)
|
||||||
|
|
||||||
add_dependencies(kge kte)
|
add_dependencies(kge kte)
|
||||||
add_custom_command(TARGET kge POST_BUILD
|
add_custom_command(TARGET kge POST_BUILD
|
||||||
@@ -399,4 +473,20 @@ if (${BUILD_GUI})
|
|||||||
# Install kge man page only when GUI is built
|
# Install kge man page only when GUI is built
|
||||||
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
||||||
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
|
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
|
||||||
|
|
||||||
|
# Optional post-build bundle fixup (can also be run from scripts).
|
||||||
|
# This provides a CMake target to run BundleUtilities' fixup_bundle on the
|
||||||
|
# built app, useful after macdeployqt to ensure non-Qt dylibs are internalized.
|
||||||
|
if (APPLE AND TARGET kge)
|
||||||
|
get_target_property(IS_BUNDLE kge MACOSX_BUNDLE)
|
||||||
|
if (IS_BUNDLE)
|
||||||
|
add_custom_target(kge_fixup_bundle ALL
|
||||||
|
COMMAND ${CMAKE_COMMAND}
|
||||||
|
-DAPP_BUNDLE=${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_PROPERTY:kge,MACOSX_BUNDLE_BUNDLE_NAME>.app
|
||||||
|
-P ${CMAKE_CURRENT_LIST_DIR}/cmake/fix_bundle.cmake
|
||||||
|
COMMENT "Running fixup_bundle on kge.app to internalize non-Qt dylibs"
|
||||||
|
VERBATIM)
|
||||||
|
add_dependencies(kge_fixup_bundle kge)
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
endif ()
|
endif ()
|
||||||
|
|||||||
794
Command.cc
794
Command.cc
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ enum class CommandId {
|
|||||||
VisualFontPickerToggle,
|
VisualFontPickerToggle,
|
||||||
// Buffers
|
// Buffers
|
||||||
BufferSwitchStart, // begin buffer switch prompt
|
BufferSwitchStart, // begin buffer switch prompt
|
||||||
|
BufferNew, // create a new empty, unnamed buffer (C-k i)
|
||||||
BufferClose,
|
BufferClose,
|
||||||
BufferNext,
|
BufferNext,
|
||||||
BufferPrev,
|
BufferPrev,
|
||||||
@@ -46,6 +47,7 @@ enum class CommandId {
|
|||||||
MoveFileStart, // move to beginning of file
|
MoveFileStart, // move to beginning of file
|
||||||
MoveFileEnd, // move to end of file
|
MoveFileEnd, // move to end of file
|
||||||
ToggleMark, // toggle mark at cursor
|
ToggleMark, // toggle mark at cursor
|
||||||
|
VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /)
|
||||||
JumpToMark, // jump to mark, set mark to previous cursor
|
JumpToMark, // jump to mark, set mark to previous cursor
|
||||||
KillRegion, // kill region between mark and cursor (to kill ring)
|
KillRegion, // kill region between mark and cursor (to kill ring)
|
||||||
CopyRegion, // copy region to kill ring (Alt-w)
|
CopyRegion, // copy region to kill ring (Alt-w)
|
||||||
|
|||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Minimal Dockerfile for building and testing kte on Linux
|
||||||
|
# This container provides a build environment with all dependencies.
|
||||||
|
# Mount the source tree at /kte when running the container.
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
g++ \
|
||||||
|
cmake \
|
||||||
|
make \
|
||||||
|
ncurses-dev \
|
||||||
|
sdl2-dev \
|
||||||
|
mesa-dev \
|
||||||
|
freetype-dev \
|
||||||
|
libx11-dev \
|
||||||
|
libxext-dev
|
||||||
|
|
||||||
|
# Set working directory where source will be mounted
|
||||||
|
WORKDIR /kte
|
||||||
|
|
||||||
|
# Default command: build and run tests
|
||||||
|
# Add DirectFB include path for SDL2 compatibility on Alpine
|
||||||
|
CMD ["sh", "-c", "cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON -DCMAKE_CXX_FLAGS='-I/usr/include/directfb' && cmake --build build --target kte && cmake --build build --target kge && cmake --build build --target kte_tests && ./build/kte_tests"]
|
||||||
250
Editor.cc
250
Editor.cc
@@ -1,6 +1,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <utility>
|
#include <cstdio>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "syntax/HighlighterRegistry.h"
|
||||||
@@ -8,6 +9,41 @@
|
|||||||
#include "syntax/NullHighlighter.h"
|
#include "syntax/NullHighlighter.h"
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
static std::string
|
||||||
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
|
{
|
||||||
|
const std::size_t nrows = b.Nrows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < nrows; i++) {
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
out.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
apply_pending_line(Editor &ed, const std::size_t line1)
|
||||||
|
{
|
||||||
|
if (line1 == 0)
|
||||||
|
return;
|
||||||
|
Buffer *b = ed.CurrentBuffer();
|
||||||
|
if (!b)
|
||||||
|
return;
|
||||||
|
const std::size_t nrows = b->Nrows();
|
||||||
|
std::size_t line = line1 > 0 ? line1 - 1 : 0; // 1-based to 0-based
|
||||||
|
if (nrows > 0) {
|
||||||
|
if (line >= nrows)
|
||||||
|
line = nrows - 1;
|
||||||
|
} else {
|
||||||
|
line = 0;
|
||||||
|
}
|
||||||
|
b->SetCursor(0, line);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
Editor::Editor()
|
Editor::Editor()
|
||||||
{
|
{
|
||||||
swap_ = std::make_unique<kte::SwapManager>();
|
swap_ = std::make_unique<kte::SwapManager>();
|
||||||
@@ -128,8 +164,8 @@ Editor::AddBuffer(const Buffer &buf)
|
|||||||
buffers_.push_back(buf);
|
buffers_.push_back(buf);
|
||||||
// Attach swap recorder
|
// Attach swap recorder
|
||||||
if (swap_) {
|
if (swap_) {
|
||||||
buffers_.back().SetSwapRecorder(swap_.get());
|
|
||||||
swap_->Attach(&buffers_.back());
|
swap_->Attach(&buffers_.back());
|
||||||
|
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
if (buffers_.size() == 1) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
@@ -143,8 +179,8 @@ Editor::AddBuffer(Buffer &&buf)
|
|||||||
{
|
{
|
||||||
buffers_.push_back(std::move(buf));
|
buffers_.push_back(std::move(buf));
|
||||||
if (swap_) {
|
if (swap_) {
|
||||||
buffers_.back().SetSwapRecorder(swap_.get());
|
|
||||||
swap_->Attach(&buffers_.back());
|
swap_->Attach(&buffers_.back());
|
||||||
|
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
if (buffers_.size() == 1) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
@@ -162,25 +198,24 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
Buffer &cur = buffers_[curbuf_];
|
Buffer &cur = buffers_[curbuf_];
|
||||||
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
||||||
const bool clean = !cur.Dirty();
|
const bool clean = !cur.Dirty();
|
||||||
const auto &rows = cur.Rows();
|
const std::size_t nrows = cur.Nrows();
|
||||||
const bool rows_empty = rows.empty();
|
const bool rows_empty = (nrows == 0);
|
||||||
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0);
|
const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
|
||||||
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
||||||
bool ok = cur.OpenFromFile(path, err);
|
bool ok = cur.OpenFromFile(path, err);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
return false;
|
return false;
|
||||||
// Ensure swap recorder is attached for this buffer
|
// Ensure swap recorder is attached for this buffer
|
||||||
if (swap_) {
|
if (swap_) {
|
||||||
cur.SetSwapRecorder(swap_.get());
|
|
||||||
swap_->Attach(&cur);
|
swap_->Attach(&cur);
|
||||||
|
cur.SetSwapRecorder(swap_->RecorderFor(&cur));
|
||||||
swap_->NotifyFilenameChanged(cur);
|
swap_->NotifyFilenameChanged(cur);
|
||||||
}
|
}
|
||||||
// Setup highlighting using registry (extension + shebang)
|
// Setup highlighting using registry (extension + shebang)
|
||||||
cur.EnsureHighlighter();
|
cur.EnsureHighlighter();
|
||||||
std::string first = "";
|
std::string first = "";
|
||||||
const auto &rows = cur.Rows();
|
if (cur.Nrows() > 0)
|
||||||
if (!rows.empty())
|
first = cur.GetLineString(0);
|
||||||
first = static_cast<std::string>(rows[0]);
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||||
if (!ft.empty()) {
|
if (!ft.empty()) {
|
||||||
cur.SetFiletype(ft);
|
cur.SetFiletype(ft);
|
||||||
@@ -197,6 +232,8 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
eng->InvalidateFrom(0);
|
eng->InvalidateFrom(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
|
CancelPrompt();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,20 +242,13 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
if (!b.OpenFromFile(path, err)) {
|
if (!b.OpenFromFile(path, err)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (swap_) {
|
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
||||||
b.SetSwapRecorder(swap_.get());
|
// final location (vector) because swap manager keys off Buffer*.
|
||||||
// path is known, notify
|
|
||||||
swap_->Attach(&b);
|
|
||||||
swap_->NotifyFilenameChanged(b);
|
|
||||||
}
|
|
||||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
||||||
b.EnsureHighlighter();
|
b.EnsureHighlighter();
|
||||||
std::string first = "";
|
std::string first = "";
|
||||||
{
|
if (b.Nrows() > 0)
|
||||||
const auto &rows = b.Rows();
|
first = b.GetLineString(0);
|
||||||
if (!rows.empty())
|
|
||||||
first = static_cast<std::string>(rows[0]);
|
|
||||||
}
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||||
if (!ft.empty()) {
|
if (!ft.empty()) {
|
||||||
b.SetFiletype(ft);
|
b.SetFiletype(ft);
|
||||||
@@ -237,11 +267,182 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
}
|
}
|
||||||
// Add as a new buffer and switch to it
|
// Add as a new buffer and switch to it
|
||||||
std::size_t idx = AddBuffer(std::move(b));
|
std::size_t idx = AddBuffer(std::move(b));
|
||||||
|
if (swap_) {
|
||||||
|
swap_->NotifyFilenameChanged(buffers_[idx]);
|
||||||
|
}
|
||||||
SwitchTo(idx);
|
SwitchTo(idx);
|
||||||
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
|
CancelPrompt();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Editor::RequestOpenFile(const std::string &path, const std::size_t line1)
|
||||||
|
{
|
||||||
|
PendingOpen p;
|
||||||
|
p.path = path;
|
||||||
|
p.line1 = line1;
|
||||||
|
pending_open_.push_back(std::move(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::HasPendingOpens() const
|
||||||
|
{
|
||||||
|
return !pending_open_.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Editor::RecoveryPromptKind
|
||||||
|
Editor::PendingRecoveryPrompt() const
|
||||||
|
{
|
||||||
|
return pending_recovery_prompt_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Editor::CancelRecoveryPrompt()
|
||||||
|
{
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||||
|
pending_recovery_open_ = PendingOpen{};
|
||||||
|
pending_recovery_swap_path_.clear();
|
||||||
|
pending_recovery_replay_err_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::ResolveRecoveryPrompt(const bool yes)
|
||||||
|
{
|
||||||
|
const RecoveryPromptKind kind = pending_recovery_prompt_;
|
||||||
|
if (kind == RecoveryPromptKind::None)
|
||||||
|
return false;
|
||||||
|
const PendingOpen req = pending_recovery_open_;
|
||||||
|
const std::string swp = pending_recovery_swap_path_;
|
||||||
|
const std::string rerr_s = pending_recovery_replay_err_;
|
||||||
|
CancelRecoveryPrompt();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
if (kind == RecoveryPromptKind::RecoverOrDiscard) {
|
||||||
|
if (yes) {
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Buffer *b = CurrentBuffer();
|
||||||
|
if (!b) {
|
||||||
|
SetStatus("Recovery failed: no buffer");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::string rerr;
|
||||||
|
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
|
||||||
|
SetStatus("Swap recovery failed: " + rerr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
b->SetDirty(true);
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Recovered " + req.path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Discard: best-effort delete swap, then open clean.
|
||||||
|
(void) std::remove(swp.c_str());
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Opened " + req.path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
||||||
|
if (yes) {
|
||||||
|
(void) std::remove(swp.c_str());
|
||||||
|
}
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
// Include a short hint that the swap was corrupt.
|
||||||
|
if (!rerr_s.empty()) {
|
||||||
|
SetStatus("Opened " + req.path + " (swap unreadable)");
|
||||||
|
} else {
|
||||||
|
SetStatus("Opened " + req.path);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::ProcessPendingOpens()
|
||||||
|
{
|
||||||
|
if (PromptActive())
|
||||||
|
return false;
|
||||||
|
if (pending_recovery_prompt_ != RecoveryPromptKind::None)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
bool opened_any = false;
|
||||||
|
while (!pending_open_.empty()) {
|
||||||
|
PendingOpen req = std::move(pending_open_.front());
|
||||||
|
pending_open_.pop_front();
|
||||||
|
if (req.path.empty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
|
||||||
|
bool swp_exists = false;
|
||||||
|
try {
|
||||||
|
swp_exists = !swp.empty() && std::filesystem::exists(std::filesystem::path(swp));
|
||||||
|
} catch (...) {
|
||||||
|
swp_exists = false;
|
||||||
|
}
|
||||||
|
if (swp_exists) {
|
||||||
|
Buffer tmp;
|
||||||
|
std::string oerr;
|
||||||
|
if (tmp.OpenFromFile(req.path, oerr)) {
|
||||||
|
const std::string orig = buffer_bytes_via_views(tmp);
|
||||||
|
std::string rerr;
|
||||||
|
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
|
||||||
|
const std::string rec = buffer_bytes_via_views(tmp);
|
||||||
|
if (rec != orig) {
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
|
||||||
|
pending_recovery_open_ = req;
|
||||||
|
pending_recovery_swap_path_ = swp;
|
||||||
|
StartPrompt(PromptKind::Confirm, "Recover", "");
|
||||||
|
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)");
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
|
||||||
|
pending_recovery_open_ = req;
|
||||||
|
pending_recovery_swap_path_ = swp;
|
||||||
|
pending_recovery_replay_err_ = rerr;
|
||||||
|
StartPrompt(PromptKind::Confirm, "Swap", "");
|
||||||
|
SetStatus(
|
||||||
|
"Swap file unreadable for " + req.path +
|
||||||
|
". Delete it? (y/N, C-g cancel)");
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
opened_any = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Opened " + req.path);
|
||||||
|
opened_any = true;
|
||||||
|
// Open at most one per call; frontends can call us again next frame.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
Editor::SwitchTo(std::size_t index)
|
Editor::SwitchTo(std::size_t index)
|
||||||
{
|
{
|
||||||
@@ -280,6 +481,13 @@ Editor::CloseBuffer(std::size_t index)
|
|||||||
if (index >= buffers_.size()) {
|
if (index >= buffers_.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (swap_) {
|
||||||
|
// 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));
|
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||||
if (buffers_.empty()) {
|
if (buffers_.empty()) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
|
|||||||
74
Editor.h
74
Editor.h
@@ -1,9 +1,47 @@
|
|||||||
/*
|
/*
|
||||||
* Editor.h - top-level editor state and buffer management
|
* 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
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <deque>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -497,6 +535,30 @@ public:
|
|||||||
|
|
||||||
bool OpenFile(const std::string &path, std::string &err);
|
bool OpenFile(const std::string &path, std::string &err);
|
||||||
|
|
||||||
|
// Request that a file be opened. The request is processed by calling
|
||||||
|
// ProcessPendingOpens() (typically once per frontend frame).
|
||||||
|
void RequestOpenFile(const std::string &path, std::size_t line1 = 0);
|
||||||
|
|
||||||
|
// If no modal prompt is active, process queued open requests.
|
||||||
|
// Returns true if a file was opened during this call.
|
||||||
|
bool ProcessPendingOpens();
|
||||||
|
|
||||||
|
[[nodiscard]] bool HasPendingOpens() const;
|
||||||
|
|
||||||
|
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
||||||
|
// prompt is active and the user's answer should be routed to ResolveRecoveryPrompt().
|
||||||
|
enum class RecoveryPromptKind {
|
||||||
|
None = 0,
|
||||||
|
RecoverOrDiscard, // y = recover swap, else discard swap and open clean
|
||||||
|
DeleteCorruptSwap // y = delete corrupt swap, else keep it
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] RecoveryPromptKind PendingRecoveryPrompt() const;
|
||||||
|
|
||||||
|
bool ResolveRecoveryPrompt(bool yes);
|
||||||
|
|
||||||
|
void CancelRecoveryPrompt();
|
||||||
|
|
||||||
// Buffer switching/closing
|
// Buffer switching/closing
|
||||||
bool SwitchTo(std::size_t index);
|
bool SwitchTo(std::size_t index);
|
||||||
|
|
||||||
@@ -550,6 +612,11 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct PendingOpen {
|
||||||
|
std::string path;
|
||||||
|
std::size_t line1{0}; // 1-based; 0 = none
|
||||||
|
};
|
||||||
|
|
||||||
std::size_t rows_ = 0, cols_ = 0;
|
std::size_t rows_ = 0, cols_ = 0;
|
||||||
int mode_ = 0;
|
int mode_ = 0;
|
||||||
int kill_ = 0; // KILL CHAIN
|
int kill_ = 0; // KILL CHAIN
|
||||||
@@ -593,6 +660,13 @@ private:
|
|||||||
std::string prompt_text_;
|
std::string prompt_text_;
|
||||||
std::string pending_overwrite_path_;
|
std::string pending_overwrite_path_;
|
||||||
|
|
||||||
|
// Deferred open + swap recovery prompt state
|
||||||
|
std::deque<PendingOpen> pending_open_;
|
||||||
|
RecoveryPromptKind pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||||
|
PendingOpen pending_recovery_open_{};
|
||||||
|
std::string pending_recovery_swap_path_;
|
||||||
|
std::string pending_recovery_replay_err_;
|
||||||
|
|
||||||
// GUI-only state (safe no-op in terminal builds)
|
// GUI-only state (safe no-op in terminal builds)
|
||||||
bool file_picker_visible_ = false;
|
bool file_picker_visible_ = false;
|
||||||
std::string file_picker_dir_;
|
std::string file_picker_dir_;
|
||||||
|
|||||||
318
ErrorHandler.cc
Normal file
318
ErrorHandler.cc
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
#include "ErrorHandler.h"
|
||||||
|
#include <chrono>
|
||||||
|
#include <ctime>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <sstream>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
ErrorHandler::ErrorHandler()
|
||||||
|
{
|
||||||
|
// Determine log file path: ~/.local/state/kte/error.log
|
||||||
|
const char *home = std::getenv("HOME");
|
||||||
|
if (home) {
|
||||||
|
fs::path log_dir = fs::path(home) / ".local" / "state" / "kte";
|
||||||
|
try {
|
||||||
|
if (!fs::exists(log_dir)) {
|
||||||
|
fs::create_directories(log_dir);
|
||||||
|
}
|
||||||
|
log_file_path_ = (log_dir / "error.log").string();
|
||||||
|
} catch (...) {
|
||||||
|
// If we can't create the directory, disable file logging
|
||||||
|
file_logging_enabled_ = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No HOME, disable file logging
|
||||||
|
file_logging_enabled_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ErrorHandler::~ErrorHandler()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
if (log_file_ &&log_file_
|
||||||
|
->
|
||||||
|
is_open()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
log_file_->flush();
|
||||||
|
log_file_->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ErrorHandler &
|
||||||
|
ErrorHandler::Instance()
|
||||||
|
{
|
||||||
|
static ErrorHandler instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Report(ErrorSeverity severity, const std::string &component,
|
||||||
|
const std::string &message, const std::string &context)
|
||||||
|
{
|
||||||
|
ErrorRecord record;
|
||||||
|
record.timestamp_ns = now_ns();
|
||||||
|
record.severity = severity;
|
||||||
|
record.component = component;
|
||||||
|
record.message = message;
|
||||||
|
record.context = context;
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
|
// Add to in-memory queue
|
||||||
|
errors_.push_back(record);
|
||||||
|
while (errors_.size() > 100) {
|
||||||
|
errors_.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
++total_error_count_;
|
||||||
|
if (severity == ErrorSeverity::Critical) {
|
||||||
|
++critical_error_count_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to log file if enabled
|
||||||
|
if (file_logging_enabled_) {
|
||||||
|
write_to_log(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Info(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context)
|
||||||
|
{
|
||||||
|
Report(ErrorSeverity::Info, component, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Warning(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context)
|
||||||
|
{
|
||||||
|
Report(ErrorSeverity::Warning, component, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Error(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context)
|
||||||
|
{
|
||||||
|
Report(ErrorSeverity::Error, component, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Critical(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context)
|
||||||
|
{
|
||||||
|
Report(ErrorSeverity::Critical, component, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
ErrorHandler::HasErrors() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
return !errors_.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
ErrorHandler::HasCriticalErrors() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
return critical_error_count_ > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
ErrorHandler::GetLastError() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
if (errors_.empty())
|
||||||
|
return "";
|
||||||
|
|
||||||
|
const ErrorRecord &e = errors_.back();
|
||||||
|
std::string result = "[" + severity_to_string(e.severity) + "] ";
|
||||||
|
result += e.component;
|
||||||
|
if (!e.context.empty()) {
|
||||||
|
result += " (" + e.context + ")";
|
||||||
|
}
|
||||||
|
result += ": " + e.message;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
ErrorHandler::GetErrorCount() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
return total_error_count_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
ErrorHandler::GetErrorCount(ErrorSeverity severity) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
std::size_t count = 0;
|
||||||
|
for (const auto &e: errors_) {
|
||||||
|
if (e.severity == severity) {
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<ErrorHandler::ErrorRecord>
|
||||||
|
ErrorHandler::GetRecentErrors(std::size_t max_count) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
std::vector<ErrorRecord> result;
|
||||||
|
result.reserve(std::min(max_count, errors_.size()));
|
||||||
|
|
||||||
|
// Return most recent first
|
||||||
|
auto it = errors_.rbegin();
|
||||||
|
for (std::size_t i = 0; i < max_count && it != errors_.rend(); ++i, ++it) {
|
||||||
|
result.push_back(*it);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::ClearErrors()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
errors_.clear();
|
||||||
|
total_error_count_ = 0;
|
||||||
|
critical_error_count_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::SetFileLoggingEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
file_logging_enabled_ = enabled;
|
||||||
|
if (!enabled && log_file_ && log_file_->is_open()) {
|
||||||
|
log_file_->flush();
|
||||||
|
log_file_->close();
|
||||||
|
log_file_.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
ErrorHandler::GetLogFilePath() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
return log_file_path_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::write_to_log(const ErrorRecord &record)
|
||||||
|
{
|
||||||
|
// Must be called with mtx_ held
|
||||||
|
if (log_file_path_.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
ensure_log_file();
|
||||||
|
if (!log_file_ || !log_file_->is_open())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Format: [timestamp] [SEVERITY] component (context): message
|
||||||
|
std::string timestamp = format_timestamp(record.timestamp_ns);
|
||||||
|
std::string severity = severity_to_string(record.severity);
|
||||||
|
|
||||||
|
*log_file_ << "[" << timestamp << "] [" << severity << "] " << record.component;
|
||||||
|
if (!record.context.empty()) {
|
||||||
|
*log_file_ << " (" << record.context << ")";
|
||||||
|
}
|
||||||
|
*log_file_ << ": " << record.message << "\n";
|
||||||
|
log_file_->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::ensure_log_file()
|
||||||
|
{
|
||||||
|
// Must be called with mtx_ held
|
||||||
|
if (log_file_ &&log_file_
|
||||||
|
->
|
||||||
|
is_open()
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (log_file_path_.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
log_file_ = std::make_unique<std::ofstream>(log_file_path_,
|
||||||
|
std::ios::app | std::ios::out);
|
||||||
|
if (!log_file_->is_open()) {
|
||||||
|
log_file_.reset();
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
log_file_.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
ErrorHandler::format_timestamp(std::uint64_t timestamp_ns) const
|
||||||
|
{
|
||||||
|
// Convert nanoseconds to time_t (seconds)
|
||||||
|
std::time_t seconds = static_cast<std::time_t>(timestamp_ns / 1000000000ULL);
|
||||||
|
std::uint64_t nanos = timestamp_ns % 1000000000ULL;
|
||||||
|
|
||||||
|
std::tm tm_buf{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
localtime_s(&tm_buf, &seconds);
|
||||||
|
#else
|
||||||
|
localtime_r(&seconds, &tm_buf);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::put_time(&tm_buf, "%Y-%m-%d %H:%M:%S");
|
||||||
|
oss << "." << std::setfill('0') << std::setw(3) << (nanos / 1000000ULL);
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
ErrorHandler::severity_to_string(ErrorSeverity severity) const
|
||||||
|
{
|
||||||
|
switch (severity) {
|
||||||
|
case ErrorSeverity::Info:
|
||||||
|
return "INFO";
|
||||||
|
case ErrorSeverity::Warning:
|
||||||
|
return "WARNING";
|
||||||
|
case ErrorSeverity::Error:
|
||||||
|
return "ERROR";
|
||||||
|
case ErrorSeverity::Critical:
|
||||||
|
return "CRITICAL";
|
||||||
|
default:
|
||||||
|
return "UNKNOWN";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::uint64_t
|
||||||
|
ErrorHandler::now_ns()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
return duration_cast<nanoseconds>(steady_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
} // namespace kte
|
||||||
106
ErrorHandler.h
Normal file
106
ErrorHandler.h
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// ErrorHandler.h - Centralized error handling and logging for kte
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <deque>
|
||||||
|
#include <mutex>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <fstream>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
enum class ErrorSeverity {
|
||||||
|
Info, // Informational messages
|
||||||
|
Warning, // Non-critical issues
|
||||||
|
Error, // Errors that affect functionality but allow continuation
|
||||||
|
Critical // Critical errors that may cause data loss or crashes
|
||||||
|
};
|
||||||
|
|
||||||
|
// Centralized error handler with logging and in-memory error tracking
|
||||||
|
class ErrorHandler {
|
||||||
|
public:
|
||||||
|
struct ErrorRecord {
|
||||||
|
std::uint64_t timestamp_ns{0};
|
||||||
|
ErrorSeverity severity{ErrorSeverity::Error};
|
||||||
|
std::string component; // e.g., "SwapManager", "Buffer", "main"
|
||||||
|
std::string message;
|
||||||
|
std::string context; // e.g., filename, buffer name, operation
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the global ErrorHandler instance
|
||||||
|
static ErrorHandler &Instance();
|
||||||
|
|
||||||
|
// Report an error with severity, component, message, and optional context
|
||||||
|
void Report(ErrorSeverity severity, const std::string &component,
|
||||||
|
const std::string &message, const std::string &context = "");
|
||||||
|
|
||||||
|
// Convenience methods for common severity levels
|
||||||
|
void Info(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context = "");
|
||||||
|
|
||||||
|
void Warning(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context = "");
|
||||||
|
|
||||||
|
void Error(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context = "");
|
||||||
|
|
||||||
|
void Critical(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context = "");
|
||||||
|
|
||||||
|
// Query error state (thread-safe)
|
||||||
|
bool HasErrors() const;
|
||||||
|
|
||||||
|
bool HasCriticalErrors() const;
|
||||||
|
|
||||||
|
std::string GetLastError() const;
|
||||||
|
|
||||||
|
std::size_t GetErrorCount() const;
|
||||||
|
|
||||||
|
std::size_t GetErrorCount(ErrorSeverity severity) const;
|
||||||
|
|
||||||
|
// Get recent errors (up to max_count, most recent first)
|
||||||
|
std::vector<ErrorRecord> GetRecentErrors(std::size_t max_count = 10) const;
|
||||||
|
|
||||||
|
// Clear in-memory error history (does not affect log file)
|
||||||
|
void ClearErrors();
|
||||||
|
|
||||||
|
// Enable/disable file logging (enabled by default)
|
||||||
|
void SetFileLoggingEnabled(bool enabled);
|
||||||
|
|
||||||
|
// Get the path to the error log file
|
||||||
|
std::string GetLogFilePath() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
ErrorHandler();
|
||||||
|
|
||||||
|
~ErrorHandler();
|
||||||
|
|
||||||
|
// Non-copyable, non-movable
|
||||||
|
ErrorHandler(const ErrorHandler &) = delete;
|
||||||
|
|
||||||
|
ErrorHandler &operator=(const ErrorHandler &) = delete;
|
||||||
|
|
||||||
|
ErrorHandler(ErrorHandler &&) = delete;
|
||||||
|
|
||||||
|
ErrorHandler &operator=(ErrorHandler &&) = delete;
|
||||||
|
|
||||||
|
void write_to_log(const ErrorRecord &record);
|
||||||
|
|
||||||
|
void ensure_log_file();
|
||||||
|
|
||||||
|
std::string format_timestamp(std::uint64_t timestamp_ns) const;
|
||||||
|
|
||||||
|
std::string severity_to_string(ErrorSeverity severity) const;
|
||||||
|
|
||||||
|
static std::uint64_t now_ns();
|
||||||
|
|
||||||
|
mutable std::mutex mtx_;
|
||||||
|
std::deque<ErrorRecord> errors_; // bounded to max 100 entries
|
||||||
|
std::size_t total_error_count_{0};
|
||||||
|
std::size_t critical_error_count_{0};
|
||||||
|
bool file_logging_enabled_{true};
|
||||||
|
std::string log_file_path_;
|
||||||
|
std::unique_ptr<std::ofstream> log_file_;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
157
ErrorRecovery.cc
Normal file
157
ErrorRecovery.cc
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// ErrorRecovery.cc - Error recovery mechanisms implementation
|
||||||
|
#include "ErrorRecovery.h"
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
CircuitBreaker::CircuitBreaker(const Config &cfg)
|
||||||
|
: config_(cfg), state_(State::Closed), failure_count_(0), success_count_(0),
|
||||||
|
last_failure_time_(std::chrono::steady_clock::time_point::min()),
|
||||||
|
state_change_time_(std::chrono::steady_clock::now()) {}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
CircuitBreaker::AllowRequest()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
switch (state_) {
|
||||||
|
case State::Closed:
|
||||||
|
// Normal operation, allow all requests
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case State::Open: {
|
||||||
|
// Check if timeout has elapsed to transition to HalfOpen
|
||||||
|
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
now - state_change_time_
|
||||||
|
);
|
||||||
|
if (elapsed >= config_.open_timeout) {
|
||||||
|
TransitionTo(State::HalfOpen);
|
||||||
|
return true; // Allow one request to test recovery
|
||||||
|
}
|
||||||
|
return false; // Circuit is open, reject request
|
||||||
|
}
|
||||||
|
|
||||||
|
case State::HalfOpen:
|
||||||
|
// Allow limited requests to test recovery
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
CircuitBreaker::RecordSuccess()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
|
switch (state_) {
|
||||||
|
case State::Closed:
|
||||||
|
// Reset failure count on success in normal operation
|
||||||
|
failure_count_ = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::HalfOpen:
|
||||||
|
++success_count_;
|
||||||
|
if (success_count_ >= config_.success_threshold) {
|
||||||
|
// Enough successes, close the circuit
|
||||||
|
TransitionTo(State::Closed);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::Open:
|
||||||
|
// Shouldn't happen (requests rejected), but handle gracefully
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
CircuitBreaker::RecordFailure()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
last_failure_time_ = now;
|
||||||
|
|
||||||
|
switch (state_) {
|
||||||
|
case State::Closed:
|
||||||
|
// Check if we need to reset the failure count (window expired)
|
||||||
|
if (IsWindowExpired()) {
|
||||||
|
failure_count_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
++failure_count_;
|
||||||
|
if (failure_count_ >= config_.failure_threshold) {
|
||||||
|
// Too many failures, open the circuit
|
||||||
|
TransitionTo(State::Open);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::HalfOpen:
|
||||||
|
// Failure during recovery test, reopen the circuit
|
||||||
|
TransitionTo(State::Open);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::Open:
|
||||||
|
// Already open, just track the failure
|
||||||
|
++failure_count_;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
CircuitBreaker::Reset()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
TransitionTo(State::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
CircuitBreaker::TransitionTo(State new_state)
|
||||||
|
{
|
||||||
|
if (state_ == new_state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state_ = new_state;
|
||||||
|
state_change_time_ = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
switch (new_state) {
|
||||||
|
case State::Closed:
|
||||||
|
failure_count_ = 0;
|
||||||
|
success_count_ = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::Open:
|
||||||
|
success_count_ = 0;
|
||||||
|
// Keep failure_count_ for diagnostics
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::HalfOpen:
|
||||||
|
success_count_ = 0;
|
||||||
|
// Keep failure_count_ for diagnostics
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
CircuitBreaker::IsWindowExpired() const
|
||||||
|
{
|
||||||
|
if (failure_count_ == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
now - last_failure_time_
|
||||||
|
);
|
||||||
|
|
||||||
|
return elapsed >= config_.window;
|
||||||
|
}
|
||||||
|
} // namespace kte
|
||||||
170
ErrorRecovery.h
Normal file
170
ErrorRecovery.h
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// ErrorRecovery.h - Error recovery mechanisms for kte
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <mutex>
|
||||||
|
#include <cerrno>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
// Classify errno values as transient (retryable) or permanent
|
||||||
|
inline bool
|
||||||
|
IsTransientError(int err)
|
||||||
|
{
|
||||||
|
switch (err) {
|
||||||
|
case EAGAIN:
|
||||||
|
#if EAGAIN != EWOULDBLOCK
|
||||||
|
case EWOULDBLOCK:
|
||||||
|
#endif
|
||||||
|
case EBUSY:
|
||||||
|
case EIO: // I/O error (may be transient on network filesystems)
|
||||||
|
case ETIMEDOUT:
|
||||||
|
case ENOSPC: // Disk full (may become available)
|
||||||
|
case EDQUOT: // Quota exceeded (may become available)
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// RetryPolicy defines retry behavior for transient failures
|
||||||
|
struct RetryPolicy {
|
||||||
|
std::size_t max_attempts{3}; // Maximum retry attempts
|
||||||
|
std::chrono::milliseconds initial_delay{100}; // Initial delay before first retry
|
||||||
|
double backoff_multiplier{2.0}; // Exponential backoff multiplier
|
||||||
|
std::chrono::milliseconds max_delay{5000}; // Maximum delay between retries
|
||||||
|
|
||||||
|
// Default policy: 3 attempts, 100ms initial, 2x backoff, 5s max
|
||||||
|
static RetryPolicy Default()
|
||||||
|
{
|
||||||
|
return RetryPolicy{};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Aggressive policy for critical operations: more attempts, faster retries
|
||||||
|
static RetryPolicy Aggressive()
|
||||||
|
{
|
||||||
|
return RetryPolicy{5, std::chrono::milliseconds(50), 1.5, std::chrono::milliseconds(2000)};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Conservative policy for non-critical operations: fewer attempts, slower retries
|
||||||
|
static RetryPolicy Conservative()
|
||||||
|
{
|
||||||
|
return RetryPolicy{2, std::chrono::milliseconds(200), 2.5, std::chrono::milliseconds(10000)};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retry a function with exponential backoff for transient errors
|
||||||
|
// Returns true on success, false on permanent failure or exhausted retries
|
||||||
|
// The function `fn` should return true on success, false on failure, and set errno on failure
|
||||||
|
template<typename Func>
|
||||||
|
bool
|
||||||
|
RetryOnTransientError(Func fn, const RetryPolicy &policy, std::string &err)
|
||||||
|
{
|
||||||
|
std::size_t attempt = 0;
|
||||||
|
std::chrono::milliseconds delay = policy.initial_delay;
|
||||||
|
|
||||||
|
while (attempt < policy.max_attempts) {
|
||||||
|
++attempt;
|
||||||
|
errno = 0;
|
||||||
|
if (fn()) {
|
||||||
|
return true; // Success
|
||||||
|
}
|
||||||
|
|
||||||
|
int saved_errno = errno;
|
||||||
|
if (!IsTransientError(saved_errno)) {
|
||||||
|
// Permanent error, don't retry
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt >= policy.max_attempts) {
|
||||||
|
// Exhausted retries
|
||||||
|
err += " (exhausted " + std::to_string(policy.max_attempts) + " retry attempts)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep before retry
|
||||||
|
std::this_thread::sleep_for(delay);
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
delay = std::chrono::milliseconds(
|
||||||
|
static_cast<long long>(delay.count() * policy.backoff_multiplier)
|
||||||
|
);
|
||||||
|
if (delay > policy.max_delay) {
|
||||||
|
delay = policy.max_delay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// CircuitBreaker prevents repeated attempts to failing operations
|
||||||
|
// States: Closed (normal), Open (failing, reject immediately), HalfOpen (testing recovery)
|
||||||
|
class CircuitBreaker {
|
||||||
|
public:
|
||||||
|
enum class State {
|
||||||
|
Closed, // Normal operation, allow all requests
|
||||||
|
Open, // Failing, reject requests immediately
|
||||||
|
HalfOpen // Testing recovery, allow limited requests
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
std::size_t failure_threshold; // Failures before opening circuit
|
||||||
|
std::chrono::seconds open_timeout; // Time before attempting recovery (Open → HalfOpen)
|
||||||
|
std::size_t success_threshold; // Successes in HalfOpen before closing
|
||||||
|
std::chrono::seconds window; // Time window for counting failures
|
||||||
|
|
||||||
|
Config()
|
||||||
|
: failure_threshold(5), open_timeout(30), success_threshold(2), window(60) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
explicit CircuitBreaker(const Config &cfg = Config());
|
||||||
|
|
||||||
|
|
||||||
|
// Check if operation is allowed (returns false if circuit is Open)
|
||||||
|
bool AllowRequest();
|
||||||
|
|
||||||
|
// Record successful operation
|
||||||
|
void RecordSuccess();
|
||||||
|
|
||||||
|
// Record failed operation
|
||||||
|
void RecordFailure();
|
||||||
|
|
||||||
|
// Get current state
|
||||||
|
State GetState() const
|
||||||
|
{
|
||||||
|
return state_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Get failure count in current window
|
||||||
|
std::size_t GetFailureCount() const
|
||||||
|
{
|
||||||
|
return failure_count_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Reset circuit to Closed state (for testing or manual intervention)
|
||||||
|
void Reset();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void TransitionTo(State new_state);
|
||||||
|
|
||||||
|
bool IsWindowExpired() const;
|
||||||
|
|
||||||
|
Config config_;
|
||||||
|
State state_;
|
||||||
|
std::size_t failure_count_;
|
||||||
|
std::size_t success_count_;
|
||||||
|
std::chrono::steady_clock::time_point last_failure_time_;
|
||||||
|
std::chrono::steady_clock::time_point state_change_time_;
|
||||||
|
mutable std::mutex mtx_;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
@@ -12,7 +12,7 @@ public:
|
|||||||
virtual ~Frontend() = default;
|
virtual ~Frontend() = default;
|
||||||
|
|
||||||
// Initialize the frontend (create window/terminal, etc.)
|
// Initialize the frontend (create window/terminal, etc.)
|
||||||
virtual bool Init(Editor &ed) = 0;
|
virtual bool Init(int &argc, char **argv, Editor &ed) = 0;
|
||||||
|
|
||||||
// Execute one iteration (poll input, dispatch, draw). Set running=false to exit.
|
// Execute one iteration (poll input, dispatch, draw). Set running=false to exit.
|
||||||
virtual void Step(Editor &ed, bool &running) = 0;
|
virtual void Step(Editor &ed, bool &running) = 0;
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ HelpText::Text()
|
|||||||
" C-k ' Toggle read-only\n"
|
" C-k ' Toggle read-only\n"
|
||||||
" C-k - Unindent region (mark required)\n"
|
" C-k - Unindent region (mark required)\n"
|
||||||
" C-k = Indent 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 ; Command prompt (:\\ )\n"
|
||||||
|
" C-k SPACE Toggle mark\n"
|
||||||
" C-k C-d Kill entire line\n"
|
" C-k C-d Kill entire line\n"
|
||||||
" C-k C-q Quit now (no confirm)\n"
|
" C-k C-q Quit now (no confirm)\n"
|
||||||
" C-k C-x Save and quit\n"
|
" C-k C-x Save and quit\n"
|
||||||
@@ -34,7 +36,9 @@ HelpText::Text()
|
|||||||
" C-k f Flush kill ring\n"
|
" C-k f Flush kill ring\n"
|
||||||
" C-k g Jump to line\n"
|
" C-k g Jump to line\n"
|
||||||
" C-k h Show this help\n"
|
" C-k h Show this help\n"
|
||||||
|
" C-k i New empty buffer\n"
|
||||||
" C-k j Jump to mark\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 l Reload buffer from disk\n"
|
||||||
" C-k n Previous buffer\n"
|
" C-k n Previous buffer\n"
|
||||||
" C-k o Change working directory (prompt)\n"
|
" C-k o Change working directory (prompt)\n"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include "GUITheme.h"
|
#include "GUITheme.h"
|
||||||
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
||||||
#include "fonts/FontRegistry.h"
|
#include "fonts/FontRegistry.h"
|
||||||
|
#include "fonts/IosevkaExtended.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "syntax/HighlighterRegistry.h"
|
||||||
#include "syntax/NullHighlighter.h"
|
#include "syntax/NullHighlighter.h"
|
||||||
|
|
||||||
@@ -29,8 +30,10 @@
|
|||||||
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
|
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
|
||||||
|
|
||||||
bool
|
bool
|
||||||
GUIFrontend::Init(Editor &ed)
|
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||||
{
|
{
|
||||||
|
(void) argc;
|
||||||
|
(void) argv;
|
||||||
// Attach editor to input handler for editor-owned features (e.g., universal argument)
|
// Attach editor to input handler for editor-owned features (e.g., universal argument)
|
||||||
input_.Attach(&ed);
|
input_.Attach(&ed);
|
||||||
// editor dimensions will be initialized during the first Step() frame
|
// editor dimensions will be initialized during the first Step() frame
|
||||||
@@ -262,10 +265,10 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
// Update editor logical rows/cols using current ImGui metrics and display size
|
// Update editor logical rows/cols using current ImGui metrics and display size
|
||||||
{
|
{
|
||||||
ImGuiIO &io = ImGui::GetIO();
|
ImGuiIO &io = ImGui::GetIO();
|
||||||
float line_h = ImGui::GetTextLineHeightWithSpacing();
|
float row_h = ImGui::GetTextLineHeightWithSpacing();
|
||||||
float ch_w = ImGui::CalcTextSize("M").x;
|
float ch_w = ImGui::CalcTextSize("M").x;
|
||||||
if (line_h <= 0.0f)
|
if (row_h <= 0.0f)
|
||||||
line_h = 16.0f;
|
row_h = 16.0f;
|
||||||
if (ch_w <= 0.0f)
|
if (ch_w <= 0.0f)
|
||||||
ch_w = 8.0f;
|
ch_w = 8.0f;
|
||||||
// Prefer ImGui IO display size; fall back to cached SDL window size
|
// Prefer ImGui IO display size; fall back to cached SDL window size
|
||||||
@@ -273,20 +276,20 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_);
|
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_);
|
||||||
|
|
||||||
// Account for the GUI window padding and the status bar height used in ImGuiRenderer.
|
// Account for the GUI window padding and the status bar height used in ImGuiRenderer.
|
||||||
// ImGuiRenderer pushes WindowPadding = (6,6) every frame, so use the same constants here
|
|
||||||
// to avoid mismatches that would cause premature scrolling.
|
|
||||||
const float pad_x = 6.0f;
|
const float pad_x = 6.0f;
|
||||||
const float pad_y = 6.0f;
|
const float pad_y = 6.0f;
|
||||||
// Status bar reserves one frame height (with spacing) inside the window
|
|
||||||
float status_h = ImGui::GetFrameHeightWithSpacing();
|
|
||||||
|
|
||||||
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
|
// Use the same logic as ImGuiRenderer for available height and status bar reservation.
|
||||||
float avail_h = std::max(0.0f, disp_h - 2.0f * pad_y - status_h);
|
float wanted_bar_h = ImGui::GetFrameHeight();
|
||||||
|
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
|
||||||
|
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
|
||||||
|
|
||||||
// Visible content rows inside the scroll child
|
// Visible content rows inside the scroll child
|
||||||
auto content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h));
|
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
|
||||||
// Editor::Rows includes the status line; add 1 back for it.
|
// Editor::Rows includes the status line; add 1 back for it.
|
||||||
std::size_t rows = std::max<std::size_t>(1, content_rows + 1);
|
std::size_t rows = content_rows + 1;
|
||||||
|
|
||||||
|
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
|
||||||
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
||||||
|
|
||||||
// Only update if changed to avoid churn
|
// Only update if changed to avoid churn
|
||||||
@@ -295,6 +298,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
||||||
for (;;) {
|
for (;;) {
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
@@ -357,14 +363,32 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
|||||||
{
|
{
|
||||||
const ImGuiIO &io = ImGui::GetIO();
|
const ImGuiIO &io = ImGui::GetIO();
|
||||||
io.Fonts->Clear();
|
io.Fonts->Clear();
|
||||||
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
|
|
||||||
|
ImFontConfig config;
|
||||||
|
config.MergeMode = false;
|
||||||
|
|
||||||
|
// Load Basic Latin + Latin Supplement
|
||||||
|
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||||
kte::Fonts::DefaultFontData,
|
kte::Fonts::DefaultFontData,
|
||||||
kte::Fonts::DefaultFontSize,
|
kte::Fonts::DefaultFontSize,
|
||||||
size_px);
|
size_px,
|
||||||
if (!font) {
|
&config,
|
||||||
font = io.Fonts->AddFontDefault();
|
io.Fonts->GetGlyphRangesDefault());
|
||||||
}
|
|
||||||
(void) font;
|
// Merge Greek and Mathematical symbols from IosevkaExtended
|
||||||
|
config.MergeMode = true;
|
||||||
|
static const ImWchar extended_ranges[] = {
|
||||||
|
0x0370, 0x03FF, // Greek and Coptic
|
||||||
|
0x2200, 0x22FF, // Mathematical Operators
|
||||||
|
0,
|
||||||
|
};
|
||||||
|
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||||
|
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedData,
|
||||||
|
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedSize,
|
||||||
|
size_px,
|
||||||
|
&config,
|
||||||
|
extended_ranges);
|
||||||
|
|
||||||
io.Fonts->Build();
|
io.Fonts->Build();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ public:
|
|||||||
|
|
||||||
~GUIFrontend() override = default;
|
~GUIFrontend() override = default;
|
||||||
|
|
||||||
bool Init(Editor &ed) override;
|
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||||
|
|
||||||
void Step(Editor &ed, bool &running) override;
|
void Step(Editor &ed, bool &running) override;
|
||||||
|
|
||||||
|
|||||||
@@ -158,8 +158,9 @@ map_key(const SDL_Keycode key,
|
|||||||
ascii_key = static_cast<int>(key);
|
ascii_key = static_cast<int>(key);
|
||||||
}
|
}
|
||||||
bool ctrl2 = (mod & KMOD_CTRL) != 0;
|
bool ctrl2 = (mod & KMOD_CTRL) != 0;
|
||||||
// If user typed a literal 'C' (or '^') as a control qualifier, keep k-prefix active
|
// If user typed a literal 'C' (uppercase) or '^' as a control qualifier, keep k-prefix active
|
||||||
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
|
// Do NOT treat lowercase 'c' as a qualifier; 'c' is a valid k-command (BufferClose).
|
||||||
|
if (ascii_key == 'C' || ascii_key == '^') {
|
||||||
k_ctrl_pending = true;
|
k_ctrl_pending = true;
|
||||||
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
|
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
|
||||||
if (ed)
|
if (ed)
|
||||||
@@ -441,11 +442,9 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
if (ed_ && ed_
|
if (ed_ && ed_
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
->
|
->
|
||||||
UArg() != 0
|
UArg() != 0
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
const char *txt = e.text.text;
|
const char *txt = e.text.text;
|
||||||
if (txt && *txt) {
|
if (txt && *txt) {
|
||||||
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
||||||
@@ -472,8 +471,8 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
ascii_key = static_cast<int>(c0);
|
ascii_key = static_cast<int>(c0);
|
||||||
}
|
}
|
||||||
if (ascii_key != 0) {
|
if (ascii_key != 0) {
|
||||||
// Qualifier via TEXTINPUT: 'C' or '^'
|
// Qualifier via TEXTINPUT: uppercase 'C' or '^' only
|
||||||
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
|
if (ascii_key == 'C' || ascii_key == '^') {
|
||||||
k_ctrl_pending_ = true;
|
k_ctrl_pending_ = true;
|
||||||
if (ed_)
|
if (ed_)
|
||||||
ed_->SetStatus("C-k C _");
|
ed_->SetStatus("C-k C _");
|
||||||
|
|||||||
373
ImGuiRenderer.cc
373
ImGuiRenderer.cc
@@ -94,8 +94,17 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
|
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserve space for status bar at bottom
|
// Reserve space for status bar at bottom.
|
||||||
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false,
|
// We calculate a height that is an exact multiple of the line height
|
||||||
|
// to avoid partial lines and "scroll past end" jitter.
|
||||||
|
float total_avail_h = ImGui::GetContentRegionAvail().y;
|
||||||
|
float wanted_bar_h = ImGui::GetFrameHeight();
|
||||||
|
float child_h_plan = std::max(0.0f, std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h);
|
||||||
|
float real_bar_h = total_avail_h - child_h_plan;
|
||||||
|
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
||||||
|
ImGui::BeginChild("scroll", ImVec2(0, child_h_plan), false,
|
||||||
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||||
|
|
||||||
// Get child window position and scroll for click handling
|
// Get child window position and scroll for click handling
|
||||||
@@ -138,160 +147,87 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
prev_buf_rowoffs = buf_rowoffs;
|
prev_buf_rowoffs = buf_rowoffs;
|
||||||
prev_buf_coloffs = buf_coloffs;
|
prev_buf_coloffs = buf_coloffs;
|
||||||
|
|
||||||
// Synchronize cursor and scrolling.
|
|
||||||
// Ensure the cursor is visible, but avoid aggressive centering so that
|
|
||||||
// the same lines remain visible until the cursor actually goes off-screen.
|
|
||||||
{
|
|
||||||
// Compute visible row range using the child window height
|
|
||||||
float child_h = ImGui::GetWindowHeight();
|
|
||||||
long first_row = static_cast<long>(scroll_y / row_h);
|
|
||||||
long vis_rows = static_cast<long>(child_h / row_h);
|
|
||||||
if (vis_rows < 1)
|
|
||||||
vis_rows = 1;
|
|
||||||
long last_row = first_row + vis_rows - 1;
|
|
||||||
|
|
||||||
long cyr = static_cast<long>(cy);
|
|
||||||
if (cyr < first_row) {
|
|
||||||
// Scroll just enough to bring the cursor line to the top
|
|
||||||
float target = static_cast<float>(cyr) * row_h;
|
|
||||||
if (target < 0.f)
|
|
||||||
target = 0.f;
|
|
||||||
float max_y = ImGui::GetScrollMaxY();
|
|
||||||
if (max_y >= 0.f && target > max_y)
|
|
||||||
target = max_y;
|
|
||||||
ImGui::SetScrollY(target);
|
|
||||||
scroll_y = ImGui::GetScrollY();
|
|
||||||
first_row = static_cast<long>(scroll_y / row_h);
|
|
||||||
last_row = first_row + vis_rows - 1;
|
|
||||||
} else if (cyr > last_row) {
|
|
||||||
// Scroll just enough to bring the cursor line to the bottom
|
|
||||||
long new_first = cyr - vis_rows + 1;
|
|
||||||
if (new_first < 0)
|
|
||||||
new_first = 0;
|
|
||||||
float target = static_cast<float>(new_first) * row_h;
|
|
||||||
float max_y = ImGui::GetScrollMaxY();
|
|
||||||
if (target < 0.f)
|
|
||||||
target = 0.f;
|
|
||||||
if (max_y >= 0.f && target > max_y)
|
|
||||||
target = max_y;
|
|
||||||
ImGui::SetScrollY(target);
|
|
||||||
scroll_y = ImGui::GetScrollY();
|
|
||||||
first_row = static_cast<long>(scroll_y / row_h);
|
|
||||||
last_row = first_row + vis_rows - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal scroll: ensure cursor column is visible
|
|
||||||
float child_w = ImGui::GetWindowWidth();
|
|
||||||
long vis_cols = static_cast<long>(child_w / space_w);
|
|
||||||
if (vis_cols < 1)
|
|
||||||
vis_cols = 1;
|
|
||||||
long first_col = static_cast<long>(scroll_x / space_w);
|
|
||||||
long last_col = first_col + vis_cols - 1;
|
|
||||||
|
|
||||||
// Compute cursor's rendered X position (accounting for tabs)
|
|
||||||
std::size_t cursor_rx = 0;
|
|
||||||
if (cy < lines.size()) {
|
|
||||||
std::string cur_line = static_cast<std::string>(lines[cy]);
|
|
||||||
const std::size_t tabw = 8;
|
|
||||||
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
|
|
||||||
if (cur_line[i] == '\t') {
|
|
||||||
cursor_rx += tabw - (cursor_rx % tabw);
|
|
||||||
} else {
|
|
||||||
cursor_rx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
long cxr = static_cast<long>(cursor_rx);
|
|
||||||
if (cxr < first_col || cxr > last_col) {
|
|
||||||
float target_x = static_cast<float>(cxr) * space_w;
|
|
||||||
// Center horizontally if possible
|
|
||||||
target_x -= (child_w / 2.0f);
|
|
||||||
if (target_x < 0.f)
|
|
||||||
target_x = 0.f;
|
|
||||||
float max_x = ImGui::GetScrollMaxX();
|
|
||||||
if (max_x >= 0.f && target_x > max_x)
|
|
||||||
target_x = max_x;
|
|
||||||
ImGui::SetScrollX(target_x);
|
|
||||||
scroll_x = ImGui::GetScrollX();
|
|
||||||
}
|
|
||||||
// Phase 3: prefetch visible viewport highlights and warm around in background
|
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
|
||||||
int fr = static_cast<int>(std::max(0L, first_row));
|
|
||||||
int rc = static_cast<int>(std::max(1L, vis_rows));
|
|
||||||
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Cache current horizontal offset in rendered columns for click handling
|
// Cache current horizontal offset in rendered columns for click handling
|
||||||
const std::size_t coloffs_now = buf->Coloffs();
|
const std::size_t coloffs_now = buf->Coloffs();
|
||||||
|
|
||||||
// Handle mouse click before rendering to avoid dependent on drawn items
|
// Mark selection state (mark -> cursor), in source coordinates
|
||||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
bool sel_active = false;
|
||||||
|
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
|
||||||
|
if (buf->MarkSet()) {
|
||||||
|
sel_sy = buf->MarkCury();
|
||||||
|
sel_sx = buf->MarkCurx();
|
||||||
|
sel_ey = buf->Cury();
|
||||||
|
sel_ex = buf->Curx();
|
||||||
|
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
|
||||||
|
std::swap(sel_sy, sel_ey);
|
||||||
|
std::swap(sel_sx, sel_ex);
|
||||||
|
}
|
||||||
|
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
|
||||||
|
}
|
||||||
|
// Visual-line selection: full-line highlight range
|
||||||
|
const bool vsel_active = buf->VisualLineActive();
|
||||||
|
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
||||||
|
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
||||||
|
|
||||||
|
static bool mouse_selecting = false;
|
||||||
|
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
|
||||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||||
// Compute content-relative position accounting for scroll
|
// Convert mouse pos to buffer row
|
||||||
// mp.y - child_window_pos.y gives us pixels from top of child window
|
|
||||||
// Adding scroll_y gives us pixels from top of content (buffer row 0)
|
|
||||||
float content_y = (mp.y - child_window_pos.y) + scroll_y;
|
float content_y = (mp.y - child_window_pos.y) + scroll_y;
|
||||||
long by_l = static_cast<long>(content_y / row_h);
|
long by_l = static_cast<long>(content_y / row_h);
|
||||||
if (by_l < 0)
|
if (by_l < 0)
|
||||||
by_l = 0;
|
by_l = 0;
|
||||||
|
|
||||||
// Convert to buffer row
|
|
||||||
std::size_t by = static_cast<std::size_t>(by_l);
|
std::size_t by = static_cast<std::size_t>(by_l);
|
||||||
if (by >= lines.size()) {
|
if (by >= lines.size())
|
||||||
if (!lines.empty())
|
by = lines.empty() ? 0 : (lines.size() - 1);
|
||||||
by = lines.size() - 1;
|
|
||||||
else
|
|
||||||
by = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute click X position relative to left edge of child window (in pixels)
|
// Convert mouse pos to rendered x
|
||||||
// This gives us the visual offset from the start of displayed content
|
|
||||||
float visual_x = mp.x - child_window_pos.x;
|
float visual_x = mp.x - child_window_pos.x;
|
||||||
if (visual_x < 0.0f)
|
if (visual_x < 0.0f)
|
||||||
visual_x = 0.0f;
|
visual_x = 0.0f;
|
||||||
|
|
||||||
// Convert visual pixel offset to rendered column, then add coloffs_now
|
|
||||||
// to get the absolute rendered column in the buffer
|
|
||||||
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
|
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
|
||||||
|
|
||||||
// Empty buffer guard: if there are no lines yet, just move to 0:0
|
// Convert rendered column to source column
|
||||||
if (lines.empty()) {
|
if (lines.empty())
|
||||||
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
|
return {0, 0};
|
||||||
} else {
|
|
||||||
// Convert rendered column (clicked_rx) to source column accounting for tabs
|
|
||||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
std::string line_clicked = static_cast<std::string>(lines[by]);
|
||||||
const std::size_t tabw = 8;
|
const std::size_t tabw = 8;
|
||||||
|
std::size_t rx = 0;
|
||||||
// Iterate through source columns, computing rendered position, to find closest match
|
|
||||||
std::size_t rx = 0; // rendered column position
|
|
||||||
std::size_t best_col = 0;
|
std::size_t best_col = 0;
|
||||||
float best_dist = std::numeric_limits<float>::infinity();
|
float best_dist = std::numeric_limits<float>::infinity();
|
||||||
float clicked_rx_f = static_cast<float>(clicked_rx);
|
float clicked_rx_f = static_cast<float>(clicked_rx);
|
||||||
|
|
||||||
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
|
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
|
||||||
// Check current position
|
|
||||||
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
|
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
|
||||||
if (dist < best_dist) {
|
if (dist < best_dist) {
|
||||||
best_dist = dist;
|
best_dist = dist;
|
||||||
best_col = i;
|
best_col = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance to next position if not at end
|
|
||||||
if (i < line_clicked.size()) {
|
if (i < line_clicked.size()) {
|
||||||
if (line_clicked[i] == '\t') {
|
rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1;
|
||||||
rx += (tabw - (rx % tabw));
|
|
||||||
} else {
|
|
||||||
rx += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return {by, best_col};
|
||||||
|
};
|
||||||
|
|
||||||
// Dispatch absolute buffer coordinates (row:col)
|
// Mouse-driven selection: set mark on press, update cursor on drag
|
||||||
|
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||||
|
mouse_selecting = true;
|
||||||
|
auto [by, bx] = mouse_pos_to_buf();
|
||||||
char tmp[64];
|
char tmp[64];
|
||||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
|
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||||
|
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||||
|
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||||
|
mbuf->SetMark(bx, by);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||||
|
auto [by, bx] = mouse_pos_to_buf();
|
||||||
|
char tmp[64];
|
||||||
|
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||||
}
|
}
|
||||||
|
if (mouse_selecting && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
||||||
|
mouse_selecting = false;
|
||||||
}
|
}
|
||||||
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
||||||
// Capture the screen position before drawing the line
|
// Capture the screen position before drawing the line
|
||||||
@@ -370,6 +306,71 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw selection background (over search highlight; under text)
|
||||||
|
if (sel_active) {
|
||||||
|
bool line_has = false;
|
||||||
|
std::size_t sx = 0, ex = 0;
|
||||||
|
if (i < sel_sy || i > sel_ey) {
|
||||||
|
line_has = false;
|
||||||
|
} else if (sel_sy == sel_ey) {
|
||||||
|
sx = sel_sx;
|
||||||
|
ex = sel_ex;
|
||||||
|
line_has = ex > sx;
|
||||||
|
} else if (i == sel_sy) {
|
||||||
|
sx = sel_sx;
|
||||||
|
ex = line.size();
|
||||||
|
line_has = ex > sx;
|
||||||
|
} else if (i == sel_ey) {
|
||||||
|
sx = 0;
|
||||||
|
ex = std::min(sel_ex, line.size());
|
||||||
|
line_has = ex > sx;
|
||||||
|
} else {
|
||||||
|
sx = 0;
|
||||||
|
ex = line.size();
|
||||||
|
line_has = ex > sx;
|
||||||
|
}
|
||||||
|
if (line_has) {
|
||||||
|
std::size_t rx_start = src_to_rx(sx);
|
||||||
|
std::size_t rx_end = src_to_rx(ex);
|
||||||
|
if (rx_end > coloffs_now) {
|
||||||
|
std::size_t vx0 = (rx_start > coloffs_now)
|
||||||
|
? (rx_start - coloffs_now)
|
||||||
|
: 0;
|
||||||
|
std::size_t vx1 = rx_end - coloffs_now;
|
||||||
|
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
|
||||||
|
line_pos.y);
|
||||||
|
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
||||||
|
line_pos.y + line_h);
|
||||||
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
||||||
|
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
|
||||||
|
const std::size_t spot_sx = std::min(buf->Curx(), line.size());
|
||||||
|
const std::size_t rx_start = src_to_rx(spot_sx);
|
||||||
|
std::size_t rx_end = rx_start;
|
||||||
|
if (spot_sx < line.size()) {
|
||||||
|
rx_end = src_to_rx(spot_sx + 1);
|
||||||
|
} else {
|
||||||
|
// EOL spot: draw a 1-cell highlight just past the last character.
|
||||||
|
rx_end = rx_start + 1;
|
||||||
|
}
|
||||||
|
if (rx_end > coloffs_now) {
|
||||||
|
std::size_t vx0 = (rx_start > coloffs_now)
|
||||||
|
? (rx_start - coloffs_now)
|
||||||
|
: 0;
|
||||||
|
std::size_t vx1 = rx_end - coloffs_now;
|
||||||
|
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
|
||||||
|
line_pos.y);
|
||||||
|
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
||||||
|
line_pos.y + line_h);
|
||||||
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Emit entire line to an expanded buffer (tabs -> spaces)
|
// Emit entire line to an expanded buffer (tabs -> spaces)
|
||||||
for (std::size_t src = 0; src < line.size(); ++src) {
|
for (std::size_t src = 0; src < line.size(); ++src) {
|
||||||
char c = line[src];
|
char c = line[src];
|
||||||
@@ -489,23 +490,98 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Synchronize cursor and scrolling after rendering all lines so content size is known.
|
||||||
|
{
|
||||||
|
float child_h_actual = ImGui::GetWindowHeight();
|
||||||
|
float child_w_actual = ImGui::GetWindowWidth();
|
||||||
|
float scroll_y_now = ImGui::GetScrollY();
|
||||||
|
float scroll_x_now = ImGui::GetScrollX();
|
||||||
|
|
||||||
|
long first_row = static_cast<long>(scroll_y_now / row_h);
|
||||||
|
long vis_rows = static_cast<long>(std::round(child_h_actual / row_h));
|
||||||
|
if (vis_rows < 1)
|
||||||
|
vis_rows = 1;
|
||||||
|
long last_row = first_row + vis_rows - 1;
|
||||||
|
|
||||||
|
long cyr = static_cast<long>(cy);
|
||||||
|
if (cyr < first_row) {
|
||||||
|
float target = static_cast<float>(cyr) * row_h;
|
||||||
|
if (target < 0.f)
|
||||||
|
target = 0.f;
|
||||||
|
float max_y = ImGui::GetScrollMaxY();
|
||||||
|
if (max_y >= 0.f && target > max_y)
|
||||||
|
target = max_y;
|
||||||
|
ImGui::SetScrollY(target);
|
||||||
|
first_row = static_cast<long>(target / row_h);
|
||||||
|
last_row = first_row + vis_rows - 1;
|
||||||
|
} else if (cyr > last_row) {
|
||||||
|
long new_first = cyr - vis_rows + 1;
|
||||||
|
if (new_first < 0)
|
||||||
|
new_first = 0;
|
||||||
|
float target = static_cast<float>(new_first) * row_h;
|
||||||
|
float max_y = ImGui::GetScrollMaxY();
|
||||||
|
if (target < 0.f)
|
||||||
|
target = 0.f;
|
||||||
|
if (max_y >= 0.f && target > max_y)
|
||||||
|
target = max_y;
|
||||||
|
ImGui::SetScrollY(target);
|
||||||
|
first_row = static_cast<long>(target / row_h);
|
||||||
|
last_row = first_row + vis_rows - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal scroll: ensure cursor column is visible
|
||||||
|
long vis_cols = static_cast<long>(std::round(child_w_actual / space_w));
|
||||||
|
if (vis_cols < 1)
|
||||||
|
vis_cols = 1;
|
||||||
|
long first_col = static_cast<long>(scroll_x_now / space_w);
|
||||||
|
long last_col = first_col + vis_cols - 1;
|
||||||
|
|
||||||
|
std::size_t cursor_rx = 0;
|
||||||
|
if (cy < lines.size()) {
|
||||||
|
std::string cur_line = static_cast<std::string>(lines[cy]);
|
||||||
|
const std::size_t tabw = 8;
|
||||||
|
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
|
||||||
|
if (cur_line[i] == '\t') {
|
||||||
|
cursor_rx += tabw - (cursor_rx % tabw);
|
||||||
|
} else {
|
||||||
|
cursor_rx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long cxr = static_cast<long>(cursor_rx);
|
||||||
|
if (cxr < first_col || cxr > last_col) {
|
||||||
|
float target_x = static_cast<float>(cxr) * space_w;
|
||||||
|
target_x -= (child_w_actual / 2.0f);
|
||||||
|
if (target_x < 0.f)
|
||||||
|
target_x = 0.f;
|
||||||
|
float max_x = ImGui::GetScrollMaxX();
|
||||||
|
if (max_x >= 0.f && target_x > max_x)
|
||||||
|
target_x = max_x;
|
||||||
|
ImGui::SetScrollX(target_x);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||||
|
int fr = static_cast<int>(std::max(0L, first_row));
|
||||||
|
int rc = static_cast<int>(std::max(1L, vis_rows));
|
||||||
|
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
||||||
|
}
|
||||||
|
}
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
|
ImGui::PopStyleVar(2); // WindowPadding, ItemSpacing
|
||||||
|
|
||||||
// Status bar spanning full width
|
// Status bar area starting right after the scroll child
|
||||||
ImGui::Separator();
|
|
||||||
|
|
||||||
// Compute full content width and draw a filled background rectangle
|
|
||||||
ImVec2 win_pos = ImGui::GetWindowPos();
|
ImVec2 win_pos = ImGui::GetWindowPos();
|
||||||
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
|
ImVec2 win_sz = ImGui::GetWindowSize();
|
||||||
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
|
float x0 = win_pos.x;
|
||||||
float x0 = win_pos.x + cr_min.x;
|
float x1 = win_pos.x + win_sz.x;
|
||||||
float x1 = win_pos.x + cr_max.x;
|
float y0 = ImGui::GetCursorScreenPos().y;
|
||||||
ImVec2 cursor = ImGui::GetCursorScreenPos();
|
float bar_h = real_bar_h;
|
||||||
float bar_h = ImGui::GetFrameHeight();
|
|
||||||
ImVec2 p0(x0, cursor.y);
|
ImVec2 p0(x0, y0);
|
||||||
ImVec2 p1(x1, cursor.y + bar_h);
|
ImVec2 p1(x1, y0 + bar_h);
|
||||||
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
|
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
|
||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
|
||||||
|
|
||||||
// If a prompt is active, replace the entire status bar with the prompt text
|
// If a prompt is active, replace the entire status bar with the prompt text
|
||||||
if (ed.PromptActive()) {
|
if (ed.PromptActive()) {
|
||||||
std::string label = ed.PromptLabel();
|
std::string label = ed.PromptLabel();
|
||||||
@@ -591,11 +667,9 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
|
|
||||||
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
|
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
|
||||||
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
|
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
|
||||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - msg_sz.y) * 0.5f));
|
||||||
ImGui::TextUnformatted(final_msg.c_str());
|
ImGui::TextUnformatted(final_msg.c_str());
|
||||||
ImGui::PopClipRect();
|
ImGui::PopClipRect();
|
||||||
// Advance cursor to after the bar to keep layout consistent
|
|
||||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
|
||||||
} else {
|
} else {
|
||||||
// Build left text
|
// Build left text
|
||||||
std::string left;
|
std::string left;
|
||||||
@@ -671,20 +745,21 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
float max_left = std::max(0.0f, right_x - left_x - pad);
|
float max_left = std::max(0.0f, right_x - left_x - pad);
|
||||||
if (max_left < left_sz.x && max_left > 10.0f) {
|
if (max_left < left_sz.x && max_left > 10.0f) {
|
||||||
// Render a clipped left using a child region
|
// Render a clipped left using a child region
|
||||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
|
||||||
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true);
|
ImGui::PushClipRect(ImVec2(left_x, y0), ImVec2(right_x - pad, y0 + bar_h),
|
||||||
|
true);
|
||||||
ImGui::TextUnformatted(left.c_str());
|
ImGui::TextUnformatted(left.c_str());
|
||||||
ImGui::PopClipRect();
|
ImGui::PopClipRect();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Draw left normally
|
// Draw left normally
|
||||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
|
||||||
ImGui::TextUnformatted(left.c_str());
|
ImGui::TextUnformatted(left.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw right
|
// Draw right
|
||||||
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
|
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
|
||||||
p0.y + (bar_h - right_sz.y) * 0.5f));
|
y0 + (bar_h - right_sz.y) * 0.5f));
|
||||||
ImGui::TextUnformatted(right.c_str());
|
ImGui::TextUnformatted(right.c_str());
|
||||||
|
|
||||||
// Draw middle message centered in remaining space
|
// Draw middle message centered in remaining space
|
||||||
@@ -696,14 +771,12 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
|
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
|
||||||
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
|
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
|
||||||
// Clip to middle region
|
// Clip to middle region
|
||||||
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true);
|
ImGui::PushClipRect(ImVec2(mid_left, y0), ImVec2(mid_right, y0 + bar_h), true);
|
||||||
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
ImGui::SetCursorScreenPos(ImVec2(msg_x, y0 + (bar_h - msg_sz.y) * 0.5f));
|
||||||
ImGui::TextUnformatted(msg.c_str());
|
ImGui::TextUnformatted(msg.c_str());
|
||||||
ImGui::PopClipRect();
|
ImGui::PopClipRect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Advance cursor to after the bar to keep layout consistent
|
|
||||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,12 +912,8 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ed.SetFilePickerDir(e.path.string());
|
ed.SetFilePickerDir(e.path.string());
|
||||||
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||||
// Open file on single click
|
// Open file on single click
|
||||||
std::string err;
|
ed.RequestOpenFile(e.path.string());
|
||||||
if (!ed.OpenFile(e.path.string(), err)) {
|
(void) ed.ProcessPendingOpens();
|
||||||
ed.SetStatus(std::string("open: ") + err);
|
|
||||||
} else {
|
|
||||||
ed.SetStatus(std::string("Opened: ") + e.name);
|
|
||||||
}
|
|
||||||
ed.SetFilePickerVisible(false);
|
ed.SetFilePickerVisible(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
KKeymap.cc
12
KKeymap.cc
@@ -17,6 +17,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'd':
|
case 'd':
|
||||||
out = CommandId::KillLine;
|
out = CommandId::KillLine;
|
||||||
return true;
|
return true;
|
||||||
|
case 's':
|
||||||
|
out = CommandId::Save;
|
||||||
|
return true;
|
||||||
case 'q':
|
case 'q':
|
||||||
out = CommandId::QuitNow;
|
out = CommandId::QuitNow;
|
||||||
return true;
|
return true;
|
||||||
@@ -42,6 +45,12 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'a':
|
case 'a':
|
||||||
out = CommandId::MarkAllAndJumpEnd;
|
out = CommandId::MarkAllAndJumpEnd;
|
||||||
return true;
|
return true;
|
||||||
|
case ' ': // C-k SPACE
|
||||||
|
out = CommandId::ToggleMark;
|
||||||
|
return true;
|
||||||
|
case 'i':
|
||||||
|
out = CommandId::BufferNew; // C-k i new empty buffer
|
||||||
|
return true;
|
||||||
case 'k':
|
case 'k':
|
||||||
out = CommandId::CenterOnCursor; // C-k k center current line
|
out = CommandId::CenterOnCursor; // C-k k center current line
|
||||||
return true;
|
return true;
|
||||||
@@ -111,6 +120,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case '=':
|
case '=':
|
||||||
out = CommandId::IndentRegion;
|
out = CommandId::IndentRegion;
|
||||||
return true;
|
return true;
|
||||||
|
case '/':
|
||||||
|
out = CommandId::VisualLineModeToggle;
|
||||||
|
return true;
|
||||||
case ';':
|
case ';':
|
||||||
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
|
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <ostream>
|
||||||
|
|
||||||
#include "PieceTable.h"
|
#include "PieceTable.h"
|
||||||
|
|
||||||
@@ -272,6 +273,7 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
|
|||||||
void
|
void
|
||||||
PieceTable::materialize() const
|
PieceTable::materialize() const
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if (!dirty_) {
|
if (!dirty_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -347,6 +349,7 @@ PieceTable::coalesceNeighbors(std::size_t index)
|
|||||||
void
|
void
|
||||||
PieceTable::InvalidateLineIndex() const
|
PieceTable::InvalidateLineIndex() const
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
line_index_dirty_ = true;
|
line_index_dirty_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,22 +357,29 @@ PieceTable::InvalidateLineIndex() const
|
|||||||
void
|
void
|
||||||
PieceTable::RebuildLineIndex() const
|
PieceTable::RebuildLineIndex() const
|
||||||
{
|
{
|
||||||
if (!line_index_dirty_)
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
|
||||||
|
if (!line_index_dirty_) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
line_index_.clear();
|
line_index_.clear();
|
||||||
line_index_.push_back(0);
|
line_index_.push_back(0);
|
||||||
|
|
||||||
std::size_t pos = 0;
|
std::size_t pos = 0;
|
||||||
for (const auto &pc: pieces_) {
|
for (const auto &pc: pieces_) {
|
||||||
const std::string &src = pc.src == Source::Original ? original_ : add_;
|
const std::string &src = pc.src == Source::Original ? original_ : add_;
|
||||||
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
|
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
|
||||||
|
|
||||||
for (std::size_t j = 0; j < pc.len; ++j) {
|
for (std::size_t j = 0; j < pc.len; ++j) {
|
||||||
if (base[j] == '\n') {
|
if (base[j] == '\n') {
|
||||||
// next line starts after the newline
|
// next line starts after the newline
|
||||||
line_index_.push_back(pos + j + 1);
|
line_index_.push_back(pos + j + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += pc.len;
|
pos += pc.len;
|
||||||
}
|
}
|
||||||
|
|
||||||
line_index_dirty_ = false;
|
line_index_dirty_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,14 +701,18 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
|||||||
len = total_size_ - byte_offset;
|
len = total_size_ - byte_offset;
|
||||||
|
|
||||||
// Fast path: return cached value if version/offset/len match
|
// Fast path: return cached value if version/offset/len match
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if (range_cache_.valid && range_cache_.version == version_ &&
|
if (range_cache_.valid && range_cache_.version == version_ &&
|
||||||
range_cache_.off == byte_offset && range_cache_.len == len) {
|
range_cache_.off == byte_offset && range_cache_.len == len) {
|
||||||
return range_cache_.data;
|
return range_cache_.data;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::string out;
|
std::string out;
|
||||||
out.reserve(len);
|
out.reserve(len);
|
||||||
if (!dirty_) {
|
if (!dirty_) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
// Already materialized; slice directly
|
// Already materialized; slice directly
|
||||||
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
|
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
|
||||||
} else {
|
} else {
|
||||||
@@ -722,11 +736,14 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
range_cache_.valid = true;
|
range_cache_.valid = true;
|
||||||
range_cache_.version = version_;
|
range_cache_.version = version_;
|
||||||
range_cache_.off = byte_offset;
|
range_cache_.off = byte_offset;
|
||||||
range_cache_.len = len;
|
range_cache_.len = len;
|
||||||
range_cache_.data = out;
|
range_cache_.data = out;
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,15 +755,21 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
|
|||||||
return start <= total_size_ ? start : std::numeric_limits<std::size_t>::max();
|
return start <= total_size_ ? start : std::numeric_limits<std::size_t>::max();
|
||||||
if (start > total_size_)
|
if (start > total_size_)
|
||||||
return std::numeric_limits<std::size_t>::max();
|
return std::numeric_limits<std::size_t>::max();
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if (find_cache_.valid &&
|
if (find_cache_.valid &&
|
||||||
find_cache_.version == version_ &&
|
find_cache_.version == version_ &&
|
||||||
find_cache_.needle == needle &&
|
find_cache_.needle == needle &&
|
||||||
find_cache_.start == start) {
|
find_cache_.start == start) {
|
||||||
return find_cache_.result;
|
return find_cache_.result;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
materialize();
|
materialize();
|
||||||
auto pos = materialized_.find(needle, start);
|
std::size_t pos;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
pos = materialized_.find(needle, start);
|
||||||
if (pos == std::string::npos)
|
if (pos == std::string::npos)
|
||||||
pos = std::numeric_limits<std::size_t>::max();
|
pos = std::numeric_limits<std::size_t>::max();
|
||||||
// Update cache
|
// Update cache
|
||||||
@@ -755,5 +778,23 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
|
|||||||
find_cache_.needle = needle;
|
find_cache_.needle = needle;
|
||||||
find_cache_.start = start;
|
find_cache_.start = start;
|
||||||
find_cache_.result = pos;
|
find_cache_.result = pos;
|
||||||
|
}
|
||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
PieceTable::WriteToStream(std::ostream &out) const
|
||||||
|
{
|
||||||
|
// Stream the content piece-by-piece without forcing full materialization
|
||||||
|
// No lock needed for original_ and add_ if they are not being modified.
|
||||||
|
// Since this is a const method and kte's piece table isn't modified by multiple threads
|
||||||
|
// (only queried), we just iterate pieces_.
|
||||||
|
for (const auto &p: pieces_) {
|
||||||
|
if (p.len == 0)
|
||||||
|
continue;
|
||||||
|
const std::string &src = (p.src == Source::Original) ? original_ : add_;
|
||||||
|
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start);
|
||||||
|
out.write(base, static_cast<std::streamsize>(p.len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
41
PieceTable.h
41
PieceTable.h
@@ -1,12 +1,48 @@
|
|||||||
/*
|
/*
|
||||||
* PieceTable.h - Alternative to GapBuffer using a piece table representation
|
* 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
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <ostream>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
|
||||||
class PieceTable {
|
class PieceTable {
|
||||||
@@ -100,6 +136,9 @@ public:
|
|||||||
// Simple search utility; returns byte offset or npos
|
// Simple search utility; returns byte offset or npos
|
||||||
[[nodiscard]] std::size_t Find(const std::string &needle, std::size_t start = 0) const;
|
[[nodiscard]] std::size_t Find(const std::string &needle, std::size_t start = 0) const;
|
||||||
|
|
||||||
|
// Stream out content without materializing the entire buffer
|
||||||
|
void WriteToStream(std::ostream &out) const;
|
||||||
|
|
||||||
// Heuristic configuration
|
// Heuristic configuration
|
||||||
void SetConsolidationParams(std::size_t piece_limit,
|
void SetConsolidationParams(std::size_t piece_limit,
|
||||||
std::size_t small_piece_threshold,
|
std::size_t small_piece_threshold,
|
||||||
@@ -177,4 +216,6 @@ private:
|
|||||||
|
|
||||||
mutable RangeCache range_cache_;
|
mutable RangeCache range_cache_;
|
||||||
mutable FindCache find_cache_;
|
mutable FindCache find_cache_;
|
||||||
|
|
||||||
|
mutable std::mutex mutex_;
|
||||||
};
|
};
|
||||||
@@ -123,8 +123,7 @@ protected:
|
|||||||
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
|
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
|
||||||
const Buffer *buf = ed_->CurrentBuffer();
|
const Buffer *buf = ed_->CurrentBuffer();
|
||||||
if (buf) {
|
if (buf) {
|
||||||
const auto &lines = buf->Rows();
|
const std::size_t nrows = buf->Nrows();
|
||||||
const std::size_t nrows = lines.size();
|
|
||||||
const std::size_t rowoffs = buf->Rowoffs();
|
const std::size_t rowoffs = buf->Rowoffs();
|
||||||
const std::size_t coloffs = buf->Coloffs();
|
const std::size_t coloffs = buf->Coloffs();
|
||||||
const std::size_t cy = buf->Cury();
|
const std::size_t cy = buf->Cury();
|
||||||
@@ -144,9 +143,8 @@ protected:
|
|||||||
|
|
||||||
// Iterate visible lines
|
// Iterate visible lines
|
||||||
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
|
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
|
||||||
// Materialize the Buffer::Line into a std::string for
|
// Get line as string for regex/iterator usage and general string ops.
|
||||||
// regex/iterator usage and general string ops.
|
const std::string line = buf->GetLineString(i);
|
||||||
const std::string line = static_cast<std::string>(lines[i]);
|
|
||||||
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
|
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
|
||||||
const int baseline = y + fm.ascent();
|
const int baseline = y + fm.ascent();
|
||||||
|
|
||||||
@@ -658,10 +656,8 @@ private:
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool
|
bool
|
||||||
GUIFrontend::Init(Editor &ed)
|
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||||
{
|
{
|
||||||
int argc = 0;
|
|
||||||
char **argv = nullptr;
|
|
||||||
app_ = new QApplication(argc, argv);
|
app_ = new QApplication(argc, argv);
|
||||||
|
|
||||||
window_ = new MainWindow(input_);
|
window_ = new MainWindow(input_);
|
||||||
@@ -777,6 +773,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
if (app_)
|
if (app_)
|
||||||
app_->processEvents();
|
app_->processEvents();
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
// Drain input queue
|
// Drain input queue
|
||||||
for (;;) {
|
for (;;) {
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
@@ -803,14 +802,8 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
const QStringList files = dlg.selectedFiles();
|
const QStringList files = dlg.selectedFiles();
|
||||||
if (!files.isEmpty()) {
|
if (!files.isEmpty()) {
|
||||||
const QString fp = files.front();
|
const QString fp = files.front();
|
||||||
std::string err;
|
ed.RequestOpenFile(fp.toStdString());
|
||||||
if (ed.OpenFile(fp.toStdString(), err)) {
|
(void) ed.ProcessPendingOpens();
|
||||||
ed.SetStatus(std::string("Opened: ") + fp.toStdString());
|
|
||||||
} else if (!err.empty()) {
|
|
||||||
ed.SetStatus(std::string("Open failed: ") + err);
|
|
||||||
} else {
|
|
||||||
ed.SetStatus("Open failed");
|
|
||||||
}
|
|
||||||
// Update picker dir for next time
|
// Update picker dir for next time
|
||||||
QFileInfo info(fp);
|
QFileInfo info(fp);
|
||||||
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public:
|
|||||||
|
|
||||||
~GUIFrontend() override = default;
|
~GUIFrontend() override = default;
|
||||||
|
|
||||||
bool Init(Editor &ed) override;
|
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||||
|
|
||||||
void Step(Editor &ed, bool &running) override;
|
void Step(Editor &ed, bool &running) override;
|
||||||
|
|
||||||
|
|||||||
@@ -287,8 +287,7 @@ QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
|
|||||||
|
|
||||||
->
|
->
|
||||||
UArg() != 0
|
UArg() != 0
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
|
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
|
||||||
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
|
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
|
||||||
int d = e.key() - Qt::Key_0;
|
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.
|
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
|
||||||
#if defined(__APPLE__)
|
#if defined(__APPLE__)
|
||||||
if (esc_meta_ || (mods & Qt::AltModifier)) {
|
if (esc_meta_ || (mods & Qt::AltModifier)) {
|
||||||
|
|
||||||
|
|
||||||
#else
|
#else
|
||||||
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
|
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
int ascii_key = 0;
|
int ascii_key = 0;
|
||||||
if (e.key() == Qt::Key_Backspace) {
|
if (e.key() == Qt::Key_Backspace) {
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -32,27 +32,27 @@ Project Goals
|
|||||||
|
|
||||||
Keybindings
|
Keybindings
|
||||||
-----------
|
-----------
|
||||||
kte maintains ke’s command model while internals evolve. Highlights (subject to refinement):
|
kte maintains ke’s command model while internals evolve. Highlights (
|
||||||
|
subject to refinement):
|
||||||
|
|
||||||
- K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or
|
- K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or
|
||||||
`C-g`.
|
`C-g`.
|
||||||
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
|
- 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).
|
`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
|
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
|
||||||
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u`
|
region), `C-y` (yank), `C-u` (universal argument).
|
||||||
(universal argument).
|
|
||||||
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
||||||
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
|
`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`
|
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
|
||||||
(close), `C-k C-r` (reload).
|
(close), `C-k l` (reload).
|
||||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g`
|
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
|
||||||
(goto line).
|
|
||||||
|
|
||||||
See `ke.md` for the canonical ke reference retained for now.
|
See `ke.md` for the canonical ke reference retained for now.
|
||||||
|
|
||||||
Build and Run
|
Build and Run
|
||||||
-------------
|
-------------
|
||||||
Prerequisites: C++17 compiler, CMake, and ncurses development headers/libs.
|
Prerequisites: C++20 compiler, CMake, and ncurses development
|
||||||
|
headers/libs.
|
||||||
|
|
||||||
Dependencies by platform
|
Dependencies by platform
|
||||||
------------------------
|
------------------------
|
||||||
@@ -62,30 +62,38 @@ Dependencies by platform
|
|||||||
- `brew install ncurses`
|
- `brew install ncurses`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- `brew install sdl2 freetype`
|
- `brew install sdl2 freetype`
|
||||||
- OpenGL is provided by the system framework on macOS; no package needed.
|
- OpenGL is provided by the system framework on macOS; no
|
||||||
|
package needed.
|
||||||
|
|
||||||
- Debian/Ubuntu
|
- Debian/Ubuntu
|
||||||
- Terminal (default):
|
- Terminal (default):
|
||||||
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
|
-
|
||||||
- The `mesa-common-dev` package provides OpenGL headers/libs (`libGL`).
|
`sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
|
||||||
|
- The `mesa-common-dev` package provides OpenGL headers/libs (
|
||||||
|
`libGL`).
|
||||||
|
|
||||||
- NixOS/Nix
|
- NixOS/Nix
|
||||||
- Terminal (default):
|
- Terminal (default):
|
||||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
- Ad-hoc shell:
|
||||||
- With flakes/devshell (example `flake.nix` inputs not provided): include
|
`nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
||||||
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell.
|
- With flakes/devshell (example `flake.nix` inputs not provided):
|
||||||
|
include
|
||||||
|
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your
|
||||||
|
devShell.
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
|
|
||||||
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable it by
|
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable
|
||||||
|
it by
|
||||||
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
|
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
|
||||||
installed for your platform.
|
installed for your platform.
|
||||||
- If you previously configured with GUI ON and want to disable it, reconfigure
|
- If you previously configured with GUI ON and want to disable it,
|
||||||
|
reconfigure
|
||||||
the build directory with `-DBUILD_GUI=OFF`.
|
the build directory with `-DBUILD_GUI=OFF`.
|
||||||
|
|
||||||
Example build:
|
Example build:
|
||||||
@@ -113,7 +121,8 @@ built as `kge`) or request the GUI from `kte`:
|
|||||||
GUI build example
|
GUI build example
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
To build with the optional GUI (after installing the GUI dependencies listed above):
|
To build with the optional GUI (after installing the GUI dependencies
|
||||||
|
listed above):
|
||||||
|
|
||||||
```
|
```
|
||||||
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON
|
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON
|
||||||
|
|||||||
174
Swap.h
174
Swap.h
@@ -7,11 +7,16 @@
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
|
#include <deque>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
|
||||||
|
#include "SwapRecorder.h"
|
||||||
|
#include "ErrorRecovery.h"
|
||||||
|
|
||||||
class Buffer;
|
class Buffer;
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
@@ -29,50 +34,88 @@ struct SwapConfig {
|
|||||||
// Grouping and durability knobs (stage 1 defaults)
|
// Grouping and durability knobs (stage 1 defaults)
|
||||||
unsigned flush_interval_ms{200}; // group small writes
|
unsigned flush_interval_ms{200}; // group small writes
|
||||||
unsigned fsync_interval_ms{1000}; // at most once per second
|
unsigned fsync_interval_ms{1000}; // at most once per second
|
||||||
};
|
|
||||||
|
|
||||||
// Lightweight interface that Buffer can call without depending on full manager impl
|
// Checkpoint/compaction knobs (stage 2 defaults)
|
||||||
class SwapRecorder {
|
// A checkpoint is a full snapshot of the buffer content written as a CHKPT record.
|
||||||
public:
|
// Compaction rewrites the swap file to contain just the latest checkpoint.
|
||||||
virtual ~SwapRecorder() = default;
|
std::size_t checkpoint_bytes{1024 * 1024}; // request checkpoint after this many queued edit-bytes
|
||||||
|
unsigned checkpoint_interval_ms{60000}; // request checkpoint at least this often while editing
|
||||||
|
std::size_t compact_bytes{8 * 1024 * 1024}; // compact on checkpoint once journal grows beyond this
|
||||||
|
|
||||||
virtual void RecordInsert(Buffer &buf, int row, int col, std::string_view text) = 0;
|
// Cleanup / retention (best-effort)
|
||||||
|
bool prune_on_startup{true};
|
||||||
virtual void RecordDelete(Buffer &buf, int row, int col, std::size_t len) = 0;
|
unsigned prune_max_age_days{30};
|
||||||
|
std::size_t prune_max_files{2048};
|
||||||
virtual void RecordSplit(Buffer &buf, int row, int col) = 0;
|
|
||||||
|
|
||||||
virtual void RecordJoin(Buffer &buf, int row) = 0;
|
|
||||||
|
|
||||||
virtual void NotifyFilenameChanged(Buffer &buf) = 0;
|
|
||||||
|
|
||||||
virtual void SetSuspended(Buffer &buf, bool on) = 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// SwapManager manages sidecar swap files and a single background writer thread.
|
// SwapManager manages sidecar swap files and a single background writer thread.
|
||||||
class SwapManager final : public SwapRecorder {
|
class SwapManager final {
|
||||||
public:
|
public:
|
||||||
SwapManager();
|
SwapManager();
|
||||||
|
|
||||||
~SwapManager() override;
|
~SwapManager();
|
||||||
|
|
||||||
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
|
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
|
||||||
void Attach(Buffer *buf);
|
void Attach(Buffer *buf);
|
||||||
|
|
||||||
// Detach and close journal.
|
// Detach and close journal.
|
||||||
void Detach(Buffer *buf);
|
// If remove_file is true, the swap file is deleted after closing.
|
||||||
|
// Intended for clean shutdown/close flows.
|
||||||
|
void Detach(Buffer *buf, bool remove_file = false);
|
||||||
|
|
||||||
// SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs)
|
// Reset (truncate-by-delete) the journal for a buffer after a clean save.
|
||||||
void NotifyFilenameChanged(Buffer &buf) override;
|
// Best-effort: closes the current fd, deletes the swap file, and resumes recording.
|
||||||
|
void ResetJournal(Buffer &buf);
|
||||||
|
|
||||||
// SwapRecorder
|
// Best-effort pruning of old swap files under the swap directory.
|
||||||
void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override;
|
// Never touches non-`.swp` files.
|
||||||
|
void PruneSwapDir();
|
||||||
|
|
||||||
void RecordDelete(Buffer &buf, int row, int col, std::size_t len) override;
|
// Block until all currently queued records have been written.
|
||||||
|
// If buf is non-null, flushes all records (stage 1) but is primarily intended
|
||||||
|
// for tests and shutdown.
|
||||||
|
void Flush(Buffer *buf = nullptr);
|
||||||
|
|
||||||
void RecordSplit(Buffer &buf, int row, int col) override;
|
// Request a full-content checkpoint record for one buffer (or all buffers if buf is null).
|
||||||
|
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
||||||
|
void Checkpoint(Buffer *buf = nullptr);
|
||||||
|
|
||||||
void RecordJoin(Buffer &buf, int row) override;
|
|
||||||
|
void SetConfig(const SwapConfig &cfg)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
cfg_ = cfg;
|
||||||
|
cv_.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Obtain a per-buffer recorder adapter that emits records for that buffer.
|
||||||
|
// The returned pointer is owned by the SwapManager and remains valid until
|
||||||
|
// Detach(buf) or SwapManager destruction.
|
||||||
|
SwapRecorder *RecorderFor(Buffer *buf);
|
||||||
|
|
||||||
|
// Notify that the buffer's filename changed (e.g., SaveAs)
|
||||||
|
void NotifyFilenameChanged(Buffer &buf);
|
||||||
|
|
||||||
|
// Replay a swap journal into an already-open buffer.
|
||||||
|
// On success, the buffer content reflects all valid journal records.
|
||||||
|
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
|
||||||
|
// state results from applying records up to the failure point; callers should
|
||||||
|
// treat this as a recovery failure and surface `err`.
|
||||||
|
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
|
||||||
|
|
||||||
|
// Compute the swap path for a file-backed buffer by filename.
|
||||||
|
// Returns empty string if filename is empty.
|
||||||
|
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
|
// Test-only hook to keep swap path logic centralized.
|
||||||
|
// (Avoid duplicating naming rules in unit tests.)
|
||||||
|
#ifdef KTE_TESTS
|
||||||
|
static std::string ComputeSwapPathForTests(const Buffer &buf)
|
||||||
|
{
|
||||||
|
return ComputeSidecarPath(buf);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// RAII guard to suspend recording for internal operations
|
// RAII guard to suspend recording for internal operations
|
||||||
class SuspendGuard {
|
class SuspendGuard {
|
||||||
@@ -88,17 +131,58 @@ public:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Per-buffer toggle
|
// Per-buffer toggle
|
||||||
void SetSuspended(Buffer &buf, bool on) override;
|
void SetSuspended(Buffer &buf, bool on);
|
||||||
|
|
||||||
|
// Error reporting for background thread
|
||||||
|
struct SwapError {
|
||||||
|
std::uint64_t timestamp_ns{0};
|
||||||
|
std::string message;
|
||||||
|
std::string buffer_name; // filename or "<unnamed>"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query error state (thread-safe)
|
||||||
|
bool HasErrors() const;
|
||||||
|
|
||||||
|
std::string GetLastError() const;
|
||||||
|
|
||||||
|
std::size_t GetErrorCount() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
class BufferRecorder final : public SwapRecorder {
|
||||||
|
public:
|
||||||
|
BufferRecorder(SwapManager &m, Buffer &b) : m_(m), buf_(b) {}
|
||||||
|
|
||||||
|
void OnInsert(int row, int col, std::string_view bytes) override;
|
||||||
|
|
||||||
|
void OnDelete(int row, int col, std::size_t len) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
SwapManager &m_;
|
||||||
|
Buffer &buf_;
|
||||||
|
};
|
||||||
|
|
||||||
|
void RecordInsert(Buffer &buf, int row, int col, std::string_view text);
|
||||||
|
|
||||||
|
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
|
||||||
|
|
||||||
|
void RecordSplit(Buffer &buf, int row, int col);
|
||||||
|
|
||||||
|
void RecordJoin(Buffer &buf, int row);
|
||||||
|
|
||||||
|
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
||||||
|
|
||||||
|
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
||||||
|
|
||||||
struct JournalCtx {
|
struct JournalCtx {
|
||||||
std::string path;
|
std::string path;
|
||||||
void *file{nullptr}; // FILE*
|
|
||||||
int fd{-1};
|
int fd{-1};
|
||||||
bool header_ok{false};
|
bool header_ok{false};
|
||||||
bool suspended{false};
|
bool suspended{false};
|
||||||
std::uint64_t last_flush_ns{0};
|
std::uint64_t last_flush_ns{0};
|
||||||
std::uint64_t last_fsync_ns{0};
|
std::uint64_t last_fsync_ns{0};
|
||||||
|
std::uint64_t last_chkpt_ns{0};
|
||||||
|
std::uint64_t edit_bytes_since_chkpt{0};
|
||||||
|
std::uint64_t approx_size_bytes{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Pending {
|
struct Pending {
|
||||||
@@ -106,26 +190,36 @@ private:
|
|||||||
SwapRecType type{SwapRecType::INS};
|
SwapRecType type{SwapRecType::INS};
|
||||||
std::vector<std::uint8_t> payload; // framed payload only
|
std::vector<std::uint8_t> payload; // framed payload only
|
||||||
bool urgent_flush{false};
|
bool urgent_flush{false};
|
||||||
|
std::uint64_t seq{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||||
|
|
||||||
|
static std::string ComputeSidecarPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
static std::uint64_t now_ns();
|
static std::uint64_t now_ns();
|
||||||
|
|
||||||
static bool ensure_parent_dir(const std::string &path);
|
static bool ensure_parent_dir(const std::string &path);
|
||||||
|
|
||||||
static bool write_header(JournalCtx &ctx);
|
static std::string SwapDirRoot();
|
||||||
|
|
||||||
static bool open_ctx(JournalCtx &ctx);
|
static bool write_header(int fd);
|
||||||
|
|
||||||
|
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err);
|
||||||
|
|
||||||
static void close_ctx(JournalCtx &ctx);
|
static void close_ctx(JournalCtx &ctx);
|
||||||
|
|
||||||
|
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record,
|
||||||
|
std::string &err);
|
||||||
|
|
||||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||||
|
|
||||||
static void put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v);
|
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
|
||||||
|
|
||||||
static void put_u24(std::uint8_t dst[3], std::uint32_t v);
|
static void put_le64(std::uint8_t dst[8], std::uint64_t v);
|
||||||
|
|
||||||
|
static void put_u24_le(std::uint8_t dst[3], std::uint32_t v);
|
||||||
|
|
||||||
void enqueue(Pending &&p);
|
void enqueue(Pending &&p);
|
||||||
|
|
||||||
@@ -133,13 +227,27 @@ private:
|
|||||||
|
|
||||||
void process_one(const Pending &p);
|
void process_one(const Pending &p);
|
||||||
|
|
||||||
|
// Error reporting helper (called from writer thread)
|
||||||
|
void report_error(const std::string &message, Buffer *buf = nullptr);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
SwapConfig cfg_{};
|
SwapConfig cfg_{};
|
||||||
std::unordered_map<Buffer *, JournalCtx> journals_;
|
std::unordered_map<Buffer *, JournalCtx> journals_;
|
||||||
std::mutex mtx_;
|
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
||||||
|
mutable std::mutex mtx_;
|
||||||
std::condition_variable cv_;
|
std::condition_variable cv_;
|
||||||
std::vector<Pending> queue_;
|
std::vector<Pending> queue_;
|
||||||
|
std::uint64_t next_seq_{0};
|
||||||
|
std::uint64_t last_processed_{0};
|
||||||
|
std::uint64_t inflight_{0};
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
std::thread worker_;
|
std::thread worker_;
|
||||||
|
|
||||||
|
// Error tracking (protected by mtx_)
|
||||||
|
std::deque<SwapError> errors_; // bounded to max 100 entries
|
||||||
|
std::size_t total_error_count_{0};
|
||||||
|
|
||||||
|
// Circuit breaker for swap operations (protected by mtx_)
|
||||||
|
CircuitBreaker circuit_breaker_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
19
SwapRecorder.h
Normal file
19
SwapRecorder.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// SwapRecorder.h - minimal swap journal recording interface for Buffer mutations
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
// SwapRecorder is a tiny, non-blocking callback interface.
|
||||||
|
// Implementations must return quickly; Buffer calls these hooks after a
|
||||||
|
// mutation succeeds.
|
||||||
|
class SwapRecorder {
|
||||||
|
public:
|
||||||
|
virtual ~SwapRecorder() = default;
|
||||||
|
|
||||||
|
virtual void OnInsert(int row, int col, std::string_view bytes) = 0;
|
||||||
|
|
||||||
|
virtual void OnDelete(int row, int col, std::size_t len) = 0;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
76
SyscallWrappers.cc
Normal file
76
SyscallWrappers.cc
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#include "SyscallWrappers.h"
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
namespace syscall {
|
||||||
|
int
|
||||||
|
Open(const char *path, int flags, mode_t mode)
|
||||||
|
{
|
||||||
|
int fd;
|
||||||
|
do {
|
||||||
|
fd = ::open(path, flags, mode);
|
||||||
|
} while (fd == -1 && errno == EINTR);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Close(int fd)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
do {
|
||||||
|
ret = ::close(fd);
|
||||||
|
} while (ret == -1 && errno == EINTR);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Fsync(int fd)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
do {
|
||||||
|
ret = ::fsync(fd);
|
||||||
|
} while (ret == -1 && errno == EINTR);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Fstat(int fd, struct stat *buf)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
do {
|
||||||
|
ret = ::fstat(fd, buf);
|
||||||
|
} while (ret == -1 && errno == EINTR);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Fchmod(int fd, mode_t mode)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
do {
|
||||||
|
ret = ::fchmod(fd, mode);
|
||||||
|
} while (ret == -1 && errno == EINTR);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Mkstemp(char *template_str)
|
||||||
|
{
|
||||||
|
int fd;
|
||||||
|
do {
|
||||||
|
fd = ::mkstemp(template_str);
|
||||||
|
} while (fd == -1 && errno == EINTR);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
} // namespace syscall
|
||||||
|
} // namespace kte
|
||||||
47
SyscallWrappers.h
Normal file
47
SyscallWrappers.h
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// SyscallWrappers.h - EINTR-safe syscall wrappers for kte
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
namespace syscall {
|
||||||
|
// EINTR-safe wrapper for open(2).
|
||||||
|
// Returns file descriptor on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Open(const char *path, int flags, mode_t mode = 0);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for close(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
// Note: Some systems may not restart close() on EINTR, but we retry anyway
|
||||||
|
// as recommended by POSIX.1-2008.
|
||||||
|
int Close(int fd);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for fsync(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Fsync(int fd);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for fstat(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Fstat(int fd, struct stat *buf);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for fchmod(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Fchmod(int fd, mode_t mode);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for mkstemp(3).
|
||||||
|
// Returns file descriptor on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
// Note: template_str must be a mutable buffer ending in "XXXXXX".
|
||||||
|
int Mkstemp(char *template_str);
|
||||||
|
|
||||||
|
// Note: rename(2) and unlink(2) are not wrapped because they operate on
|
||||||
|
// filesystem metadata and typically complete atomically without EINTR.
|
||||||
|
// If interrupted, they either succeed or fail without partial state.
|
||||||
|
} // namespace syscall
|
||||||
|
} // namespace kte
|
||||||
@@ -8,8 +8,10 @@
|
|||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
TerminalFrontend::Init(Editor &ed)
|
TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||||
{
|
{
|
||||||
|
(void) argc;
|
||||||
|
(void) argv;
|
||||||
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
|
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
|
||||||
{
|
{
|
||||||
struct termios tio{};
|
struct termios tio{};
|
||||||
@@ -73,6 +75,7 @@ TerminalFrontend::Init(Editor &ed)
|
|||||||
have_old_sigint_ = true;
|
have_old_sigint_ = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +94,9 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
|||||||
}
|
}
|
||||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
if (input_.Poll(mi)) {
|
||||||
if (mi.hasCommand) {
|
if (mi.hasCommand) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public:
|
|||||||
// Adjust if your terminal needs a different threshold.
|
// Adjust if your terminal needs a different threshold.
|
||||||
static constexpr int kEscDelayMs = 50;
|
static constexpr int kEscDelayMs = 50;
|
||||||
|
|
||||||
bool Init(Editor &ed) override;
|
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||||
|
|
||||||
void Step(Editor &ed, bool &running) override;
|
void Step(Editor &ed, bool &running) override;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "TerminalInputHandler.h"
|
#include "TerminalInputHandler.h"
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
|
#include "Command.h"
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -23,6 +24,7 @@ map_key_to_command(const int ch,
|
|||||||
bool &k_prefix,
|
bool &k_prefix,
|
||||||
bool &esc_meta,
|
bool &esc_meta,
|
||||||
bool &k_ctrl_pending,
|
bool &k_ctrl_pending,
|
||||||
|
bool &mouse_selecting,
|
||||||
Editor *ed,
|
Editor *ed,
|
||||||
MappedInput &out)
|
MappedInput &out)
|
||||||
{
|
{
|
||||||
@@ -54,13 +56,34 @@ map_key_to_command(const int ch,
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
// React to left button click/press
|
// React to left button click/press
|
||||||
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
|
if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED |
|
||||||
|
REPORT_MOUSE_POSITION))) {
|
||||||
char buf[64];
|
char buf[64];
|
||||||
// Use screen coordinates; command handler will translate via offsets
|
// Use screen coordinates; command handler will translate via offsets
|
||||||
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
|
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
|
||||||
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
|
const bool pressed = (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) != 0;
|
||||||
|
const bool released = (ev.bstate & BUTTON1_RELEASED) != 0;
|
||||||
|
const bool moved = (ev.bstate & REPORT_MOUSE_POSITION) != 0;
|
||||||
|
if (pressed) {
|
||||||
|
mouse_selecting = true;
|
||||||
|
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||||
|
if (Buffer *b = ed->CurrentBuffer()) {
|
||||||
|
b->SetMark(b->Curx(), b->Cury());
|
||||||
|
}
|
||||||
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (mouse_selecting && moved) {
|
||||||
|
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (released) {
|
||||||
|
mouse_selecting = false;
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// No actionable mouse event
|
// No actionable mouse event
|
||||||
out.hasCommand = false;
|
out.hasCommand = false;
|
||||||
@@ -178,8 +201,9 @@ map_key_to_command(const int ch,
|
|||||||
ctrl = true;
|
ctrl = true;
|
||||||
ascii_key = 'a' + (ch - 1);
|
ascii_key = 'a' + (ch - 1);
|
||||||
}
|
}
|
||||||
// If user typed literal 'C'/'c' or '^' as a qualifier, keep k-prefix and set pending
|
// If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending
|
||||||
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
|
// Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose).
|
||||||
|
if (ascii_key == 'C' || ascii_key == '^') {
|
||||||
k_ctrl_pending = true;
|
k_ctrl_pending = true;
|
||||||
if (ed)
|
if (ed)
|
||||||
ed->SetStatus("C-k C _");
|
ed->SetStatus("C-k C _");
|
||||||
@@ -291,6 +315,7 @@ TerminalInputHandler::decode_(MappedInput &out)
|
|||||||
ch,
|
ch,
|
||||||
k_prefix_, esc_meta_,
|
k_prefix_, esc_meta_,
|
||||||
k_ctrl_pending_,
|
k_ctrl_pending_,
|
||||||
|
mouse_selecting_,
|
||||||
ed_,
|
ed_,
|
||||||
out);
|
out);
|
||||||
if (!consumed)
|
if (!consumed)
|
||||||
|
|||||||
@@ -30,5 +30,8 @@ private:
|
|||||||
// Simple meta (ESC) state for ESC sequences like ESC b/f
|
// Simple meta (ESC) state for ESC sequences like ESC b/f
|
||||||
bool esc_meta_ = false;
|
bool esc_meta_ = false;
|
||||||
|
|
||||||
|
// Mouse drag selection state
|
||||||
|
bool mouse_selecting_ = false;
|
||||||
|
|
||||||
Editor *ed_ = nullptr; // attached editor for uarg handling
|
Editor *ed_ = nullptr; // attached editor for uarg handling
|
||||||
};
|
};
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
#include <clocale>
|
||||||
|
#define _XOPEN_SOURCE_EXTENDED 1
|
||||||
|
#include <cwchar>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
@@ -104,11 +107,80 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
|
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
|
||||||
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
|
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
|
||||||
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||||
bool hl_on = false;
|
|
||||||
bool cur_on = false;
|
// Mark selection (mark -> cursor), in source coordinates
|
||||||
|
bool sel_active = false;
|
||||||
|
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
|
||||||
|
if (buf->MarkSet()) {
|
||||||
|
sel_sy = buf->MarkCury();
|
||||||
|
sel_sx = buf->MarkCurx();
|
||||||
|
sel_ey = buf->Cury();
|
||||||
|
sel_ex = buf->Curx();
|
||||||
|
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
|
||||||
|
std::swap(sel_sy, sel_ey);
|
||||||
|
std::swap(sel_sx, sel_ex);
|
||||||
|
}
|
||||||
|
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
|
||||||
|
}
|
||||||
|
// Visual-line selection: full-line selection range
|
||||||
|
const bool vsel_active = buf->VisualLineActive();
|
||||||
|
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
||||||
|
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
||||||
|
auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool {
|
||||||
|
if (!sel_active)
|
||||||
|
return false;
|
||||||
|
if (y < sel_sy || y > sel_ey)
|
||||||
|
return false;
|
||||||
|
if (sel_sy == sel_ey)
|
||||||
|
return sx >= sel_sx && sx < sel_ex;
|
||||||
|
if (y == sel_sy)
|
||||||
|
return sx >= sel_sx;
|
||||||
|
if (y == sel_ey)
|
||||||
|
return sx < sel_ex;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
int written = 0;
|
int written = 0;
|
||||||
if (li < lines.size()) {
|
if (li < lines.size()) {
|
||||||
std::string line = static_cast<std::string>(lines[li]);
|
std::string line = static_cast<std::string>(lines[li]);
|
||||||
|
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
|
||||||
|
const std::size_t vsel_spot_src = vsel_on_line
|
||||||
|
? std::min(buf->Curx(), line.size())
|
||||||
|
: 0;
|
||||||
|
const bool vsel_spot_is_eol = vsel_on_line && vsel_spot_src == line.size();
|
||||||
|
std::size_t vsel_line_rx = 0;
|
||||||
|
if (vsel_spot_is_eol) {
|
||||||
|
// Compute the rendered (column) width of the line so we can highlight a
|
||||||
|
// single cell at EOL when the spot falls beyond the last character.
|
||||||
|
std::size_t rc = 0;
|
||||||
|
std::size_t si = 0;
|
||||||
|
while (si < line.size()) {
|
||||||
|
wchar_t wch = L' ';
|
||||||
|
int wch_len = 1;
|
||||||
|
std::mbstate_t state = std::mbstate_t();
|
||||||
|
size_t res = std::mbrtowc(&wch, &line[si], line.size() - si, &state);
|
||||||
|
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||||
|
wch = static_cast<unsigned char>(line[si]);
|
||||||
|
wch_len = 1;
|
||||||
|
} else if (res == 0) {
|
||||||
|
wch = L'\0';
|
||||||
|
wch_len = 1;
|
||||||
|
} else {
|
||||||
|
wch_len = static_cast<int>(res);
|
||||||
|
}
|
||||||
|
if (wch == L'\t') {
|
||||||
|
constexpr std::size_t tab_width = 8;
|
||||||
|
const std::size_t next_tab = tab_width - (rc % tab_width);
|
||||||
|
rc += next_tab;
|
||||||
|
} else {
|
||||||
|
int w = wcwidth(wch);
|
||||||
|
if (w < 0)
|
||||||
|
w = 1;
|
||||||
|
rc += static_cast<std::size_t>(w);
|
||||||
|
}
|
||||||
|
si += static_cast<std::size_t>(wch_len);
|
||||||
|
}
|
||||||
|
vsel_line_rx = rc;
|
||||||
|
}
|
||||||
src_i = 0;
|
src_i = 0;
|
||||||
render_col = 0;
|
render_col = 0;
|
||||||
// Syntax highlighting: fetch per-line spans (sanitized copy)
|
// Syntax highlighting: fetch per-line spans (sanitized copy)
|
||||||
@@ -153,39 +225,50 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
return kte::TokenKind::Default;
|
return kte::TokenKind::Default;
|
||||||
};
|
};
|
||||||
auto apply_token_attr = [&](kte::TokenKind k) {
|
auto token_attr = [&](kte::TokenKind k) -> attr_t {
|
||||||
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
|
|
||||||
attrset(A_NORMAL);
|
|
||||||
switch (k) {
|
switch (k) {
|
||||||
case kte::TokenKind::Keyword:
|
case kte::TokenKind::Keyword:
|
||||||
case kte::TokenKind::Type:
|
case kte::TokenKind::Type:
|
||||||
case kte::TokenKind::Constant:
|
case kte::TokenKind::Constant:
|
||||||
case kte::TokenKind::Function:
|
case kte::TokenKind::Function:
|
||||||
attron(A_BOLD);
|
return A_BOLD;
|
||||||
break;
|
|
||||||
case kte::TokenKind::Comment:
|
case kte::TokenKind::Comment:
|
||||||
attron(A_DIM);
|
return A_DIM;
|
||||||
break;
|
|
||||||
case kte::TokenKind::String:
|
case kte::TokenKind::String:
|
||||||
case kte::TokenKind::Char:
|
case kte::TokenKind::Char:
|
||||||
case kte::TokenKind::Number:
|
case kte::TokenKind::Number:
|
||||||
// standout a bit using A_UNDERLINE if available
|
return A_UNDERLINE;
|
||||||
attron(A_UNDERLINE);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
return 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
while (written < cols) {
|
while (written < cols) {
|
||||||
char ch = ' ';
|
|
||||||
bool from_src = false;
|
bool from_src = false;
|
||||||
|
wchar_t wch = L' ';
|
||||||
|
int wch_len = 1;
|
||||||
|
int disp_w = 1;
|
||||||
|
|
||||||
if (src_i < line.size()) {
|
if (src_i < line.size()) {
|
||||||
unsigned char c = static_cast<unsigned char>(line[src_i]);
|
// Decode UTF-8
|
||||||
if (c == '\t') {
|
std::mbstate_t state = std::mbstate_t();
|
||||||
|
size_t res = std::mbrtowc(
|
||||||
|
&wch, &line[src_i], line.size() - src_i, &state);
|
||||||
|
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||||
|
// Invalid or incomplete; treat as single byte
|
||||||
|
wch = static_cast<unsigned char>(line[src_i]);
|
||||||
|
wch_len = 1;
|
||||||
|
} else if (res == 0) {
|
||||||
|
wch = L'\0';
|
||||||
|
wch_len = 1;
|
||||||
|
} else {
|
||||||
|
wch_len = static_cast<int>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wch == L'\t') {
|
||||||
std::size_t next_tab = tabw - (render_col % tabw);
|
std::size_t next_tab = tabw - (render_col % tabw);
|
||||||
if (render_col + next_tab <= coloffs) {
|
if (render_col + next_tab <= coloffs) {
|
||||||
render_col += next_tab;
|
render_col += next_tab;
|
||||||
++src_i;
|
src_i += wch_len;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Emit spaces for tab
|
// Emit spaces for tab
|
||||||
@@ -198,98 +281,103 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
// Now render visible spaces
|
// Now render visible spaces
|
||||||
while (next_tab > 0 && written < cols) {
|
while (next_tab > 0 && written < cols) {
|
||||||
|
bool in_mark = is_src_in_mark_sel(li, src_i);
|
||||||
|
bool in_vsel =
|
||||||
|
vsel_on_line && !vsel_spot_is_eol && src_i ==
|
||||||
|
vsel_spot_src;
|
||||||
|
bool in_sel = in_mark || in_vsel;
|
||||||
bool in_hl = search_mode && is_src_in_hl(src_i);
|
bool in_hl = search_mode && is_src_in_hl(src_i);
|
||||||
bool in_cur =
|
bool in_cur =
|
||||||
has_current && li == cur_my && src_i >= cur_mx
|
has_current && li == cur_my && src_i >= cur_mx
|
||||||
&& src_i < cur_mend;
|
&&
|
||||||
// Toggle highlight attributes
|
src_i < cur_mend;
|
||||||
int attr = 0;
|
attr_t a = A_NORMAL;
|
||||||
|
a |= token_attr(token_at(src_i));
|
||||||
|
if (in_sel) {
|
||||||
|
a |= A_REVERSE;
|
||||||
|
} else {
|
||||||
if (in_hl)
|
if (in_hl)
|
||||||
attr |= A_STANDOUT;
|
a |= A_STANDOUT;
|
||||||
if (in_cur)
|
if (in_cur)
|
||||||
attr |= A_BOLD;
|
a |= A_BOLD;
|
||||||
if ((attr & A_STANDOUT) && !hl_on) {
|
|
||||||
attron(A_STANDOUT);
|
|
||||||
hl_on = true;
|
|
||||||
}
|
|
||||||
if (!(attr & A_STANDOUT) && hl_on) {
|
|
||||||
attroff(A_STANDOUT);
|
|
||||||
hl_on = false;
|
|
||||||
}
|
|
||||||
if ((attr & A_BOLD) && !cur_on) {
|
|
||||||
attron(A_BOLD);
|
|
||||||
cur_on = true;
|
|
||||||
}
|
|
||||||
if (!(attr & A_BOLD) && cur_on) {
|
|
||||||
attroff(A_BOLD);
|
|
||||||
cur_on = false;
|
|
||||||
}
|
|
||||||
// Apply syntax attribute only if not in search highlight
|
|
||||||
if (!in_hl) {
|
|
||||||
apply_token_attr(token_at(src_i));
|
|
||||||
}
|
}
|
||||||
|
attrset(a);
|
||||||
addch(' ');
|
addch(' ');
|
||||||
++written;
|
++written;
|
||||||
++render_col;
|
++render_col;
|
||||||
--next_tab;
|
--next_tab;
|
||||||
}
|
}
|
||||||
++src_i;
|
src_i += wch_len;
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
// normal char
|
// normal char
|
||||||
|
disp_w = wcwidth(wch);
|
||||||
|
if (disp_w < 0)
|
||||||
|
disp_w = 1; // non-printable or similar
|
||||||
|
|
||||||
if (render_col < coloffs) {
|
if (render_col < coloffs) {
|
||||||
++render_col;
|
render_col += disp_w;
|
||||||
++src_i;
|
src_i += wch_len;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ch = static_cast<char>(c);
|
|
||||||
from_src = true;
|
from_src = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// beyond EOL, fill spaces
|
// beyond EOL, fill spaces
|
||||||
ch = ' ';
|
wch = L' ';
|
||||||
|
wch_len = 1;
|
||||||
|
disp_w = 1;
|
||||||
from_src = false;
|
from_src = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (written + disp_w > cols) {
|
||||||
|
// would overflow, just break
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool in_mark = from_src && is_src_in_mark_sel(li, src_i);
|
||||||
|
bool in_vsel = false;
|
||||||
|
if (vsel_on_line) {
|
||||||
|
if (from_src) {
|
||||||
|
in_vsel = !vsel_spot_is_eol && src_i == vsel_spot_src;
|
||||||
|
} else {
|
||||||
|
in_vsel = vsel_spot_is_eol && render_col == vsel_line_rx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool in_sel = in_mark || in_vsel;
|
||||||
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
||||||
bool in_cur =
|
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
|
||||||
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i <
|
src_i < cur_mend;
|
||||||
cur_mend;
|
attr_t a = A_NORMAL;
|
||||||
if (in_hl && !hl_on) {
|
|
||||||
attron(A_STANDOUT);
|
|
||||||
hl_on = true;
|
|
||||||
}
|
|
||||||
if (!in_hl && hl_on) {
|
|
||||||
attroff(A_STANDOUT);
|
|
||||||
hl_on = false;
|
|
||||||
}
|
|
||||||
if (in_cur && !cur_on) {
|
|
||||||
attron(A_BOLD);
|
|
||||||
cur_on = true;
|
|
||||||
}
|
|
||||||
if (!in_cur && cur_on) {
|
|
||||||
attroff(A_BOLD);
|
|
||||||
cur_on = false;
|
|
||||||
}
|
|
||||||
if (!in_hl && from_src) {
|
|
||||||
apply_token_attr(token_at(src_i));
|
|
||||||
}
|
|
||||||
addch(static_cast<unsigned char>(ch));
|
|
||||||
++written;
|
|
||||||
++render_col;
|
|
||||||
if (from_src)
|
if (from_src)
|
||||||
++src_i;
|
a |= token_attr(token_at(src_i));
|
||||||
|
if (in_sel) {
|
||||||
|
a |= A_REVERSE;
|
||||||
|
} else {
|
||||||
|
if (in_hl)
|
||||||
|
a |= A_STANDOUT;
|
||||||
|
if (in_cur)
|
||||||
|
a |= A_BOLD;
|
||||||
|
}
|
||||||
|
attrset(a);
|
||||||
|
|
||||||
|
if (from_src) {
|
||||||
|
cchar_t cch;
|
||||||
|
wchar_t warr[2] = {wch, L'\0'};
|
||||||
|
setcchar(&cch, warr, 0, 0, nullptr);
|
||||||
|
add_wch(&cch);
|
||||||
|
} else {
|
||||||
|
addch(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
written += disp_w;
|
||||||
|
render_col += disp_w;
|
||||||
|
if (from_src)
|
||||||
|
src_i += wch_len;
|
||||||
if (src_i >= line.size() && written >= cols)
|
if (src_i >= line.size() && written >= cols)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hl_on) {
|
|
||||||
attroff(A_STANDOUT);
|
|
||||||
hl_on = false;
|
|
||||||
}
|
|
||||||
if (cur_on) {
|
|
||||||
attroff(A_BOLD);
|
|
||||||
cur_on = false;
|
|
||||||
}
|
|
||||||
attrset(A_NORMAL);
|
attrset(A_NORMAL);
|
||||||
clrtoeol();
|
clrtoeol();
|
||||||
}
|
}
|
||||||
@@ -306,14 +394,26 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
std::size_t src_i_cur = 0;
|
std::size_t src_i_cur = 0;
|
||||||
std::size_t render_col_cur = 0;
|
std::size_t render_col_cur = 0;
|
||||||
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
|
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
|
||||||
unsigned char ccur = static_cast<unsigned char>(line_for_cursor[src_i_cur]);
|
std::mbstate_t state = std::mbstate_t();
|
||||||
if (ccur == '\t') {
|
wchar_t wch;
|
||||||
|
size_t res = std::mbrtowc(
|
||||||
|
&wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur,
|
||||||
|
&state);
|
||||||
|
|
||||||
|
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||||
|
render_col_cur += 1;
|
||||||
|
src_i_cur += 1;
|
||||||
|
} else if (res == 0) {
|
||||||
|
src_i_cur += 1;
|
||||||
|
} else {
|
||||||
|
if (wch == L'\t') {
|
||||||
std::size_t next_tab = tabw - (render_col_cur % tabw);
|
std::size_t next_tab = tabw - (render_col_cur % tabw);
|
||||||
render_col_cur += next_tab;
|
render_col_cur += next_tab;
|
||||||
++src_i_cur;
|
|
||||||
} else {
|
} else {
|
||||||
++render_col_cur;
|
int dw = wcwidth(wch);
|
||||||
++src_i_cur;
|
render_col_cur += (dw < 0) ? 1 : dw;
|
||||||
|
}
|
||||||
|
src_i_cur += res;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rx_recomputed = render_col_cur;
|
rx_recomputed = render_col_cur;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
TestFrontend::Init(Editor &ed)
|
TestFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||||
{
|
{
|
||||||
|
(void) argc;
|
||||||
|
(void) argv;
|
||||||
ed.SetDimensions(24, 80);
|
ed.SetDimensions(24, 80);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -14,6 +16,9 @@ TestFrontend::Init(Editor &ed)
|
|||||||
void
|
void
|
||||||
TestFrontend::Step(Editor &ed, bool &running)
|
TestFrontend::Step(Editor &ed, bool &running)
|
||||||
{
|
{
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
if (input_.Poll(mi)) {
|
||||||
if (mi.hasCommand) {
|
if (mi.hasCommand) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public:
|
|||||||
|
|
||||||
~TestFrontend() override = default;
|
~TestFrontend() override = default;
|
||||||
|
|
||||||
bool Init(Editor &ed) override;
|
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||||
|
|
||||||
void Step(Editor &ed, bool &running) override;
|
void Step(Editor &ed, bool &running) override;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ struct UndoNode {
|
|||||||
UndoType type{};
|
UndoType type{};
|
||||||
int row{};
|
int row{};
|
||||||
int col{};
|
int col{};
|
||||||
|
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
||||||
std::string text;
|
std::string text;
|
||||||
|
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
||||||
UndoNode *child = nullptr; // next in current timeline
|
UndoNode *child = nullptr; // next in current timeline
|
||||||
UndoNode *next = nullptr; // redo branch
|
UndoNode *next = nullptr; // redo branch
|
||||||
};
|
};
|
||||||
@@ -20,6 +20,7 @@ public:
|
|||||||
available_.pop();
|
available_.pop();
|
||||||
// Node comes zeroed; ensure links are reset
|
// Node comes zeroed; ensure links are reset
|
||||||
node->text.clear();
|
node->text.clear();
|
||||||
|
node->parent = nullptr;
|
||||||
node->child = nullptr;
|
node->child = nullptr;
|
||||||
node->next = nullptr;
|
node->next = nullptr;
|
||||||
node->row = node->col = 0;
|
node->row = node->col = 0;
|
||||||
@@ -34,6 +35,7 @@ public:
|
|||||||
return;
|
return;
|
||||||
// Clear heavy fields to free memory held by strings
|
// Clear heavy fields to free memory held by strings
|
||||||
node->text.clear();
|
node->text.clear();
|
||||||
|
node->parent = nullptr;
|
||||||
node->child = nullptr;
|
node->child = nullptr;
|
||||||
node->next = nullptr;
|
node->next = nullptr;
|
||||||
node->row = node->col = 0;
|
node->row = node->col = 0;
|
||||||
|
|||||||
231
UndoSystem.cc
231
UndoSystem.cc
@@ -8,69 +8,262 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
|||||||
: buf_(&owner), tree_(tree) {}
|
: buf_(&owner), tree_(tree) {}
|
||||||
|
|
||||||
|
|
||||||
|
std::uint64_t
|
||||||
|
UndoSystem::BeginGroup()
|
||||||
|
{
|
||||||
|
// Ensure any pending typed run is sealed so the group is a distinct undo step.
|
||||||
|
commit();
|
||||||
|
if (active_group_id_ == 0)
|
||||||
|
active_group_id_ = next_group_id_++;
|
||||||
|
return active_group_id_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::EndGroup()
|
||||||
|
{
|
||||||
|
commit();
|
||||||
|
active_group_id_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::Begin(UndoType type)
|
UndoSystem::Begin(UndoType type)
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (!buf_)
|
||||||
(void) type;
|
return;
|
||||||
|
const int row = static_cast<int>(buf_->Cury());
|
||||||
|
const int col = static_cast<int>(buf_->Curx());
|
||||||
|
|
||||||
|
// Some operations should always be standalone undo steps.
|
||||||
|
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow);
|
||||||
|
if (always_standalone) {
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tree_.pending) {
|
||||||
|
if (tree_.pending->type == type) {
|
||||||
|
// Typed-run coalescing rules.
|
||||||
|
switch (type) {
|
||||||
|
case UndoType::Insert:
|
||||||
|
case UndoType::Paste: {
|
||||||
|
// Cursor must be at the end of the pending insert.
|
||||||
|
if (tree_.pending->row == row
|
||||||
|
&& col == tree_.pending->col + static_cast<int>(tree_.pending->text.size())) {
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UndoType::Delete: {
|
||||||
|
if (tree_.pending->row == row) {
|
||||||
|
// Two common delete shapes:
|
||||||
|
// 1) backspace-run: cursor moves left each time (so new col is pending.col - 1)
|
||||||
|
// 2) delete-run: cursor stays, always deleting at the same col
|
||||||
|
if (col == tree_.pending->col) {
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col + 1 == tree_.pending->col) {
|
||||||
|
// Extend a backspace run to the left; update the start column now.
|
||||||
|
tree_.pending->col = col;
|
||||||
|
pending_mode_ = PendingAppendMode::Prepend;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UndoType::Newline:
|
||||||
|
case UndoType::DeleteRow:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Can't coalesce: seal the previous pending step.
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a new pending node.
|
||||||
|
tree_.pending = new UndoNode{};
|
||||||
|
tree_.pending->type = type;
|
||||||
|
tree_.pending->row = row;
|
||||||
|
tree_.pending->col = col;
|
||||||
|
tree_.pending->group_id = active_group_id_;
|
||||||
|
tree_.pending->text.clear();
|
||||||
|
tree_.pending->parent = nullptr;
|
||||||
|
tree_.pending->child = nullptr;
|
||||||
|
tree_.pending->next = nullptr;
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::Append(char ch)
|
UndoSystem::Append(char ch)
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (!tree_.pending)
|
||||||
(void) ch;
|
return;
|
||||||
|
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||||
|
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
||||||
|
} else {
|
||||||
|
tree_.pending->text.push_back(ch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::Append(std::string_view text)
|
UndoSystem::Append(std::string_view text)
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (!tree_.pending)
|
||||||
(void) text;
|
return;
|
||||||
|
if (text.empty())
|
||||||
|
return;
|
||||||
|
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||||
|
tree_.pending->text.insert(0, text.data(), text.size());
|
||||||
|
} else {
|
||||||
|
tree_.pending->text.append(text.data(), text.size());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::commit()
|
UndoSystem::commit()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (!tree_.pending)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Drop empty text batches for text-based operations.
|
||||||
|
if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
|
||||||
|
|| tree_.pending->type == UndoType::Paste)
|
||||||
|
&& tree_.pending->text.empty()) {
|
||||||
|
delete tree_.pending;
|
||||||
|
tree_.pending = nullptr;
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tree_.root) {
|
||||||
|
tree_.root = tree_.pending;
|
||||||
|
tree_.pending->parent = nullptr;
|
||||||
|
tree_.current = tree_.pending;
|
||||||
|
} else if (!tree_.current) {
|
||||||
|
// We are at the "pre-first-edit" state (undo past the first node).
|
||||||
|
// In branching history, preserve the existing root chain as an alternate branch.
|
||||||
|
tree_.pending->parent = nullptr;
|
||||||
|
tree_.pending->next = tree_.root;
|
||||||
|
tree_.root = tree_.pending;
|
||||||
|
tree_.current = tree_.pending;
|
||||||
|
} else {
|
||||||
|
// Branching semantics: attach as a new redo branch under current.
|
||||||
|
// Make the new edit the active child by inserting it at the head.
|
||||||
|
tree_.pending->parent = tree_.current;
|
||||||
|
if (!tree_.current->child) {
|
||||||
|
tree_.current->child = tree_.pending;
|
||||||
|
} else {
|
||||||
|
tree_.pending->next = tree_.current->child;
|
||||||
|
tree_.current->child = tree_.pending;
|
||||||
|
}
|
||||||
|
tree_.current = tree_.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
tree_.pending = nullptr;
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::undo()
|
UndoSystem::undo()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
// Seal any in-progress typed run before undo.
|
||||||
|
commit();
|
||||||
|
if (!tree_.current)
|
||||||
|
return;
|
||||||
|
debug_log("undo");
|
||||||
|
const std::uint64_t gid = tree_.current->group_id;
|
||||||
|
do {
|
||||||
|
UndoNode *node = tree_.current;
|
||||||
|
apply(node, -1);
|
||||||
|
tree_.current = node->parent;
|
||||||
|
} while (gid != 0 && tree_.current && tree_.current->group_id == gid);
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::redo()
|
UndoSystem::redo(int branch_index)
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
commit();
|
||||||
|
UndoNode **head = nullptr;
|
||||||
|
if (!tree_.current) {
|
||||||
|
head = &tree_.root;
|
||||||
|
} else {
|
||||||
|
head = &tree_.current->child;
|
||||||
|
}
|
||||||
|
if (!head || !*head)
|
||||||
|
return;
|
||||||
|
if (branch_index < 0)
|
||||||
|
branch_index = 0;
|
||||||
|
|
||||||
|
// Select the Nth sibling from the branch list and make it the active head.
|
||||||
|
UndoNode *prev = nullptr;
|
||||||
|
UndoNode *sel = *head;
|
||||||
|
for (int i = 0; i < branch_index && sel; ++i) {
|
||||||
|
prev = sel;
|
||||||
|
sel = sel->next;
|
||||||
|
}
|
||||||
|
if (!sel)
|
||||||
|
return;
|
||||||
|
if (prev) {
|
||||||
|
prev->next = sel->next;
|
||||||
|
sel->next = *head;
|
||||||
|
*head = sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug_log("redo");
|
||||||
|
UndoNode *node = *head;
|
||||||
|
const std::uint64_t gid = node->group_id;
|
||||||
|
apply(node, +1);
|
||||||
|
tree_.current = node;
|
||||||
|
while (gid != 0 && tree_.current && tree_.current->child
|
||||||
|
&& tree_.current->child->group_id == gid) {
|
||||||
|
UndoNode *child = tree_.current->child;
|
||||||
|
apply(child, +1);
|
||||||
|
tree_.current = child;
|
||||||
|
}
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::mark_saved()
|
UndoSystem::mark_saved()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
commit();
|
||||||
|
tree_.saved = tree_.current;
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::discard_pending()
|
UndoSystem::discard_pending()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (tree_.pending) {
|
||||||
|
delete tree_.pending;
|
||||||
|
tree_.pending = nullptr;
|
||||||
|
}
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::clear()
|
UndoSystem::clear()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
discard_pending();
|
||||||
|
free_node(tree_.root);
|
||||||
|
tree_.root = nullptr;
|
||||||
|
tree_.current = nullptr;
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
active_group_id_ = 0;
|
||||||
|
next_group_id_ = 1;
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -79,34 +272,46 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
{
|
{
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
|
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
||||||
|
// Low-level Buffer edit primitives do not move the cursor.
|
||||||
switch (node->type) {
|
switch (node->type) {
|
||||||
case UndoType::Insert:
|
case UndoType::Insert:
|
||||||
case UndoType::Paste:
|
case UndoType::Paste:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
buf_->insert_text(node->row, node->col, node->text);
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
||||||
|
static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->text.size());
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Delete:
|
case UndoType::Delete:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->text.size());
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
buf_->insert_text(node->row, node->col, node->text);
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
||||||
|
static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Newline:
|
case UndoType::Newline:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->split_line(node->row, node->col);
|
buf_->split_line(node->row, node->col);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||||
} else {
|
} else {
|
||||||
buf_->join_lines(node->row);
|
buf_->join_lines(node->row);
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::DeleteRow:
|
case UndoType::DeleteRow:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->delete_row(node->row);
|
buf_->delete_row(node->row);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->insert_row(node->row, node->text);
|
buf_->insert_row(node->row, node->text);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
70
UndoSystem.h
70
UndoSystem.h
@@ -1,3 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* UndoSystem.h - undo/redo system with tree-based branching
|
||||||
|
*
|
||||||
|
* UndoSystem manages the undo/redo history for a Buffer. It provides:
|
||||||
|
*
|
||||||
|
* - Tree-based undo: Multiple redo branches at each node (not just linear history)
|
||||||
|
* - Atomic grouping: Multiple operations can be undone/redone as a single step
|
||||||
|
* - Dirty tracking: Marks when buffer matches last saved state
|
||||||
|
* - Efficient storage: Nodes stored in UndoTree, operations applied to Buffer
|
||||||
|
*
|
||||||
|
* Key concepts:
|
||||||
|
*
|
||||||
|
* 1. Undo tree structure:
|
||||||
|
* - Each edit creates a node in the tree
|
||||||
|
* - Undo moves up the tree (toward root)
|
||||||
|
* - Redo moves down the tree (toward leaves)
|
||||||
|
* - Multiple redo branches preserved (not lost on new edits after undo)
|
||||||
|
*
|
||||||
|
* 2. Operation lifecycle:
|
||||||
|
* - Begin(type): Start recording an operation (insert/delete)
|
||||||
|
* - Append(text): Add content to the pending operation
|
||||||
|
* - commit(): Finalize and add to undo tree
|
||||||
|
* - discard_pending(): Cancel without recording
|
||||||
|
*
|
||||||
|
* 3. Atomic grouping:
|
||||||
|
* - BeginGroup()/EndGroup(): Bracket multiple operations
|
||||||
|
* - All operations in a group share the same group_id
|
||||||
|
* - Undo/redo treats the entire group as one step
|
||||||
|
*
|
||||||
|
* 4. Integration with Buffer:
|
||||||
|
* - UndoSystem holds a reference to its owning Buffer
|
||||||
|
* - apply() executes undo/redo by calling Buffer's editing methods
|
||||||
|
* - Buffer's dirty flag updated automatically
|
||||||
|
*
|
||||||
|
* Usage pattern:
|
||||||
|
* undo_system.Begin(UndoType::Insert);
|
||||||
|
* undo_system.Append("text");
|
||||||
|
* undo_system.commit(); // Now undoable
|
||||||
|
*
|
||||||
|
* See also: UndoTree.h (storage), UndoNode.h (node structure)
|
||||||
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@@ -12,6 +53,12 @@ class UndoSystem {
|
|||||||
public:
|
public:
|
||||||
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
||||||
|
|
||||||
|
// Begin an atomic group: subsequent committed nodes with the same group_id will be
|
||||||
|
// undone/redone as a single step. Returns the active group id.
|
||||||
|
std::uint64_t BeginGroup();
|
||||||
|
|
||||||
|
void EndGroup();
|
||||||
|
|
||||||
void Begin(UndoType type);
|
void Begin(UndoType type);
|
||||||
|
|
||||||
void Append(char ch);
|
void Append(char ch);
|
||||||
@@ -22,7 +69,10 @@ public:
|
|||||||
|
|
||||||
void undo();
|
void undo();
|
||||||
|
|
||||||
void redo();
|
// Redo the current node's active child branch.
|
||||||
|
// If `branch_index` > 0, selects that redo sibling (0-based) and makes it active.
|
||||||
|
// When current is null (pre-first-edit), branches are selected among `tree_.root` siblings.
|
||||||
|
void redo(int branch_index = 0);
|
||||||
|
|
||||||
void mark_saved();
|
void mark_saved();
|
||||||
|
|
||||||
@@ -32,7 +82,20 @@ public:
|
|||||||
|
|
||||||
void UpdateBufferReference(Buffer &new_buf);
|
void UpdateBufferReference(Buffer &new_buf);
|
||||||
|
|
||||||
|
#if defined(KTE_TESTS)
|
||||||
|
// Test-only introspection hook.
|
||||||
|
const UndoTree &TreeForTests() const
|
||||||
|
{
|
||||||
|
return tree_;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
enum class PendingAppendMode : std::uint8_t {
|
||||||
|
Append,
|
||||||
|
Prepend,
|
||||||
|
};
|
||||||
|
|
||||||
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
||||||
void free_node(UndoNode *node);
|
void free_node(UndoNode *node);
|
||||||
|
|
||||||
@@ -48,6 +111,11 @@ private:
|
|||||||
|
|
||||||
void update_dirty_flag();
|
void update_dirty_flag();
|
||||||
|
|
||||||
|
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
||||||
|
|
||||||
|
std::uint64_t active_group_id_ = 0;
|
||||||
|
std::uint64_t next_group_id_ = 1;
|
||||||
|
|
||||||
Buffer *buf_;
|
Buffer *buf_;
|
||||||
UndoTree &tree_;
|
UndoTree &tree_;
|
||||||
};
|
};
|
||||||
78
cmake/fix_bundle.cmake
Normal file
78
cmake/fix_bundle.cmake
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.15)
|
||||||
|
|
||||||
|
# Fix up a macOS .app bundle by copying non-Qt dylibs into
|
||||||
|
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# cmake -DAPP_BUNDLE=/path/to/kge.app -P cmake/fix_bundle.cmake
|
||||||
|
|
||||||
|
if (NOT APP_BUNDLE)
|
||||||
|
message(FATAL_ERROR "APP_BUNDLE not set. Invoke with -DAPP_BUNDLE=/path/to/App.app")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
get_filename_component(APP_DIR "${APP_BUNDLE}" ABSOLUTE)
|
||||||
|
set(EXECUTABLE "${APP_DIR}/Contents/MacOS/kge")
|
||||||
|
|
||||||
|
if (NOT EXISTS "${EXECUTABLE}")
|
||||||
|
message(FATAL_ERROR "Executable not found at: ${EXECUTABLE}")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
include(BundleUtilities)
|
||||||
|
|
||||||
|
# Directories to search when resolving prerequisites. We include Homebrew so that
|
||||||
|
# if any deps are currently resolved from there, fixup_bundle will copy them into
|
||||||
|
# the bundle and rewrite install names to be self-contained.
|
||||||
|
set(DIRS
|
||||||
|
"/usr/local/lib"
|
||||||
|
"/opt/homebrew/lib"
|
||||||
|
"/opt/homebrew/opt"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
|
||||||
|
# libs it references recursively. Qt frameworks already live in the bundle after
|
||||||
|
# macdeployqt; this step is primarily for non-Qt dylibs (glib, icu, pcre2, zstd,
|
||||||
|
# dbus, etc.).
|
||||||
|
# fixup_bundle often fails if copied libraries are read-only.
|
||||||
|
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
|
||||||
|
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
|
||||||
|
set(gp_otool "/usr/bin/otool")
|
||||||
|
set(CMAKE_INSTALL_NAME_TOOL "/usr/bin/install_name_tool")
|
||||||
|
set(CMAKE_OTOOL "/usr/bin/otool")
|
||||||
|
set(ENV{PATH} "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||||
|
|
||||||
|
execute_process(COMMAND chmod -R u+w "${APP_DIR}/Contents/Frameworks")
|
||||||
|
|
||||||
|
fixup_bundle("${APP_DIR}" "" "${DIRS}")
|
||||||
|
|
||||||
|
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
|
||||||
|
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
|
||||||
|
# for it to be allowed to run.
|
||||||
|
# We sign deep, but sometimes explicit signing of components is more reliable.
|
||||||
|
message(STATUS "Re-signing ${APP_DIR} after fixup...")
|
||||||
|
|
||||||
|
# 1. Sign dylibs in Frameworks
|
||||||
|
file(GLOB_RECURSE DYLIBS "${APP_DIR}/Contents/Frameworks/*.dylib")
|
||||||
|
foreach (DYLIB ${DYLIBS})
|
||||||
|
message(STATUS "Signing ${DYLIB}...")
|
||||||
|
execute_process(COMMAND /usr/bin/codesign --force --sign - "${DYLIB}")
|
||||||
|
endforeach ()
|
||||||
|
|
||||||
|
# 2. Sign nested executables
|
||||||
|
message(STATUS "Signing nested kte...")
|
||||||
|
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kte")
|
||||||
|
|
||||||
|
# 3. Sign the main executable explicitly
|
||||||
|
message(STATUS "Signing main kge...")
|
||||||
|
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kge")
|
||||||
|
|
||||||
|
# 4. Sign the main bundle
|
||||||
|
execute_process(
|
||||||
|
COMMAND /usr/bin/codesign --force --deep --sign - "${APP_DIR}"
|
||||||
|
RESULT_VARIABLE CODESIGN_RESULT
|
||||||
|
)
|
||||||
|
|
||||||
|
if (NOT CODESIGN_RESULT EQUAL 0)
|
||||||
|
message(FATAL_ERROR "Codesign failed with error: ${CODESIGN_RESULT}")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
message(STATUS "fix_bundle.cmake completed for ${APP_DIR}")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
lib,
|
pkgs ? import <nixpkgs> {},
|
||||||
|
lib ? pkgs.lib,
|
||||||
stdenv,
|
stdenv,
|
||||||
cmake,
|
cmake,
|
||||||
ncurses,
|
ncurses,
|
||||||
|
|||||||
28
docker-build.sh
Executable file
28
docker-build.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Helper script to test Linux builds using Docker/Podman
|
||||||
|
# This script mounts the current source tree into a Linux container,
|
||||||
|
# builds kte in terminal-only mode, and runs the test suite.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Detect whether to use docker or podman
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
CONTAINER_CMD="docker"
|
||||||
|
elif command -v podman &> /dev/null; then
|
||||||
|
CONTAINER_CMD="podman"
|
||||||
|
else
|
||||||
|
echo "Error: Neither docker nor podman found in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE_NAME="kte-linux"
|
||||||
|
|
||||||
|
# Check if image exists, if not, build it
|
||||||
|
if ! $CONTAINER_CMD image inspect "$IMAGE_NAME" &> /dev/null; then
|
||||||
|
echo "Building $IMAGE_NAME image..."
|
||||||
|
$CONTAINER_CMD build -t "$IMAGE_NAME" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the container with the current directory mounted
|
||||||
|
echo "Running Linux build and tests..."
|
||||||
|
$CONTAINER_CMD run --rm -v "$(pwd):/kte" "$IMAGE_NAME"
|
||||||
245
docs/BENCHMARKS.md
Normal file
245
docs/BENCHMARKS.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# kte Benchmarking and Testing Guide
|
||||||
|
|
||||||
|
This document describes the benchmarking infrastructure and testing
|
||||||
|
improvements added to ensure high performance and correctness of core
|
||||||
|
operations.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The kte test suite now includes comprehensive benchmarks and migration
|
||||||
|
coverage tests to:
|
||||||
|
|
||||||
|
- Measure performance of core operations (PieceTable, Buffer, syntax
|
||||||
|
highlighting)
|
||||||
|
- Ensure no performance regressions from refactorings
|
||||||
|
- Validate correctness of API migrations (Buffer::Rows() →
|
||||||
|
GetLineString/GetLineView)
|
||||||
|
- Provide performance baselines for future optimizations
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### All Tests (including benchmarks)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
|
||||||
|
search, etc.
|
||||||
|
- **15 benchmark tests**: Performance measurements for critical
|
||||||
|
operations
|
||||||
|
- **30 migration coverage tests**: Edge cases and correctness validation
|
||||||
|
|
||||||
|
Total: **98 tests**
|
||||||
|
|
||||||
|
## Benchmark Results
|
||||||
|
|
||||||
|
### Buffer Iteration Patterns (5,000 lines)
|
||||||
|
|
||||||
|
| Pattern | Time | Speedup vs Rows() |
|
||||||
|
|-----------------------------------------|---------|-------------------|
|
||||||
|
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
|
||||||
|
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
|
||||||
|
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
|
||||||
|
|
||||||
|
**Key Insight**: `GetLineView()` provides zero-copy access and is
|
||||||
|
dramatically faster than materializing the entire rows cache.
|
||||||
|
|
||||||
|
### PieceTable Operations (10,000 lines)
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
|-----------------------------|---------|
|
||||||
|
| Sequential inserts (10K) | 2.1 ms |
|
||||||
|
| Random inserts (5K) | 32.9 ms |
|
||||||
|
| `GetLine()` sequential | 4.7 ms |
|
||||||
|
| `GetLineRange()` sequential | 1.3 ms |
|
||||||
|
|
||||||
|
### Buffer Operations
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
|--------------------------------------|---------|
|
||||||
|
| `Nrows()` (1M calls) | 13.0 ms |
|
||||||
|
| `GetLineString()` (10K lines) | 4.8 ms |
|
||||||
|
| `GetLineView()` (10K lines) | 1.6 ms |
|
||||||
|
| `Rows()` materialization (10K lines) | 6.2 ms |
|
||||||
|
|
||||||
|
### Syntax Highlighting
|
||||||
|
|
||||||
|
| Operation | Time | Notes |
|
||||||
|
|------------------------------------|---------|----------------|
|
||||||
|
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
|
||||||
|
| HighlighterEngine cache population | 19.9 ms | |
|
||||||
|
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
|
||||||
|
|
||||||
|
### Large File Performance
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
|---------------------------------|---------|
|
||||||
|
| Insert 50K lines | 0.53 ms |
|
||||||
|
| Iterate 50K lines (GetLineView) | 2.7 ms |
|
||||||
|
| Random access (10K accesses) | 1.8 ms |
|
||||||
|
|
||||||
|
## API Differences: GetLineString vs GetLineView
|
||||||
|
|
||||||
|
Understanding the difference between these APIs is critical:
|
||||||
|
|
||||||
|
### `GetLineString(row)`
|
||||||
|
|
||||||
|
- Returns: `std::string` (copy)
|
||||||
|
- Content: Line text **without** trailing newline
|
||||||
|
- Use case: When you need to modify the string or store it
|
||||||
|
- Example: `"hello"` for line `"hello\n"`
|
||||||
|
|
||||||
|
### `GetLineView(row)`
|
||||||
|
|
||||||
|
- Returns: `std::string_view` (zero-copy)
|
||||||
|
- Content: Raw line range **including** trailing newline
|
||||||
|
- Use case: Read-only access, maximum performance
|
||||||
|
- Example: `"hello\n"` for line `"hello\n"`
|
||||||
|
- **Warning**: View becomes invalid after buffer modifications
|
||||||
|
|
||||||
|
### `Rows()`
|
||||||
|
|
||||||
|
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
|
||||||
|
- Content: Lines **without** trailing newlines
|
||||||
|
- Use case: Legacy code, being phased out
|
||||||
|
- Performance: Slower due to materialization overhead
|
||||||
|
|
||||||
|
## Migration Coverage Tests
|
||||||
|
|
||||||
|
The `test_migration_coverage.cc` file provides 30 tests covering:
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Empty buffers
|
||||||
|
- Single lines (with/without newlines)
|
||||||
|
- Very long lines (10,000 characters)
|
||||||
|
- Many empty lines (1,000 newlines)
|
||||||
|
|
||||||
|
### Consistency
|
||||||
|
|
||||||
|
- `GetLineString()` vs `GetLineView()` vs `Rows()`
|
||||||
|
- Consistency after edits (insert, delete, split, join)
|
||||||
|
|
||||||
|
### Boundary Conditions
|
||||||
|
|
||||||
|
- First line access
|
||||||
|
- Last line access
|
||||||
|
- Line range boundaries
|
||||||
|
|
||||||
|
### Special Characters
|
||||||
|
|
||||||
|
- Tabs, carriage returns, null bytes
|
||||||
|
- Unicode (UTF-8 multibyte characters)
|
||||||
|
|
||||||
|
### Stress Tests
|
||||||
|
|
||||||
|
- Large files (10,000 lines)
|
||||||
|
- Many small operations (100+ inserts)
|
||||||
|
- Alternating insert/delete patterns
|
||||||
|
|
||||||
|
### Regression Tests
|
||||||
|
|
||||||
|
- Shebang detection pattern (Editor.cc)
|
||||||
|
- Empty buffer check pattern (Editor.cc)
|
||||||
|
- Syntax highlighter pattern (all highlighters)
|
||||||
|
- Swap snapshot pattern (Swap.cc)
|
||||||
|
|
||||||
|
## Performance Recommendations
|
||||||
|
|
||||||
|
Based on benchmark results:
|
||||||
|
|
||||||
|
1. **Prefer `GetLineView()` for read-only access**
|
||||||
|
- 11x faster than `Rows()` for iteration
|
||||||
|
- Zero-copy, minimal overhead
|
||||||
|
- Use immediately (view invalidates on edit)
|
||||||
|
|
||||||
|
2. **Use `GetLineString()` when you need a copy**
|
||||||
|
- Still 1.7x faster than `Rows()`
|
||||||
|
- Safe to store and modify
|
||||||
|
- Strips trailing newlines automatically
|
||||||
|
|
||||||
|
3. **Avoid `Rows()` in hot paths**
|
||||||
|
- Materializes entire line cache
|
||||||
|
- Slower for large files
|
||||||
|
- Being phased out (legacy API)
|
||||||
|
|
||||||
|
4. **Cache `Nrows()` in tight loops**
|
||||||
|
- Very fast (13ms for 1M calls)
|
||||||
|
- But still worth caching in inner loops
|
||||||
|
|
||||||
|
5. **Leverage HighlighterEngine caching**
|
||||||
|
- 38x speedup on cache hits
|
||||||
|
- Automatically invalidates on edits
|
||||||
|
- Prefetch viewport for smooth scrolling
|
||||||
|
|
||||||
|
## Adding New Benchmarks
|
||||||
|
|
||||||
|
To add a new benchmark:
|
||||||
|
|
||||||
|
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
|
||||||
|
2. Use `BenchmarkTimer` to measure critical sections:
|
||||||
|
```cpp
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Operation description");
|
||||||
|
// ... code to benchmark ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Print section headers with `std::cout` for clarity
|
||||||
|
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
TEST(Benchmark_MyOperation) {
|
||||||
|
std::cout << "\n=== My Operation Benchmark ===\n";
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_test_data();
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
std::size_t result = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("My operation on 10K lines");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
result += my_operation(buf, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(result > 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Continuous Performance Monitoring
|
||||||
|
|
||||||
|
Run benchmarks regularly to detect regressions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests and save output
|
||||||
|
./cmake-build-debug/kte_tests > benchmark_results.txt
|
||||||
|
|
||||||
|
# Compare with baseline
|
||||||
|
diff benchmark_baseline.txt benchmark_results.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
|
||||||
|
- Significant time increases (>20%) in any benchmark
|
||||||
|
- New operations that are slower than expected
|
||||||
|
- Cache effectiveness degradation
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The benchmark suite provides:
|
||||||
|
|
||||||
|
- **Performance validation**: Ensures migrations don't regress
|
||||||
|
performance
|
||||||
|
- **Optimization guidance**: Identifies fastest APIs for each use case
|
||||||
|
- **Regression detection**: Catches performance issues early
|
||||||
|
- **Documentation**: Demonstrates correct API usage patterns
|
||||||
|
|
||||||
|
All 98 tests pass with 0 failures, confirming both correctness and
|
||||||
|
performance of the migrated codebase.
|
||||||
1138
docs/DEVELOPER_GUIDE.md
Normal file
1138
docs/DEVELOPER_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
549
docs/audits/error-propagation-standardization.md
Normal file
549
docs/audits/error-propagation-standardization.md
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
# Error Propagation Standardization Report
|
||||||
|
|
||||||
|
**Project:** kte (Kyle's Text Editor)
|
||||||
|
**Date:** 2026-02-17
|
||||||
|
**Auditor:** Error Propagation Standardization Review
|
||||||
|
**Language:** C++20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This report documents the standardization of error propagation patterns
|
||||||
|
across the kte codebase. Following the implementation of centralized
|
||||||
|
error handling (ErrorHandler), this audit identifies inconsistencies in
|
||||||
|
error propagation and provides concrete remediation recommendations.
|
||||||
|
|
||||||
|
**Key Findings:**
|
||||||
|
|
||||||
|
- **Dominant Pattern**: `bool + std::string &err` is used consistently
|
||||||
|
in Buffer and SwapManager for I/O operations
|
||||||
|
- **Inconsistencies**: PieceTable has no error reporting mechanism; some
|
||||||
|
internal helpers lack error propagation
|
||||||
|
- **Standard Chosen**: `bool + std::string &err` pattern (C++20 project,
|
||||||
|
std::expected not available)
|
||||||
|
- **Documentation**: Comprehensive error handling conventions added to
|
||||||
|
DEVELOPER_GUIDE.md
|
||||||
|
|
||||||
|
**Overall Assessment**: The codebase has a **solid foundation** with the
|
||||||
|
`bool + err` pattern used consistently in critical I/O paths. Primary
|
||||||
|
gaps are in PieceTable memory allocation error handling and some
|
||||||
|
internal helper functions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CURRENT STATE ANALYSIS
|
||||||
|
|
||||||
|
### 1.1 Error Propagation Patterns Found
|
||||||
|
|
||||||
|
#### Pattern 1: `bool + std::string &err` (Dominant)
|
||||||
|
|
||||||
|
**Usage**: File I/O, swap operations, resource allocation
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- `Buffer::OpenFromFile(const std::string &path, std::string &err)` (
|
||||||
|
Buffer.h:72)
|
||||||
|
- `Buffer::Save(std::string &err)` (Buffer.h:74)
|
||||||
|
- `Buffer::SaveAs(const std::string &path, std::string &err)` (Buffer.h:
|
||||||
|
75)
|
||||||
|
- `Editor::OpenFile(const std::string &path, std::string &err)` (
|
||||||
|
Editor.h:536)
|
||||||
|
-
|
||||||
|
`SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)` (
|
||||||
|
Swap.h:104)
|
||||||
|
-
|
||||||
|
`SwapManager::open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` (
|
||||||
|
Swap.h:208)
|
||||||
|
-
|
||||||
|
`SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err)` (
|
||||||
|
Swap.h:212-213)
|
||||||
|
|
||||||
|
**Assessment**: ✅ **Excellent** - Consistent, well-implemented,
|
||||||
|
integrated with ErrorHandler
|
||||||
|
|
||||||
|
#### Pattern 2: `void` (State Changes)
|
||||||
|
|
||||||
|
**Usage**: Setters, cursor movement, flag toggles, internal state
|
||||||
|
modifications
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- `Buffer::SetCursor(std::size_t x, std::size_t y)` (Buffer.h:348)
|
||||||
|
- `Buffer::SetDirty(bool d)` (Buffer.h:368)
|
||||||
|
- `Buffer::SetMark(std::size_t x, std::size_t y)` (Buffer.h:387)
|
||||||
|
- `Buffer::insert_text(int row, int col, std::string_view text)` (
|
||||||
|
Buffer.h:545)
|
||||||
|
- `Buffer::delete_text(int row, int col, std::size_t len)` (Buffer.h:
|
||||||
|
547)
|
||||||
|
- `Editor::SetStatus(const std::string &msg)` (Editor.h:various)
|
||||||
|
|
||||||
|
**Assessment**: ✅ **Appropriate** - These operations are infallible
|
||||||
|
state changes
|
||||||
|
|
||||||
|
#### Pattern 3: `bool` without error parameter (Control Flow)
|
||||||
|
|
||||||
|
**Usage**: Validation checks, control flow decisions
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- `Editor::ProcessPendingOpens()` (Editor.h:544)
|
||||||
|
- `Editor::ResolveRecoveryPrompt(bool yes)` (Editor.h:558)
|
||||||
|
- `Editor::SwitchTo(std::size_t index)` (Editor.h:563)
|
||||||
|
- `Editor::CloseBuffer(std::size_t index)` (Editor.h:565)
|
||||||
|
|
||||||
|
**Assessment**: ✅ **Appropriate** - Success/failure is sufficient for
|
||||||
|
control flow
|
||||||
|
|
||||||
|
#### Pattern 4: No Error Reporting (PieceTable)
|
||||||
|
|
||||||
|
**Usage**: Memory allocation, text manipulation
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- `void PieceTable::Reserve(std::size_t newCapacity)` (PieceTable.h:71)
|
||||||
|
- `void PieceTable::Append(const char *s, std::size_t len)` (
|
||||||
|
PieceTable.h:75)
|
||||||
|
-
|
||||||
|
`void PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)` (
|
||||||
|
PieceTable.h:118)
|
||||||
|
- `char *PieceTable::Data()` (PieceTable.h:89-93) - returns nullptr on
|
||||||
|
allocation failure
|
||||||
|
|
||||||
|
**Assessment**: ⚠️ **Gap** - Memory allocation failures are not reported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. STANDARDIZATION DECISION
|
||||||
|
|
||||||
|
### 2.1 Chosen Pattern: `bool + std::string &err`
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
|
||||||
|
1. **C++20 Project**: `std::expected` (C++23) is not available
|
||||||
|
2. **Existing Adoption**: Already used consistently in Buffer,
|
||||||
|
SwapManager, Editor for I/O operations
|
||||||
|
3. **Clear Semantics**: `bool` return indicates success/failure, `err`
|
||||||
|
provides details
|
||||||
|
4. **ErrorHandler Integration**: Works seamlessly with centralized error
|
||||||
|
logging
|
||||||
|
5. **Zero Overhead**: No exceptions, no dynamic allocation for error
|
||||||
|
paths
|
||||||
|
6. **Testability**: Easy to verify error messages in unit tests
|
||||||
|
|
||||||
|
**Alternative Considered**: `std::expected<T, std::string>` (C++23)
|
||||||
|
|
||||||
|
- **Rejected**: Requires C++23, would require major refactoring, not
|
||||||
|
available in current toolchain
|
||||||
|
|
||||||
|
### 2.2 Pattern Selection Guidelines
|
||||||
|
|
||||||
|
| Operation Type | Pattern | Example |
|
||||||
|
|---------------------|---------------------------|-----------------------------------------------------------------------------------|
|
||||||
|
| File I/O | `bool + std::string &err` | `Buffer::Save(std::string &err)` |
|
||||||
|
| Syscalls | `bool + std::string &err` | `open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` |
|
||||||
|
| Resource Allocation | `bool + std::string &err` | Future: `PieceTable::Reserve(std::size_t cap, std::string &err)` |
|
||||||
|
| Parsing/Validation | `bool + std::string &err` | `SwapManager::ReplayFile(Buffer &buf, const std::string &path, std::string &err)` |
|
||||||
|
| State Changes | `void` | `Buffer::SetCursor(std::size_t x, std::size_t y)` |
|
||||||
|
| Control Flow | `bool` (no err) | `Editor::SwitchTo(std::size_t index)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. INCONSISTENCIES AND GAPS
|
||||||
|
|
||||||
|
### 3.1 PieceTable Memory Allocation (Severity: 6/10)
|
||||||
|
|
||||||
|
**Finding**: PieceTable methods that allocate memory (`Reserve`,
|
||||||
|
`Append`, `Insert`, `Data`) do not report allocation failures.
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
|
||||||
|
- Memory allocation failures are silent
|
||||||
|
- `Data()` returns `nullptr` on failure, but callers may not check
|
||||||
|
- Large file operations could fail without user notification
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// PieceTable.h:71
|
||||||
|
void Reserve(std::size_t newCapacity); // No error reporting
|
||||||
|
|
||||||
|
// PieceTable.h:89-93
|
||||||
|
char *Data(); // Returns nullptr on allocation failure
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remediation Priority**: **Medium** - Memory allocation failures are
|
||||||
|
rare on modern systems, but should be handled for robustness
|
||||||
|
|
||||||
|
**Recommended Fix**:
|
||||||
|
|
||||||
|
**Option 1: Add error parameter to fallible operations** (Preferred)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// PieceTable.h
|
||||||
|
bool Reserve(std::size_t newCapacity, std::string &err);
|
||||||
|
bool Append(const char *s, std::size_t len, std::string &err);
|
||||||
|
bool Insert(std::size_t byte_offset, const char *text, std::size_t len, std::string &err);
|
||||||
|
|
||||||
|
// Returns nullptr on failure; check with HasMaterializationError()
|
||||||
|
char *Data();
|
||||||
|
bool HasMaterializationError() const;
|
||||||
|
std::string GetMaterializationError() const;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Use exceptions for allocation failures** (Not recommended)
|
||||||
|
|
||||||
|
PieceTable could throw `std::bad_alloc` on allocation failures, but this
|
||||||
|
conflicts with the project's error handling philosophy and would require
|
||||||
|
exception handling throughout the codebase.
|
||||||
|
|
||||||
|
**Option 3: Status quo with improved documentation** (Minimal change)
|
||||||
|
|
||||||
|
Document that `Data()` can return `nullptr` and callers must check. Add
|
||||||
|
assertions in debug builds.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// PieceTable.h
|
||||||
|
// Returns pointer to materialized buffer, or nullptr if materialization fails.
|
||||||
|
// Callers MUST check for nullptr before dereferencing.
|
||||||
|
char *Data();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**: **Option 3** for now (document + assertions), *
|
||||||
|
*Option 1** if memory allocation errors become a concern in production.
|
||||||
|
|
||||||
|
### 3.2 Internal Helper Functions (Severity: 4/10)
|
||||||
|
|
||||||
|
**Finding**: Some internal helper functions in Swap.cc and Buffer.cc use
|
||||||
|
`bool` returns without error parameters.
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Swap.cc:562
|
||||||
|
static bool ensure_parent_dir(const std::string &path); // No error details
|
||||||
|
|
||||||
|
// Swap.cc:579
|
||||||
|
static bool write_header(int fd); // No error details
|
||||||
|
|
||||||
|
// Buffer.cc:101
|
||||||
|
static bool write_all_fd(int fd, const char *data, std::size_t len, std::string &err); // ✅ Good
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Limited - These are internal helpers called by functions
|
||||||
|
that do report errors
|
||||||
|
|
||||||
|
**Remediation Priority**: **Low** - Callers already provide error
|
||||||
|
context
|
||||||
|
|
||||||
|
**Recommended Fix**: Add error parameters to internal helpers for
|
||||||
|
consistency
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Swap.cc
|
||||||
|
static bool ensure_parent_dir(const std::string &path, std::string &err);
|
||||||
|
static bool write_header(int fd, std::string &err);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: **Deferred** - Low priority, callers already provide
|
||||||
|
adequate error context
|
||||||
|
|
||||||
|
### 3.3 Editor Control Flow Methods (Severity: 2/10)
|
||||||
|
|
||||||
|
**Finding**: Editor methods like `SwitchTo()`, `CloseBuffer()` return
|
||||||
|
`bool` without error details.
|
||||||
|
|
||||||
|
**Assessment**: ✅ **Appropriate** - These are control flow decisions
|
||||||
|
where success/failure is sufficient
|
||||||
|
|
||||||
|
**Remediation**: **None needed** - Current pattern is correct for this
|
||||||
|
use case
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ERRORHANDLER INTEGRATION STATUS
|
||||||
|
|
||||||
|
### 4.1 Components with ErrorHandler Integration
|
||||||
|
|
||||||
|
✅ **Buffer** (Buffer.cc)
|
||||||
|
|
||||||
|
- `OpenFromFile()` - Reports file open, seek, read errors
|
||||||
|
- `Save()` - Reports write errors
|
||||||
|
- `SaveAs()` - Reports write errors
|
||||||
|
|
||||||
|
✅ **SwapManager** (Swap.cc)
|
||||||
|
|
||||||
|
- `report_error()` - All swap file errors reported
|
||||||
|
- Background thread errors captured and logged
|
||||||
|
- Errno captured for all syscalls
|
||||||
|
|
||||||
|
✅ **main** (main.cc)
|
||||||
|
|
||||||
|
- Top-level exception handler reports Critical errors
|
||||||
|
- Both `std::exception` and unknown exceptions captured
|
||||||
|
|
||||||
|
### 4.2 Components Without ErrorHandler Integration
|
||||||
|
|
||||||
|
⚠️ **PieceTable** (PieceTable.cc)
|
||||||
|
|
||||||
|
- No error reporting mechanism
|
||||||
|
- Memory allocation failures are silent
|
||||||
|
|
||||||
|
⚠️ **Editor** (Editor.cc)
|
||||||
|
|
||||||
|
- File operations delegate to Buffer (✅ covered)
|
||||||
|
- Control flow methods don't need error reporting (✅ appropriate)
|
||||||
|
|
||||||
|
⚠️ **Command** (Command.cc)
|
||||||
|
|
||||||
|
- Commands use `Editor::SetStatus()` for user-facing messages
|
||||||
|
- No ErrorHandler integration for command failures
|
||||||
|
- **Assessment**: Commands are user-initiated actions; status messages
|
||||||
|
are appropriate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DOCUMENTATION STATUS
|
||||||
|
|
||||||
|
### 5.1 Error Handling Conventions (DEVELOPER_GUIDE.md)
|
||||||
|
|
||||||
|
✅ **Added comprehensive section** covering:
|
||||||
|
|
||||||
|
- Three standard error propagation patterns
|
||||||
|
- Pattern selection guidelines with decision tree
|
||||||
|
- ErrorHandler integration requirements
|
||||||
|
- Code examples for file I/O, syscalls, background threads, top-level
|
||||||
|
handlers
|
||||||
|
- Anti-patterns and best practices
|
||||||
|
- Error log location and format
|
||||||
|
- Migration guide for updating existing code
|
||||||
|
|
||||||
|
**Location**: `docs/DEVELOPER_GUIDE.md` section 7
|
||||||
|
|
||||||
|
### 5.2 API Documentation
|
||||||
|
|
||||||
|
⚠️ **Gap**: Individual function documentation in headers could be
|
||||||
|
improved
|
||||||
|
|
||||||
|
**Recommendation**: Add brief comments to public APIs documenting error
|
||||||
|
behavior
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Buffer.h
|
||||||
|
// Opens a file and loads its content into the buffer.
|
||||||
|
// Returns false on failure; err contains detailed error message.
|
||||||
|
// Errors are logged to ErrorHandler.
|
||||||
|
bool OpenFromFile(const std::string &path, std::string &err);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. REMEDIATION RECOMMENDATIONS
|
||||||
|
|
||||||
|
### 6.1 High Priority (Severity 7-10)
|
||||||
|
|
||||||
|
**None identified** - Critical error handling gaps were addressed in
|
||||||
|
previous sessions:
|
||||||
|
|
||||||
|
- ✅ Top-level exception handler added (Severity 9/10)
|
||||||
|
- ✅ Background thread error reporting added (Severity 9/10)
|
||||||
|
- ✅ File I/O error checking added (Severity 8/10)
|
||||||
|
- ✅ Errno capture added to swap operations (Severity 7/10)
|
||||||
|
- ✅ Centralized error handling implemented (Severity 7/10)
|
||||||
|
|
||||||
|
### 6.2 Medium Priority (Severity 4-6)
|
||||||
|
|
||||||
|
#### 6.2.1 PieceTable Memory Allocation Error Handling (Severity: 6/10)
|
||||||
|
|
||||||
|
**Action**: Document that `Data()` can return `nullptr` and add debug
|
||||||
|
assertions
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// PieceTable.h
|
||||||
|
// Returns pointer to materialized buffer, or nullptr if materialization fails
|
||||||
|
// due to memory allocation error. Callers MUST check for nullptr.
|
||||||
|
char *Data();
|
||||||
|
|
||||||
|
// PieceTable.cc
|
||||||
|
char *PieceTable::Data() {
|
||||||
|
materialize();
|
||||||
|
assert(materialized_ != nullptr && "PieceTable materialization failed");
|
||||||
|
return materialized_;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: Low (documentation + assertions)
|
||||||
|
**Risk**: Low (no API changes)
|
||||||
|
**Timeline**: Next maintenance cycle
|
||||||
|
|
||||||
|
#### 6.2.2 Add Error Parameters to Internal Helpers (Severity: 4/10)
|
||||||
|
|
||||||
|
**Action**: Add `std::string &err` parameters to `ensure_parent_dir()`
|
||||||
|
and `write_header()`
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Swap.cc
|
||||||
|
static bool ensure_parent_dir(const std::string &path, std::string &err) {
|
||||||
|
try {
|
||||||
|
fs::path p(path);
|
||||||
|
fs::path dir = p.parent_path();
|
||||||
|
if (dir.empty())
|
||||||
|
return true;
|
||||||
|
if (!fs::exists(dir))
|
||||||
|
fs::create_directories(dir);
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
err = std::string("Failed to create directory: ") + e.what();
|
||||||
|
return false;
|
||||||
|
} catch (...) {
|
||||||
|
err = "Failed to create directory: unknown error";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: Low (update 2 functions + call sites)
|
||||||
|
**Risk**: Low (internal helpers only)
|
||||||
|
**Timeline**: Next maintenance cycle
|
||||||
|
|
||||||
|
### 6.3 Low Priority (Severity 1-3)
|
||||||
|
|
||||||
|
#### 6.3.1 Add Function-Level Error Documentation (Severity: 3/10)
|
||||||
|
|
||||||
|
**Action**: Add brief comments to public APIs documenting error behavior
|
||||||
|
|
||||||
|
**Effort**: Medium (many functions to document)
|
||||||
|
**Risk**: None (documentation only)
|
||||||
|
**Timeline**: Ongoing as code is touched
|
||||||
|
|
||||||
|
#### 6.3.2 Add ErrorHandler Integration to Commands (Severity: 2/10)
|
||||||
|
|
||||||
|
**Action**: Consider logging command failures to ErrorHandler for
|
||||||
|
diagnostics
|
||||||
|
|
||||||
|
**Assessment**: **Not recommended** - Commands are user-initiated
|
||||||
|
actions; status messages are more appropriate than error logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. TESTING RECOMMENDATIONS
|
||||||
|
|
||||||
|
### 7.1 Error Handling Test Coverage
|
||||||
|
|
||||||
|
**Current State**:
|
||||||
|
|
||||||
|
- ✅ Swap file error handling tested (test_swap_edge_cases.cc)
|
||||||
|
- ✅ Buffer I/O error handling tested (test_buffer_io.cc)
|
||||||
|
- ⚠️ PieceTable allocation failure testing missing
|
||||||
|
|
||||||
|
**Recommendations**:
|
||||||
|
|
||||||
|
1. **Add PieceTable allocation failure tests** (if Option 1 from 3.1 is
|
||||||
|
implemented)
|
||||||
|
2. **Add ErrorHandler query tests** - Verify error logging and retrieval
|
||||||
|
3. **Add errno capture tests** - Verify errno is captured correctly in
|
||||||
|
syscall failures
|
||||||
|
|
||||||
|
### 7.2 Test Examples
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// test_error_handler.cc
|
||||||
|
TEST(ErrorHandler, LogsErrorsWithContext) {
|
||||||
|
ErrorHandler::Instance().Error("TestComponent", "Test error", "test.txt");
|
||||||
|
EXPECT_TRUE(ErrorHandler::Instance().HasErrors());
|
||||||
|
EXPECT_EQ(ErrorHandler::Instance().GetErrorCount(), 1);
|
||||||
|
std::string last = ErrorHandler::Instance().GetLastError();
|
||||||
|
EXPECT_TRUE(last.find("Test error") != std::string::npos);
|
||||||
|
EXPECT_TRUE(last.find("test.txt") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// test_piece_table.cc (if Option 1 implemented)
|
||||||
|
TEST(PieceTable, ReportsAllocationFailure) {
|
||||||
|
PieceTable pt;
|
||||||
|
std::string err;
|
||||||
|
// Attempt to allocate huge buffer
|
||||||
|
bool ok = pt.Reserve(SIZE_MAX, err);
|
||||||
|
EXPECT_FALSE(ok);
|
||||||
|
EXPECT_FALSE(err.empty());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. MIGRATION CHECKLIST
|
||||||
|
|
||||||
|
For developers updating existing code to follow error handling
|
||||||
|
conventions:
|
||||||
|
|
||||||
|
- [ ] Identify all error-prone operations (file I/O, syscalls,
|
||||||
|
allocations)
|
||||||
|
- [ ] Add `std::string &err` parameter if not present
|
||||||
|
- [ ] Clear `err` at function start: `err.clear();`
|
||||||
|
- [ ] Capture `errno` immediately after syscall failures:
|
||||||
|
`int saved_errno = errno;`
|
||||||
|
- [ ] Build detailed error messages with context (paths, operation
|
||||||
|
details)
|
||||||
|
- [ ] Call `ErrorHandler::Instance().Error()` at all error sites
|
||||||
|
- [ ] Return `false` on failure, `true` on success
|
||||||
|
- [ ] Update all call sites to handle the error parameter
|
||||||
|
- [ ] Write unit tests that verify error handling
|
||||||
|
- [ ] Update function documentation to describe error behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. SUMMARY AND NEXT STEPS
|
||||||
|
|
||||||
|
### 9.1 Achievements
|
||||||
|
|
||||||
|
✅ **Standardized on `bool + std::string &err` pattern** for error-prone
|
||||||
|
operations
|
||||||
|
✅ **Documented comprehensive error handling conventions** in
|
||||||
|
DEVELOPER_GUIDE.md
|
||||||
|
✅ **Identified and prioritized remaining gaps** (PieceTable, internal
|
||||||
|
helpers)
|
||||||
|
✅ **Integrated ErrorHandler** into Buffer, SwapManager, and main
|
||||||
|
✅ **Established clear pattern selection guidelines** for future
|
||||||
|
development
|
||||||
|
|
||||||
|
### 9.2 Remaining Work
|
||||||
|
|
||||||
|
**Medium Priority**:
|
||||||
|
|
||||||
|
1. Document PieceTable `Data()` nullptr behavior and add assertions
|
||||||
|
2. Add error parameters to internal helper functions
|
||||||
|
|
||||||
|
**Low Priority**:
|
||||||
|
|
||||||
|
3. Add function-level error documentation to public APIs
|
||||||
|
4. Add ErrorHandler query tests
|
||||||
|
|
||||||
|
### 9.3 Conclusion
|
||||||
|
|
||||||
|
The kte codebase has achieved **strong error handling consistency** with
|
||||||
|
the `bool + std::string &err` pattern used uniformly across critical I/O
|
||||||
|
paths. The centralized ErrorHandler provides comprehensive logging and
|
||||||
|
UI integration. Remaining gaps are minor and primarily affect edge
|
||||||
|
cases (memory allocation failures) that are rare in practice.
|
||||||
|
|
||||||
|
**Overall Grade**: **B+ (8.5/10)**
|
||||||
|
|
||||||
|
**Strengths**:
|
||||||
|
|
||||||
|
- Consistent error propagation in Buffer and SwapManager
|
||||||
|
- Comprehensive ErrorHandler integration
|
||||||
|
- Excellent documentation in DEVELOPER_GUIDE.md
|
||||||
|
- Errno capture for all syscalls
|
||||||
|
- Top-level exception handling
|
||||||
|
|
||||||
|
**Areas for Improvement**:
|
||||||
|
|
||||||
|
- PieceTable memory allocation error handling
|
||||||
|
- Internal helper function error propagation
|
||||||
|
- Function-level API documentation
|
||||||
|
|
||||||
|
The error handling infrastructure is **production-ready** and provides a
|
||||||
|
solid foundation for reliable operation and debugging.
|
||||||
@@ -12,11 +12,14 @@ Goals
|
|||||||
|
|
||||||
Model overview
|
Model overview
|
||||||
--------------
|
--------------
|
||||||
Per open buffer, maintain a sidecar swap journal next to the file:
|
Per open buffer, maintain a swap journal in a per-user state directory:
|
||||||
|
|
||||||
- Path: `.<basename>.kte.swp` in the same directory as the file (for
|
- Path: `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp` (or
|
||||||
unnamed/unsaved buffers, use a per‑session temp dir like
|
`~/.local/state/kte/swap/...`)
|
||||||
`$TMPDIR/kte/` with a random UUID).
|
where `<encoded-path>` is the file path with separators replaced (e.g.
|
||||||
|
`/home/kyle/tmp/test.txt` → `home!kyle!tmp!test.txt.swp`).
|
||||||
|
Unnamed/unsaved
|
||||||
|
buffers use a unique `unnamed-<pid>-<counter>.swp` name.
|
||||||
- Format: append‑only journal of editing operations with periodic
|
- Format: append‑only journal of editing operations with periodic
|
||||||
checkpoints.
|
checkpoints.
|
||||||
- Crash safety: only append, fsync as per policy; checkpoint via
|
- Crash safety: only append, fsync as per policy; checkpoint via
|
||||||
@@ -84,7 +87,7 @@ Recovery flow
|
|||||||
|
|
||||||
On opening a file:
|
On opening a file:
|
||||||
|
|
||||||
1. Detect swap sidecar `.<basename>.kte.swp`.
|
1. Detect swap journal `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp`.
|
||||||
2. Validate header, iterate records verifying CRCs.
|
2. Validate header, iterate records verifying CRCs.
|
||||||
3. Compare recorded original file identity against actual file; if
|
3. Compare recorded original file identity against actual file; if
|
||||||
mismatch, warn user but allow recovery (content wins).
|
mismatch, warn user but allow recovery (content wins).
|
||||||
@@ -98,7 +101,7 @@ Stability & corruption mitigation
|
|||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
- Append‑only with per‑record CRC32 guards against torn writes.
|
- Append‑only with per‑record CRC32 guards against torn writes.
|
||||||
- Atomic checkpoint rotation: write `.<basename>.kte.swp.tmp`, fsync,
|
- Atomic checkpoint rotation: write `<encoded-path>.swp.tmp`, fsync,
|
||||||
then rename over old `.swp`.
|
then rename over old `.swp`.
|
||||||
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
|
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
|
||||||
64–128 MB). Compaction creates a fresh file with a single checkpoint.
|
64–128 MB). Compaction creates a fresh file with a single checkpoint.
|
||||||
@@ -117,8 +120,8 @@ Security considerations
|
|||||||
Interoperability & UX
|
Interoperability & UX
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
- Use a distinctive extension `.kte.swp` to avoid conflicts with other
|
- Use a distinctive directory (`$XDG_STATE_HOME/kte/swap`) to avoid
|
||||||
editors.
|
conflicts with other editors’ `.swp` conventions.
|
||||||
- Status bar indicator when swap is active; commands to purge/compact.
|
- Status bar indicator when swap is active; commands to purge/compact.
|
||||||
- On save: do not delete swap immediately; keep until the buffer is
|
- On save: do not delete swap immediately; keep until the buffer is
|
||||||
clean and idle for a short grace period (allows undo of accidental
|
clean and idle for a short grace period (allows undo of accidental
|
||||||
|
|||||||
163
docs/plans/test-plan.md
Normal file
163
docs/plans/test-plan.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
### Unit testing plan (headless, no interactive frontend)
|
||||||
|
|
||||||
|
#### Principles
|
||||||
|
- Headless-only: exercise core components directly (`PieceTable`, `Buffer`, `UndoSystem`, `OptimizedSearch`, and minimal `Editor` flows) without starting `kte` or `kge`.
|
||||||
|
- Deterministic and fast: avoid timers, GUI, environment-specific behavior; prefer in-memory operations and temporary files.
|
||||||
|
- Regression-focused: encode prior failures (save/newline mismatch, legacy `rows_` writes) as explicit tests to prevent recurrences.
|
||||||
|
|
||||||
|
#### Harness and execution
|
||||||
|
- Single binary: use target `kte_tests` (already present) to compile and run all tests under `tests/` with the minimal in-tree framework (`tests/Test.h`, `tests/TestRunner.cc`).
|
||||||
|
- No GUI/ncurses deps: link only engine sources (PieceTable/Buffer/Undo/Search/Undo* and syntax minimal set), not frontends.
|
||||||
|
- How to build/run:
|
||||||
|
- Debug profile:
|
||||||
|
```
|
||||||
|
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-debug -DBUILD_TESTS=ON && \
|
||||||
|
cmake --build /Users/kyle/src/kte/cmake-build-debug --target kte_tests && \
|
||||||
|
/Users/kyle/src/kte/cmake-build-debug/kte_tests
|
||||||
|
```
|
||||||
|
- Release profile:
|
||||||
|
```
|
||||||
|
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-release -DBUILD_TESTS=ON && \
|
||||||
|
cmake --build /Users/kyle/src/kte/cmake-build-release --target kte_tests && \
|
||||||
|
/Users/kyle/src/kte/cmake-build-release/kte_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test catalog (summary table)
|
||||||
|
|
||||||
|
The table below catalogs all unit tests defined in this plan. It is headless-only and maps directly to the suites A–H described later. “Implemented” reflects current coverage in `kte_tests`.
|
||||||
|
|
||||||
|
| Suite | ID | Name | Description (1‑line) | Headless | Implemented |
|
||||||
|
|:-----:|:---:|:------------------------------------------|:-------------------------------------------------------------------------------------|:--------:|:-----------:|
|
||||||
|
| A | 1 | SaveAs then Save (append) | New buffer → write two lines → `SaveAs` → append → `Save`; verify exact bytes. | Yes | ✓ |
|
||||||
|
| A | 2 | Open existing then Save | Open seeded file, append, `Save`; verify overwrite bytes. | Yes | ✓ |
|
||||||
|
| A | 3 | Open non-existent then SaveAs | Start from non-existent path, insert `hello, world\n`, `SaveAs`; verify bytes. | Yes | ✓ |
|
||||||
|
| A | 4 | Trailing newline preservation | Verify saving preserves presence/absence of final `\n`. | Yes | Planned |
|
||||||
|
| A | 5 | Empty buffer saves | Empty → `SaveAs` → 0 bytes; then insert `\n` → `Save` → 1 byte. | Yes | Planned |
|
||||||
|
| A | 6 | Large file streaming | 1–4 MiB with periodic newlines; size and content integrity. | Yes | Planned |
|
||||||
|
| A | 7 | Tilde expansion | `SaveAs` with `~/...`; re-open to confirm path/content. | Yes | Planned |
|
||||||
|
| A | 8 | Error propagation | Save to unwritable path → expect failure and error message. | Yes | Planned |
|
||||||
|
| B | 1 | Insert/Delete LineCount | Basic inserts/deletes and line counting sanity. | Yes | ✓ |
|
||||||
|
| B | 2 | Line/Col conversions | `LineColToByteOffset` and reverse around boundaries. | Yes | ✓ |
|
||||||
|
| B | 3 | Delete spanning newlines | Delete ranges that cross line breaks; verify bytes/lines. | Yes | Planned |
|
||||||
|
| B | 4 | Split/Join equivalence | `split_line` followed by `join_lines` yields original bytes. | Yes | Planned |
|
||||||
|
| B | 5 | Stream vs Data equivalence | `WriteToStream` matches `GetRange`/`Data()` after edits. | Yes | Planned |
|
||||||
|
| B | 6 | UTF‑8 bytes stability | Multibyte sequences behave correctly (byte-based ops). | Yes | Planned |
|
||||||
|
| C | 1 | insert_text/delete_text | Edits at start/middle/end; `Rows()` mirrors PieceTable. | Yes | Planned |
|
||||||
|
| C | 2 | split_line/join_lines | Effects and snapshots across multiple positions. | Yes | Planned |
|
||||||
|
| C | 3 | insert_row/delete_row | Replace paragraph by row ops; verify bytes/linecount. | Yes | Planned |
|
||||||
|
| C | 4 | Cache invalidation | After each mutation, `Rows()` matches `LineCount()`. | Yes | Planned |
|
||||||
|
| D | 1 | Grouped insert undo | Contiguous typing undone/redone as a group. | Yes | Planned |
|
||||||
|
| D | 2 | Delete/Newline undo/redo | Backspace/Delete and Newline transitions across undo/redo. | Yes | Planned |
|
||||||
|
| D | 3 | Mark saved & dirty | Dirty/save markers interact correctly with undo/redo. | Yes | Planned |
|
||||||
|
| E | 1 | Search parity basic | `OptimizedSearch::find_all` vs `std::string` reference. | Yes | ✓ |
|
||||||
|
| E | 2 | Large text search | ~1 MiB random text/patterns parity. | Yes | Planned |
|
||||||
|
| F | 1 | Editor open & reload | Open via `Editor`, modify, reload, verify on-disk bytes. | Yes | Planned |
|
||||||
|
| F | 2 | Read-only toggle | Toggle and verify enforcement/behavior of saves. | Yes | Planned |
|
||||||
|
| F | 3 | Prompt lifecycle | Start/Accept/Cancel prompt doesn’t corrupt state. | Yes | Planned |
|
||||||
|
| G | 1 | Saved only newline regression | Insert text + newline; `Save` includes both bytes. | Yes | Planned |
|
||||||
|
| G | 2 | Backspace crash regression | PieceTable-backed delete/join path remains stable. | Yes | Planned |
|
||||||
|
| G | 3 | Overwrite-confirm path | Saving over existing path succeeds and is correct. | Yes | Planned |
|
||||||
|
| H | 1 | Many small edits | 10k small edits; final bytes correct within time bounds. | Yes | Planned |
|
||||||
|
| H | 2 | Consolidation equivalence | After many edits, stream vs data produce identical bytes. | Yes | Planned |
|
||||||
|
|
||||||
|
Legend: Implemented = ✓, Planned = to be added per Coverage roadmap.
|
||||||
|
|
||||||
|
### Test suites and cases
|
||||||
|
|
||||||
|
#### A) Filesystem I/O via Buffer
|
||||||
|
1) SaveAs then Save (append)
|
||||||
|
- New buffer → `insert_text` two lines (explicit `\n`) → `SaveAs(tmp)` → insert a third line → `Save()`.
|
||||||
|
- Assert file bytes equal exact expected string.
|
||||||
|
2) Open existing then Save
|
||||||
|
- Seed a file on disk; `OpenFromFile(path)` → append line → `Save()`.
|
||||||
|
- Assert file bytes updated exactly.
|
||||||
|
3) Open non-existent then SaveAs
|
||||||
|
- `OpenFromFile(nonexistent)` → assert `IsFileBacked()==false` → insert `"hello, world\n"` → `SaveAs(path)`.
|
||||||
|
- Read back exact bytes.
|
||||||
|
4) Trailing newline preservation
|
||||||
|
- Case (a) last line without `\n`; (b) last line with `\n` → save and verify bytes unchanged.
|
||||||
|
5) Empty buffer saves
|
||||||
|
- `SaveAs(tmp)` on empty buffer → 0-byte file. Then insert `"\n"` and `Save()` → 1-byte file.
|
||||||
|
6) Large file streaming
|
||||||
|
- Insert ~1–4 MiB of data with periodic newlines. `SaveAs` then `Save`; verify size matches `content_.Size()` and bytes integrity.
|
||||||
|
7) Path normalization and tilde expansion
|
||||||
|
- `SaveAs("~/.../file.txt")` → verify path expands to `$HOME` and file content round-trips with `OpenFromFile`.
|
||||||
|
8) Error propagation (guarded)
|
||||||
|
- Attempt save into a non-writable path; expect `Save/SaveAs` returns false with non-empty error. Mark as skipped in environments lacking such path.
|
||||||
|
|
||||||
|
#### B) PieceTable semantics
|
||||||
|
1) Line counting and deletion across lines
|
||||||
|
- Insert `"abc\n123\nxyz"` → 3 lines; delete middle line range → 2 lines; validate `GetLine` contents.
|
||||||
|
2) Position conversions
|
||||||
|
- Validate `LineColToByteOffset` and `ByteOffsetToLineCol` at start/end of lines and EOF, especially around `\n`.
|
||||||
|
3) Delete spanning newlines
|
||||||
|
- Remove a range that crosses line boundaries; verify resulting bytes, `LineCount` and line contents.
|
||||||
|
4) Split/join equivalence
|
||||||
|
- Split at various columns; then join adjacent lines; verify bytes equal original.
|
||||||
|
5) WriteToStream vs materialized `Data()`
|
||||||
|
- After multiple inserts/deletes (without forcing `Data()`), stream to `std::ostringstream`; compare with `GetRange(0, Size())`, then call `Data()` and re-compare.
|
||||||
|
6) UTF-8 bytes stability
|
||||||
|
- Insert multibyte sequences (e.g., `"héllo"`, `"中文"`, emoji) as raw bytes; ensure line counting and conversions behave (byte-based API; no crashes/corruption).
|
||||||
|
|
||||||
|
#### C) Buffer editing helpers and rows cache correctness
|
||||||
|
1) `insert_text`/`delete_text`
|
||||||
|
- Apply at start/middle/end of lines; immediately call `Rows()` and validate contents/lengths mirror PieceTable.
|
||||||
|
2) `split_line` and `join_lines`
|
||||||
|
- Verify content effects and `Rows()` snapshots for multiple positions and consecutive operations.
|
||||||
|
3) `insert_row`/`delete_row`
|
||||||
|
- Replace a paragraph by deleting N rows then inserting N′ rows; verify bytes and `LineCount`.
|
||||||
|
4) Cache invalidation
|
||||||
|
- After each mutation, fetch `Rows()`; assert `Nrows() == content.LineCount()` and no stale data remains.
|
||||||
|
|
||||||
|
#### D) UndoSystem semantics
|
||||||
|
1) Grouped contiguous insert undo
|
||||||
|
- Emulate typing at a single location via repeated `insert_text`; one `undo()` should remove the whole run; `redo()` restores it.
|
||||||
|
2) Delete/newline undo/redo
|
||||||
|
- Simulate backspace/delete (`delete_text` and `join_lines`) and newline (`split_line`); verify content transitions across `undo()`/`redo()`.
|
||||||
|
3) Mark saved and dirty flag
|
||||||
|
- After successful save, call `UndoSystem::mark_saved()` (via existing pathways) and ensure dirty state pairing behaves as intended (at least: `SetDirty(false)` plus save does not break undo/redo).
|
||||||
|
|
||||||
|
#### E) Search algorithms
|
||||||
|
1) Parity with `std::string::find`
|
||||||
|
- Use `OptimizedSearch::find_all` across edge cases (empty needle/text, overlaps like `"aaaaa"` vs `"aa"`, Unicode byte sequences). Compare to reference implementation.
|
||||||
|
2) Large text
|
||||||
|
- Random ASCII text ~1 MiB; random patterns; results match reference.
|
||||||
|
|
||||||
|
#### F) Editor non-interactive flows (no frontend)
|
||||||
|
1) Open and reload
|
||||||
|
- Through `Editor`, open file; modify the underlying `Buffer` directly; invoke reload (`Buffer::OpenFromFile` or `cmd_reload_buffer` if you bring `Command.cc` into the test target). Verify bytes match the on-disk file after reload.
|
||||||
|
2) Read-only toggle
|
||||||
|
- Toggle `Buffer::ToggleReadOnly()`; confirm flag value changes and that subsequent saves still execute when not read-only (or, if enforcement exists, that mutations are appropriately restricted).
|
||||||
|
3) Prompt lifecycle (headless)
|
||||||
|
- Exercise `StartPrompt` → `AcceptPrompt` → `CancelPrompt`; ensure state resets and does not corrupt buffer/editor state.
|
||||||
|
|
||||||
|
#### G) Regression tests for reported bugs
|
||||||
|
1) “Saved only newline”
|
||||||
|
- Build buffer content via `insert_text` followed by `split_line` for newline; `Save` then validate bytes include both the text and newline.
|
||||||
|
2) Backspace crash path
|
||||||
|
- Mimic backspace behavior using PieceTable-backed helpers (`delete_text`/`join_lines`); ensure no dependency on legacy `rows_` mutation and no memory issues.
|
||||||
|
3) Overwrite-confirm path behavior
|
||||||
|
- Start with non-file-backed buffer named to collide with an existing file; perform `SaveAs(existing_path)` and assert success and correctness on disk (unit test bypasses interactive confirm, validating underlying write path).
|
||||||
|
|
||||||
|
#### H) Performance/stress sanity
|
||||||
|
1) Many small edits
|
||||||
|
- 10k single-char inserts and interleaved deletes; assert final bytes; keep within conservative runtime bounds.
|
||||||
|
2) Consolidation heuristics
|
||||||
|
- After many edits, call both `WriteToStream` and `Data()` and verify identical bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Coverage roadmap
|
||||||
|
- Phase 1 (already implemented and passing):
|
||||||
|
- Buffer I/O basics (A.1–A.3), PieceTable basics (B.1–B.2), Search parity (E.1).
|
||||||
|
- Phase 2 (add next):
|
||||||
|
- Buffer I/O edge cases (A.4–A.7), deeper PieceTable ops (B.3–B.6), Buffer helpers and cache (C.1–C.4), Undo semantics (D.1–D.2), Regression set (G.1–G.3).
|
||||||
|
- Phase 3:
|
||||||
|
- Editor flows (F.1–F.3), performance/stress (H.1–H.2), and optional integration of `Command.cc` into the test target to exercise non-interactive command execution paths directly.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Use per-test temp files under the repo root or a unique temp directory; ensure cleanup after assertions.
|
||||||
|
- For HOME-dependent tests (tilde expansion), set `HOME` in the test process if not present or skip with a clear message.
|
||||||
|
- On macOS Debug, a benign allocator warning may appear; rely on process exit code for pass/fail.
|
||||||
237
docs/swap.md
Normal file
237
docs/swap.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Swap journaling (crash recovery)
|
||||||
|
|
||||||
|
kte has a small “swap” system: an append-only per-buffer journal that
|
||||||
|
records edits so they can be replayed after a crash.
|
||||||
|
|
||||||
|
This document describes the **currently implemented** swap system (stage
|
||||||
|
2), as implemented in `Swap.h` / `Swap.cc`.
|
||||||
|
|
||||||
|
## What it is (and what it is not)
|
||||||
|
|
||||||
|
- The swap file is a **journal of editing operations** (currently
|
||||||
|
inserts, deletes, and periodic full-buffer checkpoints).
|
||||||
|
- It is written by a **single background writer thread** owned by
|
||||||
|
`kte::SwapManager`.
|
||||||
|
- It is intended for **best-effort crash recovery**.
|
||||||
|
|
||||||
|
kte automatically deletes/resets swap journals after a **clean save**
|
||||||
|
and when
|
||||||
|
closing a clean buffer, so old swap files do not accumulate under normal
|
||||||
|
workflows. A best-effort prune also runs at startup to remove very old
|
||||||
|
swap
|
||||||
|
files.
|
||||||
|
|
||||||
|
## Automatic recovery prompt
|
||||||
|
|
||||||
|
When kte opens a file-backed buffer, it checks whether a corresponding
|
||||||
|
swap journal exists.
|
||||||
|
|
||||||
|
- If a swap file exists and replay succeeds *and* produces different
|
||||||
|
content than what is currently on disk, kte prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Recover swap edits for <path>? (y/N, C-g cancel)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `y`: open the file and apply swap replay (buffer becomes dirty)
|
||||||
|
- `Enter` (default) / any non-`y`: delete the swap file (
|
||||||
|
best-effort)
|
||||||
|
and open the file normally
|
||||||
|
- `C-g`: cancel opening the file
|
||||||
|
|
||||||
|
- If a swap file exists but is unreadable/corrupt, kte prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Swap file unreadable for <path>. Delete it? (y/N, C-g cancel)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `y`: delete the swap file (best-effort) and open the file normally
|
||||||
|
- `Enter` (default): keep the swap file and open the file normally
|
||||||
|
- `C-g`: cancel opening the file
|
||||||
|
|
||||||
|
## Where swap files live
|
||||||
|
|
||||||
|
Swap files are stored under an XDG-style per-user *state* directory:
|
||||||
|
|
||||||
|
- If `XDG_STATE_HOME` is set and non-empty:
|
||||||
|
- `$XDG_STATE_HOME/kte/swap/…`
|
||||||
|
- Otherwise, if `HOME` is set:
|
||||||
|
- `~/.local/state/kte/swap/…`
|
||||||
|
- Last resort fallback:
|
||||||
|
- `<system-temp>/kte/state/kte/swap/…` (via
|
||||||
|
`std::filesystem::temp_directory_path()`)
|
||||||
|
|
||||||
|
Swap files are always created with permissions `0600`.
|
||||||
|
|
||||||
|
### Swap file naming
|
||||||
|
|
||||||
|
For file-backed buffers, the swap filename is derived from the buffer’s
|
||||||
|
path:
|
||||||
|
|
||||||
|
1. Take a canonical-ish path key (`std::filesystem::weakly_canonical`,
|
||||||
|
else `absolute`, else the raw `Buffer::Filename()`).
|
||||||
|
2. Encode it so it’s human-identifiable:
|
||||||
|
- Strip one leading path separator (`/` or `\\`)
|
||||||
|
- Replace path separators (`/` and `\\`) with `!`
|
||||||
|
- Append `.swp`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/kyle/tmp/test.txt -> home!kyle!tmp!test.txt.swp
|
||||||
|
```
|
||||||
|
|
||||||
|
If the resulting name would be long (over ~200 characters), kte falls
|
||||||
|
back to a shorter stable name:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<basename>.<fnv1a64(path-key-as-hex)>.swp
|
||||||
|
```
|
||||||
|
|
||||||
|
For unnamed/unsaved buffers, kte uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unnamed-<pid>-<counter>.swp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle (when swap is written)
|
||||||
|
|
||||||
|
`kte::SwapManager` is owned by `Editor` (see `Editor.cc`). Buffers are
|
||||||
|
attached for journaling when they are added/opened.
|
||||||
|
|
||||||
|
- `SwapManager::Attach(Buffer*)` starts tracking a buffer and
|
||||||
|
establishes its swap path.
|
||||||
|
- `Buffer` emits swap events from its low-level edit APIs:
|
||||||
|
- `Buffer::insert_text()` calls `SwapRecorder::OnInsert()`
|
||||||
|
- `Buffer::delete_text()` calls `SwapRecorder::OnDelete()`
|
||||||
|
- `Buffer::split_line()` / `join_lines()` are represented as
|
||||||
|
insert/delete of `\n` (they do **not** emit `SPLIT`/`JOIN` records
|
||||||
|
in stage 1).
|
||||||
|
- `SwapManager::Detach(Buffer*)` flushes queued records, `fsync()`s, and
|
||||||
|
closes the journal.
|
||||||
|
- On `Save As` / filename changes,
|
||||||
|
`SwapManager::NotifyFilenameChanged(Buffer&)` closes the existing
|
||||||
|
journal and switches to a new path.
|
||||||
|
- Note: the old swap file is currently left on disk (no
|
||||||
|
cleanup/rotation yet).
|
||||||
|
|
||||||
|
## Durability and performance
|
||||||
|
|
||||||
|
Swap writing is best-effort and asynchronous:
|
||||||
|
|
||||||
|
- Records are queued from the UI/editing thread(s).
|
||||||
|
- A background writer thread wakes at least every
|
||||||
|
`SwapConfig::flush_interval_ms` (default `200ms`) to write any queued
|
||||||
|
records.
|
||||||
|
- `fsync()` is throttled to at most once per
|
||||||
|
`SwapConfig::fsync_interval_ms` (default `1000ms`) per open swap file.
|
||||||
|
- `SwapManager::Flush()` blocks until the queue is fully written; it is
|
||||||
|
primarily used by tests and shutdown paths.
|
||||||
|
|
||||||
|
If a crash happens while writing, the swap file may end with a partial
|
||||||
|
record. Replay detects truncation/CRC mismatch and fails safely.
|
||||||
|
|
||||||
|
## On-disk format (v1)
|
||||||
|
|
||||||
|
The file is:
|
||||||
|
|
||||||
|
1. A fixed-size 64-byte header
|
||||||
|
2. Followed by a stream of records
|
||||||
|
|
||||||
|
All multi-byte integers in the swap file are **little-endian**.
|
||||||
|
|
||||||
|
### Header (64 bytes)
|
||||||
|
|
||||||
|
Layout (stage 1):
|
||||||
|
|
||||||
|
- `magic` (8 bytes): `KTE_SWP\0`
|
||||||
|
- `version` (`u32`): currently `1`
|
||||||
|
- `flags` (`u32`): currently `0`
|
||||||
|
- `created_time` (`u64`): Unix seconds
|
||||||
|
- remaining bytes are reserved/padding (currently zeroed)
|
||||||
|
|
||||||
|
### Record framing
|
||||||
|
|
||||||
|
Each record is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[type: u8][len: u24][payload: len bytes][crc32: u32]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `len` is a 24-bit little-endian length of the payload (`0..0xFFFFFF`).
|
||||||
|
- `crc32` is computed over the 4-byte record header (`type + len`)
|
||||||
|
followed by the payload bytes.
|
||||||
|
|
||||||
|
### Record types
|
||||||
|
|
||||||
|
Type codes are defined in `SwapRecType` (`Swap.h`). Stage 1 primarily
|
||||||
|
emits:
|
||||||
|
|
||||||
|
- `INS` (`1`): insert bytes at `(row, col)`
|
||||||
|
- `DEL` (`2`): delete `len` bytes at `(row, col)`
|
||||||
|
|
||||||
|
Other type codes exist for forward compatibility (`SPLIT`, `JOIN`,
|
||||||
|
`META`, `CHKPT`), but are not produced by the current `SwapRecorder`
|
||||||
|
interface.
|
||||||
|
|
||||||
|
### Payload encoding (v1)
|
||||||
|
|
||||||
|
Every payload starts with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[encver: u8]
|
||||||
|
```
|
||||||
|
|
||||||
|
Currently `encver` must be `1`.
|
||||||
|
|
||||||
|
#### `INS` payload (encver = 1)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[encver: u8 = 1]
|
||||||
|
[row: u32]
|
||||||
|
[col: u32]
|
||||||
|
[nbytes:u32]
|
||||||
|
[bytes: nbytes]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `DEL` payload (encver = 1)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[encver: u8 = 1]
|
||||||
|
[row: u32]
|
||||||
|
[col: u32]
|
||||||
|
[len: u32]
|
||||||
|
```
|
||||||
|
|
||||||
|
`row`/`col` are 0-based and are interpreted the same way as
|
||||||
|
`Buffer::insert_text()` / `Buffer::delete_text()`.
|
||||||
|
|
||||||
|
## Replay / recovery
|
||||||
|
|
||||||
|
Swap replay is implemented as a low-level API:
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
`bool kte::SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- The caller supplies an **already-open** `Buffer` (typically loaded
|
||||||
|
from the on-disk file) and a swap path.
|
||||||
|
- `ReplayFile()` validates header magic/version, then iterates records.
|
||||||
|
- On a truncated file or CRC mismatch, it returns `false` and sets
|
||||||
|
`err`.
|
||||||
|
- On unknown record types, it ignores them (forward compatibility).
|
||||||
|
- On failure, the buffer may have had a prefix of records applied;
|
||||||
|
callers should treat this as “recovery failed”.
|
||||||
|
|
||||||
|
Important: if the buffer is currently attached to a `SwapManager`, you
|
||||||
|
should suspend/disable recording during replay (or detach first),
|
||||||
|
otherwise replayed edits would be re-journaled.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Swap behavior and format are validated by unit tests:
|
||||||
|
|
||||||
|
- `tests/test_swap_writer.cc` (header, permissions, record CRC framing)
|
||||||
|
- `tests/test_swap_replay.cc` (record replay and truncation handling)
|
||||||
5438
fonts/BerkeleyMono.h
Normal file
5438
fonts/BerkeleyMono.h
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
#include "Font.h"
|
#include "Font.h"
|
||||||
|
#include "IosevkaExtended.h"
|
||||||
|
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
|
|
||||||
@@ -8,16 +9,32 @@ Font::Load(const float size) const
|
|||||||
{
|
{
|
||||||
const ImGuiIO &io = ImGui::GetIO();
|
const ImGuiIO &io = ImGui::GetIO();
|
||||||
io.Fonts->Clear();
|
io.Fonts->Clear();
|
||||||
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
|
|
||||||
|
ImFontConfig config;
|
||||||
|
config.MergeMode = false;
|
||||||
|
|
||||||
|
// Load Basic Latin + Latin Supplement
|
||||||
|
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||||
this->data_,
|
this->data_,
|
||||||
this->size_,
|
this->size_,
|
||||||
size);
|
size,
|
||||||
|
&config,
|
||||||
|
io.Fonts->GetGlyphRangesDefault());
|
||||||
|
|
||||||
if (!font) {
|
// Merge Greek and Mathematical symbols from IosevkaExtended as fallback
|
||||||
font = io.Fonts->AddFontDefault();
|
config.MergeMode = true;
|
||||||
}
|
static const ImWchar extended_ranges[] = {
|
||||||
|
0x0370, 0x03FF, // Greek and Coptic
|
||||||
|
0x2200, 0x22FF, // Mathematical Operators
|
||||||
|
0,
|
||||||
|
};
|
||||||
|
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||||
|
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedData,
|
||||||
|
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedSize,
|
||||||
|
size,
|
||||||
|
&config,
|
||||||
|
extended_ranges);
|
||||||
|
|
||||||
(void) font;
|
|
||||||
io.Fonts->Build();
|
io.Fonts->Build();
|
||||||
}
|
}
|
||||||
} // namespace kte::Fonts
|
} // namespace kte::Fonts
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "BrassMonoCode.h"
|
#include "BerkeleyMono.h"
|
||||||
|
|
||||||
namespace kte::Fonts {
|
namespace kte::Fonts {
|
||||||
// Provide default embedded font aliases used by GUIFrontend fallback loader
|
// Provide default embedded font aliases used by GUIFrontend fallback loader
|
||||||
inline const unsigned int DefaultFontSize = BrassMonoCode::DefaultFontBoldCompressedSize;
|
inline const unsigned int DefaultFontSize = BerkeleyMono::DefaultFontRegularCompressedSize;
|
||||||
inline const unsigned int *DefaultFontData = BrassMonoCode::DefaultFontBoldCompressedData;
|
inline const unsigned int *DefaultFontData = BerkeleyMono::DefaultFontRegularCompressedData;
|
||||||
|
|
||||||
class Font {
|
class Font {
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "B612Mono.h"
|
#include "B612Mono.h"
|
||||||
|
#include "BerkeleyMono.h"
|
||||||
#include "BrassMono.h"
|
#include "BrassMono.h"
|
||||||
#include "BrassMonoCode.h"
|
#include "BrassMonoCode.h"
|
||||||
#include "FiraCode.h"
|
#include "FiraCode.h"
|
||||||
|
|||||||
@@ -7,14 +7,24 @@ InstallDefaultFonts()
|
|||||||
{
|
{
|
||||||
FontRegistry::Instance().Register(std::make_unique<Font>(
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
"default",
|
"default",
|
||||||
BrassMono::DefaultFontBoldCompressedData,
|
BerkeleyMono::DefaultFontBoldCompressedData,
|
||||||
BrassMono::DefaultFontBoldCompressedSize
|
BerkeleyMono::DefaultFontBoldCompressedSize
|
||||||
));
|
));
|
||||||
FontRegistry::Instance().Register(std::make_unique<Font>(
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
"b612",
|
"b612",
|
||||||
B612Mono::DefaultFontRegularCompressedData,
|
B612Mono::DefaultFontRegularCompressedData,
|
||||||
B612Mono::DefaultFontRegularCompressedSize
|
B612Mono::DefaultFontRegularCompressedSize
|
||||||
));
|
));
|
||||||
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
|
"berkeley",
|
||||||
|
BerkeleyMono::DefaultFontRegularCompressedData,
|
||||||
|
BerkeleyMono::DefaultFontRegularCompressedSize
|
||||||
|
));
|
||||||
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
|
"berkeley-bold",
|
||||||
|
BerkeleyMono::DefaultFontBoldCompressedData,
|
||||||
|
BerkeleyMono::DefaultFontBoldCompressedSize
|
||||||
|
));
|
||||||
FontRegistry::Instance().Register(std::make_unique<Font>(
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
"brassmono",
|
"brassmono",
|
||||||
BrassMono::DefaultFontRegularCompressedData,
|
BrassMono::DefaultFontRegularCompressedData,
|
||||||
|
|||||||
25719
fonts/Go.h
25719
fonts/Go.h
File diff suppressed because it is too large
Load Diff
22047
fonts/Triplicate.h
22047
fonts/Triplicate.h
File diff suppressed because it is too large
Load Diff
59
main.cc
59
main.cc
@@ -1,3 +1,4 @@
|
|||||||
|
#include <clocale>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "Frontend.h"
|
#include "Frontend.h"
|
||||||
#include "TerminalFrontend.h"
|
#include "TerminalFrontend.h"
|
||||||
|
#include "ErrorHandler.h"
|
||||||
|
|
||||||
#if defined(KTE_BUILD_GUI)
|
#if defined(KTE_BUILD_GUI)
|
||||||
#if defined(KTE_USE_QT)
|
#if defined(KTE_USE_QT)
|
||||||
@@ -111,8 +113,10 @@ RunStressHighlighter(unsigned seconds)
|
|||||||
|
|
||||||
|
|
||||||
int
|
int
|
||||||
main(int argc, const char *argv[])
|
main(int argc, char *argv[])
|
||||||
{
|
{
|
||||||
|
std::setlocale(LC_ALL, "");
|
||||||
|
|
||||||
Editor editor;
|
Editor editor;
|
||||||
|
|
||||||
// CLI parsing using getopt_long
|
// CLI parsing using getopt_long
|
||||||
@@ -133,7 +137,7 @@ main(int argc, const char *argv[])
|
|||||||
int opt;
|
int opt;
|
||||||
int long_index = 0;
|
int long_index = 0;
|
||||||
unsigned stress_seconds = 0;
|
unsigned stress_seconds = 0;
|
||||||
while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "gthV", long_opts, &long_index)) != -1) {
|
while ((opt = getopt_long(argc, argv, "gthV", long_opts, &long_index)) != -1) {
|
||||||
switch (opt) {
|
switch (opt) {
|
||||||
case 'g':
|
case 'g':
|
||||||
req_gui = true;
|
req_gui = true;
|
||||||
@@ -178,10 +182,13 @@ main(int argc, const char *argv[])
|
|||||||
return RunStressHighlighter(stress_seconds);
|
return RunStressHighlighter(stress_seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top-level exception handler to prevent data loss and ensure cleanup
|
||||||
|
try {
|
||||||
// Determine frontend
|
// Determine frontend
|
||||||
#if !defined(KTE_BUILD_GUI)
|
#if !defined(KTE_BUILD_GUI)
|
||||||
if (req_gui) {
|
if (req_gui) {
|
||||||
std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed." <<
|
std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed."
|
||||||
|
<<
|
||||||
std::endl;
|
std::endl;
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
@@ -194,6 +201,7 @@ main(int argc, const char *argv[])
|
|||||||
} else {
|
} else {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Default depends on build target: kge defaults to GUI, kte to terminal
|
// Default depends on build target: kge defaults to GUI, kte to terminal
|
||||||
#if defined(KTE_DEFAULT_GUI)
|
#if defined(KTE_DEFAULT_GUI)
|
||||||
use_gui = true;
|
use_gui = true;
|
||||||
@@ -206,6 +214,9 @@ main(int argc, const char *argv[])
|
|||||||
// Open files passed on the CLI; support +N to jump to line N in the next file.
|
// Open files passed on the CLI; support +N to jump to line N in the next file.
|
||||||
// If no files are provided, create an empty buffer.
|
// If no files are provided, create an empty buffer.
|
||||||
if (optind < argc) {
|
if (optind < argc) {
|
||||||
|
// Seed a scratch buffer so the UI has something to show while deferred opens
|
||||||
|
// (and potential swap recovery prompts) are processed.
|
||||||
|
editor.AddBuffer(Buffer());
|
||||||
std::size_t pending_line = 0; // 0 = no pending line
|
std::size_t pending_line = 0; // 0 = no pending line
|
||||||
for (int i = optind; i < argc; ++i) {
|
for (int i = optind; i < argc; ++i) {
|
||||||
const char *arg = argv[i];
|
const char *arg = argv[i];
|
||||||
@@ -241,29 +252,9 @@ main(int argc, const char *argv[])
|
|||||||
// Fall through: not a +number, treat as filename starting with '+'
|
// Fall through: not a +number, treat as filename starting with '+'
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string err;
|
|
||||||
const std::string path = arg;
|
const std::string path = arg;
|
||||||
if (!editor.OpenFile(path, err)) {
|
editor.RequestOpenFile(path, pending_line);
|
||||||
editor.SetStatus("open: " + err);
|
pending_line = 0; // consumed (if set)
|
||||||
std::cerr << "kte: " << err << "\n";
|
|
||||||
} else if (pending_line > 0) {
|
|
||||||
// Apply pending +N to the just-opened (current) buffer
|
|
||||||
if (Buffer *b = editor.CurrentBuffer()) {
|
|
||||||
std::size_t nrows = b->Nrows();
|
|
||||||
std::size_t line = pending_line > 0 ? pending_line - 1 : 0;
|
|
||||||
// 1-based to 0-based
|
|
||||||
if (nrows > 0) {
|
|
||||||
if (line >= nrows)
|
|
||||||
line = nrows - 1;
|
|
||||||
} else {
|
|
||||||
line = 0;
|
|
||||||
}
|
|
||||||
b->SetCursor(0, line);
|
|
||||||
// Do not force viewport offsets here; the frontend/renderer
|
|
||||||
// will establish dimensions and normalize visibility on first draw.
|
|
||||||
}
|
|
||||||
pending_line = 0; // consumed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// If we ended with a pending +N but no subsequent file, ignore it.
|
// If we ended with a pending +N but no subsequent file, ignore it.
|
||||||
} else {
|
} else {
|
||||||
@@ -302,11 +293,13 @@ main(int argc, const char *argv[])
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if (!fe->Init(editor)) {
|
if (!fe->Init(argc, argv, editor)) {
|
||||||
std::cerr << "kte: failed to initialize frontend" << std::endl;
|
std::cerr << "kte: failed to initialize frontend" << std::endl;
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Execute(editor, CommandId::CenterOnCursor);
|
||||||
|
|
||||||
bool running = true;
|
bool running = true;
|
||||||
while (running) {
|
while (running) {
|
||||||
fe->Step(editor, running);
|
fe->Step(editor, running);
|
||||||
@@ -315,4 +308,18 @@ main(int argc, const char *argv[])
|
|||||||
fe->Shutdown();
|
fe->Shutdown();
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
std::string msg = std::string("Unhandled exception: ") + e.what();
|
||||||
|
kte::ErrorHandler::Instance().Critical("main", msg);
|
||||||
|
std::cerr << "\n*** FATAL ERROR ***\n"
|
||||||
|
<< "kte encountered an unhandled exception: " << e.what() << "\n"
|
||||||
|
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
|
||||||
|
return 1;
|
||||||
|
} catch (...) {
|
||||||
|
kte::ErrorHandler::Instance().Critical("main", "Unknown exception");
|
||||||
|
std::cerr << "\n*** FATAL ERROR ***\n"
|
||||||
|
<< "kte encountered an unknown exception.\n"
|
||||||
|
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,14 +16,18 @@ open .
|
|||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
mkdir -p cmake-build-release-qt
|
mkdir -p cmake-build-release-qt
|
||||||
cmake -S . -B cmake-build-release -DBUILD_GUI=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
|
cmake -S . -B cmake-build-release-qt -DBUILD_GUI=ON -DKTE_USE_QT=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
|
||||||
|
|
||||||
cd cmake-build-release-qt
|
cd cmake-build-release-qt
|
||||||
make clean
|
make clean
|
||||||
rm -fr kge.app* kge-qt.app*
|
rm -fr kge.app* kge-qt.app*
|
||||||
make
|
make
|
||||||
mv kge.app kge-qt.app
|
mv -f kge.app kge-qt.app
|
||||||
macdeployqt -always-overwrite kge-qt.app
|
# Use the same Qt's macdeployqt as used for building; ensure it overwrites in-bundle paths
|
||||||
|
macdeployqt kge-qt.app -always-overwrite -verbose=3
|
||||||
|
|
||||||
|
# Run CMake BundleUtilities fixup to internalize non-Qt dylibs and rewrite install names
|
||||||
|
cmake -DAPP_BUNDLE="$(pwd)/kge-qt.app" -P "${PWD%/*}/cmake/fix_bundle.cmake"
|
||||||
zip -r kge-qt.app.zip kge-qt.app
|
zip -r kge-qt.app.zip kge-qt.app
|
||||||
sha256sum kge-qt.app.zip
|
sha256sum kge-qt.app.zip
|
||||||
open .
|
open .
|
||||||
|
|||||||
@@ -60,11 +60,10 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
|||||||
const LineState &prev,
|
const LineState &prev,
|
||||||
std::vector<HighlightSpan> &out) const
|
std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
StatefulHighlighter::LineState state = prev;
|
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;
|
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())
|
if (s.empty())
|
||||||
return state;
|
return state;
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ ErlangHighlighter::ErlangHighlighter()
|
|||||||
void
|
void
|
||||||
ErlangHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
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) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
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 n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ ForthHighlighter::ForthHighlighter()
|
|||||||
void
|
void
|
||||||
ForthHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
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) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
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 n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,9 @@ GoHighlighter::GoHighlighter()
|
|||||||
void
|
void
|
||||||
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
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) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
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 n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
int bol = 0;
|
int bol = 0;
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
|
|||||||
// Only use cached state if it's for the current version and row still exists
|
// Only use cached state if it's for the current version and row still exists
|
||||||
if (r <= row - 1 && kv.second.version == buf_version) {
|
if (r <= row - 1 && kv.second.version == buf_version) {
|
||||||
// Validate that the cached row index is still valid in the buffer
|
// 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)
|
if (r > best)
|
||||||
best = r;
|
best = r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ is_digit(char c)
|
|||||||
void
|
void
|
||||||
JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
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) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
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 n = static_cast<int>(s.size());
|
||||||
auto push = [&](int a, int b, TokenKind k) {
|
auto push = [&](int a, int b, TokenKind k) {
|
||||||
if (b > a)
|
if (b > a)
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ LispHighlighter::LispHighlighter()
|
|||||||
void
|
void
|
||||||
LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
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) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
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 n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
int bol = 0;
|
int bol = 0;
|
||||||
|
|||||||
@@ -24,10 +24,9 @@ MarkdownHighlighter::HighlightLineStateful(const Buffer &buf, int row, const Lin
|
|||||||
std::vector<HighlightSpan> &out) const
|
std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
StatefulHighlighter::LineState state = prev;
|
StatefulHighlighter::LineState state = prev;
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return state;
|
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());
|
int n = static_cast<int>(s.size());
|
||||||
|
|
||||||
// Reuse in_block_comment flag as "in fenced code" state.
|
// Reuse in_block_comment flag as "in fenced code" state.
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ namespace kte {
|
|||||||
void
|
void
|
||||||
NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
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) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
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 n = static_cast<int>(s.size());
|
||||||
if (n <= 0)
|
if (n <= 0)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -50,10 +50,9 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS
|
|||||||
std::vector<HighlightSpan> &out) const
|
std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
StatefulHighlighter::LineState state = prev;
|
StatefulHighlighter::LineState state = prev;
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return state;
|
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());
|
int n = static_cast<int>(s.size());
|
||||||
|
|
||||||
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
||||||
|
|||||||
@@ -47,10 +47,9 @@ RustHighlighter::RustHighlighter()
|
|||||||
void
|
void
|
||||||
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
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) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
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 n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
while (i < n) {
|
while (i < n) {
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k)
|
|||||||
void
|
void
|
||||||
ShellHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
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) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
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 n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
// if first non-space is '#', whole line is comment
|
// if first non-space is '#', whole line is comment
|
||||||
|
|||||||
@@ -47,10 +47,9 @@ SqlHighlighter::SqlHighlighter()
|
|||||||
void
|
void
|
||||||
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
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) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
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 n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
// Verify OptimizedSearch against std::string reference across patterns and sizes
|
|
||||||
#include <cassert>
|
|
||||||
#include <cstddef>
|
|
||||||
#include <random>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "OptimizedSearch.h"
|
|
||||||
|
|
||||||
|
|
||||||
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;
|
|
||||||
std::size_t from = 0;
|
|
||||||
while (true) {
|
|
||||||
auto p = text.find(pat, from);
|
|
||||||
if (p == std::string::npos)
|
|
||||||
break;
|
|
||||||
res.push_back(p);
|
|
||||||
from = p + pat.size(); // non-overlapping
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static void
|
|
||||||
run_case(std::size_t textLen, std::size_t patLen, unsigned seed)
|
|
||||||
{
|
|
||||||
std::mt19937 rng(seed);
|
|
||||||
std::uniform_int_distribution<int> dist('a', 'z');
|
|
||||||
std::string text(textLen, '\0');
|
|
||||||
for (auto &ch: text)
|
|
||||||
ch = static_cast<char>(dist(rng));
|
|
||||||
std::string pat(patLen, '\0');
|
|
||||||
for (auto &ch: pat)
|
|
||||||
ch = static_cast<char>(dist(rng));
|
|
||||||
|
|
||||||
// Guarantee at least one match when possible
|
|
||||||
if (textLen >= patLen && patLen > 0) {
|
|
||||||
std::size_t pos = textLen / 3;
|
|
||||||
if (pos + patLen <= text.size())
|
|
||||||
std::copy(pat.begin(), pat.end(), text.begin() + static_cast<long>(pos));
|
|
||||||
}
|
|
||||||
|
|
||||||
OptimizedSearch os;
|
|
||||||
auto got = os.find_all(text, pat, 0);
|
|
||||||
auto ref = ref_find_all(text, pat);
|
|
||||||
assert(got == ref);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
int
|
|
||||||
main()
|
|
||||||
{
|
|
||||||
// Edge cases
|
|
||||||
run_case(0, 0, 1);
|
|
||||||
run_case(0, 1, 2);
|
|
||||||
run_case(1, 0, 3);
|
|
||||||
run_case(1, 1, 4);
|
|
||||||
|
|
||||||
// Various sizes
|
|
||||||
for (std::size_t t = 128; t <= 4096; t *= 2) {
|
|
||||||
for (std::size_t p = 1; p <= 64; p *= 2) {
|
|
||||||
run_case(t, p, static_cast<unsigned>(t + p));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Larger random
|
|
||||||
run_case(100000, 16, 12345);
|
|
||||||
run_case(250000, 32, 67890);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
338
test_undo.cc
338
test_undo.cc
@@ -1,338 +0,0 @@
|
|||||||
#include <cassert>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iostream>
|
|
||||||
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include "Command.h"
|
|
||||||
#include "Editor.h"
|
|
||||||
#include "TestFrontend.h"
|
|
||||||
|
|
||||||
|
|
||||||
int
|
|
||||||
main()
|
|
||||||
{
|
|
||||||
// Install default commands
|
|
||||||
InstallDefaultCommands();
|
|
||||||
|
|
||||||
Editor editor;
|
|
||||||
TestFrontend frontend;
|
|
||||||
|
|
||||||
// Initialize frontend
|
|
||||||
if (!frontend.Init(editor)) {
|
|
||||||
std::cerr << "Failed to initialize frontend\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a temporary test file
|
|
||||||
std::string err;
|
|
||||||
const char *tmpfile = "/tmp/kte_test_undo.txt";
|
|
||||||
{
|
|
||||||
std::ofstream f(tmpfile);
|
|
||||||
if (!f) {
|
|
||||||
std::cerr << "Failed to create temp file\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
f << "\n"; // Write one newline so file isn't empty
|
|
||||||
f.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editor.OpenFile(tmpfile, err)) {
|
|
||||||
std::cerr << "Failed to open test file: " << err << "\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Buffer *buf = editor.CurrentBuffer();
|
|
||||||
assert(buf != nullptr);
|
|
||||||
|
|
||||||
// Initialize cursor to (0,0) explicitly
|
|
||||||
buf->SetCursor(0, 0);
|
|
||||||
|
|
||||||
std::cout << "test_undo: Testing undo/redo system\n";
|
|
||||||
std::cout << "====================================\n\n";
|
|
||||||
|
|
||||||
bool running = true;
|
|
||||||
|
|
||||||
// Test 1: Insert text and verify buffer contains expected text
|
|
||||||
std::cout << "Test 1: Insert text 'Hello'\n";
|
|
||||||
frontend.Input().QueueText("Hello");
|
|
||||||
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(buf->Rows().size() >= 1);
|
|
||||||
std::string line_after_insert = std::string(buf->Rows()[0]);
|
|
||||||
assert(line_after_insert == "Hello");
|
|
||||||
std::cout << " Buffer content: '" << line_after_insert << "'\n";
|
|
||||||
std::cout << " ✓ Text insertion verified\n\n";
|
|
||||||
|
|
||||||
// Test 2: Undo insertion - text should be removed
|
|
||||||
std::cout << "Test 2: Undo insertion\n";
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(buf->Rows().size() >= 1);
|
|
||||||
std::string line_after_undo = std::string(buf->Rows()[0]);
|
|
||||||
assert(line_after_undo == "");
|
|
||||||
std::cout << " Buffer content: '" << line_after_undo << "'\n";
|
|
||||||
std::cout << " ✓ Undo successful - text removed\n\n";
|
|
||||||
|
|
||||||
// Test 3: Redo insertion - text should be restored
|
|
||||||
std::cout << "Test 3: Redo insertion\n";
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(buf->Rows().size() >= 1);
|
|
||||||
std::string line_after_redo = std::string(buf->Rows()[0]);
|
|
||||||
assert(line_after_redo == "Hello");
|
|
||||||
std::cout << " Buffer content: '" << line_after_redo << "'\n";
|
|
||||||
std::cout << " ✓ Redo successful - text restored\n\n";
|
|
||||||
|
|
||||||
// Test 4: Branching behavior – redo is discarded after new edits
|
|
||||||
std::cout << "Test 4: Branching behavior (redo discarded after new edits)\n";
|
|
||||||
// Reset to empty by undoing the last redo and the original insert, then reinsert 'abc'
|
|
||||||
// Ensure buffer is empty before starting this scenario
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo); // undo Hello
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "");
|
|
||||||
|
|
||||||
// Type a contiguous word 'abc' (single batch)
|
|
||||||
frontend.Input().QueueText("abc");
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "abc");
|
|
||||||
|
|
||||||
// Undo once – should remove the whole batch and leave empty
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "");
|
|
||||||
|
|
||||||
// Now type new text 'X' – this should create a new branch and discard old redo chain
|
|
||||||
frontend.Input().QueueText("X");
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "X");
|
|
||||||
|
|
||||||
// Attempt Redo – should be a no-op (redo branch was discarded by new edit)
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "X");
|
|
||||||
// Undo and Redo along the new branch should still work
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "X");
|
|
||||||
std::cout << " ✓ Redo discarded after new edit; new branch undo/redo works\n\n";
|
|
||||||
|
|
||||||
// Clear buffer state for next tests: undo to empty if needed
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "");
|
|
||||||
|
|
||||||
// Test 5: UTF-8 insertion and undo/redo round-trip
|
|
||||||
std::cout << "Test 5: UTF-8 insertion 'é漢' and undo/redo\n";
|
|
||||||
const std::string utf8_text = "é漢"; // multi-byte UTF-8 (2 bytes + 3 bytes)
|
|
||||||
frontend.Input().QueueText(utf8_text);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == utf8_text);
|
|
||||||
// Undo should remove the entire contiguous insertion batch
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "");
|
|
||||||
// Redo restores it
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == utf8_text);
|
|
||||||
std::cout << " ✓ UTF-8 insert round-trips with undo/redo\n\n";
|
|
||||||
|
|
||||||
// Clear for next test
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "");
|
|
||||||
|
|
||||||
// Test 6: Multi-line operations (newline split and join via backspace at BOL)
|
|
||||||
std::cout << "Test 6: Newline split and join via backspace at BOL\n";
|
|
||||||
// Insert "ab" then newline then "cd" → expect two lines
|
|
||||||
frontend.Input().QueueText("ab");
|
|
||||||
frontend.Input().QueueCommand(CommandId::Newline);
|
|
||||||
frontend.Input().QueueText("cd");
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(buf->Rows().size() >= 2);
|
|
||||||
assert(std::string(buf->Rows()[0]) == "ab");
|
|
||||||
assert(std::string(buf->Rows()[1]) == "cd");
|
|
||||||
std::cout << " ✓ Split into two lines\n";
|
|
||||||
|
|
||||||
// Undo once – should remove "cd" insertion leaving two lines ["ab", ""] or join depending on commit
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
// Current design batches typing on the second line; after undo, the second line should exist but be empty
|
|
||||||
assert(buf->Rows().size() >= 2);
|
|
||||||
assert(std::string(buf->Rows()[0]) == "ab");
|
|
||||||
assert(std::string(buf->Rows()[1]) == "");
|
|
||||||
|
|
||||||
// Undo the newline – should rejoin to a single line "ab"
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(buf->Rows().size() >= 1);
|
|
||||||
assert(std::string(buf->Rows()[0]) == "ab");
|
|
||||||
|
|
||||||
// Redo twice to get back to ["ab","cd"]
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "ab");
|
|
||||||
assert(std::string(buf->Rows()[1]) == "cd");
|
|
||||||
std::cout << " ✓ Newline undo/redo round-trip\n";
|
|
||||||
|
|
||||||
// Now join via Backspace at beginning of second line
|
|
||||||
frontend.Input().QueueCommand(CommandId::MoveDown); // ensure we're on the second line
|
|
||||||
frontend.Input().QueueCommand(CommandId::MoveHome); // go to BOL on second line
|
|
||||||
frontend.Input().QueueCommand(CommandId::Backspace); // join with previous line
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(buf->Rows().size() >= 1);
|
|
||||||
assert(std::string(buf->Rows()[0]) == "abcd");
|
|
||||||
std::cout << " ✓ Backspace at BOL joins lines\n";
|
|
||||||
|
|
||||||
// Undo/Redo the join
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(buf->Rows().size() >= 1);
|
|
||||||
assert(std::string(buf->Rows()[0]) == "abcd");
|
|
||||||
std::cout << " ✓ Join undo/redo round-trip\n\n";
|
|
||||||
|
|
||||||
// Test 7: Typing batching – a contiguous word undone in one step
|
|
||||||
std::cout << "Test 7: Typing batching (single undo removes whole word)\n";
|
|
||||||
// Clear current line first
|
|
||||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
|
||||||
frontend.Input().QueueCommand(CommandId::KillToEOL);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]).empty());
|
|
||||||
// Type a word and verify one undo clears it
|
|
||||||
frontend.Input().QueueText("hello");
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "hello");
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]).empty());
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "hello");
|
|
||||||
std::cout << " ✓ Contiguous typing batched into single undo step\n\n";
|
|
||||||
|
|
||||||
// Test 8: Forward delete batching at a fixed anchor column
|
|
||||||
std::cout << "Test 8: Forward delete batching at fixed anchor (DeleteChar)\n";
|
|
||||||
// Prepare line content
|
|
||||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
|
||||||
frontend.Input().QueueCommand(CommandId::KillToEOL);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
frontend.Input().QueueText("abcdef");
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
// Ensure cursor at anchor column 0
|
|
||||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
|
||||||
// Delete three chars at cursor; should batch into one Delete node
|
|
||||||
frontend.Input().QueueCommand(CommandId::DeleteChar, "", 3);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "def");
|
|
||||||
// Single undo should restore the entire deleted run
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
|
||||||
// Redo should remove the same run again
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "def");
|
|
||||||
std::cout << " ✓ Forward delete batched and undo/redo round-trips\n\n";
|
|
||||||
|
|
||||||
// Test 9: Backspace batching with prepend rule (cursor moves left)
|
|
||||||
std::cout << "Test 9: Backspace batching with prepend rule\n";
|
|
||||||
// Restore to full string then backspace a run
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo); // bring back to "abcdef"
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
|
||||||
// Move to end and backspace three characters; should batch into one Delete node
|
|
||||||
frontend.Input().QueueCommand(CommandId::MoveEnd);
|
|
||||||
frontend.Input().QueueCommand(CommandId::Backspace, "", 3);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "abc");
|
|
||||||
// Single undo restores the deleted run
|
|
||||||
frontend.Input().QueueCommand(CommandId::Undo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
|
||||||
// Redo removes it again
|
|
||||||
frontend.Input().QueueCommand(CommandId::Redo);
|
|
||||||
while (!frontend.Input().IsEmpty() && running) {
|
|
||||||
frontend.Step(editor, running);
|
|
||||||
}
|
|
||||||
assert(std::string(buf->Rows()[0]) == "abc");
|
|
||||||
std::cout << " ✓ Backspace run batched and undo/redo round-trips\n\n";
|
|
||||||
|
|
||||||
frontend.Shutdown();
|
|
||||||
|
|
||||||
std::cout << "====================================\n";
|
|
||||||
std::cout << "All tests passed!\n";
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
77
tests/Test.h
Normal file
77
tests/Test.h
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Minimal header-only unit test framework for kte
|
||||||
|
#pragma once
|
||||||
|
#include <functional>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <chrono>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace ktet {
|
||||||
|
struct TestCase {
|
||||||
|
std::string name;
|
||||||
|
std::function<void()> fn;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
inline std::vector<TestCase> &
|
||||||
|
registry()
|
||||||
|
{
|
||||||
|
static std::vector<TestCase> r;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct Registrar {
|
||||||
|
Registrar(const char *name, std::function<void()> fn)
|
||||||
|
{
|
||||||
|
registry().push_back(TestCase{std::string(name), std::move(fn)});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
struct AssertionFailure {
|
||||||
|
std::string msg;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// 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) \
|
||||||
|
static void name(); \
|
||||||
|
static ::ktet::Registrar _reg_##name(#name, &name); \
|
||||||
|
static void name()
|
||||||
|
|
||||||
|
#define EXPECT_TRUE(x) ::ktet::expect((x), #x, __FILE__, __LINE__)
|
||||||
|
#define ASSERT_TRUE(x) ::ktet::assert_true((x), #x, __FILE__, __LINE__)
|
||||||
|
#define ASSERT_EQ(a,b) ::ktet::assert_eq_impl((a),(b), #a, #b, __FILE__, __LINE__)
|
||||||
138
tests/TestHarness.h
Normal file
138
tests/TestHarness.h
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// TestHarness.h - small helper layer for driving kte headlessly in tests
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
namespace ktet {
|
||||||
|
inline void
|
||||||
|
InstallDefaultCommandsOnce()
|
||||||
|
{
|
||||||
|
static bool installed = false;
|
||||||
|
if (!installed) {
|
||||||
|
InstallDefaultCommands();
|
||||||
|
installed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestHarness {
|
||||||
|
public:
|
||||||
|
TestHarness()
|
||||||
|
{
|
||||||
|
InstallDefaultCommandsOnce();
|
||||||
|
editor_.SetDimensions(24, 80);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
b.SetVirtualName("+TEST+");
|
||||||
|
editor_.AddBuffer(std::move(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Editor &
|
||||||
|
EditorRef()
|
||||||
|
{
|
||||||
|
return editor_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Buffer &
|
||||||
|
Buf()
|
||||||
|
{
|
||||||
|
return *editor_.CurrentBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] const Buffer &
|
||||||
|
Buf() const
|
||||||
|
{
|
||||||
|
return *editor_.CurrentBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Exec(CommandId id, const std::string &arg = std::string(), int ucount = 0)
|
||||||
|
{
|
||||||
|
if (ucount > 0) {
|
||||||
|
editor_.SetUniversalArg(1, ucount);
|
||||||
|
} else {
|
||||||
|
editor_.UArgClear();
|
||||||
|
}
|
||||||
|
return Execute(editor_, id, arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
InsertText(std::string_view text)
|
||||||
|
{
|
||||||
|
if (text.find('\n') != std::string_view::npos || text.find('\r') != std::string_view::npos)
|
||||||
|
return false;
|
||||||
|
return Exec(CommandId::InsertText, std::string(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
TypeText(std::string_view text)
|
||||||
|
{
|
||||||
|
for (char ch: text) {
|
||||||
|
if (ch == '\n') {
|
||||||
|
Exec(CommandId::Newline);
|
||||||
|
} else if (ch == '\r') {
|
||||||
|
// ignore
|
||||||
|
} else {
|
||||||
|
Exec(CommandId::InsertText, std::string(1, ch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::string
|
||||||
|
Text() const
|
||||||
|
{
|
||||||
|
const auto &rows = Buf().Rows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
out += static_cast<std::string>(rows[i]);
|
||||||
|
if (i + 1 < rows.size())
|
||||||
|
out.push_back('\n');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::string
|
||||||
|
Line(std::size_t y) const
|
||||||
|
{
|
||||||
|
return Buf().GetLineString(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
SaveAs(const std::string &path, std::string &err)
|
||||||
|
{
|
||||||
|
return Buf().SaveAs(path, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Undo(int ucount = 0)
|
||||||
|
{
|
||||||
|
return Exec(CommandId::Undo, std::string(), ucount);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Redo(int ucount = 0)
|
||||||
|
{
|
||||||
|
return Exec(CommandId::Redo, std::string(), ucount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
Editor editor_;
|
||||||
|
};
|
||||||
|
} // namespace ktet
|
||||||
36
tests/TestRunner.cc
Normal file
36
tests/TestRunner.cc
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
#include <iostream>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
main()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
auto ® = ktet::registry();
|
||||||
|
std::cout << "kte unit tests: " << reg.size() << " test(s)\n";
|
||||||
|
int failed = 0;
|
||||||
|
auto t0 = steady_clock::now();
|
||||||
|
for (const auto &tc: reg) {
|
||||||
|
auto ts = steady_clock::now();
|
||||||
|
try {
|
||||||
|
tc.fn();
|
||||||
|
auto te = steady_clock::now();
|
||||||
|
auto ms = duration_cast<milliseconds>(te - ts).count();
|
||||||
|
std::cout << "[ OK ] " << tc.name << " (" << ms << " ms)\n";
|
||||||
|
} catch (const ktet::AssertionFailure &e) {
|
||||||
|
++failed;
|
||||||
|
std::cerr << "[FAIL] " << tc.name << " -> " << e.msg << "\n";
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
++failed;
|
||||||
|
std::cerr << "[EXCP] " << tc.name << " -> " << e.what() << "\n";
|
||||||
|
} catch (...) {
|
||||||
|
++failed;
|
||||||
|
std::cerr << "[EXCP] " << tc.name << " -> unknown exception\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto t1 = steady_clock::now();
|
||||||
|
auto total_ms = duration_cast<milliseconds>(t1 - t0).count();
|
||||||
|
std::cout << "Done in " << total_ms << " ms. Failures: " << failed << "\n";
|
||||||
|
return failed == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
411
tests/test_benchmarks.cc
Normal file
411
tests/test_benchmarks.cc
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
/*
|
||||||
|
* test_benchmarks.cc - Performance benchmarks for core kte operations
|
||||||
|
*
|
||||||
|
* This file measures the performance of critical operations to ensure
|
||||||
|
* that migrations and refactorings don't introduce performance regressions.
|
||||||
|
*
|
||||||
|
* Benchmarks cover:
|
||||||
|
* - PieceTable operations (insert, delete, GetLine, GetLineRange)
|
||||||
|
* - Buffer operations (Nrows, GetLineString, GetLineView)
|
||||||
|
* - Iteration patterns (comparing old Rows() vs new GetLineString/GetLineView)
|
||||||
|
* - Syntax highlighting on large files
|
||||||
|
*
|
||||||
|
* Each benchmark reports execution time in milliseconds.
|
||||||
|
*/
|
||||||
|
#include "Test.h"
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "PieceTable.h"
|
||||||
|
#include "syntax/CppHighlighter.h"
|
||||||
|
#include "syntax/HighlighterEngine.h"
|
||||||
|
#include <chrono>
|
||||||
|
#include <iostream>
|
||||||
|
#include <random>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Benchmark timing utility
|
||||||
|
class BenchmarkTimer {
|
||||||
|
public:
|
||||||
|
BenchmarkTimer(const char *name) : name_(name), start_(std::chrono::high_resolution_clock::now()) {}
|
||||||
|
|
||||||
|
|
||||||
|
~BenchmarkTimer()
|
||||||
|
{
|
||||||
|
auto end = std::chrono::high_resolution_clock::now();
|
||||||
|
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start_);
|
||||||
|
double ms = duration.count() / 1000.0;
|
||||||
|
std::cout << " [BENCH] " << name_ << ": " << ms << " ms\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
const char *name_;
|
||||||
|
std::chrono::high_resolution_clock::time_point start_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate test data
|
||||||
|
std::string
|
||||||
|
generate_large_file(std::size_t num_lines, std::size_t avg_line_length)
|
||||||
|
{
|
||||||
|
std::mt19937 rng(42);
|
||||||
|
std::string result;
|
||||||
|
result.reserve(num_lines * (avg_line_length + 1));
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < num_lines; ++i) {
|
||||||
|
std::size_t line_len = avg_line_length + (rng() % 20) - 10; // ±10 chars variation
|
||||||
|
for (std::size_t j = 0; j < line_len; ++j) {
|
||||||
|
char c = 'a' + (rng() % 26);
|
||||||
|
result.push_back(c);
|
||||||
|
}
|
||||||
|
result.push_back('\n');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
generate_cpp_code(std::size_t num_lines)
|
||||||
|
{
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << "#include <iostream>\n";
|
||||||
|
oss << "#include <vector>\n";
|
||||||
|
oss << "#include <string>\n\n";
|
||||||
|
oss << "namespace test {\n";
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < num_lines / 10; ++i) {
|
||||||
|
oss << "class TestClass" << i << " {\n";
|
||||||
|
oss << "public:\n";
|
||||||
|
oss << " void method" << i << "() {\n";
|
||||||
|
oss << " // Comment line\n";
|
||||||
|
oss << " int x = " << i << ";\n";
|
||||||
|
oss << " std::string s = \"test string\";\n";
|
||||||
|
oss << " for (int j = 0; j < 100; ++j) {\n";
|
||||||
|
oss << " x += j;\n";
|
||||||
|
oss << " }\n";
|
||||||
|
oss << " }\n";
|
||||||
|
oss << "};\n\n";
|
||||||
|
}
|
||||||
|
oss << "} // namespace test\n";
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
} // anonymous namespace
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PieceTable Benchmarks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Benchmark_PieceTable_Sequential_Inserts)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== PieceTable Sequential Insert Benchmark ===\n";
|
||||||
|
PieceTable pt;
|
||||||
|
const std::size_t num_ops = 10000;
|
||||||
|
const char *text = "line\n";
|
||||||
|
const std::size_t text_len = 5;
|
||||||
|
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("10K sequential inserts at end");
|
||||||
|
for (std::size_t i = 0; i < num_ops; ++i) {
|
||||||
|
pt.Insert(pt.Size(), text, text_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSERT_EQ(pt.LineCount(), num_ops + 1); // +1 for final empty line
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Benchmark_PieceTable_Random_Inserts)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== PieceTable Random Insert Benchmark ===\n";
|
||||||
|
PieceTable pt;
|
||||||
|
const std::size_t num_ops = 5000;
|
||||||
|
const char *text = "xyz\n";
|
||||||
|
const std::size_t text_len = 4;
|
||||||
|
std::mt19937 rng(123);
|
||||||
|
|
||||||
|
// Pre-populate with some content
|
||||||
|
std::string initial = generate_large_file(1000, 50);
|
||||||
|
pt.Insert(0, initial.data(), initial.size());
|
||||||
|
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("5K random inserts");
|
||||||
|
for (std::size_t i = 0; i < num_ops; ++i) {
|
||||||
|
std::size_t pos = rng() % (pt.Size() + 1);
|
||||||
|
pt.Insert(pos, text, text_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Benchmark_PieceTable_GetLine_Sequential)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== PieceTable GetLine Sequential Benchmark ===\n";
|
||||||
|
PieceTable pt;
|
||||||
|
std::string data = generate_large_file(10000, 80);
|
||||||
|
pt.Insert(0, data.data(), data.size());
|
||||||
|
|
||||||
|
std::size_t total_chars = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("GetLine on 10K lines (sequential)");
|
||||||
|
for (std::size_t i = 0; i < pt.LineCount(); ++i) {
|
||||||
|
std::string line = pt.GetLine(i);
|
||||||
|
total_chars += line.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(total_chars > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Benchmark_PieceTable_GetLineRange_Sequential)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== PieceTable GetLineRange Sequential Benchmark ===\n";
|
||||||
|
PieceTable pt;
|
||||||
|
std::string data = generate_large_file(10000, 80);
|
||||||
|
pt.Insert(0, data.data(), data.size());
|
||||||
|
|
||||||
|
std::size_t total_ranges = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("GetLineRange on 10K lines (sequential)");
|
||||||
|
for (std::size_t i = 0; i < pt.LineCount(); ++i) {
|
||||||
|
auto range = pt.GetLineRange(i);
|
||||||
|
total_ranges += (range.second - range.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(total_ranges > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Buffer Benchmarks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Benchmark_Buffer_Nrows_Repeated_Calls)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== Buffer Nrows Benchmark ===\n";
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_large_file(10000, 80);
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
std::size_t sum = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("1M calls to Nrows()");
|
||||||
|
for (int i = 0; i < 1000000; ++i) {
|
||||||
|
sum += buf.Nrows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(sum > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Benchmark_Buffer_GetLineString_Sequential)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== Buffer GetLineString Sequential Benchmark ===\n";
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_large_file(10000, 80);
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
std::size_t total_chars = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("GetLineString on 10K lines");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
std::string line = buf.GetLineString(i);
|
||||||
|
total_chars += line.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(total_chars > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Benchmark_Buffer_GetLineView_Sequential)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== Buffer GetLineView Sequential Benchmark ===\n";
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_large_file(10000, 80);
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
std::size_t total_chars = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("GetLineView on 10K lines");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
auto view = buf.GetLineView(i);
|
||||||
|
total_chars += view.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(total_chars > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Benchmark_Buffer_Rows_Materialization)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== Buffer Rows() Materialization Benchmark ===\n";
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_large_file(10000, 80);
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
std::size_t total_chars = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Rows() materialization + iteration on 10K lines");
|
||||||
|
const auto &rows = buf.Rows();
|
||||||
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
total_chars += rows[i].size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(total_chars > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Benchmark_Buffer_Iteration_Comparison)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== Buffer Iteration Pattern Comparison ===\n";
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_large_file(5000, 80);
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
std::size_t sum1 = 0, sum2 = 0, sum3 = 0;
|
||||||
|
|
||||||
|
// Pattern 1: Old style with Rows()
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Pattern 1: Rows() + iteration");
|
||||||
|
const auto &rows = buf.Rows();
|
||||||
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
sum1 += rows[i].size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: New style with GetLineString
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Pattern 2: Nrows() + GetLineString");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
sum2 += buf.GetLineString(i).size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: New style with GetLineView (zero-copy)
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Pattern 3: Nrows() + GetLineView (zero-copy)");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
sum3 += buf.GetLineView(i).size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sum1 and sum2 should match (both strip newlines)
|
||||||
|
ASSERT_EQ(sum1, sum2);
|
||||||
|
// sum3 includes newlines, so it will be larger
|
||||||
|
EXPECT_TRUE(sum3 > sum2);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Syntax Highlighting Benchmarks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Benchmark_Syntax_CppHighlighter_Large_File)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== Syntax Highlighting Benchmark ===\n";
|
||||||
|
Buffer buf;
|
||||||
|
std::string cpp_code = generate_cpp_code(1000);
|
||||||
|
buf.insert_text(0, 0, cpp_code);
|
||||||
|
buf.EnsureHighlighter();
|
||||||
|
|
||||||
|
auto highlighter = std::make_unique<kte::CppHighlighter>();
|
||||||
|
std::size_t total_spans = 0;
|
||||||
|
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("C++ highlighting on ~1000 lines");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
std::vector<kte::HighlightSpan> spans;
|
||||||
|
highlighter->HighlightLine(buf, static_cast<int>(i), spans);
|
||||||
|
total_spans += spans.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(total_spans > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Benchmark_Syntax_HighlighterEngine_Cached)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== HighlighterEngine Cache Benchmark ===\n";
|
||||||
|
Buffer buf;
|
||||||
|
std::string cpp_code = generate_cpp_code(1000);
|
||||||
|
buf.insert_text(0, 0, cpp_code);
|
||||||
|
buf.EnsureHighlighter();
|
||||||
|
|
||||||
|
auto *engine = buf.Highlighter();
|
||||||
|
if (engine) {
|
||||||
|
engine->SetHighlighter(std::make_unique<kte::CppHighlighter>());
|
||||||
|
|
||||||
|
// First pass: populate cache
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("First pass (cache population)");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
engine->GetLine(buf, static_cast<int>(i), buf.Version());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: use cache
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Second pass (cache hits)");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
engine->GetLine(buf, static_cast<int>(i), buf.Version());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Large File Stress Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Benchmark_Large_File_50K_Lines)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== Large File (50K lines) Benchmark ===\n";
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_large_file(50000, 80);
|
||||||
|
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Insert 50K lines");
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 50001); // +1 for final line
|
||||||
|
|
||||||
|
std::size_t total = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Iterate 50K lines with GetLineView");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
total += buf.GetLineView(i).size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(total > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Benchmark_Random_Access_Pattern)
|
||||||
|
{
|
||||||
|
std::cout << "\n=== Random Access Pattern Benchmark ===\n";
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_large_file(10000, 80);
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
std::mt19937 rng(456);
|
||||||
|
std::size_t total = 0;
|
||||||
|
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("10K random line accesses with GetLineView");
|
||||||
|
for (int i = 0; i < 10000; ++i) {
|
||||||
|
std::size_t line = rng() % buf.Nrows();
|
||||||
|
total += buf.GetLineView(line).size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(total > 0);
|
||||||
|
}
|
||||||
104
tests/test_buffer_io.cc
Normal file
104
tests/test_buffer_io.cc
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_buffer_io_1.tmp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
// insert two lines
|
||||||
|
b.insert_text(0, 0, std::string("Hello, world!\n"));
|
||||||
|
b.insert_text(1, 0, std::string("Second line\n"));
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.SaveAs(path, err));
|
||||||
|
ASSERT_EQ(err.empty(), true);
|
||||||
|
|
||||||
|
// append another line then Save()
|
||||||
|
b.insert_text(2, 0, std::string("Third\n"));
|
||||||
|
b.SetDirty(true);
|
||||||
|
ASSERT_TRUE(b.Save(err));
|
||||||
|
ASSERT_EQ(err.empty(), true);
|
||||||
|
|
||||||
|
std::string got = read_all(path);
|
||||||
|
ASSERT_EQ(got, std::string("Hello, world!\nSecond line\nThird\n"));
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Buffer_Save_after_Open_existing)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_buffer_io_2.tmp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary);
|
||||||
|
out << "abc\n123\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(err.empty(), true);
|
||||||
|
|
||||||
|
b.insert_text(2, 0, std::string("tail\n"));
|
||||||
|
b.SetDirty(true);
|
||||||
|
ASSERT_TRUE(b.Save(err));
|
||||||
|
ASSERT_EQ(err.empty(), true);
|
||||||
|
|
||||||
|
std::string got = read_all(path);
|
||||||
|
ASSERT_EQ(got, std::string("abc\n123\ntail\n"));
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Buffer_Open_nonexistent_then_SaveAs)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_buffer_io_3.tmp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(err.empty(), true);
|
||||||
|
ASSERT_EQ(b.IsFileBacked(), false);
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string("hello, world"));
|
||||||
|
b.insert_text(0, 12, std::string("\n"));
|
||||||
|
b.SetDirty(true);
|
||||||
|
ASSERT_TRUE(b.SaveAs(path, err));
|
||||||
|
ASSERT_EQ(err.empty(), true);
|
||||||
|
|
||||||
|
std::string got = read_all(path);
|
||||||
|
ASSERT_EQ(got, std::string("hello, world\n"));
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
142
tests/test_buffer_rows.cc
Normal file
142
tests/test_buffer_rows.cc
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <limits>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
static std::vector<std::string>
|
||||||
|
split_lines_preserve_trailing_empty(const std::string &s)
|
||||||
|
{
|
||||||
|
std::vector<std::string> out;
|
||||||
|
std::size_t start = 0;
|
||||||
|
for (std::size_t i = 0; i <= s.size(); i++) {
|
||||||
|
if (i == s.size() || s[i] == '\n') {
|
||||||
|
out.push_back(s.substr(start, i - start));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.empty())
|
||||||
|
out.push_back(std::string());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::vector<std::size_t>
|
||||||
|
line_starts_for(const std::string &s)
|
||||||
|
{
|
||||||
|
std::vector<std::size_t> starts;
|
||||||
|
starts.push_back(0);
|
||||||
|
for (std::size_t i = 0; i < s.size(); i++) {
|
||||||
|
if (s[i] == '\n')
|
||||||
|
starts.push_back(i + 1);
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::size_t
|
||||||
|
ref_linecol_to_offset(const std::string &s, std::size_t row, std::size_t col)
|
||||||
|
{
|
||||||
|
auto starts = line_starts_for(s);
|
||||||
|
if (starts.empty())
|
||||||
|
return 0;
|
||||||
|
if (row >= starts.size())
|
||||||
|
return s.size();
|
||||||
|
std::size_t start = starts[row];
|
||||||
|
std::size_t end = (row + 1 < starts.size()) ? starts[row + 1] : s.size();
|
||||||
|
if (end > start && s[end - 1] == '\n')
|
||||||
|
end -= 1; // clamp before trailing newline
|
||||||
|
return start + std::min(col, end - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
check_buffer_matches_model(const Buffer &b, const std::string &model)
|
||||||
|
{
|
||||||
|
auto expected_lines = split_lines_preserve_trailing_empty(model);
|
||||||
|
const auto &rows = b.Rows();
|
||||||
|
ASSERT_EQ(rows.size(), expected_lines.size());
|
||||||
|
ASSERT_EQ(b.Nrows(), rows.size());
|
||||||
|
|
||||||
|
auto starts = line_starts_for(model);
|
||||||
|
ASSERT_EQ(starts.size(), expected_lines.size());
|
||||||
|
|
||||||
|
std::string via_views;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||||
|
ASSERT_EQ(std::string(rows[i]), expected_lines[i]);
|
||||||
|
ASSERT_EQ(b.GetLineString(i), expected_lines[i]);
|
||||||
|
|
||||||
|
std::size_t exp_start = starts[i];
|
||||||
|
std::size_t exp_end = (i + 1 < starts.size()) ? starts[i + 1] : model.size();
|
||||||
|
auto r = b.GetLineRange(i);
|
||||||
|
ASSERT_EQ(r.first, exp_start);
|
||||||
|
ASSERT_EQ(r.second, exp_end);
|
||||||
|
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
ASSERT_EQ(std::string(v), model.substr(exp_start, exp_end - exp_start));
|
||||||
|
via_views.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
ASSERT_EQ(via_views, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(Buffer_RowsCache_MultiLineEdits_StayConsistent)
|
||||||
|
{
|
||||||
|
Buffer b;
|
||||||
|
std::string model;
|
||||||
|
|
||||||
|
check_buffer_matches_model(b, model);
|
||||||
|
|
||||||
|
// Insert text and newlines in a few different ways.
|
||||||
|
b.insert_text(0, 0, std::string("abc"));
|
||||||
|
model.insert(0, "abc");
|
||||||
|
check_buffer_matches_model(b, model);
|
||||||
|
|
||||||
|
b.split_line(0, 1); // a\nbc
|
||||||
|
model.insert(ref_linecol_to_offset(model, 0, 1), "\n");
|
||||||
|
check_buffer_matches_model(b, model);
|
||||||
|
|
||||||
|
b.insert_text(1, 2, std::string("X")); // a\nbcX
|
||||||
|
model.insert(ref_linecol_to_offset(model, 1, 2), "X");
|
||||||
|
check_buffer_matches_model(b, model);
|
||||||
|
|
||||||
|
b.join_lines(0); // abcX
|
||||||
|
{
|
||||||
|
std::size_t off = ref_linecol_to_offset(model, 0, std::numeric_limits<std::size_t>::max());
|
||||||
|
if (off < model.size() && model[off] == '\n')
|
||||||
|
model.erase(off, 1);
|
||||||
|
}
|
||||||
|
check_buffer_matches_model(b, model);
|
||||||
|
|
||||||
|
// Insert a multi-line segment in one shot.
|
||||||
|
b.insert_text(0, 2, std::string("\n123\nxyz"));
|
||||||
|
model.insert(ref_linecol_to_offset(model, 0, 2), "\n123\nxyz");
|
||||||
|
check_buffer_matches_model(b, model);
|
||||||
|
|
||||||
|
// Delete spanning across a newline.
|
||||||
|
b.delete_text(0, 1, 5);
|
||||||
|
{
|
||||||
|
std::size_t start = ref_linecol_to_offset(model, 0, 1);
|
||||||
|
std::size_t actual = std::min<std::size_t>(5, model.size() - start);
|
||||||
|
model.erase(start, actual);
|
||||||
|
}
|
||||||
|
check_buffer_matches_model(b, model);
|
||||||
|
|
||||||
|
// Insert/delete whole rows.
|
||||||
|
b.insert_row(1, std::string_view("ROW"));
|
||||||
|
model.insert(ref_linecol_to_offset(model, 1, 0), "ROW\n");
|
||||||
|
check_buffer_matches_model(b, model);
|
||||||
|
|
||||||
|
b.delete_row(1);
|
||||||
|
{
|
||||||
|
auto starts = line_starts_for(model);
|
||||||
|
if (1 < (int) starts.size()) {
|
||||||
|
std::size_t start = starts[1];
|
||||||
|
std::size_t end = (2 < starts.size()) ? starts[2] : model.size();
|
||||||
|
model.erase(start, end - start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check_buffer_matches_model(b, model);
|
||||||
|
}
|
||||||
110
tests/test_command_semantics.cc
Normal file
110
tests/test_command_semantics.cc
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "TestHarness.h"
|
||||||
|
|
||||||
|
using ktet::TestHarness;
|
||||||
|
|
||||||
|
|
||||||
|
TEST(CommandSemantics_KillToEOL_KillChain_And_Yank)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string("abc\ndef"));
|
||||||
|
b.SetCursor(1, 0); // a|bc
|
||||||
|
|
||||||
|
ed.KillRingClear();
|
||||||
|
ed.SetKillChain(false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
|
||||||
|
ASSERT_EQ(h.Text(), std::string("a\ndef"));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("bc"));
|
||||||
|
|
||||||
|
// At EOL, KillToEOL kills the newline (join).
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
|
||||||
|
ASSERT_EQ(h.Text(), std::string("adef"));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
|
||||||
|
|
||||||
|
// Yank pastes the kill ring head and breaks the kill chain.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Yank));
|
||||||
|
ASSERT_EQ(h.Text(), std::string("abc\ndef"));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
|
||||||
|
ASSERT_EQ(ed.KillChain(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(CommandSemantics_ToggleMark_JumpToMark)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string("hello"));
|
||||||
|
b.SetCursor(2, 0);
|
||||||
|
ASSERT_EQ(b.MarkSet(), false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||||
|
ASSERT_EQ(b.MarkSet(), true);
|
||||||
|
ASSERT_EQ(b.MarkCurx(), (std::size_t) 2);
|
||||||
|
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
|
||||||
|
|
||||||
|
b.SetCursor(4, 0);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::JumpToMark));
|
||||||
|
ASSERT_EQ(b.Curx(), (std::size_t) 2);
|
||||||
|
ASSERT_EQ(b.Cury(), (std::size_t) 0);
|
||||||
|
// Jump-to-mark swaps: mark becomes previous cursor.
|
||||||
|
ASSERT_EQ(b.MarkSet(), true);
|
||||||
|
ASSERT_EQ(b.MarkCurx(), (std::size_t) 4);
|
||||||
|
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(CommandSemantics_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;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string("hello world"));
|
||||||
|
b.SetCursor(0, 0);
|
||||||
|
|
||||||
|
ed.KillRingClear();
|
||||||
|
ed.SetKillChain(false);
|
||||||
|
|
||||||
|
// Copy "hello" (region [0,5)).
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||||
|
b.SetCursor(5, 0);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::CopyRegion));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("hello"));
|
||||||
|
ASSERT_EQ(b.MarkSet(), false);
|
||||||
|
ASSERT_EQ(h.Text(), std::string("hello world"));
|
||||||
|
|
||||||
|
// Kill "world" (region [6,11)).
|
||||||
|
ed.SetKillChain(false);
|
||||||
|
b.SetCursor(6, 0);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||||
|
b.SetCursor(11, 0);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::KillRegion));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("world"));
|
||||||
|
ASSERT_EQ(b.MarkSet(), false);
|
||||||
|
ASSERT_EQ(h.Text(), std::string("hello "));
|
||||||
|
}
|
||||||
12
tests/test_daily_driver_harness.cc
Normal file
12
tests/test_daily_driver_harness.cc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h"
|
||||||
|
|
||||||
|
|
||||||
|
TEST(DailyDriverHarness_Smoke_CanCreateBufferAndInsertText)
|
||||||
|
{
|
||||||
|
ktet::TestHarness h;
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.InsertText("hello"));
|
||||||
|
ASSERT_EQ(h.Line(0), std::string("hello"));
|
||||||
|
}
|
||||||
191
tests/test_daily_workflows.cc
Normal file
191
tests/test_daily_workflows.cc
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
* 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"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
read_file_bytes(const std::string &path)
|
||||||
|
{
|
||||||
|
std::ifstream in(path, std::ios::binary);
|
||||||
|
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
|
{
|
||||||
|
const auto &rows = b.Rows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
out.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (DailyWorkflow_OpenEditSave_Transcript)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::string path = "./.kte_ut_daily_open_edit_save.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "one\n");
|
||||||
|
const std::string npath = std::filesystem::canonical(path).string();
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
// Seed an empty buffer so OpenFile can reuse it.
|
||||||
|
{
|
||||||
|
Buffer scratch;
|
||||||
|
ed.AddBuffer(std::move(scratch));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), npath);
|
||||||
|
|
||||||
|
// Append two new lines via commands (no UI).
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "two"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "three"));
|
||||||
|
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Save));
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(read_file_bytes(npath), buffer_bytes_via_views(*ed.CurrentBuffer()));
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(npath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (DailyWorkflow_MultiBufferSwitchClose_Transcript)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::string p1 = "./.kte_ut_daily_buf_1.txt";
|
||||||
|
const std::string p2 = "./.kte_ut_daily_buf_2.txt";
|
||||||
|
std::remove(p1.c_str());
|
||||||
|
std::remove(p2.c_str());
|
||||||
|
write_file_bytes(p1, "aaa\n");
|
||||||
|
write_file_bytes(p2, "bbb\n");
|
||||||
|
const std::string np1 = std::filesystem::canonical(p1).string();
|
||||||
|
const std::string np2 = std::filesystem::canonical(p2).string();
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
{
|
||||||
|
Buffer scratch;
|
||||||
|
ed.AddBuffer(std::move(scratch));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(p1, err));
|
||||||
|
ASSERT_TRUE(ed.OpenFile(p2, err));
|
||||||
|
ASSERT_EQ(ed.BufferCount(), (std::size_t) 2);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
|
||||||
|
|
||||||
|
// Switch back and forth.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::BufferPrev));
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::BufferNext));
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
|
||||||
|
|
||||||
|
// Close current buffer (p2); ensure we land on p1.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::BufferClose));
|
||||||
|
ASSERT_EQ(ed.BufferCount(), (std::size_t) 1);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
|
||||||
|
|
||||||
|
std::remove(p1.c_str());
|
||||||
|
std::remove(p2.c_str());
|
||||||
|
std::remove(np1.c_str());
|
||||||
|
std::remove(np2.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (DailyWorkflow_CrashRecovery_SwapReplay_Transcript)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::string path = "./.kte_ut_daily_swap_recover.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\nline2\n");
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
{
|
||||||
|
Buffer scratch;
|
||||||
|
ed.AddBuffer(std::move(scratch));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||||
|
Buffer *buf = ed.CurrentBuffer();
|
||||||
|
ASSERT_TRUE(buf != nullptr);
|
||||||
|
|
||||||
|
// Make unsaved edits through command execution.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveDown));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveHome));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "ZZ"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "TAIL"));
|
||||||
|
|
||||||
|
// Ensure journal is durable and capture expected bytes.
|
||||||
|
ed.Swap()->Flush(buf);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(*buf);
|
||||||
|
const std::string expected = buffer_bytes_via_views(*buf);
|
||||||
|
|
||||||
|
// "Crash": reopen from disk (original file content) into a fresh Buffer and replay.
|
||||||
|
Buffer recovered;
|
||||||
|
ASSERT_TRUE(recovered.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(recovered, swap_path, err));
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(recovered), expected);
|
||||||
|
|
||||||
|
// Cleanup.
|
||||||
|
ed.Swap()->Detach(buf);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
84
tests/test_kkeymap.cc
Normal file
84
tests/test_kkeymap.cc
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "KKeymap.h"
|
||||||
|
|
||||||
|
#include <ncurses.h>
|
||||||
|
|
||||||
|
|
||||||
|
TEST(KKeymap_KPrefix_CanonicalChords)
|
||||||
|
{
|
||||||
|
CommandId id{};
|
||||||
|
|
||||||
|
// From docs/ke.md (K-commands)
|
||||||
|
ASSERT_TRUE(KLookupKCommand('s', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::Save);
|
||||||
|
ASSERT_TRUE(KLookupKCommand('s', true, id)); // C-k C-s
|
||||||
|
ASSERT_EQ(id, CommandId::Save);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand('d', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::KillToEOL);
|
||||||
|
ASSERT_TRUE(KLookupKCommand('d', true, id)); // C-k C-d
|
||||||
|
ASSERT_EQ(id, CommandId::KillLine);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand(' ', false, id)); // C-k SPACE
|
||||||
|
ASSERT_EQ(id, CommandId::ToggleMark);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand('j', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::JumpToMark);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand('f', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::FlushKillRing);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand('y', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::Yank);
|
||||||
|
|
||||||
|
// Unknown should not map
|
||||||
|
ASSERT_EQ(KLookupKCommand('Z', false, id), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(KKeymap_CtrlChords_CanonicalChords)
|
||||||
|
{
|
||||||
|
CommandId id{};
|
||||||
|
|
||||||
|
// From docs/ke.md (other keybindings)
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('n', id));
|
||||||
|
ASSERT_EQ(id, CommandId::MoveDown);
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('p', id));
|
||||||
|
ASSERT_EQ(id, CommandId::MoveUp);
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('f', id));
|
||||||
|
ASSERT_EQ(id, CommandId::MoveRight);
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('b', id));
|
||||||
|
ASSERT_EQ(id, CommandId::MoveLeft);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('w', id));
|
||||||
|
ASSERT_EQ(id, CommandId::KillRegion);
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('y', id));
|
||||||
|
ASSERT_EQ(id, CommandId::Yank);
|
||||||
|
|
||||||
|
ASSERT_EQ(KLookupCtrlCommand('z', id), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(KKeymap_EscChords_CanonicalChords)
|
||||||
|
{
|
||||||
|
CommandId id{};
|
||||||
|
|
||||||
|
// From docs/ke.md (ESC bindings)
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('b', id));
|
||||||
|
ASSERT_EQ(id, CommandId::WordPrev);
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('f', id));
|
||||||
|
ASSERT_EQ(id, CommandId::WordNext);
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('d', id));
|
||||||
|
ASSERT_EQ(id, CommandId::DeleteWordNext);
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('q', id));
|
||||||
|
ASSERT_EQ(id, CommandId::ReflowParagraph);
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('w', id));
|
||||||
|
ASSERT_EQ(id, CommandId::CopyRegion);
|
||||||
|
|
||||||
|
// ESC BACKSPACE
|
||||||
|
ASSERT_TRUE(KLookupEscCommand(KEY_BACKSPACE, id));
|
||||||
|
ASSERT_EQ(id, CommandId::DeleteWordPrev);
|
||||||
|
|
||||||
|
ASSERT_EQ(KLookupEscCommand('z', id), false);
|
||||||
|
}
|
||||||
448
tests/test_migration_coverage.cc
Normal file
448
tests/test_migration_coverage.cc
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/*
|
||||||
|
* test_migration_coverage.cc - Edge case tests for Buffer::Line migration
|
||||||
|
*
|
||||||
|
* This file provides comprehensive test coverage for the migration from
|
||||||
|
* Buffer::Rows() to direct PieceTable operations using Nrows(), GetLineString(),
|
||||||
|
* and GetLineView().
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Edge cases: empty buffers, single lines, very long lines
|
||||||
|
* - Boundary conditions: first line, last line, out-of-bounds
|
||||||
|
* - Consistency: GetLineString vs GetLineView vs Rows()
|
||||||
|
* - Performance: large files, many small operations
|
||||||
|
* - Correctness: special characters, newlines, unicode
|
||||||
|
*/
|
||||||
|
#include "Test.h"
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Edge Case Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Migration_EmptyBuffer_Nrows)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 1); // Empty buffer has 1 logical line
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_EmptyBuffer_GetLineString)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_EmptyBuffer_GetLineView)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
auto view = buf.GetLineView(0);
|
||||||
|
ASSERT_EQ(view.size(), (std::size_t) 0);
|
||||||
|
ASSERT_EQ(std::string(view), std::string(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_SingleLine_NoNewline)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("hello"));
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 1);
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("hello"));
|
||||||
|
ASSERT_EQ(std::string(buf.GetLineView(0)), std::string("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_SingleLine_WithNewline)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("hello\n"));
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 2); // Line + empty line after newline
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("hello"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(1), std::string(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_MultipleLines_TrailingNewline)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("line1\nline2\nline3\n"));
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 4); // 3 lines + empty line
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("line1"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(1), std::string("line2"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(2), std::string("line3"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(3), std::string(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_MultipleLines_NoTrailingNewline)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("line1\nline2\nline3"));
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 3);
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("line1"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(1), std::string("line2"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(2), std::string("line3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_VeryLongLine)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
std::string long_line(10000, 'x');
|
||||||
|
buf.insert_text(0, 0, long_line);
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 1);
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), long_line);
|
||||||
|
ASSERT_EQ(buf.GetLineString(0).size(), (std::size_t) 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_ManyEmptyLines)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
std::string many_newlines(1000, '\n');
|
||||||
|
buf.insert_text(0, 0, many_newlines);
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 1001); // 1000 newlines = 1001 lines
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
ASSERT_EQ(buf.GetLineString(i), std::string(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Consistency Tests: GetLineString vs GetLineView vs Rows()
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Migration_Consistency_AllMethods)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("abc\n123\nxyz"));
|
||||||
|
|
||||||
|
const auto &rows = buf.Rows();
|
||||||
|
ASSERT_EQ(buf.Nrows(), rows.size());
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
std::string via_string = buf.GetLineString(i);
|
||||||
|
std::string via_rows = std::string(rows[i]);
|
||||||
|
// GetLineString and Rows() both strip newlines
|
||||||
|
ASSERT_EQ(via_string, via_rows);
|
||||||
|
// GetLineView includes the raw range (with newlines if present)
|
||||||
|
// Just verify it's accessible
|
||||||
|
(void) buf.GetLineView(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_Consistency_AfterEdits)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("line1\nline2\nline3\n"));
|
||||||
|
|
||||||
|
// Edit: insert in middle
|
||||||
|
buf.insert_text(1, 2, std::string("XX"));
|
||||||
|
|
||||||
|
const auto &rows = buf.Rows();
|
||||||
|
ASSERT_EQ(buf.Nrows(), rows.size());
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
// GetLineString and Rows() both strip newlines
|
||||||
|
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit: delete line
|
||||||
|
buf.delete_row(1);
|
||||||
|
|
||||||
|
const auto &rows2 = buf.Rows();
|
||||||
|
ASSERT_EQ(buf.Nrows(), rows2.size());
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
ASSERT_EQ(buf.GetLineString(i), std::string(rows2[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Boundary Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Migration_FirstLine_Access)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("first\nsecond\nthird"));
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("first"));
|
||||||
|
// GetLineView includes newline: "first\n"
|
||||||
|
auto view0 = buf.GetLineView(0);
|
||||||
|
EXPECT_TRUE(view0.size() >= 5); // at least "first"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_LastLine_Access)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("first\nsecond\nthird"));
|
||||||
|
|
||||||
|
std::size_t last = buf.Nrows() - 1;
|
||||||
|
ASSERT_EQ(buf.GetLineString(last), std::string("third"));
|
||||||
|
ASSERT_EQ(std::string(buf.GetLineView(last)), std::string("third"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_GetLineRange_Boundaries)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("abc\n123\nxyz"));
|
||||||
|
|
||||||
|
// First line
|
||||||
|
auto r0 = buf.GetLineRange(0);
|
||||||
|
ASSERT_EQ(r0.first, (std::size_t) 0);
|
||||||
|
ASSERT_EQ(r0.second, (std::size_t) 4); // "abc\n"
|
||||||
|
|
||||||
|
// Last line
|
||||||
|
std::size_t last = buf.Nrows() - 1;
|
||||||
|
(void) buf.GetLineRange(last); // Verify it doesn't crash
|
||||||
|
ASSERT_EQ(buf.GetLineString(last), std::string("xyz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Special Characters and Unicode
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Migration_SpecialChars_Tabs)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("line\twith\ttabs"));
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("line\twith\ttabs"));
|
||||||
|
ASSERT_EQ(std::string(buf.GetLineView(0)), std::string("line\twith\ttabs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_SpecialChars_CarriageReturn)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("line\rwith\rcr"));
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("line\rwith\rcr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_SpecialChars_NullBytes)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
std::string with_null = "abc";
|
||||||
|
with_null.push_back('\0');
|
||||||
|
with_null += "def";
|
||||||
|
buf.insert_text(0, 0, with_null);
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.GetLineString(0).size(), (std::size_t) 7);
|
||||||
|
ASSERT_EQ(buf.GetLineView(0).size(), (std::size_t) 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_Unicode_BasicMultibyte)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
std::string utf8 = "Hello 世界 🌍";
|
||||||
|
buf.insert_text(0, 0, utf8);
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), utf8);
|
||||||
|
ASSERT_EQ(std::string(buf.GetLineView(0)), utf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Large File Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Migration_LargeFile_10K_Lines)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
std::string data;
|
||||||
|
for (int i = 0; i < 10000; ++i) {
|
||||||
|
data += "Line " + std::to_string(i) + "\n";
|
||||||
|
}
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 10001); // +1 for final empty line
|
||||||
|
|
||||||
|
// Spot check some lines
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("Line 0"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(5000), std::string("Line 5000"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(9999), std::string("Line 9999"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(10000), std::string(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_LargeFile_Iteration_Consistency)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
std::string data;
|
||||||
|
for (int i = 0; i < 1000; ++i) {
|
||||||
|
data += "Line " + std::to_string(i) + "\n";
|
||||||
|
}
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
// Iterate with GetLineString (strips newlines, must add back)
|
||||||
|
std::string reconstructed1;
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
if (i > 0) {
|
||||||
|
reconstructed1 += '\n';
|
||||||
|
}
|
||||||
|
reconstructed1 += buf.GetLineString(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate with GetLineView (includes newlines)
|
||||||
|
std::string reconstructed2;
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
auto view = buf.GetLineView(i);
|
||||||
|
reconstructed2.append(view.data(), view.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLineView should match original exactly
|
||||||
|
ASSERT_EQ(reconstructed2, data);
|
||||||
|
// GetLineString reconstruction should match (without final empty line)
|
||||||
|
EXPECT_TRUE(reconstructed1.size() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stress Tests: Many Small Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Migration_Stress_ManySmallInserts)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("start\n"));
|
||||||
|
|
||||||
|
for (int i = 0; i < 100; ++i) {
|
||||||
|
buf.insert_text(1, 0, std::string("x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 2);
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("start"));
|
||||||
|
ASSERT_EQ(buf.GetLineString(1).size(), (std::size_t) 100);
|
||||||
|
|
||||||
|
// Verify consistency
|
||||||
|
const auto &rows = buf.Rows();
|
||||||
|
ASSERT_EQ(buf.GetLineString(1), std::string(rows[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_Stress_ManyLineInserts)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
|
||||||
|
for (int i = 0; i < 500; ++i) {
|
||||||
|
buf.insert_row(buf.Nrows() - 1, std::string_view("line"));
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 501); // 500 + initial empty line
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < 500; ++i) {
|
||||||
|
ASSERT_EQ(buf.GetLineString(i), std::string("line"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_Stress_AlternatingInsertDelete)
|
||||||
|
{
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("a\nb\nc\nd\ne\n"));
|
||||||
|
|
||||||
|
for (int i = 0; i < 50; ++i) {
|
||||||
|
std::size_t nrows = buf.Nrows();
|
||||||
|
if (nrows > 2) {
|
||||||
|
buf.delete_row(1);
|
||||||
|
}
|
||||||
|
buf.insert_row(1, std::string_view("new"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify consistency after many operations
|
||||||
|
const auto &rows = buf.Rows();
|
||||||
|
ASSERT_EQ(buf.Nrows(), rows.size());
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
// GetLineString and Rows() both strip newlines
|
||||||
|
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Regression Tests: Specific Migration Scenarios
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST (Migration_Shebang_Detection)
|
||||||
|
{
|
||||||
|
// Test the pattern used in Editor.cc for shebang detection
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("#!/usr/bin/env python3\nprint('hello')"));
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), (std::size_t) 2);
|
||||||
|
|
||||||
|
std::string first_line = "";
|
||||||
|
if (buf.Nrows() > 0) {
|
||||||
|
first_line = buf.GetLineString(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSERT_EQ(first_line, std::string("#!/usr/bin/env python3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_EmptyBufferCheck_Pattern)
|
||||||
|
{
|
||||||
|
// Test the pattern used in Editor.cc for empty buffer detection
|
||||||
|
Buffer buf;
|
||||||
|
|
||||||
|
const std::size_t nrows = buf.Nrows();
|
||||||
|
const bool rows_empty = (nrows == 0);
|
||||||
|
const bool single_empty_line = (nrows == 1 && buf.GetLineView(0).size() == 0);
|
||||||
|
|
||||||
|
ASSERT_EQ(rows_empty, false);
|
||||||
|
ASSERT_EQ(single_empty_line, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_SyntaxHighlighter_Pattern)
|
||||||
|
{
|
||||||
|
// Test the pattern used in syntax highlighters
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}"));
|
||||||
|
|
||||||
|
for (std::size_t row = 0; row < buf.Nrows(); ++row) {
|
||||||
|
// This is the pattern used in all migrated highlighters
|
||||||
|
if (row >= buf.Nrows()) {
|
||||||
|
break; // Should never happen
|
||||||
|
}
|
||||||
|
std::string line = buf.GetLineString(row);
|
||||||
|
// Successfully accessed line - size() is always valid for std::string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (Migration_SwapSnapshot_Pattern)
|
||||||
|
{
|
||||||
|
// Test the pattern used in Swap.cc for buffer snapshots
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, std::string("line1\nline2\nline3\n"));
|
||||||
|
|
||||||
|
const std::size_t nrows = buf.Nrows();
|
||||||
|
std::string snapshot;
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < nrows; ++i) {
|
||||||
|
auto view = buf.GetLineView(i);
|
||||||
|
snapshot.append(view.data(), view.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(snapshot.size() > 0);
|
||||||
|
ASSERT_EQ(snapshot, std::string("line1\nline2\nline3\n"));
|
||||||
|
}
|
||||||
199
tests/test_piece_table.cc
Normal file
199
tests/test_piece_table.cc
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
#include <array>
|
||||||
|
#include <random>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
static std::vector<std::size_t>
|
||||||
|
LineStartsFor(const std::string &s)
|
||||||
|
{
|
||||||
|
std::vector<std::size_t> starts;
|
||||||
|
starts.push_back(0);
|
||||||
|
for (std::size_t i = 0; i < s.size(); i++) {
|
||||||
|
if (s[i] == '\n')
|
||||||
|
starts.push_back(i + 1);
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
LineContentFor(const std::string &s, std::size_t line_num)
|
||||||
|
{
|
||||||
|
auto starts = LineStartsFor(s);
|
||||||
|
if (starts.empty() || line_num >= starts.size())
|
||||||
|
return std::string();
|
||||||
|
std::size_t start = starts[line_num];
|
||||||
|
std::size_t end = (line_num + 1 < starts.size()) ? starts[line_num + 1] : s.size();
|
||||||
|
if (end > start && s[end - 1] == '\n')
|
||||||
|
end -= 1;
|
||||||
|
return s.substr(start, end - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (PieceTable_Insert_Delete_LineCount)
|
||||||
|
{
|
||||||
|
PieceTable pt;
|
||||||
|
// start empty
|
||||||
|
ASSERT_EQ(pt.Size(), (std::size_t) 0);
|
||||||
|
ASSERT_EQ(pt.LineCount(), (std::size_t) 1); // empty buffer has 1 logical line
|
||||||
|
|
||||||
|
// Insert some text with newlines
|
||||||
|
const char *t = "abc\n123\nxyz"; // last line without trailing NL
|
||||||
|
pt.Insert(0, t, 11);
|
||||||
|
ASSERT_EQ(pt.Size(), (std::size_t) 11);
|
||||||
|
ASSERT_EQ(pt.LineCount(), (std::size_t) 3);
|
||||||
|
|
||||||
|
// Check get line
|
||||||
|
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
|
||||||
|
ASSERT_EQ(pt.GetLine(1), std::string("123"));
|
||||||
|
ASSERT_EQ(pt.GetLine(2), std::string("xyz"));
|
||||||
|
|
||||||
|
// Delete middle line entirely including its trailing NL
|
||||||
|
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
|
||||||
|
pt.Delete(r.first, r.second - r.first);
|
||||||
|
ASSERT_EQ(pt.LineCount(), (std::size_t) 2);
|
||||||
|
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
|
||||||
|
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (PieceTable_LineCol_Conversions)
|
||||||
|
{
|
||||||
|
PieceTable pt;
|
||||||
|
std::string s = "hello\nworld\n"; // two lines with trailing NL
|
||||||
|
pt.Insert(0, s.data(), s.size());
|
||||||
|
|
||||||
|
// Byte offsets of starts
|
||||||
|
auto off0 = pt.LineColToByteOffset(0, 0);
|
||||||
|
auto off1 = pt.LineColToByteOffset(1, 0);
|
||||||
|
auto off2 = pt.LineColToByteOffset(2, 0); // EOF
|
||||||
|
ASSERT_EQ(off0, (std::size_t) 0);
|
||||||
|
ASSERT_EQ(off1, (std::size_t) 6); // "hello\n"
|
||||||
|
ASSERT_EQ(off2, pt.Size());
|
||||||
|
|
||||||
|
auto lc0 = pt.ByteOffsetToLineCol(0);
|
||||||
|
auto lc1 = pt.ByteOffsetToLineCol(6);
|
||||||
|
ASSERT_EQ(lc0.first, (std::size_t) 0);
|
||||||
|
ASSERT_EQ(lc0.second, (std::size_t) 0);
|
||||||
|
ASSERT_EQ(lc1.first, (std::size_t) 1);
|
||||||
|
ASSERT_EQ(lc1.second, (std::size_t) 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic)
|
||||||
|
{
|
||||||
|
PieceTable pt;
|
||||||
|
std::string model;
|
||||||
|
|
||||||
|
std::mt19937 rng(0xC0FFEEu);
|
||||||
|
const std::vector<std::string> corpus = {
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
"xyz",
|
||||||
|
"123",
|
||||||
|
"\n",
|
||||||
|
"!\n",
|
||||||
|
"foo\nbar",
|
||||||
|
"end\n",
|
||||||
|
};
|
||||||
|
|
||||||
|
auto check_invariants = [&](const char *where) {
|
||||||
|
(void) where;
|
||||||
|
ASSERT_EQ(pt.Size(), model.size());
|
||||||
|
ASSERT_EQ(pt.GetRange(0, pt.Size()), model);
|
||||||
|
|
||||||
|
auto starts = LineStartsFor(model);
|
||||||
|
ASSERT_EQ(pt.LineCount(), starts.size());
|
||||||
|
|
||||||
|
// Spot-check a few line ranges and contents.
|
||||||
|
std::size_t last = starts.empty() ? (std::size_t) 0 : (starts.size() - 1);
|
||||||
|
std::size_t mid = (starts.size() > 2) ? (std::size_t) 1 : last;
|
||||||
|
const std::array<std::size_t, 3> probe_lines = {(std::size_t) 0, last, mid};
|
||||||
|
for (auto line: probe_lines) {
|
||||||
|
if (starts.empty())
|
||||||
|
break;
|
||||||
|
if (line >= starts.size())
|
||||||
|
continue;
|
||||||
|
std::size_t exp_start = starts[line];
|
||||||
|
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
|
||||||
|
auto r = pt.GetLineRange(line);
|
||||||
|
ASSERT_EQ(r.first, exp_start);
|
||||||
|
ASSERT_EQ(r.second, exp_end);
|
||||||
|
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trips for a few offsets.
|
||||||
|
const std::vector<std::size_t> probe_offsets = {
|
||||||
|
0,
|
||||||
|
model.size() / 2,
|
||||||
|
model.size(),
|
||||||
|
};
|
||||||
|
for (auto off: probe_offsets) {
|
||||||
|
auto lc = pt.ByteOffsetToLineCol(off);
|
||||||
|
auto back = pt.LineColToByteOffset(lc.first, lc.second);
|
||||||
|
ASSERT_EQ(back, off);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check_invariants("initial");
|
||||||
|
|
||||||
|
for (int step = 0; step < 250; step++) {
|
||||||
|
bool do_insert = model.empty() || ((rng() % 3u) != 0u); // bias toward insert
|
||||||
|
if (do_insert) {
|
||||||
|
const std::string &ins = corpus[rng() % corpus.size()];
|
||||||
|
std::size_t pos = model.empty() ? 0 : (rng() % (model.size() + 1));
|
||||||
|
pt.Insert(pos, ins.data(), ins.size());
|
||||||
|
model.insert(pos, ins);
|
||||||
|
} else {
|
||||||
|
std::size_t pos = rng() % model.size();
|
||||||
|
std::size_t max = std::min<std::size_t>(8, model.size() - pos);
|
||||||
|
std::size_t len = 1 + (rng() % max);
|
||||||
|
pt.Delete(pos, len);
|
||||||
|
model.erase(pos, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also validate GetRange on a small random window when non-empty.
|
||||||
|
if (!model.empty()) {
|
||||||
|
std::size_t off = rng() % model.size();
|
||||||
|
std::size_t max = std::min<std::size_t>(16, model.size() - off);
|
||||||
|
std::size_t len = 1 + (rng() % max);
|
||||||
|
ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
check_invariants("step");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full line-by-line range verification at the end.
|
||||||
|
auto starts = LineStartsFor(model);
|
||||||
|
for (std::size_t line = 0; line < starts.size(); line++) {
|
||||||
|
std::size_t exp_start = starts[line];
|
||||||
|
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
|
||||||
|
auto r = pt.GetLineRange(line);
|
||||||
|
ASSERT_EQ(r.first, exp_start);
|
||||||
|
ASSERT_EQ(r.second, exp_end);
|
||||||
|
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/test_reflow_indented_bullets.cc
Normal file
78
tests/test_reflow_indented_bullets.cc
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
to_string_rows(const Buffer &buf)
|
||||||
|
{
|
||||||
|
std::string out;
|
||||||
|
for (const auto &r: buf.Rows()) {
|
||||||
|
out += static_cast<std::string>(r);
|
||||||
|
out.push_back('\n');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(ReflowParagraph_IndentedBullets_PreserveStructure)
|
||||||
|
{
|
||||||
|
InstallDefaultCommands();
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
// Test the example from the issue: indented list items should not be merged
|
||||||
|
const std::string initial =
|
||||||
|
"+ something at the top\n"
|
||||||
|
" + something indented\n"
|
||||||
|
"+ the next line\n";
|
||||||
|
b.insert_text(0, 0, initial);
|
||||||
|
// Put cursor on first item
|
||||||
|
b.SetCursor(0, 0);
|
||||||
|
ed.AddBuffer(std::move(b));
|
||||||
|
|
||||||
|
Buffer *buf = ed.CurrentBuffer();
|
||||||
|
ASSERT_TRUE(buf != nullptr);
|
||||||
|
|
||||||
|
// Use a width that's larger than all lines (so no wrapping should occur)
|
||||||
|
const int width = 80;
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
|
||||||
|
|
||||||
|
const auto &rows = buf->Rows();
|
||||||
|
const std::string result = to_string_rows(*buf);
|
||||||
|
|
||||||
|
// We should have 3 lines (plus possibly a trailing empty line)
|
||||||
|
ASSERT_TRUE(rows.size() >= 3);
|
||||||
|
|
||||||
|
// Check that the structure is preserved
|
||||||
|
std::string line0 = static_cast<std::string>(rows[0]);
|
||||||
|
std::string line1 = static_cast<std::string>(rows[1]);
|
||||||
|
std::string line2 = static_cast<std::string>(rows[2]);
|
||||||
|
|
||||||
|
// First line should start with "+ "
|
||||||
|
EXPECT_TRUE(line0.rfind("+ ", 0) == 0);
|
||||||
|
EXPECT_TRUE(line0.find("something at the top") != std::string::npos);
|
||||||
|
|
||||||
|
// Second line should start with " + " (two spaces, then +)
|
||||||
|
EXPECT_TRUE(line1.rfind(" + ", 0) == 0);
|
||||||
|
EXPECT_TRUE(line1.find("something indented") != std::string::npos);
|
||||||
|
|
||||||
|
// Third line should start with "+ "
|
||||||
|
EXPECT_TRUE(line2.rfind("+ ", 0) == 0);
|
||||||
|
EXPECT_TRUE(line2.find("the next line") != std::string::npos);
|
||||||
|
|
||||||
|
// The indented line should NOT be merged with the first line
|
||||||
|
EXPECT_TRUE(line0.find("indented") == std::string::npos);
|
||||||
|
|
||||||
|
// Debug output if something goes wrong
|
||||||
|
if (line0.rfind("+ ", 0) != 0 || line1.rfind(" + ", 0) != 0 || line2.rfind("+ ", 0) != 0) {
|
||||||
|
std::cerr << "Reflow did not preserve indented bullet structure:\n" << result << "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
102
tests/test_reflow_paragraph.cc
Normal file
102
tests/test_reflow_paragraph.cc
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#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_NumberedList_HangingIndent)
|
||||||
|
{
|
||||||
|
InstallDefaultCommands();
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
// Two list items in one paragraph (no blank lines).
|
||||||
|
// Second line of each item already uses a hanging indent.
|
||||||
|
const std::string initial =
|
||||||
|
"1. one two three four five six seven eight nine ten eleven\n"
|
||||||
|
" twelve thirteen fourteen\n"
|
||||||
|
"10. alpha beta gamma delta epsilon zeta eta theta iota kappa lambda\n"
|
||||||
|
" mu nu xi omicron\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);
|
||||||
|
|
||||||
|
const int width = 25;
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
|
||||||
|
|
||||||
|
const auto &rows = buf->Rows();
|
||||||
|
ASSERT_TRUE(!rows.empty());
|
||||||
|
const std::string dump = to_string_rows(*buf);
|
||||||
|
|
||||||
|
// Find the start of the second item.
|
||||||
|
bool any_too_long = false;
|
||||||
|
std::size_t idx_10 = rows.size();
|
||||||
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
const std::string line = static_cast<std::string>(rows[i]);
|
||||||
|
if (static_cast<int>(line.size()) > width)
|
||||||
|
any_too_long = true;
|
||||||
|
if (line.rfind("10. ", 0) == 0) {
|
||||||
|
idx_10 = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ASSERT_TRUE(idx_10 < rows.size());
|
||||||
|
if (any_too_long) {
|
||||||
|
std::cerr << "Reflow produced a line longer than width=" << width << "\n";
|
||||||
|
std::cerr << to_string_rows(*buf) << "\n";
|
||||||
|
}
|
||||||
|
EXPECT_TRUE(!any_too_long);
|
||||||
|
|
||||||
|
// Item 1: first line has "1. ", continuation lines have 3 spaces.
|
||||||
|
for (std::size_t i = 0; i < idx_10; ++i) {
|
||||||
|
const std::string line = static_cast<std::string>(rows[i]);
|
||||||
|
if (i == 0) {
|
||||||
|
ASSERT_TRUE(line.rfind("1. ", 0) == 0);
|
||||||
|
} else {
|
||||||
|
ASSERT_TRUE(line.rfind(" ", 0) == 0);
|
||||||
|
ASSERT_TRUE(line.rfind("1. ", 0) != 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item 10: first line has "10. ", continuation lines have 4 spaces.
|
||||||
|
ASSERT_TRUE(static_cast<std::string>(rows[idx_10]).rfind("10. ", 0) == 0);
|
||||||
|
bool bad_10 = false;
|
||||||
|
for (std::size_t i = idx_10 + 1; i < rows.size(); ++i) {
|
||||||
|
const std::string line = static_cast<std::string>(rows[i]);
|
||||||
|
if (line.empty())
|
||||||
|
break; // paragraph terminator / trailing empty line
|
||||||
|
if (line.rfind(" ", 0) != 0)
|
||||||
|
bad_10 = true;
|
||||||
|
if (line.rfind("10. ", 0) == 0)
|
||||||
|
bad_10 = true;
|
||||||
|
}
|
||||||
|
if (bad_10) {
|
||||||
|
std::cerr << "Unexpected prefix in reflow output:\n" << dump << "\n";
|
||||||
|
}
|
||||||
|
ASSERT_TRUE(!bad_10);
|
||||||
|
|
||||||
|
// Debug helper if something goes wrong (kept as a string for easy inspection).
|
||||||
|
EXPECT_TRUE(!to_string_rows(*buf).empty());
|
||||||
|
}
|
||||||
46
tests/test_search.cc
Normal file
46
tests/test_search.cc
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
#include "OptimizedSearch.h"
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
std::size_t from = 0;
|
||||||
|
while (true) {
|
||||||
|
auto p = text.find(pat, from);
|
||||||
|
if (p == std::string::npos)
|
||||||
|
break;
|
||||||
|
res.push_back(p);
|
||||||
|
from = p + pat.size();
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(OptimizedSearch_basic_cases)
|
||||||
|
{
|
||||||
|
OptimizedSearch os;
|
||||||
|
struct Case {
|
||||||
|
std::string text;
|
||||||
|
std::string pat;
|
||||||
|
} cases[] = {
|
||||||
|
{"", ""},
|
||||||
|
{"", "a"},
|
||||||
|
{"a", ""},
|
||||||
|
{"a", "a"},
|
||||||
|
{"aaaaa", "aa"},
|
||||||
|
{"hello world", "world"},
|
||||||
|
{"abcabcabc", "abc"},
|
||||||
|
{"the quick brown fox", "fox"},
|
||||||
|
};
|
||||||
|
for (auto &c: cases) {
|
||||||
|
auto got = os.find_all(c.text, c.pat, 0);
|
||||||
|
auto ref = ref_find_all(c.text, c.pat);
|
||||||
|
ASSERT_EQ(got, ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
tests/test_search_replace_flow.cc
Normal file
129
tests/test_search_replace_flow.cc
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h"
|
||||||
|
|
||||||
|
using ktet::TestHarness;
|
||||||
|
|
||||||
|
// These tests intentionally drive the prompt-based search/replace UI headlessly
|
||||||
|
// via `Execute(Editor&, CommandId, ...)` to lock down behavior without ncurses.
|
||||||
|
|
||||||
|
TEST(SearchFlow_FindStart_Success_LeavesCursorOnMatch_And_ClearsSearchState)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, "abc def abc");
|
||||||
|
b.SetCursor(0, 0);
|
||||||
|
b.SetOffsets(0, 0);
|
||||||
|
|
||||||
|
// Keep a mark set to ensure search doesn't clobber it.
|
||||||
|
b.SetMark(0, 0);
|
||||||
|
ASSERT_TRUE(b.MarkSet());
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::FindStart));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::Search);
|
||||||
|
ASSERT_TRUE(ed.SearchActive());
|
||||||
|
|
||||||
|
// Typing into the prompt uses InsertText and should jump to the first match.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::InsertText, "def"));
|
||||||
|
ASSERT_EQ(b.Cury(), (std::size_t) 0);
|
||||||
|
ASSERT_EQ(b.Curx(), (std::size_t) 4);
|
||||||
|
|
||||||
|
// Enter (Newline) accepts the prompt and ends incremental search.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
ASSERT_TRUE(!ed.PromptActive());
|
||||||
|
ASSERT_TRUE(!ed.SearchActive());
|
||||||
|
ASSERT_TRUE(b.MarkSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SearchFlow_FindStart_NotFound_RestoresOrigin_And_ClearsSearchState)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, "hello world\nsecond line\n");
|
||||||
|
b.SetCursor(3, 0);
|
||||||
|
b.SetOffsets(1, 2);
|
||||||
|
|
||||||
|
const std::size_t ox = b.Curx();
|
||||||
|
const std::size_t oy = b.Cury();
|
||||||
|
const std::size_t orow = b.Rowoffs();
|
||||||
|
const std::size_t ocol = b.Coloffs();
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::FindStart));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_TRUE(ed.SearchActive());
|
||||||
|
|
||||||
|
// Not-found should restore cursor/viewport to the saved origin while still in prompt.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::InsertText, "zzzz"));
|
||||||
|
ASSERT_EQ(b.Curx(), ox);
|
||||||
|
ASSERT_EQ(b.Cury(), oy);
|
||||||
|
ASSERT_EQ(b.Rowoffs(), orow);
|
||||||
|
ASSERT_EQ(b.Coloffs(), ocol);
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
ASSERT_TRUE(!ed.PromptActive());
|
||||||
|
ASSERT_TRUE(!ed.SearchActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, "abc abc\n");
|
||||||
|
b.SetCursor(0, 0);
|
||||||
|
|
||||||
|
const std::string before = h.Text();
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind);
|
||||||
|
|
||||||
|
// Accept empty find -> proceed to ReplaceWith.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
|
||||||
|
|
||||||
|
// Provide replacement and accept -> should cancel due to empty find.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::InsertText, "X"));
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
|
||||||
|
ASSERT_TRUE(!ed.PromptActive());
|
||||||
|
ASSERT_TRUE(!ed.SearchActive());
|
||||||
|
ASSERT_EQ(h.Text(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, "abc\ndef\n");
|
||||||
|
b.SetCursor(1, 0);
|
||||||
|
b.SetOffsets(0, 0);
|
||||||
|
|
||||||
|
const std::size_t ox = b.Curx();
|
||||||
|
const std::size_t oy = b.Cury();
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::RegexFindStart));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::RegexSearch);
|
||||||
|
|
||||||
|
// Invalid regex should not crash; cursor should remain at origin due to no matches.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::InsertText, "("));
|
||||||
|
ASSERT_EQ(b.Curx(), ox);
|
||||||
|
ASSERT_EQ(b.Cury(), oy);
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
ASSERT_TRUE(!ed.PromptActive());
|
||||||
|
ASSERT_TRUE(!ed.SearchActive());
|
||||||
|
}
|
||||||
131
tests/test_swap_cleanup.cc
Normal file
131
tests/test_swap_cleanup.cc
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapCleanup_ResetJournalOnSave)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const fs::path xdg_root = fs::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_swap_cleanup_") + std::to_string((int) ::getpid()));
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
fs::create_directories(xdg_root);
|
||||||
|
|
||||||
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||||
|
const std::string xdg_s = xdg_root.string();
|
||||||
|
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
|
||||||
|
|
||||||
|
const std::string path = (xdg_root / "work" / "file.txt").string();
|
||||||
|
fs::create_directories((xdg_root / "work"));
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\n");
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
// Seed scratch buffer so OpenFile can reuse it.
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||||
|
Buffer *b = ed.CurrentBuffer();
|
||||||
|
ASSERT_TRUE(b != nullptr);
|
||||||
|
|
||||||
|
// Edit to ensure swap is created.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
|
||||||
|
ASSERT_TRUE(b->Dirty());
|
||||||
|
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
|
||||||
|
ASSERT_TRUE(fs::exists(swp));
|
||||||
|
|
||||||
|
// Save should reset/delete the journal.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Save));
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
ASSERT_TRUE(!fs::exists(swp));
|
||||||
|
|
||||||
|
// Subsequent edits should recreate a fresh swap.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Y"));
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
ASSERT_TRUE(fs::exists(swp));
|
||||||
|
|
||||||
|
// Cleanup.
|
||||||
|
ed.Swap()->Detach(b);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swp.c_str());
|
||||||
|
if (!old_xdg.empty())
|
||||||
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
|
else
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapCleanup_PruneSwapDir_ByAge)
|
||||||
|
{
|
||||||
|
const fs::path xdg_root = fs::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_swap_prune_") + std::to_string((int) ::getpid()));
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
fs::create_directories(xdg_root);
|
||||||
|
|
||||||
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||||
|
const std::string xdg_s = xdg_root.string();
|
||||||
|
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
|
||||||
|
|
||||||
|
const fs::path swapdir = xdg_root / "kte" / "swap";
|
||||||
|
fs::create_directories(swapdir);
|
||||||
|
const fs::path oldp = swapdir / "old.swp";
|
||||||
|
const fs::path newp = swapdir / "new.swp";
|
||||||
|
const fs::path keep = swapdir / "note.txt";
|
||||||
|
write_file_bytes(oldp.string(), "x");
|
||||||
|
write_file_bytes(newp.string(), "y");
|
||||||
|
write_file_bytes(keep.string(), "z");
|
||||||
|
|
||||||
|
// Make old.swp look old (2 days ago) and new.swp recent.
|
||||||
|
std::error_code ec;
|
||||||
|
fs::last_write_time(oldp, fs::file_time_type::clock::now() - std::chrono::hours(48), ec);
|
||||||
|
fs::last_write_time(newp, fs::file_time_type::clock::now(), ec);
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
kte::SwapConfig cfg;
|
||||||
|
cfg.prune_on_startup = false;
|
||||||
|
cfg.prune_max_age_days = 1;
|
||||||
|
cfg.prune_max_files = 0; // disable count-based pruning for this test
|
||||||
|
sm.SetConfig(cfg);
|
||||||
|
sm.PruneSwapDir();
|
||||||
|
|
||||||
|
ASSERT_TRUE(!fs::exists(oldp));
|
||||||
|
ASSERT_TRUE(fs::exists(newp));
|
||||||
|
ASSERT_TRUE(fs::exists(keep));
|
||||||
|
|
||||||
|
// Cleanup.
|
||||||
|
std::remove(newp.string().c_str());
|
||||||
|
std::remove(keep.string().c_str());
|
||||||
|
if (!old_xdg.empty())
|
||||||
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
|
else
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
}
|
||||||
813
tests/test_swap_edge_cases.cc
Normal file
813
tests/test_swap_edge_cases.cc
Normal file
@@ -0,0 +1,813 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Swap.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
// CRC32 helper (same algorithm as SwapManager::crc32)
|
||||||
|
static std::uint32_t
|
||||||
|
crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0)
|
||||||
|
{
|
||||||
|
static std::uint32_t table[256];
|
||||||
|
static bool inited = false;
|
||||||
|
if (!inited) {
|
||||||
|
for (std::uint32_t i = 0; i < 256; ++i) {
|
||||||
|
std::uint32_t c = i;
|
||||||
|
for (int j = 0; j < 8; ++j)
|
||||||
|
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
|
||||||
|
table[i] = c;
|
||||||
|
}
|
||||||
|
inited = true;
|
||||||
|
}
|
||||||
|
std::uint32_t c = ~seed;
|
||||||
|
for (std::size_t i = 0; i < len; ++i)
|
||||||
|
c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8);
|
||||||
|
return ~c;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Build a valid 64-byte swap file header
|
||||||
|
static std::string
|
||||||
|
build_swap_header()
|
||||||
|
{
|
||||||
|
std::uint8_t hdr[64];
|
||||||
|
std::memset(hdr, 0, sizeof(hdr));
|
||||||
|
// Magic
|
||||||
|
const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
|
||||||
|
std::memcpy(hdr, magic, 8);
|
||||||
|
// Version = 1 (little-endian)
|
||||||
|
hdr[8] = 1;
|
||||||
|
hdr[9] = 0;
|
||||||
|
hdr[10] = 0;
|
||||||
|
hdr[11] = 0;
|
||||||
|
// Flags = 0
|
||||||
|
// Created time (just use 0 for tests)
|
||||||
|
return std::string(reinterpret_cast<char *>(hdr), sizeof(hdr));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Build a swap record: [type u8][len u24][payload][crc32 u32]
|
||||||
|
static std::string
|
||||||
|
build_swap_record(std::uint8_t type, const std::vector<std::uint8_t> &payload)
|
||||||
|
{
|
||||||
|
std::vector<std::uint8_t> record;
|
||||||
|
|
||||||
|
// Record header: type(1) + length(3)
|
||||||
|
record.push_back(type);
|
||||||
|
std::uint32_t len = static_cast<std::uint32_t>(payload.size());
|
||||||
|
record.push_back(static_cast<std::uint8_t>(len & 0xFFu));
|
||||||
|
record.push_back(static_cast<std::uint8_t>((len >> 8) & 0xFFu));
|
||||||
|
record.push_back(static_cast<std::uint8_t>((len >> 16) & 0xFFu));
|
||||||
|
|
||||||
|
// Payload
|
||||||
|
record.insert(record.end(), payload.begin(), payload.end());
|
||||||
|
|
||||||
|
// CRC32 (compute over header + payload)
|
||||||
|
std::uint32_t crc = crc32(record.data(), record.size());
|
||||||
|
record.push_back(static_cast<std::uint8_t>(crc & 0xFFu));
|
||||||
|
record.push_back(static_cast<std::uint8_t>((crc >> 8) & 0xFFu));
|
||||||
|
record.push_back(static_cast<std::uint8_t>((crc >> 16) & 0xFFu));
|
||||||
|
record.push_back(static_cast<std::uint8_t>((crc >> 24) & 0xFFu));
|
||||||
|
|
||||||
|
return std::string(reinterpret_cast<char *>(record.data()), record.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Build complete swap file with header and records
|
||||||
|
static std::string
|
||||||
|
build_swap_file(const std::vector<std::string> &records)
|
||||||
|
{
|
||||||
|
std::string file = build_swap_header();
|
||||||
|
for (const auto &rec: records) {
|
||||||
|
file += rec;
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Write bytes to file
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Helper to encode u32 little-endian
|
||||||
|
static void
|
||||||
|
put_u32_le(std::vector<std::uint8_t> &out, std::uint32_t v)
|
||||||
|
{
|
||||||
|
out.push_back(static_cast<std::uint8_t>(v & 0xFFu));
|
||||||
|
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFFu));
|
||||||
|
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFFu));
|
||||||
|
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFFu));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// 1. MINIMUM VALID PAYLOAD SIZE TESTS
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
TEST (SwapEdge_INS_MinimumValidPayload)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_ins_min.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_ins_min.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// INS record: encver(1) + row(4) + col(4) + nbytes(4) = 13 bytes minimum
|
||||||
|
// nbytes=0 means zero-length insertion
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
put_u32_le(payload, 0); // col
|
||||||
|
put_u32_le(payload, 0); // nbytes=0
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_DEL_MinimumValidPayload)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_del_min.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_del_min.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// DEL record: encver(1) + row(4) + col(4) + dlen(4) = 13 bytes minimum
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
put_u32_le(payload, 0); // col
|
||||||
|
put_u32_le(payload, 0); // dlen=0
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::DEL), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_SPLIT_MinimumValidPayload)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_split_min.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_split_min.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// SPLIT record: encver(1) + row(4) + col(4) = 9 bytes minimum
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
put_u32_le(payload, 0); // col
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::SPLIT), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_JOIN_MinimumValidPayload)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_join_min.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_join_min.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\nworld\n");
|
||||||
|
|
||||||
|
// JOIN record: encver(1) + row(4) = 5 bytes minimum
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::JOIN), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_CHKPT_MinimumValidPayload)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_chkpt_min.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_chkpt_min.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// CHKPT record: encver(1) + nbytes(4) = 5 bytes minimum
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // nbytes=0
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// 2. TRUNCATED PAYLOAD TESTS (BELOW MINIMUM)
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
TEST (SwapEdge_INS_TruncatedPayload_1Byte)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_ins_trunc1.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_ins_trunc1.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// INS record with only 1 byte (just encver)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver only
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("INS payload too short") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_INS_TruncatedPayload_5Bytes)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_ins_trunc5.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_ins_trunc5.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// INS record with 5 bytes (encver + row only)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("INS payload too short") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_DEL_TruncatedPayload_9Bytes)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_del_trunc9.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_del_trunc9.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// DEL record with 9 bytes (encver + row + col, missing dlen)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
put_u32_le(payload, 0); // col
|
||||||
|
// missing dlen
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::DEL), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("DEL payload too short") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_SPLIT_TruncatedPayload_5Bytes)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_split_trunc5.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_split_trunc5.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// SPLIT record with 5 bytes (encver + row, missing col)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
// missing col
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::SPLIT), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("SPLIT payload too short") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_JOIN_TruncatedPayload_1Byte)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_join_trunc1.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_join_trunc1.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\nworld\n");
|
||||||
|
|
||||||
|
// JOIN record with 1 byte (just encver)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver only
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::JOIN), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("JOIN payload too short") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_CHKPT_TruncatedPayload_3Bytes)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_chkpt_trunc3.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_chkpt_trunc3.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// CHKPT record with 3 bytes (encver + partial nbytes)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
payload.push_back(0); // partial nbytes (only 2 bytes instead of 4)
|
||||||
|
payload.push_back(0);
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("CHKPT payload too short") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// 3. DATA OVERFLOW TESTS
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
TEST (SwapEdge_INS_TruncatedData_NbytesExceedsPayload)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_ins_overflow.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_ins_overflow.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// INS record where nbytes=100 but payload only contains 13 bytes total
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
put_u32_le(payload, 0); // col
|
||||||
|
put_u32_le(payload, 100); // nbytes=100 (but no data follows)
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("Truncated INS payload bytes") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_CHKPT_TruncatedData_NbytesExceedsPayload)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_chkpt_overflow.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_chkpt_overflow.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// CHKPT record where nbytes=1000 but payload only contains 5 bytes total
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 1000); // nbytes=1000 (but no data follows)
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("Truncated CHKPT payload bytes") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// 4. UNSUPPORTED ENCODING VERSION TESTS
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
TEST (SwapEdge_INS_UnsupportedEncodingVersion)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_ins_badenc.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_ins_badenc.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// INS record with encver=2 (unsupported)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(2); // encver=2 (unsupported)
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
put_u32_le(payload, 0); // col
|
||||||
|
put_u32_le(payload, 0); // nbytes
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("Unsupported swap payload encoding") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_CHKPT_UnsupportedEncodingVersion)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_chkpt_badenc.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_chkpt_badenc.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// CHKPT record with encver=99 (unsupported)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(99); // encver=99 (unsupported)
|
||||||
|
put_u32_le(payload, 0); // nbytes
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("Unsupported swap checkpoint encoding") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// 5. BOUNDARY CONDITION TESTS
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
TEST (SwapEdge_INS_ExactlyEnoughBytes)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_ins_exact.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_ins_exact.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// INS record with nbytes=10 and exactly 23 bytes total (13 header + 10 data)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
put_u32_le(payload, 0); // col
|
||||||
|
put_u32_le(payload, 10); // nbytes=10
|
||||||
|
// Add exactly 10 bytes of data
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
payload.push_back('X');
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_INS_OneByteTooFew)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_ins_toofew.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_ins_toofew.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// INS record with nbytes=10 but only 22 bytes total (13 header + 9 data)
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1); // encver
|
||||||
|
put_u32_le(payload, 0); // row
|
||||||
|
put_u32_le(payload, 0); // col
|
||||||
|
put_u32_le(payload, 10); // nbytes=10
|
||||||
|
// Add only 9 bytes of data (one too few)
|
||||||
|
for (int i = 0; i < 9; i++) {
|
||||||
|
payload.push_back('X');
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("Truncated INS payload bytes") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// 6. MIXED VALID AND INVALID RECORDS
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
TEST (SwapEdge_MixedRecords_ValidThenInvalid)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_mixed1.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_mixed1.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// First record: valid INS
|
||||||
|
std::vector<std::uint8_t> payload1;
|
||||||
|
payload1.push_back(1); // encver
|
||||||
|
put_u32_le(payload1, 0); // row
|
||||||
|
put_u32_le(payload1, 0); // col
|
||||||
|
put_u32_le(payload1, 1); // nbytes=1
|
||||||
|
payload1.push_back('X'); // data
|
||||||
|
|
||||||
|
std::string rec1 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload1);
|
||||||
|
|
||||||
|
// Second record: truncated DEL
|
||||||
|
std::vector<std::uint8_t> payload2;
|
||||||
|
payload2.push_back(1); // encver only
|
||||||
|
|
||||||
|
std::string rec2 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::DEL), payload2);
|
||||||
|
|
||||||
|
std::string file = build_swap_file({rec1, rec2});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("DEL payload too short") != std::string::npos);
|
||||||
|
|
||||||
|
// Verify first INS was applied before failure
|
||||||
|
auto view = b.GetLineView(0);
|
||||||
|
std::string line(view.data(), view.size());
|
||||||
|
ASSERT_TRUE(line.find('X') != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapEdge_MixedRecords_MultipleValidOneInvalid)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_mixed2.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_mixed2.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "ab\n");
|
||||||
|
|
||||||
|
// First record: valid INS at (0,0)
|
||||||
|
std::vector<std::uint8_t> payload1;
|
||||||
|
payload1.push_back(1);
|
||||||
|
put_u32_le(payload1, 0);
|
||||||
|
put_u32_le(payload1, 0);
|
||||||
|
put_u32_le(payload1, 1);
|
||||||
|
payload1.push_back('X');
|
||||||
|
std::string rec1 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload1);
|
||||||
|
|
||||||
|
// Second record: valid INS at (0,1)
|
||||||
|
std::vector<std::uint8_t> payload2;
|
||||||
|
payload2.push_back(1);
|
||||||
|
put_u32_le(payload2, 0);
|
||||||
|
put_u32_le(payload2, 1);
|
||||||
|
put_u32_le(payload2, 1);
|
||||||
|
payload2.push_back('Y');
|
||||||
|
std::string rec2 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload2);
|
||||||
|
|
||||||
|
// Third record: truncated SPLIT
|
||||||
|
std::vector<std::uint8_t> payload3;
|
||||||
|
payload3.push_back(1); // encver only
|
||||||
|
std::string rec3 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::SPLIT), payload3);
|
||||||
|
|
||||||
|
std::string file = build_swap_file({rec1, rec2, rec3});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("SPLIT payload too short") != std::string::npos);
|
||||||
|
|
||||||
|
// Verify first two INS were applied
|
||||||
|
auto view = b.GetLineView(0);
|
||||||
|
std::string line(view.data(), view.size());
|
||||||
|
ASSERT_TRUE(line.find('X') != std::string::npos);
|
||||||
|
ASSERT_TRUE(line.find('Y') != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// 7. EMPTY PAYLOAD TEST
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
TEST (SwapEdge_EmptyPayload_INS)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_empty.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_empty.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// INS record with zero-length payload
|
||||||
|
std::vector<std::uint8_t> payload; // empty
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("INS payload too short") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// 8. CRC MISMATCH TEST
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
TEST (SwapEdge_ValidStructure_BadCRC)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_edge_badcrc.txt";
|
||||||
|
const std::string swap_path = "./.kte_ut_edge_badcrc.swp";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
// Build a valid INS record
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
payload.push_back(1);
|
||||||
|
put_u32_le(payload, 0);
|
||||||
|
put_u32_le(payload, 0);
|
||||||
|
put_u32_le(payload, 1);
|
||||||
|
payload.push_back('X');
|
||||||
|
|
||||||
|
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
|
||||||
|
|
||||||
|
// Corrupt the CRC (last 4 bytes)
|
||||||
|
rec[rec.size() - 1] ^= 0xFF;
|
||||||
|
|
||||||
|
std::string file = build_swap_file({rec});
|
||||||
|
write_file_bytes(swap_path, file);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
|
||||||
|
ASSERT_TRUE(err.find("CRC mismatch") != std::string::npos);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
94
tests/test_swap_git_editor.cc
Normal file
94
tests/test_swap_git_editor.cc
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Simulate git editor workflow: open file, edit, save, edit more, close.
|
||||||
|
// The swap file should be deleted on close, not left behind.
|
||||||
|
TEST(SwapCleanup_GitEditorWorkflow)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const fs::path xdg_root = fs::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_git_editor_") + std::to_string((int) ::getpid()));
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
fs::create_directories(xdg_root);
|
||||||
|
|
||||||
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||||
|
const std::string xdg_s = xdg_root.string();
|
||||||
|
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
|
||||||
|
|
||||||
|
// Simulate git's COMMIT_EDITMSG path
|
||||||
|
const std::string path = (xdg_root / ".git" / "COMMIT_EDITMSG").string();
|
||||||
|
fs::create_directories((xdg_root / ".git"));
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "# Enter commit message\n");
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||||
|
Buffer *b = ed.CurrentBuffer();
|
||||||
|
ASSERT_TRUE(b != nullptr);
|
||||||
|
|
||||||
|
// User edits the file
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
|
||||||
|
ASSERT_TRUE(b->Dirty());
|
||||||
|
|
||||||
|
// User saves (git will read this)
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Save));
|
||||||
|
ASSERT_TRUE(!b->Dirty());
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
|
||||||
|
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
|
||||||
|
// After save, swap should be deleted
|
||||||
|
ASSERT_TRUE(!fs::exists(swp));
|
||||||
|
|
||||||
|
// User makes more edits (common in git editor workflow - refining message)
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Y"));
|
||||||
|
ASSERT_TRUE(b->Dirty());
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
|
||||||
|
// Now there's a new swap file for the unsaved edits
|
||||||
|
ASSERT_TRUE(fs::exists(swp));
|
||||||
|
|
||||||
|
// User closes the buffer (or kte exits)
|
||||||
|
// This simulates what happens when git is done and kte closes
|
||||||
|
const std::size_t idx = ed.CurrentBufferIndex();
|
||||||
|
ed.CloseBuffer(idx);
|
||||||
|
|
||||||
|
// The swap file should be deleted on close, even though buffer was dirty
|
||||||
|
// This prevents stale swap files when used as git editor
|
||||||
|
ASSERT_TRUE(!fs::exists(swp));
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
std::remove(path.c_str());
|
||||||
|
if (!old_xdg.empty())
|
||||||
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
|
else
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
}
|
||||||
104
tests/test_swap_recorder.cc
Normal file
104
tests/test_swap_recorder.cc
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "SwapRecorder.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct SwapEvent {
|
||||||
|
enum class Type {
|
||||||
|
Insert,
|
||||||
|
Delete,
|
||||||
|
};
|
||||||
|
|
||||||
|
Type type;
|
||||||
|
int row;
|
||||||
|
int col;
|
||||||
|
std::string bytes;
|
||||||
|
std::size_t len = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FakeSwapRecorder final : public kte::SwapRecorder {
|
||||||
|
public:
|
||||||
|
std::vector<SwapEvent> events;
|
||||||
|
|
||||||
|
|
||||||
|
void OnInsert(int row, int col, std::string_view bytes) override
|
||||||
|
{
|
||||||
|
SwapEvent e;
|
||||||
|
e.type = SwapEvent::Type::Insert;
|
||||||
|
e.row = row;
|
||||||
|
e.col = col;
|
||||||
|
e.bytes = std::string(bytes);
|
||||||
|
e.len = 0;
|
||||||
|
events.push_back(std::move(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void OnDelete(int row, int col, std::size_t len) override
|
||||||
|
{
|
||||||
|
SwapEvent e;
|
||||||
|
e.type = SwapEvent::Type::Delete;
|
||||||
|
e.row = row;
|
||||||
|
e.col = col;
|
||||||
|
e.len = len;
|
||||||
|
events.push_back(std::move(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapRecorder_InsertABC)
|
||||||
|
{
|
||||||
|
Buffer b;
|
||||||
|
FakeSwapRecorder rec;
|
||||||
|
b.SetSwapRecorder(&rec);
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string_view("abc"));
|
||||||
|
|
||||||
|
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
|
||||||
|
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert);
|
||||||
|
ASSERT_EQ(rec.events[0].row, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].col, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].bytes, std::string("abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapRecorder_InsertNewline)
|
||||||
|
{
|
||||||
|
Buffer b;
|
||||||
|
FakeSwapRecorder rec;
|
||||||
|
b.SetSwapRecorder(&rec);
|
||||||
|
|
||||||
|
b.split_line(0, 0);
|
||||||
|
|
||||||
|
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
|
||||||
|
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert);
|
||||||
|
ASSERT_EQ(rec.events[0].row, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].col, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].bytes, std::string("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapRecorder_DeleteSpanningNewline)
|
||||||
|
{
|
||||||
|
Buffer b;
|
||||||
|
// Prepare content without a recorder (should be no-op)
|
||||||
|
b.insert_text(0, 0, std::string_view("ab"));
|
||||||
|
b.split_line(0, 2);
|
||||||
|
b.insert_text(1, 0, std::string_view("cd"));
|
||||||
|
|
||||||
|
FakeSwapRecorder rec;
|
||||||
|
b.SetSwapRecorder(&rec);
|
||||||
|
|
||||||
|
// Delete "b\n c" (3 bytes) starting at row 0, col 1.
|
||||||
|
b.delete_text(0, 1, 3);
|
||||||
|
|
||||||
|
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
|
||||||
|
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Delete);
|
||||||
|
ASSERT_EQ(rec.events[0].row, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].col, 1);
|
||||||
|
ASSERT_EQ(rec.events[0].len, (std::size_t) 3);
|
||||||
|
}
|
||||||
280
tests/test_swap_recovery_prompt.cc
Normal file
280
tests/test_swap_recovery_prompt.cc
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
#include "Swap.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
read_file_bytes(const std::string &path)
|
||||||
|
{
|
||||||
|
std::ifstream in(path, std::ios::binary);
|
||||||
|
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
|
{
|
||||||
|
const auto &rows = b.Rows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
out.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct ScopedXdgStateHome {
|
||||||
|
std::string old;
|
||||||
|
bool had{false};
|
||||||
|
|
||||||
|
|
||||||
|
explicit ScopedXdgStateHome(const std::string &p)
|
||||||
|
{
|
||||||
|
const char *old_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
had = (old_p && *old_p);
|
||||||
|
old = old_p ? std::string(old_p) : std::string();
|
||||||
|
setenv("XDG_STATE_HOME", p.c_str(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
~ScopedXdgStateHome()
|
||||||
|
{
|
||||||
|
if (had && !old.empty()) {
|
||||||
|
setenv("XDG_STATE_HOME", old.c_str(), 1);
|
||||||
|
} else {
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapRecoveryPrompt_Recover_ReplaysSwap)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_recover_") +
|
||||||
|
std::to_string((int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||||
|
|
||||||
|
const std::filesystem::path work = xdg_root / "work";
|
||||||
|
std::filesystem::create_directories(work);
|
||||||
|
const std::string file_path = (work / "recover.txt").string();
|
||||||
|
write_file_bytes(file_path, "base\nline2\n");
|
||||||
|
|
||||||
|
// Create a swap journal with unsaved edits.
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
b.insert_text(1, 0, std::string("ZZ"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
const std::string expected = buffer_bytes_via_views(b);
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
// Now attempt to open via Editor deferred-open; this should trigger a recovery prompt.
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
ed.RequestOpenFile(b.Filename());
|
||||||
|
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), true);
|
||||||
|
|
||||||
|
// Answer 'y' to recover.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "y"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), false);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), expected);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Dirty(), true);
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||||
|
|
||||||
|
std::remove(file_path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapRecoveryPrompt_Discard_DeletesSwapAndOpensClean)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_discard_") +
|
||||||
|
std::to_string((int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||||
|
|
||||||
|
const std::filesystem::path work = xdg_root / "work";
|
||||||
|
std::filesystem::create_directories(work);
|
||||||
|
const std::string file_path = (work / "discard.txt").string();
|
||||||
|
write_file_bytes(file_path, "base\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
ed.RequestOpenFile(b.Filename());
|
||||||
|
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), true);
|
||||||
|
|
||||||
|
// Default answer (empty) is 'no' => discard.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), false);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), read_file_bytes(b.Filename()));
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Dirty(), false);
|
||||||
|
ASSERT_EQ(std::filesystem::exists(swap_path), false);
|
||||||
|
|
||||||
|
std::remove(file_path.c_str());
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapRecoveryPrompt_Cancel_AbortsOpen)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_cancel_") +
|
||||||
|
std::to_string((int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||||
|
|
||||||
|
const std::filesystem::path work = xdg_root / "work";
|
||||||
|
std::filesystem::create_directories(work);
|
||||||
|
const std::string file_path = (work / "cancel.txt").string();
|
||||||
|
write_file_bytes(file_path, "base\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
ed.RequestOpenFile(b.Filename());
|
||||||
|
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), true);
|
||||||
|
|
||||||
|
// Cancel the prompt (C-g / Refresh).
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Refresh));
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), false);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename().empty(), true);
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||||
|
|
||||||
|
std::remove(file_path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapRecoveryPrompt_CorruptSwap_OffersDelete)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_corrupt_") +
|
||||||
|
std::to_string((int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||||
|
|
||||||
|
const std::filesystem::path work = xdg_root / "work";
|
||||||
|
std::filesystem::create_directories(work);
|
||||||
|
const std::string file_path = (work / "corrupt.txt").string();
|
||||||
|
write_file_bytes(file_path, "base\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
|
||||||
|
// Write a corrupt swap file at the expected location.
|
||||||
|
try {
|
||||||
|
std::filesystem::create_directories(std::filesystem::path(swap_path).parent_path());
|
||||||
|
} catch (...) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
write_file_bytes(swap_path, "x");
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
ed.RequestOpenFile(b.Filename());
|
||||||
|
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::DeleteCorruptSwap);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), true);
|
||||||
|
|
||||||
|
// Answer 'y' to delete the corrupt swap and proceed.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "y"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), false);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), read_file_bytes(b.Filename()));
|
||||||
|
ASSERT_EQ(std::filesystem::exists(swap_path), false);
|
||||||
|
|
||||||
|
std::remove(file_path.c_str());
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
227
tests/test_swap_replay.cc
Normal file
227
tests/test_swap_replay.cc
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Swap.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
read_file_bytes(const std::string &path)
|
||||||
|
{
|
||||||
|
std::ifstream in(path, std::ios::binary);
|
||||||
|
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
|
{
|
||||||
|
const auto &rows = b.Rows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
out.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::vector<std::uint8_t>
|
||||||
|
record_types_from_bytes(const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::vector<std::uint8_t> types;
|
||||||
|
if (bytes.size() < 64)
|
||||||
|
return types;
|
||||||
|
std::size_t off = 64;
|
||||||
|
while (off < bytes.size()) {
|
||||||
|
if (bytes.size() - off < 8)
|
||||||
|
break;
|
||||||
|
const std::uint8_t type = static_cast<std::uint8_t>(bytes[off + 0]);
|
||||||
|
const std::uint32_t len = (std::uint32_t) static_cast<std::uint8_t>(bytes[off + 1]) |
|
||||||
|
((std::uint32_t) static_cast<std::uint8_t>(bytes[off + 2]) << 8) |
|
||||||
|
((std::uint32_t) static_cast<std::uint8_t>(bytes[off + 3]) << 16);
|
||||||
|
const std::size_t crc_off = off + 4 + (std::size_t) len;
|
||||||
|
if (crc_off + 4 > bytes.size())
|
||||||
|
break;
|
||||||
|
types.push_back(type);
|
||||||
|
off = crc_off + 4;
|
||||||
|
}
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapReplay_RecordFlushReopenReplay_ExactBytesMatch)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_swap_replay_1.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\nline2\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
|
||||||
|
// Edits (no save): swap should capture these.
|
||||||
|
b.insert_text(0, 0, std::string("X")); // Xbase\nline2\n
|
||||||
|
b.delete_text(1, 1, 2); // delete "in" from "line2"
|
||||||
|
b.split_line(0, 3); // Xba\nse...
|
||||||
|
b.join_lines(0); // join back
|
||||||
|
b.insert_text(1, 0, std::string("ZZ")); // insert at start of line2
|
||||||
|
b.delete_text(0, 0, 1); // delete leading X
|
||||||
|
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
const std::string expected = buffer_bytes_via_views(b);
|
||||||
|
|
||||||
|
// Close journal before replaying (for determinism)
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
Buffer b2;
|
||||||
|
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapReplay_TruncatedLog_FailsSafely)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_swap_replay_2.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
const std::string bytes = read_file_bytes(swap_path);
|
||||||
|
ASSERT_TRUE(bytes.size() > 70); // header + at least one record
|
||||||
|
|
||||||
|
const std::string trunc_path = swap_path + ".trunc";
|
||||||
|
write_file_bytes(trunc_path, bytes.substr(0, bytes.size() - 1));
|
||||||
|
|
||||||
|
Buffer b2;
|
||||||
|
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||||
|
std::string rerr;
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b2, trunc_path, rerr), false);
|
||||||
|
ASSERT_EQ(rerr.empty(), false);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
std::remove(trunc_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapReplay_Checkpoint_Midstream_ExactBytesMatch)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_swap_replay_chkpt_1.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\nline2\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
|
||||||
|
// Some edits, then an explicit checkpoint, then more edits.
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
sm.Checkpoint(&b);
|
||||||
|
b.insert_text(1, 0, std::string("ZZ"));
|
||||||
|
b.delete_text(0, 0, 1);
|
||||||
|
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
const std::string expected = buffer_bytes_via_views(b);
|
||||||
|
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
Buffer b2;
|
||||||
|
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(SwapCompaction_RewritesToSingleCheckpoint)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_swap_compact_1.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
kte::SwapConfig cfg;
|
||||||
|
cfg.checkpoint_bytes = 0;
|
||||||
|
cfg.checkpoint_interval_ms = 0;
|
||||||
|
cfg.compact_bytes = 1; // force compaction on any checkpoint
|
||||||
|
sm.SetConfig(cfg);
|
||||||
|
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
|
||||||
|
// Ensure there is at least one non-checkpoint record on disk first.
|
||||||
|
b.insert_text(0, 0, std::string("abc"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
|
||||||
|
// Now emit a checkpoint; compaction should rewrite the file to just that checkpoint.
|
||||||
|
sm.Checkpoint(&b);
|
||||||
|
sm.Flush(&b);
|
||||||
|
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
const std::string expected = buffer_bytes_via_views(b);
|
||||||
|
|
||||||
|
// Close journal.
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
const std::string bytes = read_file_bytes(swap_path);
|
||||||
|
const std::vector<std::uint8_t> types = record_types_from_bytes(bytes);
|
||||||
|
ASSERT_EQ(types.size(), (std::size_t) 1);
|
||||||
|
ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::CHKPT);
|
||||||
|
|
||||||
|
Buffer b2;
|
||||||
|
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user