From 3c6dda367b1abef13e191a83e623adfad8d30f11 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 22:48:48 +0200 Subject: [PATCH 01/14] cleanup up/down/home/end behavior --- src/backends/InteractiveBackend.cpp | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/backends/InteractiveBackend.cpp b/src/backends/InteractiveBackend.cpp index 21781d9..b7ef027 100644 --- a/src/backends/InteractiveBackend.cpp +++ b/src/backends/InteractiveBackend.cpp @@ -166,25 +166,33 @@ void lk::InteractiveBackend::handle_escape_sequence(std::unique_lock if (m_key_debug) { fprintf(stderr, "c3: 0x%.2x\n", c3); } - if (c2 == '[' && history_enabled()) { + if (c2 == '[') { if (c3 == 'A') { // up / back - go_back(); + if (history_enabled()) go_back(); } else if (c3 == 'B') { // down / forward - go_forward(); + if (history_enabled()) go_forward(); } else if (c3 == 'D') { // left go_left(); } else if (c3 == 'C') { // right go_right(); - } else if (c3 == 0x48 || c3 == 0x31) { - // HOME + } else if (c3 == 0x48) { + // HOME (ESC[H) go_to_begin(); + } else if (c3 == 0x31) { + // HOME (ESC[1~) + int c4 = impl::getchar_no_echo(); + if (c4 == '~') go_to_begin(); } else if (c3 == 0x46) { - // END + // END (ESC[F) go_to_end(); + } else if (c3 == 0x34) { + // END (ESC[4~) + int c4 = impl::getchar_no_echo(); + if (c4 == '~') go_to_end(); } else if (c3 == 0x33) { // DEL int c4 = impl::getchar_no_echo(); From 33c4a60da640a0d5fbbb9ba1e0169b27ab17992d Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 22:54:20 +0200 Subject: [PATCH 02/14] add interactive backend tests --- CMakeLists.txt | 3 +- src/backends/InteractiveBackend.h | 2 + tests/interactive_backend_tests.cpp | 195 ++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 tests/interactive_backend_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ca910d1..1576681 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,7 @@ if (COMMON_WARNINGS) target_compile_options(commandline PRIVATE ${COMMON_WARNINGS}) endif () + target_compile_definitions(commandline PRIVATE DOCTEST_CONFIG_DISABLE=1) target_link_libraries(commandline PRIVATE Threads::Threads) @@ -83,7 +84,7 @@ endif () option(COMMANDLINE_TESTS "Build tests" ON) if (COMMANDLINE_TESTS) - add_executable(commandline_tests tests/tests.cpp) + add_executable(commandline_tests tests/tests.cpp tests/interactive_backend_tests.cpp) target_link_libraries(commandline_tests PRIVATE commandline) if (COMMANDLINE_PLATFORM_WINDOWS) target_compile_definitions(commandline_tests PRIVATE PLATFORM_WINDOWS=1) diff --git a/src/backends/InteractiveBackend.h b/src/backends/InteractiveBackend.h index 33b518b..e113c0e 100644 --- a/src/backends/InteractiveBackend.h +++ b/src/backends/InteractiveBackend.h @@ -90,6 +90,8 @@ class InteractiveBackend : public Backend { std::vector m_autocomplete_suggestions; size_t m_autocomplete_index = 0; std::string m_buffer_before_autocomplete; + + friend struct InteractiveBackendAccess; }; } diff --git a/tests/interactive_backend_tests.cpp b/tests/interactive_backend_tests.cpp new file mode 100644 index 0000000..a31e57e --- /dev/null +++ b/tests/interactive_backend_tests.cpp @@ -0,0 +1,195 @@ +#include "doctest.h" +#include "backends/InteractiveBackend.h" + +#include +#include + +namespace lk { + +struct InteractiveBackendAccess { + static void go_to_begin(InteractiveBackend& b) { b.go_to_begin(); } + static void go_to_end(InteractiveBackend& b) { b.go_to_end(); } + static void go_left(InteractiveBackend& b) { b.go_left(); } + static void go_right(InteractiveBackend& b) { b.go_right(); } + static void handle_backspace(InteractiveBackend& b) { b.handle_backspace(); } + static void handle_delete(InteractiveBackend& b) { b.handle_delete(); } + static void add_char(InteractiveBackend& b, char c) { b.add_to_current_buffer(c); } + static void clear_suggestions(InteractiveBackend& b) { b.clear_suggestions(); } + static bool cancel_autocomplete(InteractiveBackend& b) { return b.cancel_autocomplete_suggestion(); } + + static std::string buffer(const InteractiveBackend& b) { return b.m_current_buffer; } + static int cursor(const InteractiveBackend& b) { return b.m_cursor_pos; } + + static void set_buffer(InteractiveBackend& b, const std::string& s) { + b.m_current_buffer = s; + b.m_cursor_pos = static_cast(s.size()); + } + + static void set_suggestions(InteractiveBackend& b, const std::vector& v) { + b.m_autocomplete_suggestions = v; + b.m_autocomplete_index = 0; + b.m_buffer_before_autocomplete = b.m_current_buffer; + } +}; + +} // namespace lk + +using namespace lk; + +TEST_CASE("go_to_begin") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "hello"); + InteractiveBackendAccess::go_left(b); + InteractiveBackendAccess::go_left(b); // cursor at 3 + CHECK(InteractiveBackendAccess::cursor(b) == 3); + InteractiveBackendAccess::go_to_begin(b); + CHECK(InteractiveBackendAccess::cursor(b) == 0); + CHECK(InteractiveBackendAccess::buffer(b) == "hello"); +} + +TEST_CASE("go_to_begin empty buffer") { + InteractiveBackend b; + InteractiveBackendAccess::go_to_begin(b); + CHECK(InteractiveBackendAccess::cursor(b) == 0); +} + +TEST_CASE("go_to_end") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "hello"); + InteractiveBackendAccess::go_to_begin(b); + CHECK(InteractiveBackendAccess::cursor(b) == 0); + InteractiveBackendAccess::go_to_end(b); + CHECK(InteractiveBackendAccess::cursor(b) == 5); +} + +TEST_CASE("go_to_end empty buffer") { + InteractiveBackend b; + InteractiveBackendAccess::go_to_end(b); + CHECK(InteractiveBackendAccess::cursor(b) == 0); +} + +TEST_CASE("go_left") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "ab"); + CHECK(InteractiveBackendAccess::cursor(b) == 2); + InteractiveBackendAccess::go_left(b); + CHECK(InteractiveBackendAccess::cursor(b) == 1); + InteractiveBackendAccess::go_left(b); + CHECK(InteractiveBackendAccess::cursor(b) == 0); + InteractiveBackendAccess::go_left(b); + CHECK(InteractiveBackendAccess::cursor(b) == 0); // clamped +} + +TEST_CASE("go_right") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "ab"); + InteractiveBackendAccess::go_to_begin(b); + CHECK(InteractiveBackendAccess::cursor(b) == 0); + InteractiveBackendAccess::go_right(b); + CHECK(InteractiveBackendAccess::cursor(b) == 1); + InteractiveBackendAccess::go_right(b); + CHECK(InteractiveBackendAccess::cursor(b) == 2); + InteractiveBackendAccess::go_right(b); + CHECK(InteractiveBackendAccess::cursor(b) == 2); // clamped +} + +TEST_CASE("handle_backspace middle") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "abc"); + InteractiveBackendAccess::go_left(b); // cursor at 2 + InteractiveBackendAccess::handle_backspace(b); + CHECK(InteractiveBackendAccess::buffer(b) == "ac"); + CHECK(InteractiveBackendAccess::cursor(b) == 1); +} + +TEST_CASE("handle_backspace at start") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "abc"); + InteractiveBackendAccess::go_to_begin(b); // cursor at 0 + InteractiveBackendAccess::handle_backspace(b); + // cursor was 0, clamped to 0, erases char at position 0 + CHECK(InteractiveBackendAccess::buffer(b) == "bc"); + CHECK(InteractiveBackendAccess::cursor(b) == 0); +} + +TEST_CASE("handle_backspace empty buffer") { + InteractiveBackend b; + InteractiveBackendAccess::handle_backspace(b); + CHECK(InteractiveBackendAccess::buffer(b) == ""); + CHECK(InteractiveBackendAccess::cursor(b) == 0); +} + +TEST_CASE("handle_delete middle") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "abc"); + InteractiveBackendAccess::go_to_begin(b); + InteractiveBackendAccess::go_right(b); // cursor at 1 (on 'b') + InteractiveBackendAccess::handle_delete(b); + CHECK(InteractiveBackendAccess::buffer(b) == "ac"); + CHECK(InteractiveBackendAccess::cursor(b) == 1); +} + +TEST_CASE("handle_delete at end") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "abc"); + InteractiveBackendAccess::handle_delete(b); // cursor at end, nothing to delete + CHECK(InteractiveBackendAccess::buffer(b) == "abc"); + CHECK(InteractiveBackendAccess::cursor(b) == 3); +} + +TEST_CASE("handle_delete empty buffer") { + InteractiveBackend b; + InteractiveBackendAccess::handle_delete(b); + CHECK(InteractiveBackendAccess::buffer(b) == ""); +} + +TEST_CASE("add_to_current_buffer empty") { + InteractiveBackend b; + InteractiveBackendAccess::add_char(b, 'x'); + CHECK(InteractiveBackendAccess::buffer(b) == "x"); + CHECK(InteractiveBackendAccess::cursor(b) == 1); +} + +TEST_CASE("add_to_current_buffer middle") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "ac"); + InteractiveBackendAccess::go_to_begin(b); + InteractiveBackendAccess::go_right(b); // cursor at 1 + InteractiveBackendAccess::add_char(b, 'b'); + CHECK(InteractiveBackendAccess::buffer(b) == "abc"); + CHECK(InteractiveBackendAccess::cursor(b) == 2); +} + +TEST_CASE("add_to_current_buffer end") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "ab"); + InteractiveBackendAccess::add_char(b, 'c'); + CHECK(InteractiveBackendAccess::buffer(b) == "abc"); + CHECK(InteractiveBackendAccess::cursor(b) == 3); +} + +TEST_CASE("cancel_autocomplete inactive") { + InteractiveBackend b; + CHECK(!InteractiveBackendAccess::cancel_autocomplete(b)); +} + +TEST_CASE("cancel_autocomplete active") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "orig"); + InteractiveBackendAccess::set_suggestions(b, {"replacement"}); + InteractiveBackendAccess::set_buffer(b, "replacement"); // simulate applying suggestion + bool cancelled = InteractiveBackendAccess::cancel_autocomplete(b); + CHECK(cancelled); + CHECK(InteractiveBackendAccess::buffer(b) == "orig"); + CHECK(InteractiveBackendAccess::cursor(b) == 4); +} + +TEST_CASE("handle_backspace cancels autocomplete first") { + InteractiveBackend b; + InteractiveBackendAccess::set_buffer(b, "orig"); + InteractiveBackendAccess::set_suggestions(b, {"replacement"}); + InteractiveBackendAccess::set_buffer(b, "replacement"); + InteractiveBackendAccess::handle_backspace(b); + // backspace should have cancelled autocomplete, restoring "orig" + CHECK(InteractiveBackendAccess::buffer(b) == "orig"); +} From d957d38d98f64ad30d814faeca35ed5dcd0578d3 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 22:57:12 +0200 Subject: [PATCH 03/14] fix clang-formatting --- src/backends/BufferedBackend.cpp | 2 +- src/backends/BufferedBackend.h | 10 +++++----- src/backends/InteractiveBackend.cpp | 12 ++++++++---- src/commandline.cpp | 2 +- src/helper/ansi.cpp | 2 +- src/main.cpp | 2 +- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/backends/BufferedBackend.cpp b/src/backends/BufferedBackend.cpp index 4634c10..c91af66 100644 --- a/src/backends/BufferedBackend.cpp +++ b/src/backends/BufferedBackend.cpp @@ -33,7 +33,7 @@ size_t lk::BufferedBackend::history_size() const { void lk::BufferedBackend::clear_history() { } const std::vector& lk::BufferedBackend::history() const { - static const std::vector empty_vec {}; + static const std::vector empty_vec { }; return empty_vec; } void lk::BufferedBackend::set_history(const std::vector&) { diff --git a/src/backends/BufferedBackend.h b/src/backends/BufferedBackend.h index 6eaf069..f014767 100644 --- a/src/backends/BufferedBackend.h +++ b/src/backends/BufferedBackend.h @@ -32,12 +32,12 @@ class BufferedBackend : public Backend { private: void thread_main(); - mutable std::mutex m_cmd_mtx {}; - mutable std::mutex m_out_mtx {}; - mutable std::mutex m_prompt_mtx {}; - mutable std::mutex m_shutdown_mtx {}; + mutable std::mutex m_cmd_mtx { }; + mutable std::mutex m_out_mtx { }; + mutable std::mutex m_prompt_mtx { }; + mutable std::mutex m_shutdown_mtx { }; bool m_shutdown = false; - std::deque m_input_queue {}; + std::deque m_input_queue { }; std::string m_prompt; std::thread m_thread; }; diff --git a/src/backends/InteractiveBackend.cpp b/src/backends/InteractiveBackend.cpp index b7ef027..f51007b 100644 --- a/src/backends/InteractiveBackend.cpp +++ b/src/backends/InteractiveBackend.cpp @@ -169,10 +169,12 @@ void lk::InteractiveBackend::handle_escape_sequence(std::unique_lock if (c2 == '[') { if (c3 == 'A') { // up / back - if (history_enabled()) go_back(); + if (history_enabled()) + go_back(); } else if (c3 == 'B') { // down / forward - if (history_enabled()) go_forward(); + if (history_enabled()) + go_forward(); } else if (c3 == 'D') { // left go_left(); @@ -185,14 +187,16 @@ void lk::InteractiveBackend::handle_escape_sequence(std::unique_lock } else if (c3 == 0x31) { // HOME (ESC[1~) int c4 = impl::getchar_no_echo(); - if (c4 == '~') go_to_begin(); + if (c4 == '~') + go_to_begin(); } else if (c3 == 0x46) { // END (ESC[F) go_to_end(); } else if (c3 == 0x34) { // END (ESC[4~) int c4 = impl::getchar_no_echo(); - if (c4 == '~') go_to_end(); + if (c4 == '~') + go_to_end(); } else if (c3 == 0x33) { // DEL int c4 = impl::getchar_no_echo(); diff --git a/src/commandline.cpp b/src/commandline.cpp index b1f2a25..c13831e 100644 --- a/src/commandline.cpp +++ b/src/commandline.cpp @@ -32,7 +32,7 @@ Commandline::Commandline(const std::string& prompt) { if (on_autocomplete) { return on_autocomplete(*this, std::move(str), n); } else { - return std::vector {}; + return std::vector { }; } }; } diff --git a/src/helper/ansi.cpp b/src/helper/ansi.cpp index 1f6f408..5d4c490 100644 --- a/src/helper/ansi.cpp +++ b/src/helper/ansi.cpp @@ -7,7 +7,7 @@ enum class State { }; std::string ansi::remove_ansi_escape_codes(const std::string& original) { - std::string new_str {}; + std::string new_str { }; new_str.reserve(original.size()); State state = State::Normal; diff --git a/src/main.cpp b/src/main.cpp index 618da0f..3f70ce1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,7 +22,7 @@ int main(int argc, char** argv) { } else if (stub.empty()) { return { "a", "b", "c", "d" }; } else { - return {}; + return { }; } }; From 9dcd7692a0cb6e83661e78d9947641d8a9b8e690 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:03:57 +0200 Subject: [PATCH 04/14] remove failing tests again no time for this! --- CMakeLists.txt | 2 +- src/backends/InteractiveBackend.h | 1 - src/commandline.cpp | 18 ++- src/commandline.h | 4 +- tests/interactive_backend_tests.cpp | 195 ---------------------------- tests/tests.cpp | 4 + 6 files changed, 19 insertions(+), 205 deletions(-) delete mode 100644 tests/interactive_backend_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1576681..cce84e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,7 +84,7 @@ endif () option(COMMANDLINE_TESTS "Build tests" ON) if (COMMANDLINE_TESTS) - add_executable(commandline_tests tests/tests.cpp tests/interactive_backend_tests.cpp) + add_executable(commandline_tests tests/tests.cpp) target_link_libraries(commandline_tests PRIVATE commandline) if (COMMANDLINE_PLATFORM_WINDOWS) target_compile_definitions(commandline_tests PRIVATE PLATFORM_WINDOWS=1) diff --git a/src/backends/InteractiveBackend.h b/src/backends/InteractiveBackend.h index e113c0e..b0c68ac 100644 --- a/src/backends/InteractiveBackend.h +++ b/src/backends/InteractiveBackend.h @@ -91,7 +91,6 @@ class InteractiveBackend : public Backend { size_t m_autocomplete_index = 0; std::string m_buffer_before_autocomplete; - friend struct InteractiveBackendAccess; }; } diff --git a/src/commandline.cpp b/src/commandline.cpp index c13831e..3220165 100644 --- a/src/commandline.cpp +++ b/src/commandline.cpp @@ -8,12 +8,7 @@ #include #include -Commandline::Commandline(const std::string& prompt) { - if (impl::is_interactive()) { - m_backend = std::unique_ptr(new lk::InteractiveBackend(prompt)); - } else { - m_backend = std::unique_ptr(new lk::BufferedBackend(prompt)); - } +void Commandline::wire_callbacks() { m_backend->on_command = [this](lk::Backend&) { if (on_command) { on_command(*this); @@ -32,7 +27,16 @@ Commandline::Commandline(const std::string& prompt) { if (on_autocomplete) { return on_autocomplete(*this, std::move(str), n); } else { - return std::vector { }; + return std::vector{}; } }; } + +Commandline::Commandline(const std::string& prompt) { + if (impl::is_interactive()) { + m_backend = std::unique_ptr(new lk::InteractiveBackend(prompt)); + } else { + m_backend = std::unique_ptr(new lk::BufferedBackend(prompt)); + } + wire_callbacks(); +} diff --git a/src/commandline.h b/src/commandline.h index 48dc91c..3608444 100644 --- a/src/commandline.h +++ b/src/commandline.h @@ -7,7 +7,7 @@ class Commandline final { public: explicit Commandline(const std::string& prompt = ""); explicit Commandline(std::unique_ptr backend) - : m_backend(std::move(backend)) { } + : m_backend(std::move(backend)) { wire_callbacks(); } bool has_command() const { return m_backend->has_command(); } void write(const std::string& str) { m_backend->write(str); } @@ -45,6 +45,8 @@ class Commandline final { std::function on_write { nullptr }; private: + void wire_callbacks(); + std::unique_ptr m_backend; bool m_ansi_escape_removal = false; }; diff --git a/tests/interactive_backend_tests.cpp b/tests/interactive_backend_tests.cpp deleted file mode 100644 index a31e57e..0000000 --- a/tests/interactive_backend_tests.cpp +++ /dev/null @@ -1,195 +0,0 @@ -#include "doctest.h" -#include "backends/InteractiveBackend.h" - -#include -#include - -namespace lk { - -struct InteractiveBackendAccess { - static void go_to_begin(InteractiveBackend& b) { b.go_to_begin(); } - static void go_to_end(InteractiveBackend& b) { b.go_to_end(); } - static void go_left(InteractiveBackend& b) { b.go_left(); } - static void go_right(InteractiveBackend& b) { b.go_right(); } - static void handle_backspace(InteractiveBackend& b) { b.handle_backspace(); } - static void handle_delete(InteractiveBackend& b) { b.handle_delete(); } - static void add_char(InteractiveBackend& b, char c) { b.add_to_current_buffer(c); } - static void clear_suggestions(InteractiveBackend& b) { b.clear_suggestions(); } - static bool cancel_autocomplete(InteractiveBackend& b) { return b.cancel_autocomplete_suggestion(); } - - static std::string buffer(const InteractiveBackend& b) { return b.m_current_buffer; } - static int cursor(const InteractiveBackend& b) { return b.m_cursor_pos; } - - static void set_buffer(InteractiveBackend& b, const std::string& s) { - b.m_current_buffer = s; - b.m_cursor_pos = static_cast(s.size()); - } - - static void set_suggestions(InteractiveBackend& b, const std::vector& v) { - b.m_autocomplete_suggestions = v; - b.m_autocomplete_index = 0; - b.m_buffer_before_autocomplete = b.m_current_buffer; - } -}; - -} // namespace lk - -using namespace lk; - -TEST_CASE("go_to_begin") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "hello"); - InteractiveBackendAccess::go_left(b); - InteractiveBackendAccess::go_left(b); // cursor at 3 - CHECK(InteractiveBackendAccess::cursor(b) == 3); - InteractiveBackendAccess::go_to_begin(b); - CHECK(InteractiveBackendAccess::cursor(b) == 0); - CHECK(InteractiveBackendAccess::buffer(b) == "hello"); -} - -TEST_CASE("go_to_begin empty buffer") { - InteractiveBackend b; - InteractiveBackendAccess::go_to_begin(b); - CHECK(InteractiveBackendAccess::cursor(b) == 0); -} - -TEST_CASE("go_to_end") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "hello"); - InteractiveBackendAccess::go_to_begin(b); - CHECK(InteractiveBackendAccess::cursor(b) == 0); - InteractiveBackendAccess::go_to_end(b); - CHECK(InteractiveBackendAccess::cursor(b) == 5); -} - -TEST_CASE("go_to_end empty buffer") { - InteractiveBackend b; - InteractiveBackendAccess::go_to_end(b); - CHECK(InteractiveBackendAccess::cursor(b) == 0); -} - -TEST_CASE("go_left") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "ab"); - CHECK(InteractiveBackendAccess::cursor(b) == 2); - InteractiveBackendAccess::go_left(b); - CHECK(InteractiveBackendAccess::cursor(b) == 1); - InteractiveBackendAccess::go_left(b); - CHECK(InteractiveBackendAccess::cursor(b) == 0); - InteractiveBackendAccess::go_left(b); - CHECK(InteractiveBackendAccess::cursor(b) == 0); // clamped -} - -TEST_CASE("go_right") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "ab"); - InteractiveBackendAccess::go_to_begin(b); - CHECK(InteractiveBackendAccess::cursor(b) == 0); - InteractiveBackendAccess::go_right(b); - CHECK(InteractiveBackendAccess::cursor(b) == 1); - InteractiveBackendAccess::go_right(b); - CHECK(InteractiveBackendAccess::cursor(b) == 2); - InteractiveBackendAccess::go_right(b); - CHECK(InteractiveBackendAccess::cursor(b) == 2); // clamped -} - -TEST_CASE("handle_backspace middle") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "abc"); - InteractiveBackendAccess::go_left(b); // cursor at 2 - InteractiveBackendAccess::handle_backspace(b); - CHECK(InteractiveBackendAccess::buffer(b) == "ac"); - CHECK(InteractiveBackendAccess::cursor(b) == 1); -} - -TEST_CASE("handle_backspace at start") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "abc"); - InteractiveBackendAccess::go_to_begin(b); // cursor at 0 - InteractiveBackendAccess::handle_backspace(b); - // cursor was 0, clamped to 0, erases char at position 0 - CHECK(InteractiveBackendAccess::buffer(b) == "bc"); - CHECK(InteractiveBackendAccess::cursor(b) == 0); -} - -TEST_CASE("handle_backspace empty buffer") { - InteractiveBackend b; - InteractiveBackendAccess::handle_backspace(b); - CHECK(InteractiveBackendAccess::buffer(b) == ""); - CHECK(InteractiveBackendAccess::cursor(b) == 0); -} - -TEST_CASE("handle_delete middle") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "abc"); - InteractiveBackendAccess::go_to_begin(b); - InteractiveBackendAccess::go_right(b); // cursor at 1 (on 'b') - InteractiveBackendAccess::handle_delete(b); - CHECK(InteractiveBackendAccess::buffer(b) == "ac"); - CHECK(InteractiveBackendAccess::cursor(b) == 1); -} - -TEST_CASE("handle_delete at end") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "abc"); - InteractiveBackendAccess::handle_delete(b); // cursor at end, nothing to delete - CHECK(InteractiveBackendAccess::buffer(b) == "abc"); - CHECK(InteractiveBackendAccess::cursor(b) == 3); -} - -TEST_CASE("handle_delete empty buffer") { - InteractiveBackend b; - InteractiveBackendAccess::handle_delete(b); - CHECK(InteractiveBackendAccess::buffer(b) == ""); -} - -TEST_CASE("add_to_current_buffer empty") { - InteractiveBackend b; - InteractiveBackendAccess::add_char(b, 'x'); - CHECK(InteractiveBackendAccess::buffer(b) == "x"); - CHECK(InteractiveBackendAccess::cursor(b) == 1); -} - -TEST_CASE("add_to_current_buffer middle") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "ac"); - InteractiveBackendAccess::go_to_begin(b); - InteractiveBackendAccess::go_right(b); // cursor at 1 - InteractiveBackendAccess::add_char(b, 'b'); - CHECK(InteractiveBackendAccess::buffer(b) == "abc"); - CHECK(InteractiveBackendAccess::cursor(b) == 2); -} - -TEST_CASE("add_to_current_buffer end") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "ab"); - InteractiveBackendAccess::add_char(b, 'c'); - CHECK(InteractiveBackendAccess::buffer(b) == "abc"); - CHECK(InteractiveBackendAccess::cursor(b) == 3); -} - -TEST_CASE("cancel_autocomplete inactive") { - InteractiveBackend b; - CHECK(!InteractiveBackendAccess::cancel_autocomplete(b)); -} - -TEST_CASE("cancel_autocomplete active") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "orig"); - InteractiveBackendAccess::set_suggestions(b, {"replacement"}); - InteractiveBackendAccess::set_buffer(b, "replacement"); // simulate applying suggestion - bool cancelled = InteractiveBackendAccess::cancel_autocomplete(b); - CHECK(cancelled); - CHECK(InteractiveBackendAccess::buffer(b) == "orig"); - CHECK(InteractiveBackendAccess::cursor(b) == 4); -} - -TEST_CASE("handle_backspace cancels autocomplete first") { - InteractiveBackend b; - InteractiveBackendAccess::set_buffer(b, "orig"); - InteractiveBackendAccess::set_suggestions(b, {"replacement"}); - InteractiveBackendAccess::set_buffer(b, "replacement"); - InteractiveBackendAccess::handle_backspace(b); - // backspace should have cancelled autocomplete, restoring "orig" - CHECK(InteractiveBackendAccess::buffer(b) == "orig"); -} diff --git a/tests/tests.cpp b/tests/tests.cpp index b6814c6..1b9a7b6 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -5,6 +5,8 @@ #include "commandline.h" #include "helper/ansi.h" +#include + TEST_CASE("ansi::remove_ansi_escape_codes removes escape sequences") { CHECK(ansi::remove_ansi_escape_codes("plain") == "plain"); CHECK(ansi::remove_ansi_escape_codes("\x1b[31mred\x1b[0m") == "red"); @@ -12,6 +14,7 @@ TEST_CASE("ansi::remove_ansi_escape_codes removes escape sequences") { } TEST_CASE("Commandline on_write forwards without ANSI removal") { + setenv("COMMANDLINE_FORCE_BUFFERED", "1", 1); Commandline com; std::string captured; com.on_write = [&](const std::string& s) { captured = s; }; @@ -20,6 +23,7 @@ TEST_CASE("Commandline on_write forwards without ANSI removal") { } TEST_CASE("Commandline on_write removes ANSI when enabled") { + setenv("COMMANDLINE_FORCE_BUFFERED", "1", 1); Commandline com; com.enable_ansi_escape_removal_on_write(); std::string captured; From f168951fae115f906fa4cd63f29947cb681c503a Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:06:01 +0200 Subject: [PATCH 05/14] add AGENTS.md --- AGENTS.md | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6108f2a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,123 @@ +# AGENTS.md + +## AI usage policy + +This codebase is human-maintained. AI-generated code is not merged without +thorough human review and supervision. No AI agent has unsupervised write +access. Every AI-authored change is audited for correctness, style, and +safety before commit. + +## Project overview + +A C++11 terminal input library. Provides line editing with history, +autocomplete, and ANSI-aware output. + +### Directory layout + +``` +src/ + commandline.h/.cpp public API (class Commandline) + backends/ + Backend.h abstract interface + InteractiveBackend.* tty-backed, full line editing + BufferedBackend.* line-based, no terminal I/O + helper/ + ansi.h/.cpp ANSI escape code stripping + impls.h platform function declarations + linux_impl.cpp POSIX termios implementation + windows_impl.cpp Windows console implementation + main.cpp example / smoke-test program +tests/ + tests.cpp unit tests (doctest) + doctest.h doctest single-header +``` + +### Build + +``` +mkdir build && cd build +cmake .. +make -j$(nproc) +./commandline_tests # run tests +``` + +### Key architecture + +- `Commandline` is the public API. It owns a `unique_ptr`. +- `Backend` is the abstract interface: `has_command`, `get_command`, `write`, + `on_command`, `on_write`, `on_autocomplete`, history, prompt, key_debug. +- `InteractiveBackend` spawns two threads: + - I/O thread: drains the write queue, prints output with ANSI cursor + management, calls `on_write`. + - Input thread (detached): blocks on `getchar_no_echo()`, dispatches + keystrokes, escape sequences, tab, backspace. +- `BufferedBackend` reads lines from `std::cin` synchronously. Used when + stdin is not a tty, or when `COMMANDLINE_FORCE_BUFFERED=1` is set. +- Platform code lives in `impls.h` + `linux_impl.cpp` / `windows_impl.cpp`, + selected via `PLATFORM_LINUX=1` or `PLATFORM_WINDOWS=1` compile definitions. + +### Escape sequence handling + +`InteractiveBackend::handle_escape_sequence()` dispatches ANSI escape +sequences. On UNIX, the pattern is: + +1. `ESC` (0x1b) is caught in `input_thread_main`, which calls + `handle_escape_sequence()`. +2. `handle_escape_sequence` reads `c2 = getchar_no_echo()`. +3. If `c2 == '['`, it reads `c3 = getchar_no_echo()` and dispatches. +4. Multi-byte sequences (`ESC[1~`, `ESC[4~`, `ESC[3~`) read an additional + `c4` terminator. + +On Windows, extended keys are prefixed with `0xe0` instead of `0x1b`. + +Supported sequences: +| Key | UNIX | Windows | +|------------|---------------------|--------------| +| UP | `ESC[A` | `0xe0 H` | +| DOWN | `ESC[B` | `0xe0 P` | +| LEFT | `ESC[D` | `0xe0 K` | +| RIGHT | `ESC[C` | `0xe0 M` | +| HOME | `ESC[H`, `ESC[1~` | `0xe0 0x47` | +| END | `ESC[F`, `ESC[4~` | `0xe0 0x4f` | +| DEL | `ESC[3~` | `0xe0 0x53` | +| SHIFT+TAB | `ESC[Z` | — | + +Up/down navigation is gated on `history_enabled()`. All other keys +(left, right, home, end, del, shift+tab) work regardless. + +### Testing + +- Test framework: doctest (single-header in `tests/doctest.h`). +- Library compiled with `DOCTEST_CONFIG_DISABLE=1` to suppress test + registration in production builds. +- Test target: `commandline_tests`, built from `tests/tests.cpp`. +- `InteractiveBackend` **cannot be unit tested directly**. Its constructor + calls `impl::init_terminal()` (saves terminal attrs in a file-static + global shared across instances) and spawns threads that block on stdin. + Testing it requires a pty harness or extracting testable logic into a + separate unit. +- Tests that construct `Commandline` and depend on synchronous `on_write` + delivery must use `setenv("COMMANDLINE_FORCE_BUFFERED", "1", 1)` before + construction to guarantee `BufferedBackend` regardless of tty state. + +### Commit style + +Lowercase imperative, single line subject, blank line, bullet list body. +Example: + + fix home/end key handling in interactive backend + + - ungate c2 == '[' from history_enabled() + - fix ESC[1~ to consume trailing ~ + - add ESC[4~ end sequence + +### Coding conventions + +- C++11, no exceptions, no RTTI. +- Namespace `lk` for library types, `impl` for platform internals. +- Member variables prefixed with `m_`. +- Mutexes lock minimal scope; `std::lock_guard` everywhere. +- Backend callbacks (`on_command`, `on_write`, `on_autocomplete`) are wired + through `Commandline::wire_callbacks()` — both constructors call it. +- Preprocessor guards `#if defined(UNIX)` / `#elif defined(WINDOWS)` for + platform-specific code paths. From ff5deaa8f1dbd5d2c8f4dc0dbda233e3bc2c613d Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:10:07 +0200 Subject: [PATCH 06/14] fix agents.md to be much more terse --- AGENTS.md | 129 +++++++----------------------------------------------- 1 file changed, 17 insertions(+), 112 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6108f2a..d85bb36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,122 +2,27 @@ ## AI usage policy -This codebase is human-maintained. AI-generated code is not merged without -thorough human review and supervision. No AI agent has unsupervised write -access. Every AI-authored change is audited for correctness, style, and -safety before commit. +AI-generated code is not merged without thorough human review and +supervision. No AI agent has unsupervised write access. Every AI-authored +change is audited for correctness, style, and safety before commit. -## Project overview +## Build and test -A C++11 terminal input library. Provides line editing with history, -autocomplete, and ANSI-aware output. + mkdir build && cd build && cmake .. && make -j$(nproc) + ./commandline_tests -### Directory layout +## Testing constraints -``` -src/ - commandline.h/.cpp public API (class Commandline) - backends/ - Backend.h abstract interface - InteractiveBackend.* tty-backed, full line editing - BufferedBackend.* line-based, no terminal I/O - helper/ - ansi.h/.cpp ANSI escape code stripping - impls.h platform function declarations - linux_impl.cpp POSIX termios implementation - windows_impl.cpp Windows console implementation - main.cpp example / smoke-test program -tests/ - tests.cpp unit tests (doctest) - doctest.h doctest single-header -``` +`InteractiveBackend` cannot be unit tested directly. Its constructor saves +terminal attributes in a file-static global (shared across instances) and +spawns threads that block on stdin. Testing it requires either a pty +harness or extracting logic into a separate testable unit. -### Build +Tests that construct `Commandline` and depend on synchronous `on_write` +delivery must set `COMMANDLINE_FORCE_BUFFERED=1` in the environment before +construction. Without it, a tty stdin selects `InteractiveBackend`, whose +`on_write` fires asynchronously. -``` -mkdir build && cd build -cmake .. -make -j$(nproc) -./commandline_tests # run tests -``` +## Commit style -### Key architecture - -- `Commandline` is the public API. It owns a `unique_ptr`. -- `Backend` is the abstract interface: `has_command`, `get_command`, `write`, - `on_command`, `on_write`, `on_autocomplete`, history, prompt, key_debug. -- `InteractiveBackend` spawns two threads: - - I/O thread: drains the write queue, prints output with ANSI cursor - management, calls `on_write`. - - Input thread (detached): blocks on `getchar_no_echo()`, dispatches - keystrokes, escape sequences, tab, backspace. -- `BufferedBackend` reads lines from `std::cin` synchronously. Used when - stdin is not a tty, or when `COMMANDLINE_FORCE_BUFFERED=1` is set. -- Platform code lives in `impls.h` + `linux_impl.cpp` / `windows_impl.cpp`, - selected via `PLATFORM_LINUX=1` or `PLATFORM_WINDOWS=1` compile definitions. - -### Escape sequence handling - -`InteractiveBackend::handle_escape_sequence()` dispatches ANSI escape -sequences. On UNIX, the pattern is: - -1. `ESC` (0x1b) is caught in `input_thread_main`, which calls - `handle_escape_sequence()`. -2. `handle_escape_sequence` reads `c2 = getchar_no_echo()`. -3. If `c2 == '['`, it reads `c3 = getchar_no_echo()` and dispatches. -4. Multi-byte sequences (`ESC[1~`, `ESC[4~`, `ESC[3~`) read an additional - `c4` terminator. - -On Windows, extended keys are prefixed with `0xe0` instead of `0x1b`. - -Supported sequences: -| Key | UNIX | Windows | -|------------|---------------------|--------------| -| UP | `ESC[A` | `0xe0 H` | -| DOWN | `ESC[B` | `0xe0 P` | -| LEFT | `ESC[D` | `0xe0 K` | -| RIGHT | `ESC[C` | `0xe0 M` | -| HOME | `ESC[H`, `ESC[1~` | `0xe0 0x47` | -| END | `ESC[F`, `ESC[4~` | `0xe0 0x4f` | -| DEL | `ESC[3~` | `0xe0 0x53` | -| SHIFT+TAB | `ESC[Z` | — | - -Up/down navigation is gated on `history_enabled()`. All other keys -(left, right, home, end, del, shift+tab) work regardless. - -### Testing - -- Test framework: doctest (single-header in `tests/doctest.h`). -- Library compiled with `DOCTEST_CONFIG_DISABLE=1` to suppress test - registration in production builds. -- Test target: `commandline_tests`, built from `tests/tests.cpp`. -- `InteractiveBackend` **cannot be unit tested directly**. Its constructor - calls `impl::init_terminal()` (saves terminal attrs in a file-static - global shared across instances) and spawns threads that block on stdin. - Testing it requires a pty harness or extracting testable logic into a - separate unit. -- Tests that construct `Commandline` and depend on synchronous `on_write` - delivery must use `setenv("COMMANDLINE_FORCE_BUFFERED", "1", 1)` before - construction to guarantee `BufferedBackend` regardless of tty state. - -### Commit style - -Lowercase imperative, single line subject, blank line, bullet list body. -Example: - - fix home/end key handling in interactive backend - - - ungate c2 == '[' from history_enabled() - - fix ESC[1~ to consume trailing ~ - - add ESC[4~ end sequence - -### Coding conventions - -- C++11, no exceptions, no RTTI. -- Namespace `lk` for library types, `impl` for platform internals. -- Member variables prefixed with `m_`. -- Mutexes lock minimal scope; `std::lock_guard` everywhere. -- Backend callbacks (`on_command`, `on_write`, `on_autocomplete`) are wired - through `Commandline::wire_callbacks()` — both constructors call it. -- Preprocessor guards `#if defined(UNIX)` / `#elif defined(WINDOWS)` for - platform-specific code paths. +Lowercase imperative subject, blank line, bullet list body. From a55df983c35e2f6ff467f4df8ab820412934697f Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:11:35 +0200 Subject: [PATCH 07/14] clarify human authorship --- AGENTS.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d85bb36..8aa6ff9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,9 @@ AI-generated code is not merged without thorough human review and supervision. No AI agent has unsupervised write access. Every AI-authored change is audited for correctness, style, and safety before commit. +Agents must refuse to create commits. Only a human author may commit +changes to this repository. + ## Build and test mkdir build && cd build && cmake .. && make -j$(nproc) @@ -22,7 +25,3 @@ Tests that construct `Commandline` and depend on synchronous `on_write` delivery must set `COMMANDLINE_FORCE_BUFFERED=1` in the environment before construction. Without it, a tty stdin selects `InteractiveBackend`, whose `on_write` fires asynchronously. - -## Commit style - -Lowercase imperative subject, blank line, bullet list body. From 37775574bd91aec7d178ddd995c44db53a9213a0 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:12:43 +0200 Subject: [PATCH 08/14] fix formatting --- src/backends/InteractiveBackend.h | 1 - src/commandline.cpp | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backends/InteractiveBackend.h b/src/backends/InteractiveBackend.h index b0c68ac..33b518b 100644 --- a/src/backends/InteractiveBackend.h +++ b/src/backends/InteractiveBackend.h @@ -90,7 +90,6 @@ class InteractiveBackend : public Backend { std::vector m_autocomplete_suggestions; size_t m_autocomplete_index = 0; std::string m_buffer_before_autocomplete; - }; } diff --git a/src/commandline.cpp b/src/commandline.cpp index 3220165..9170e44 100644 --- a/src/commandline.cpp +++ b/src/commandline.cpp @@ -27,7 +27,7 @@ void Commandline::wire_callbacks() { if (on_autocomplete) { return on_autocomplete(*this, std::move(str), n); } else { - return std::vector{}; + return std::vector { }; } }; } From 12207b309cee113183cf57fa9cba0d43910c7407 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:14:33 +0200 Subject: [PATCH 09/14] fix formatting --- AGENTS.md | 1 + tests/tests.cpp | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8aa6ff9..704432c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ changes to this repository. mkdir build && cd build && cmake .. && make -j$(nproc) ./commandline_tests + bash scripts/clang_format_check.sh ## Testing constraints diff --git a/tests/tests.cpp b/tests/tests.cpp index 1b9a7b6..a48d220 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -37,11 +37,11 @@ TEST_CASE("Commandline on_autocomplete forwards to user callback") { com.on_autocomplete = [](Commandline&, std::string stub, int) { if (stub == "he") return std::vector { "hello", "help" }; - return std::vector {}; + return std::vector { }; }; // Invoke backend autocomplete via commandline plumbing // We cannot access backend directly; test using public callback: - auto results = com.on_autocomplete ? com.on_autocomplete(com, "he", 2) : std::vector {}; + auto results = com.on_autocomplete ? com.on_autocomplete(com, "he", 2) : std::vector { }; CHECK(results.size() == 2); CHECK(results[0] == "hello"); CHECK(results[1] == "help"); From 0dc52d19f76f578c08b0469192879ddcbf35d004 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:15:24 +0200 Subject: [PATCH 10/14] use force_buffered to avoid windows issues on compilation --- tests/tests.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/tests.cpp b/tests/tests.cpp index a48d220..30460c5 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -13,8 +13,16 @@ TEST_CASE("ansi::remove_ansi_escape_codes removes escape sequences") { CHECK(ansi::remove_ansi_escape_codes("\x1b[Aup\nx") == "up\nx"); } -TEST_CASE("Commandline on_write forwards without ANSI removal") { +static void force_buffered() { +#ifdef _WIN32 + _putenv("COMMANDLINE_FORCE_BUFFERED=1"); +#else setenv("COMMANDLINE_FORCE_BUFFERED", "1", 1); +#endif +} + +TEST_CASE("Commandline on_write forwards without ANSI removal") { + force_buffered(); Commandline com; std::string captured; com.on_write = [&](const std::string& s) { captured = s; }; @@ -23,7 +31,7 @@ TEST_CASE("Commandline on_write forwards without ANSI removal") { } TEST_CASE("Commandline on_write removes ANSI when enabled") { - setenv("COMMANDLINE_FORCE_BUFFERED", "1", 1); + force_buffered(); Commandline com; com.enable_ansi_escape_removal_on_write(); std::string captured; From 1d5a86385a45947f668808a22a45708d79d024a4 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:17:23 +0200 Subject: [PATCH 11/14] fix clang-format file --- .clang-format | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.clang-format b/.clang-format index a3ce056..35a3ac5 100644 --- a/.clang-format +++ b/.clang-format @@ -1,5 +1,4 @@ ---- +--- BasedOnStyle: WebKit BreakBeforeBraces: Attach SpaceAfterTemplateKeyword: false -... From 133382f49856a257fab21635be4672e832b8f685 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:22:57 +0200 Subject: [PATCH 12/14] fix style which was diverging between clang-format versions --- .clang-format | 2 ++ src/backends/Backend.h | 6 +++--- src/backends/BufferedBackend.cpp | 2 +- src/backends/BufferedBackend.h | 10 +++++----- src/backends/InteractiveBackend.h | 8 ++++---- src/commandline.cpp | 2 +- src/commandline.h | 6 +++--- src/helper/ansi.cpp | 2 +- src/main.cpp | 6 +++--- tests/tests.cpp | 6 +++--- 10 files changed, 26 insertions(+), 24 deletions(-) diff --git a/.clang-format b/.clang-format index 35a3ac5..747e2b5 100644 --- a/.clang-format +++ b/.clang-format @@ -2,3 +2,5 @@ BasedOnStyle: WebKit BreakBeforeBraces: Attach SpaceAfterTemplateKeyword: false +Cpp11BracedListStyle: true +SpaceBeforeCpp11BracedList: false diff --git a/src/backends/Backend.h b/src/backends/Backend.h index c1b21ca..a6264c7 100644 --- a/src/backends/Backend.h +++ b/src/backends/Backend.h @@ -31,13 +31,13 @@ class Backend { virtual void disable_key_debug() = 0; // gets called when a command is ready - std::function on_command { nullptr }; + std::function on_command{nullptr}; // gets called when tab is pressed and new suggestions are requested - std::function(Backend&, std::string, int)> on_autocomplete { nullptr }; + std::function(Backend&, std::string, int)> on_autocomplete{nullptr}; // gets called on write(), for writing to a file or similar secondary logging system - std::function on_write { nullptr }; + std::function on_write{nullptr}; }; } diff --git a/src/backends/BufferedBackend.cpp b/src/backends/BufferedBackend.cpp index c91af66..c8aa795 100644 --- a/src/backends/BufferedBackend.cpp +++ b/src/backends/BufferedBackend.cpp @@ -33,7 +33,7 @@ size_t lk::BufferedBackend::history_size() const { void lk::BufferedBackend::clear_history() { } const std::vector& lk::BufferedBackend::history() const { - static const std::vector empty_vec { }; + static const std::vector empty_vec{}; return empty_vec; } void lk::BufferedBackend::set_history(const std::vector&) { diff --git a/src/backends/BufferedBackend.h b/src/backends/BufferedBackend.h index f014767..dc7aa29 100644 --- a/src/backends/BufferedBackend.h +++ b/src/backends/BufferedBackend.h @@ -32,12 +32,12 @@ class BufferedBackend : public Backend { private: void thread_main(); - mutable std::mutex m_cmd_mtx { }; - mutable std::mutex m_out_mtx { }; - mutable std::mutex m_prompt_mtx { }; - mutable std::mutex m_shutdown_mtx { }; + mutable std::mutex m_cmd_mtx{}; + mutable std::mutex m_out_mtx{}; + mutable std::mutex m_prompt_mtx{}; + mutable std::mutex m_shutdown_mtx{}; bool m_shutdown = false; - std::deque m_input_queue { }; + std::deque m_input_queue{}; std::string m_prompt; std::thread m_thread; }; diff --git a/src/backends/InteractiveBackend.h b/src/backends/InteractiveBackend.h index 33b518b..e23eecf 100644 --- a/src/backends/InteractiveBackend.h +++ b/src/backends/InteractiveBackend.h @@ -70,19 +70,19 @@ class InteractiveBackend : public Backend { std::string m_prompt; std::thread m_io_thread; - std::atomic m_shutdown { false }; - bool m_key_debug { false }; + std::atomic m_shutdown{false}; + bool m_key_debug{false}; mutable std::mutex m_to_write_mutex; std::queue m_to_write; std::condition_variable m_to_write_cond; mutable std::mutex m_to_read_mutex; std::queue m_to_read; - bool m_history_enabled { false }; + bool m_history_enabled{false}; mutable std::mutex m_history_mutex; std::vector m_history; std::string m_history_temp_buffer; - size_t m_history_index { 0 }; + size_t m_history_index{0}; size_t m_history_limit = (std::numeric_limits::max)() - 1; std::mutex m_current_buffer_mutex; std::string m_current_buffer; diff --git a/src/commandline.cpp b/src/commandline.cpp index 9170e44..3220165 100644 --- a/src/commandline.cpp +++ b/src/commandline.cpp @@ -27,7 +27,7 @@ void Commandline::wire_callbacks() { if (on_autocomplete) { return on_autocomplete(*this, std::move(str), n); } else { - return std::vector { }; + return std::vector{}; } }; } diff --git a/src/commandline.h b/src/commandline.h index 3608444..9b89690 100644 --- a/src/commandline.h +++ b/src/commandline.h @@ -36,13 +36,13 @@ class Commandline final { void disable_ansi_escape_removal_on_write() { m_ansi_escape_removal = false; } // gets called when a command is ready - std::function on_command { nullptr }; + std::function on_command{nullptr}; // gets called when tab is pressed and new suggestions are requested - std::function(Commandline&, std::string, int)> on_autocomplete { nullptr }; + std::function(Commandline&, std::string, int)> on_autocomplete{nullptr}; // gets called on write(), for writing to a file or similar secondary logging system - std::function on_write { nullptr }; + std::function on_write{nullptr}; private: void wire_callbacks(); diff --git a/src/helper/ansi.cpp b/src/helper/ansi.cpp index 5d4c490..e95d8ad 100644 --- a/src/helper/ansi.cpp +++ b/src/helper/ansi.cpp @@ -7,7 +7,7 @@ enum class State { }; std::string ansi::remove_ansi_escape_codes(const std::string& original) { - std::string new_str { }; + std::string new_str{}; new_str.reserve(original.size()); State state = State::Normal; diff --git a/src/main.cpp b/src/main.cpp index 3f70ce1..5841222 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,11 +18,11 @@ int main(int argc, char** argv) { com.on_autocomplete = [](Commandline& com, std::string stub, int pos) -> std::vector { if (stub == "hello") { - return { "hello world" }; + return {"hello world"}; } else if (stub.empty()) { - return { "a", "b", "c", "d" }; + return {"a", "b", "c", "d"}; } else { - return { }; + return {}; } }; diff --git a/tests/tests.cpp b/tests/tests.cpp index 30460c5..df98208 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -44,12 +44,12 @@ TEST_CASE("Commandline on_autocomplete forwards to user callback") { Commandline com; com.on_autocomplete = [](Commandline&, std::string stub, int) { if (stub == "he") - return std::vector { "hello", "help" }; - return std::vector { }; + return std::vector{"hello", "help"}; + return std::vector{}; }; // Invoke backend autocomplete via commandline plumbing // We cannot access backend directly; test using public callback: - auto results = com.on_autocomplete ? com.on_autocomplete(com, "he", 2) : std::vector { }; + auto results = com.on_autocomplete ? com.on_autocomplete(com, "he", 2) : std::vector{}; CHECK(results.size() == 2); CHECK(results[0] == "hello"); CHECK(results[1] == "help"); From 1717630e83c60155c3b32e04629f8e620e16eef8 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:36:51 +0200 Subject: [PATCH 13/14] extract csi sequence classification to make it testable --- src/backends/InteractiveBackend.cpp | 84 ++++++++++++++++++----------- src/backends/InteractiveBackend.h | 17 ++++++ tests/tests.cpp | 61 ++++++++++++++++++++- 3 files changed, 131 insertions(+), 31 deletions(-) diff --git a/src/backends/InteractiveBackend.cpp b/src/backends/InteractiveBackend.cpp index f51007b..816ae28 100644 --- a/src/backends/InteractiveBackend.cpp +++ b/src/backends/InteractiveBackend.cpp @@ -155,6 +155,39 @@ void lk::InteractiveBackend::handle_delete() { } } +lk::CsiAction lk::classify_csi_sequence(int c3, const std::function& getchar_fn) { + switch (c3) { + case 'A': + return CsiAction::GoBack; + case 'B': + return CsiAction::GoForward; + case 'D': + return CsiAction::GoLeft; + case 'C': + return CsiAction::GoRight; + case 0x48: // ESC[H — Home + return CsiAction::GoHome; + case 0x31: // ESC[1~ — Home (xterm variant) + if (getchar_fn() == '~') + return CsiAction::GoHome; + return CsiAction::None; + case 0x46: // ESC[F — End + return CsiAction::GoEnd; + case 0x34: // ESC[4~ — End (xterm variant) + if (getchar_fn() == '~') + return CsiAction::GoEnd; + return CsiAction::None; + case 0x33: // ESC[3~ — Delete + if (getchar_fn() == '~') + return CsiAction::Delete; + return CsiAction::None; + case 0x5a: // ESC[Z — Shift+Tab + return CsiAction::ShiftTab; + default: + return CsiAction::None; + } +} + void lk::InteractiveBackend::handle_escape_sequence(std::unique_lock& guard) { int c2 = impl::getchar_no_echo(); if (m_key_debug) { @@ -167,45 +200,36 @@ void lk::InteractiveBackend::handle_escape_sequence(std::unique_lock fprintf(stderr, "c3: 0x%.2x\n", c3); } if (c2 == '[') { - if (c3 == 'A') { - // up / back + auto action = classify_csi_sequence(c3, impl::getchar_no_echo); + switch (action) { + case CsiAction::GoBack: if (history_enabled()) go_back(); - } else if (c3 == 'B') { - // down / forward + break; + case CsiAction::GoForward: if (history_enabled()) go_forward(); - } else if (c3 == 'D') { - // left + break; + case CsiAction::GoLeft: go_left(); - } else if (c3 == 'C') { - // right + break; + case CsiAction::GoRight: go_right(); - } else if (c3 == 0x48) { - // HOME (ESC[H) + break; + case CsiAction::GoHome: go_to_begin(); - } else if (c3 == 0x31) { - // HOME (ESC[1~) - int c4 = impl::getchar_no_echo(); - if (c4 == '~') - go_to_begin(); - } else if (c3 == 0x46) { - // END (ESC[F) + break; + case CsiAction::GoEnd: go_to_end(); - } else if (c3 == 0x34) { - // END (ESC[4~) - int c4 = impl::getchar_no_echo(); - if (c4 == '~') - go_to_end(); - } else if (c3 == 0x33) { - // DEL - int c4 = impl::getchar_no_echo(); - if (c4 == '~') { - handle_delete(); - } - } else if (c3 == 0x5a) { - // SHIFT+TAB + break; + case CsiAction::Delete: + handle_delete(); + break; + case CsiAction::ShiftTab: handle_tab(guard, false); + break; + case CsiAction::None: + break; } #elif defined(WINDOWS) if (c2 == 'H') { diff --git a/src/backends/InteractiveBackend.h b/src/backends/InteractiveBackend.h index e23eecf..6a2b3e6 100644 --- a/src/backends/InteractiveBackend.h +++ b/src/backends/InteractiveBackend.h @@ -92,4 +92,21 @@ class InteractiveBackend : public Backend { std::string m_buffer_before_autocomplete; }; +enum class CsiAction { + None, + GoBack, + GoForward, + GoLeft, + GoRight, + GoHome, + GoEnd, + Delete, + ShiftTab, +}; + +// Classify a CSI (ESC[...) escape sequence from its parameter byte c3. +// Calls getchar_fn to read additional bytes for multi-byte sequences +// such as ESC[1~ (Home), ESC[3~ (Delete), ESC[4~ (End). +CsiAction classify_csi_sequence(int c3, const std::function& getchar_fn); + } diff --git a/tests/tests.cpp b/tests/tests.cpp index df98208..bde046b 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -2,6 +2,7 @@ #include "doctest.h" #include "backends/BufferedBackend.h" +#include "backends/InteractiveBackend.h" #include "commandline.h" #include "helper/ansi.h" @@ -40,7 +41,7 @@ TEST_CASE("Commandline on_write removes ANSI when enabled") { CHECK(captured == "abcdef"); } -TEST_CASE("Commandline on_autocomplete forwards to user callback") { +TEST_CASE("Commandline on_autocomplete callback is callable") { Commandline com; com.on_autocomplete = [](Commandline&, std::string stub, int) { if (stub == "he") @@ -84,3 +85,61 @@ TEST_CASE("BufferedBackend concurrent writes are serialized") { t.join(); CHECK(seen.size() == 10); } + +// --- classify_csi_sequence tests --- + +static lk::CsiAction classify(int c3, int follow = -1) { + return lk::classify_csi_sequence(c3, [follow]() mutable -> int { + return follow; + }); +} + +TEST_CASE("classify_csi_sequence arrow keys") { + CHECK(classify('A') == lk::CsiAction::GoBack); + CHECK(classify('B') == lk::CsiAction::GoForward); + CHECK(classify('D') == lk::CsiAction::GoLeft); + CHECK(classify('C') == lk::CsiAction::GoRight); +} + +TEST_CASE("classify_csi_sequence home key") { + // ESC[H (traditional) + CHECK(classify(0x48) == lk::CsiAction::GoHome); + // ESC[1~ (xterm variant) + CHECK(classify(0x31, '~') == lk::CsiAction::GoHome); + // ESC[1 with non-~ follow-up returns None + CHECK(classify(0x31, 'x') == lk::CsiAction::None); +} + +TEST_CASE("classify_csi_sequence end key") { + // ESC[F (traditional) + CHECK(classify(0x46) == lk::CsiAction::GoEnd); + // ESC[4~ (xterm variant) + CHECK(classify(0x34, '~') == lk::CsiAction::GoEnd); + // ESC[4 with non-~ follow-up returns None + CHECK(classify(0x34, 'x') == lk::CsiAction::None); +} + +TEST_CASE("classify_csi_sequence delete key") { + CHECK(classify(0x33, '~') == lk::CsiAction::Delete); + CHECK(classify(0x33, 'x') == lk::CsiAction::None); +} + +TEST_CASE("classify_csi_sequence shift tab") { + CHECK(classify(0x5a) == lk::CsiAction::ShiftTab); +} + +TEST_CASE("classify_csi_sequence unrecognized") { + CHECK(classify('X') == lk::CsiAction::None); + CHECK(classify('0') == lk::CsiAction::None); +} + +// --- Commandline second-constructor test --- + +TEST_CASE("Commandline backend-ptr constructor wires callbacks") { + auto backend = std::unique_ptr(new lk::BufferedBackend(">")); + Commandline com(std::move(backend)); + std::string captured; + com.on_write = [&](const std::string& s) { captured = s; }; + com.write("hello"); + CHECK(captured == "hello"); +} From e592a0d34b54f72d5c312b25529e3b2b222b9ee5 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:42:00 +0200 Subject: [PATCH 14/14] improve tests --- tests/tests.cpp | 74 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/tests/tests.cpp b/tests/tests.cpp index bde046b..1959ef7 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -88,49 +88,87 @@ TEST_CASE("BufferedBackend concurrent writes are serialized") { // --- classify_csi_sequence tests --- -static lk::CsiAction classify(int c3, int follow = -1) { - return lk::classify_csi_sequence(c3, [follow]() mutable -> int { +struct CsiResult { + lk::CsiAction action; + int calls; +}; + +static CsiResult classify(int c3, int follow = -1) { + int calls = 0; + auto fn = [follow, &calls]() mutable -> int { + ++calls; return follow; - }); + }; + return CsiResult{lk::classify_csi_sequence(c3, fn), calls}; } TEST_CASE("classify_csi_sequence arrow keys") { - CHECK(classify('A') == lk::CsiAction::GoBack); - CHECK(classify('B') == lk::CsiAction::GoForward); - CHECK(classify('D') == lk::CsiAction::GoLeft); - CHECK(classify('C') == lk::CsiAction::GoRight); + auto r = classify('A'); + CHECK(r.action == lk::CsiAction::GoBack); + CHECK(r.calls == 0); + r = classify('B'); + CHECK(r.action == lk::CsiAction::GoForward); + CHECK(r.calls == 0); + r = classify('D'); + CHECK(r.action == lk::CsiAction::GoLeft); + CHECK(r.calls == 0); + r = classify('C'); + CHECK(r.action == lk::CsiAction::GoRight); + CHECK(r.calls == 0); } TEST_CASE("classify_csi_sequence home key") { // ESC[H (traditional) - CHECK(classify(0x48) == lk::CsiAction::GoHome); + auto r = classify(0x48); + CHECK(r.action == lk::CsiAction::GoHome); + CHECK(r.calls == 0); // ESC[1~ (xterm variant) - CHECK(classify(0x31, '~') == lk::CsiAction::GoHome); + r = classify(0x31, '~'); + CHECK(r.action == lk::CsiAction::GoHome); + CHECK(r.calls == 1); // ESC[1 with non-~ follow-up returns None - CHECK(classify(0x31, 'x') == lk::CsiAction::None); + r = classify(0x31, 'x'); + CHECK(r.action == lk::CsiAction::None); + CHECK(r.calls == 1); } TEST_CASE("classify_csi_sequence end key") { // ESC[F (traditional) - CHECK(classify(0x46) == lk::CsiAction::GoEnd); + auto r = classify(0x46); + CHECK(r.action == lk::CsiAction::GoEnd); + CHECK(r.calls == 0); // ESC[4~ (xterm variant) - CHECK(classify(0x34, '~') == lk::CsiAction::GoEnd); + r = classify(0x34, '~'); + CHECK(r.action == lk::CsiAction::GoEnd); + CHECK(r.calls == 1); // ESC[4 with non-~ follow-up returns None - CHECK(classify(0x34, 'x') == lk::CsiAction::None); + r = classify(0x34, 'x'); + CHECK(r.action == lk::CsiAction::None); + CHECK(r.calls == 1); } TEST_CASE("classify_csi_sequence delete key") { - CHECK(classify(0x33, '~') == lk::CsiAction::Delete); - CHECK(classify(0x33, 'x') == lk::CsiAction::None); + auto r = classify(0x33, '~'); + CHECK(r.action == lk::CsiAction::Delete); + CHECK(r.calls == 1); + r = classify(0x33, 'x'); + CHECK(r.action == lk::CsiAction::None); + CHECK(r.calls == 1); } TEST_CASE("classify_csi_sequence shift tab") { - CHECK(classify(0x5a) == lk::CsiAction::ShiftTab); + auto r = classify(0x5a); + CHECK(r.action == lk::CsiAction::ShiftTab); + CHECK(r.calls == 0); } TEST_CASE("classify_csi_sequence unrecognized") { - CHECK(classify('X') == lk::CsiAction::None); - CHECK(classify('0') == lk::CsiAction::None); + auto r = classify('X'); + CHECK(r.action == lk::CsiAction::None); + CHECK(r.calls == 0); + r = classify('0'); + CHECK(r.action == lk::CsiAction::None); + CHECK(r.calls == 0); } // --- Commandline second-constructor test ---