From a4b92ca698770dc64639e51c76edb5dee16d2792 Mon Sep 17 00:00:00 2001 From: Felix Morgner Date: Sat, 20 Jun 2026 10:25:09 +0200 Subject: lib: switch to policy based scanner design --- CMakeLists.txt | 7 +- ttwhy/scanners/ansi.cppm | 261 ------------------------------ ttwhy/scanners/ansi.tests.cpp | 38 ----- ttwhy/scanners/concepts.cppm | 10 ++ ttwhy/scanners/events.cppm | 7 +- ttwhy/scanners/mod.cppm | 3 +- ttwhy/scanners/terminal_policies.cppm | 72 +++++++++ ttwhy/scanners/terminal_scanner.cppm | 236 +++++++++++++++++++++++++++ ttwhy/scanners/terminal_scanner.tests.cpp | 38 +++++ 9 files changed, 368 insertions(+), 304 deletions(-) delete mode 100644 ttwhy/scanners/ansi.cppm delete mode 100644 ttwhy/scanners/ansi.tests.cpp create mode 100644 ttwhy/scanners/terminal_policies.cppm create mode 100644 ttwhy/scanners/terminal_scanner.cppm create mode 100644 ttwhy/scanners/terminal_scanner.tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 390afbc..12536ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,10 +61,11 @@ target_sources("ttwhy-core" PUBLIC "ttwhy/routers/echo.cppm" "ttwhy/routers/mod.cppm" - "ttwhy/scanners/ansi.cppm" "ttwhy/scanners/concepts.cppm" - "ttwhy/scanners/mod.cppm" "ttwhy/scanners/events.cppm" + "ttwhy/scanners/mod.cppm" + "ttwhy/scanners/terminal_policies.cppm" + "ttwhy/scanners/terminal_scanner.cppm" ) target_include_directories("ttwhy-core" PUBLIC @@ -111,7 +112,7 @@ if(BUILD_TESTING) add_executable("ttwhy::tests" ALIAS "ttwhy-tests") target_sources("ttwhy-tests" PRIVATE - "ttwhy/scanners/ansi.tests.cpp" + "ttwhy/scanners/terminal_scanner.tests.cpp" ) target_link_libraries("ttwhy-tests" PRIVATE diff --git a/ttwhy/scanners/ansi.cppm b/ttwhy/scanners/ansi.cppm deleted file mode 100644 index 886e699..0000000 --- a/ttwhy/scanners/ansi.cppm +++ /dev/null @@ -1,261 +0,0 @@ -module; - -#include -#include - -#include -#include - -export module ttwhy.scanners:ansi; - -import :events; - -namespace ttwhy::scanners::detail -{ - - /// Events - - struct byte_received - { - char value; - }; - - struct timeout_expired - { - }; - - /// States - - constexpr auto idle = boost::sml::state; - constexpr auto escape_sequence = boost::sml::state; - constexpr auto csi_sequence = boost::sml::state; - constexpr auto ss3_sequence = boost::sml::state; - - /// Guards - - constexpr auto is_backspace = [](byte_received e) { - return e.value == '\x08' || e.value == '\x7f'; - }; - - constexpr auto is_tab = [](byte_received e) { - return e.value == '\x09'; - }; - - constexpr auto is_enter = [](byte_received e) { - return e.value == '\x0a' || e.value == '\x0d'; - }; - - constexpr auto is_escape = [](byte_received e) { - return e.value == '\x1b'; - }; - - constexpr auto is_printable = [](byte_received e) { - return e.value >= 0x20 && e.value <= 0x7e; - }; - - constexpr auto is_csi_introducer = [](byte_received e) { - return e.value == '['; - }; - - constexpr auto is_ss3_introducer = [](byte_received e) { - return e.value == 'O'; - }; - - constexpr auto is_invalid_introducer = [](byte_received e) { - return !(is_ss3_introducer(e) || is_csi_introducer(e)); - }; - - constexpr auto is_csi_param = [](byte_received e) { - return e.value >= 0x20 && e.value <= 0x3f; - }; - - constexpr auto is_csi_terminator = [](byte_received e) { - return e.value >= 0x40 && e.value <= 0x7e; - }; - - /// Transitions - - template - struct transition_table - { - auto operator()() const noexcept - { - using namespace boost::sml; - - constexpr auto push_character = [](byte_received const & event, SinkType & sink) { - sink(character_event{event.value}); - }; - - constexpr auto push_backspace = [](SinkType & sink) { - sink(control_event{control_key::backspace}); - }; - - constexpr auto push_tab = [](SinkType & sink) { - sink(control_event(control_key::tab)); - }; - - constexpr auto push_enter = [](SinkType & sink) { - sink(control_event(control_key::enter)); - }; - - constexpr auto emit_timeout_escape = [](SinkType & sink, std::string & buffer) { - sink(control_event{control_key::escape}); - buffer.clear(); - }; - - constexpr auto fallback_escape = [](byte_received const & event, SinkType & sink) { - sink(control_event{control_key::escape}); - if (event.value >= 0x20 && event.value <= 0x7e) - { - sink(character_event{event.value}); - } - }; - - constexpr auto clear_csi = [](std::string & buffer) { - buffer.clear(); - }; - - constexpr auto push_csi_parameter = [](byte_received const & event, std::string & buffer) { - buffer.push_back(event.value); - }; - - constexpr auto resolve_csi = [](byte_received const & event, std::string & buffer, SinkType & sink) { - if (event.value == '~') - { - switch (buffer.at(0)) - { - case '1': - case '7': - sink(navigation_event{navigation_key::home}); - break; - case '2': - sink(navigation_event{navigation_key::insert_key}); - break; - case '3': - sink(navigation_event{navigation_key::delete_key}); - break; - case '4': - case '8': - sink(navigation_event{navigation_key::end}); - break; - case '5': - sink(navigation_event{navigation_key::page_up}); - break; - case '6': - sink(navigation_event{navigation_key::page_down}); - break; - default: - } - } - else - { - switch (event.value) - { - case 'A': - sink(navigation_event{navigation_key::up}); - break; - case 'B': - sink(navigation_event{navigation_key::down}); - break; - case 'C': - sink(navigation_event{navigation_key::right}); - break; - case 'D': - sink(navigation_event{navigation_key::left}); - break; - case 'H': - sink(navigation_event{navigation_key::home}); - break; - case 'F': - sink(navigation_event{navigation_key::end}); - break; - default: - } - } - buffer.clear(); - }; - - constexpr auto resolve_ss3 = [](byte_received const & event, SinkType & sink) { - switch (event.value) - { - case 'A': - sink(navigation_event{navigation_key::up}); - break; - case 'B': - sink(navigation_event{navigation_key::down}); - break; - case 'C': - sink(navigation_event{navigation_key::right}); - break; - case 'D': - sink(navigation_event{navigation_key::left}); - break; - case 'H': - sink(navigation_event{navigation_key::home}); - break; - case 'F': - sink(navigation_event{navigation_key::end}); - break; - default: - } - }; - - // clang-format off - return make_transition_table( - *idle + event[is_escape] = escape_sequence, - idle + event[is_backspace] / push_backspace = idle, - idle + event[is_tab] / push_tab = idle, - idle + event[is_enter] / push_enter = idle, - idle + event[is_printable] / push_character = idle, - - escape_sequence + event[is_csi_introducer] / clear_csi = csi_sequence, - escape_sequence + event[is_ss3_introducer] = ss3_sequence, - escape_sequence + event[is_invalid_introducer] / fallback_escape = idle, - escape_sequence + event / emit_timeout_escape = idle, - - csi_sequence + event[is_csi_param] / push_csi_parameter = csi_sequence, - csi_sequence + event[is_csi_terminator] / resolve_csi = idle, - csi_sequence + event / emit_timeout_escape = idle, - - ss3_sequence + event / resolve_ss3 = idle, - ss3_sequence + event / emit_timeout_escape = idle - ); - // clang-format on - } - }; - -} // namespace ttwhy::scanners::detail - -export namespace ttwhy::scanners -{ - - template - struct ansi - { - explicit ansi(SinkType & sink) - : m_state_machine{sink, m_csi_buffer} - {} - - auto process(std::span buffer) -> void - { - std::ranges::for_each(buffer, [&](auto byte) { m_state_machine.process_event(detail::byte_received{byte}); }); - } - - auto timeout() - { - m_state_machine.process_event(detail::timeout_expired{}); - } - - [[nodiscard]] auto is_pending() const -> bool - { - return m_state_machine.is(detail::escape_sequence) || // - m_state_machine.is(detail::csi_sequence) || // - m_state_machine.is(detail::ss3_sequence); - } - - private: - std::string m_csi_buffer{}; - boost::sml::sm> m_state_machine; - }; - -} // namespace ttwhy::scanners diff --git a/ttwhy/scanners/ansi.tests.cpp b/ttwhy/scanners/ansi.tests.cpp deleted file mode 100644 index 6fdc8af..0000000 --- a/ttwhy/scanners/ansi.tests.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include - -#include // IWYU pragma: keep -#include -#include - -import ttwhy.scanners; - -using namespace std::string_view_literals; - -[[nodiscard]] constexpr auto static is_character(ttwhy::scanners::input_event & event, char expected) -> bool -{ - auto const * data = std::get_if(&event); - return data != nullptr && data->value == expected; -} - -SCENARIO("The ANSI scanner processes printable ASCII and standard C0 control characters", "[scanner][ansi]") -{ - GIVEN("An initialized scanner and event sink") - { - auto queue = std::vector{}; - auto sink = [&queue](auto const & event) { - queue.push_back(event); - }; - auto scanner = ttwhy::scanners::ansi{sink}; - - WHEN("Processing a standard printable character") - { - scanner.process("A"); - - THEN("It yields a single character event") - { - REQUIRE(queue.size() == 1); - CHECK(is_character(queue.at(0), 'A')); - } - } - } -} diff --git a/ttwhy/scanners/concepts.cppm b/ttwhy/scanners/concepts.cppm index 003874c..8db3ae6 100644 --- a/ttwhy/scanners/concepts.cppm +++ b/ttwhy/scanners/concepts.cppm @@ -2,9 +2,12 @@ module; #include #include +#include export module ttwhy.scanners:concepts; +import :events; + namespace ttwhy { @@ -15,4 +18,11 @@ namespace ttwhy { a.is_pending() } -> std::same_as; }; + export template + concept ansi_sink = requires(Sink sink) { + { sink(std::declval()) } -> std::same_as; + { sink(std::declval()) } -> std::same_as; + { sink(std::declval()) } -> std::same_as; + }; + } // namespace ttwhy diff --git a/ttwhy/scanners/events.cppm b/ttwhy/scanners/events.cppm index 3e2f4f0..03ab9eb 100644 --- a/ttwhy/scanners/events.cppm +++ b/ttwhy/scanners/events.cppm @@ -43,5 +43,10 @@ namespace ttwhy::scanners navigation_key key; }; - export using input_event = std::variant; + export struct ctrl_chord_event + { + char key; + }; + + export using input_event = std::variant; } // namespace ttwhy::scanners diff --git a/ttwhy/scanners/mod.cppm b/ttwhy/scanners/mod.cppm index 8dc3009..54f1e0e 100644 --- a/ttwhy/scanners/mod.cppm +++ b/ttwhy/scanners/mod.cppm @@ -1,5 +1,6 @@ export module ttwhy.scanners; -export import :ansi; export import :concepts; export import :events; +export import :terminal_policies; +export import :terminal_scanner; diff --git a/ttwhy/scanners/terminal_policies.cppm b/ttwhy/scanners/terminal_policies.cppm new file mode 100644 index 0000000..87e54b3 --- /dev/null +++ b/ttwhy/scanners/terminal_policies.cppm @@ -0,0 +1,72 @@ +module; + +#include + +export module ttwhy.scanners:terminal_policies; + +import :events; +import :concepts; + +namespace ttwhy::scanners +{ + + export struct ansi_policy + { + constexpr auto static resolve_vt220_keypad(std::string & buffer, ttwhy::ansi_sink auto & sink) -> void + { + auto key = [](auto terminator) { + switch (terminator) + { + case '1': + case '7': + return navigation_key::home; + case '2': + return navigation_key::insert_key; + case '3': + return navigation_key::delete_key; + case '4': + case '8': + return navigation_key::end; + case '5': + return navigation_key::page_up; + case '6': + return navigation_key::page_down; + default: + return static_cast(-1); + } + }(buffer.back()); + sink(navigation_event{key}); + buffer.clear(); + } + + constexpr auto static resolve_vt100_cursor(char terminator, ttwhy::ansi_sink auto & sink) -> void + { + auto key = [terminator] { + switch (terminator) + { + case 'A': + return navigation_key::up; + case 'B': + return navigation_key::down; + case 'C': + return navigation_key::right; + case 'D': + return navigation_key::left; + case 'H': + return navigation_key::home; + case 'F': + return navigation_key::end; + default: + return static_cast(-1); + } + }(); + sink(navigation_event{key}); + } + + constexpr auto static resolve_ss3(char terminator, ttwhy::ansi_sink auto & sink) -> void + { + return resolve_vt100_cursor(terminator, sink); + } + }; + +} // namespace ttwhy::scanners diff --git a/ttwhy/scanners/terminal_scanner.cppm b/ttwhy/scanners/terminal_scanner.cppm new file mode 100644 index 0000000..9284b84 --- /dev/null +++ b/ttwhy/scanners/terminal_scanner.cppm @@ -0,0 +1,236 @@ +module; + +#include +#include + +#include +#include +#include + +export module ttwhy.scanners:terminal_scanner; + +import :concepts; +import :events; +import :terminal_policies; + +namespace ttwhy::scanners::detail +{ + + /// Events + + struct byte_received + { + char value; + }; + + struct c1_received + { + unsigned char value; + }; + + struct timeout_expired + { + }; + + /// States + + constexpr auto idle = boost::sml::state; + constexpr auto escape_sequence = boost::sml::state; + constexpr auto csi_sequence = boost::sml::state; + constexpr auto ss3_sequence = boost::sml::state; + + /// Guards + + constexpr auto is_backspace = [](byte_received e) { + return e.value == '\x08' || e.value == '\x7f'; + }; + + constexpr auto is_tab = [](byte_received e) { + return e.value == '\x09'; + }; + + constexpr auto is_enter = [](byte_received e) { + return e.value == '\x0a' || e.value == '\x0d'; + }; + + constexpr auto is_escape = [](byte_received e) { + return e.value == '\x1b'; + }; + + constexpr auto is_printable = [](byte_received e) { + return e.value >= 0x20 && e.value <= 0x7e; + }; + + constexpr auto is_c0_chord = [](byte_received e) { + return e.value >= 0x00 && e.value <= 0x1f && !is_backspace(e) && !is_tab(e) && !is_escape(e); + }; + + constexpr auto is_fe = [](byte_received e) { + return e.value >= 0x40 && e.value <= 0x5f; + }; + + constexpr auto is_csi = [](c1_received e) { + return e.value == 0x9b; + }; + + constexpr auto is_ss3 = [](c1_received e) { + return e.value == 0x8f; + }; + + constexpr auto is_unhandled_c1 = [](c1_received e) { + return !(is_csi(e) || is_ss3(e)); + }; + + constexpr auto is_csi_param = [](byte_received e) { + return e.value >= 0x20 && e.value <= 0x3f; + }; + + constexpr auto is_vt100_terminator = [](byte_received e) { + return e.value >= 0x40 && e.value <= 0x7d; + }; + + constexpr auto is_vt220_terminator = [](byte_received e) { + return e.value == '~'; + }; + + /// Transitions + + template + struct transition_table + { + auto operator()() const noexcept + { + using namespace boost::sml; + + constexpr auto push_character = [](byte_received event, Sink & sink) { + sink(character_event{event.value}); + }; + + constexpr auto push_backspace = [](Sink & sink) { + sink(control_event{control_key::backspace}); + }; + + constexpr auto push_tab = [](Sink & sink) { + sink(control_event(control_key::tab)); + }; + + constexpr auto push_enter = [](Sink & sink) { + sink(control_event(control_key::enter)); + }; + + constexpr auto push_c0_chord = [](byte_received event, Sink & sink) { + sink(ctrl_chord_event{static_cast(event.value + 0x40)}); + }; + + constexpr auto dispatch_c1 = [](byte_received event, back::process process) { + process(c1_received{static_cast(event.value + 0x40)}); + }; + + constexpr auto fallback_fe = [](c1_received event, Sink & sink) { + sink(control_event{control_key::escape}); + sink(character_event{static_cast(event.value - 0x40)}); + }; + + constexpr auto emit_timeout_escape = [](Sink & sink, std::string & buffer) { + sink(control_event{control_key::escape}); + buffer.clear(); + }; + + constexpr auto fallback_escape = [](byte_received event, Sink & sink) { + sink(control_event{control_key::escape}); + if (event.value >= 0x20 && event.value <= 0x7e) + { + sink(character_event{event.value}); + } + }; + + constexpr auto clear_csi = [](std::string & buffer) { + buffer.clear(); + }; + + constexpr auto push_csi_parameter = [](byte_received const & event, std::string & buffer) { + buffer.push_back(event.value); + }; + + constexpr auto resolve_vt100_cursor = [](byte_received event, std::string & buffer, Sink & sink) { + TerminalPolicy::resolve_vt100_cursor(event.value, sink); + buffer.clear(); + }; + + constexpr auto resolve_vt220_keypad = [](std::string & buffer, Sink & sink) { + TerminalPolicy::resolve_vt220_keypad(buffer, sink); + buffer.clear(); + }; + + constexpr auto resolve_ss3 = [](byte_received event, Sink & sink) { + TerminalPolicy::resolve_ss3(event.value, sink); + }; + + // clang-format off + return make_transition_table( + *idle + event[is_escape] = escape_sequence, + idle + event[is_backspace] / push_backspace = idle, + idle + event[is_tab] / push_tab = idle, + idle + event[is_enter] / push_enter = idle, + idle + event[is_c0_chord] / push_c0_chord = idle, + idle + event[is_printable] / push_character = idle, + + escape_sequence + event[is_fe] / dispatch_c1 = idle, + escape_sequence + event[!is_fe] / fallback_escape = idle, + escape_sequence + event / emit_timeout_escape = idle, + + idle + event[is_csi] / clear_csi = csi_sequence, + idle + event[is_ss3] = ss3_sequence, + idle + event[is_unhandled_c1] / fallback_fe = idle, + + csi_sequence + event[is_csi_param] / push_csi_parameter = csi_sequence, + csi_sequence + event[is_vt220_terminator] / resolve_vt220_keypad = idle, + csi_sequence + event[is_vt100_terminator] / resolve_vt100_cursor = idle, + csi_sequence + event / emit_timeout_escape = idle, + + ss3_sequence + event / resolve_ss3 = idle, + ss3_sequence + event / emit_timeout_escape = idle + ); + // clang-format on + } + }; + +} // namespace ttwhy::scanners::detail + +export namespace ttwhy::scanners +{ + + template + struct terminal_scanner + { + explicit terminal_scanner(Sink & sink) + : m_state_machine{sink, m_csi_buffer} + {} + + auto process(std::span buffer) -> void + { + std::ranges::for_each(buffer, [&](auto byte) { m_state_machine.process_event(detail::byte_received{byte}); }); + } + + auto timeout() + { + m_state_machine.process_event(detail::timeout_expired{}); + } + + [[nodiscard]] auto is_pending() const -> bool + { + return m_state_machine.is(detail::escape_sequence) || // + m_state_machine.is(detail::csi_sequence) || // + m_state_machine.is(detail::ss3_sequence); + } + + private: + std::string m_csi_buffer{}; + boost::sml::sm, boost::sml::process_queue> + m_state_machine; + }; + + template + using ansi = terminal_scanner; + +} // namespace ttwhy::scanners diff --git a/ttwhy/scanners/terminal_scanner.tests.cpp b/ttwhy/scanners/terminal_scanner.tests.cpp new file mode 100644 index 0000000..6fdc8af --- /dev/null +++ b/ttwhy/scanners/terminal_scanner.tests.cpp @@ -0,0 +1,38 @@ +#include + +#include // IWYU pragma: keep +#include +#include + +import ttwhy.scanners; + +using namespace std::string_view_literals; + +[[nodiscard]] constexpr auto static is_character(ttwhy::scanners::input_event & event, char expected) -> bool +{ + auto const * data = std::get_if(&event); + return data != nullptr && data->value == expected; +} + +SCENARIO("The ANSI scanner processes printable ASCII and standard C0 control characters", "[scanner][ansi]") +{ + GIVEN("An initialized scanner and event sink") + { + auto queue = std::vector{}; + auto sink = [&queue](auto const & event) { + queue.push_back(event); + }; + auto scanner = ttwhy::scanners::ansi{sink}; + + WHEN("Processing a standard printable character") + { + scanner.process("A"); + + THEN("It yields a single character event") + { + REQUIRE(queue.size() == 1); + CHECK(is_character(queue.at(0), 'A')); + } + } + } +} -- cgit v1.2.3