diff --git a/.clang-format b/.clang-format index a3ce056..747e2b5 100644 --- a/.clang-format +++ b/.clang-format @@ -1,5 +1,6 @@ ---- +--- BasedOnStyle: WebKit BreakBeforeBraces: Attach SpaceAfterTemplateKeyword: false -... +Cpp11BracedListStyle: true +SpaceBeforeCpp11BracedList: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..704432c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# AGENTS.md + +## AI usage policy + +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) + ./commandline_tests + bash scripts/clang_format_check.sh + +## Testing constraints + +`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. + +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. diff --git a/CMakeLists.txt b/CMakeLists.txt index ca910d1..cce84e4 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) 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 4634c10..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 6eaf069..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.cpp b/src/backends/InteractiveBackend.cpp index 21781d9..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) { @@ -166,34 +199,37 @@ 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 (c3 == 'A') { - // up / back - go_back(); - } else if (c3 == 'B') { - // down / forward - go_forward(); - } else if (c3 == 'D') { - // left + if (c2 == '[') { + auto action = classify_csi_sequence(c3, impl::getchar_no_echo); + switch (action) { + case CsiAction::GoBack: + if (history_enabled()) + go_back(); + break; + case CsiAction::GoForward: + if (history_enabled()) + go_forward(); + break; + case CsiAction::GoLeft: go_left(); - } else if (c3 == 'C') { - // right + break; + case CsiAction::GoRight: go_right(); - } else if (c3 == 0x48 || c3 == 0x31) { - // HOME + break; + case CsiAction::GoHome: go_to_begin(); - } else if (c3 == 0x46) { - // END + break; + case CsiAction::GoEnd: 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 33b518b..6a2b3e6 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; @@ -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/src/commandline.cpp b/src/commandline.cpp index b1f2a25..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..9b89690 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); } @@ -36,15 +36,17 @@ 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(); + std::unique_ptr m_backend; bool m_ansi_escape_removal = false; }; diff --git a/src/helper/ansi.cpp b/src/helper/ansi.cpp index 1f6f408..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 618da0f..5841222 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,9 +18,9 @@ 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 {}; } diff --git a/tests/tests.cpp b/tests/tests.cpp index b6814c6..1959ef7 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -2,16 +2,28 @@ #include "doctest.h" #include "backends/BufferedBackend.h" +#include "backends/InteractiveBackend.h" #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"); CHECK(ansi::remove_ansi_escape_codes("\x1b[Aup\nx") == "up\nx"); } +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; }; @@ -20,6 +32,7 @@ TEST_CASE("Commandline on_write forwards without ANSI removal") { } TEST_CASE("Commandline on_write removes ANSI when enabled") { + force_buffered(); Commandline com; com.enable_ansi_escape_removal_on_write(); std::string captured; @@ -28,16 +41,16 @@ 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") - 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"); @@ -72,3 +85,99 @@ TEST_CASE("BufferedBackend concurrent writes are serialized") { t.join(); CHECK(seen.size() == 10); } + +// --- classify_csi_sequence tests --- + +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") { + 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) + auto r = classify(0x48); + CHECK(r.action == lk::CsiAction::GoHome); + CHECK(r.calls == 0); + // ESC[1~ (xterm variant) + r = classify(0x31, '~'); + CHECK(r.action == lk::CsiAction::GoHome); + CHECK(r.calls == 1); + // ESC[1 with non-~ follow-up returns 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) + auto r = classify(0x46); + CHECK(r.action == lk::CsiAction::GoEnd); + CHECK(r.calls == 0); + // ESC[4~ (xterm variant) + r = classify(0x34, '~'); + CHECK(r.action == lk::CsiAction::GoEnd); + CHECK(r.calls == 1); + // ESC[4 with non-~ follow-up returns None + r = classify(0x34, 'x'); + CHECK(r.action == lk::CsiAction::None); + CHECK(r.calls == 1); +} + +TEST_CASE("classify_csi_sequence delete key") { + 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") { + auto r = classify(0x5a); + CHECK(r.action == lk::CsiAction::ShiftTab); + CHECK(r.calls == 0); +} + +TEST_CASE("classify_csi_sequence unrecognized") { + 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 --- + +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"); +}