From b524600da0c1f2ab1386cf2b4ef0bbfdffe098a5 Mon Sep 17 00:00:00 2001 From: lisyarus Date: Thu, 19 May 2022 17:27:58 +0300 Subject: [PATCH] Add tagged_text description, parser & tests --- libs/ui/CMakeLists.txt | 2 + libs/ui/include/psemek/ui/tagged_text.hpp | 46 ++++++ libs/ui/source/tagged_text.cpp | 106 ++++++++++++++ libs/ui/tests/tagged_text.cpp | 164 ++++++++++++++++++++++ 4 files changed, 318 insertions(+) create mode 100644 libs/ui/include/psemek/ui/tagged_text.hpp create mode 100644 libs/ui/source/tagged_text.cpp create mode 100644 libs/ui/tests/tagged_text.cpp diff --git a/libs/ui/CMakeLists.txt b/libs/ui/CMakeLists.txt index ca71c3b8..bf07791a 100644 --- a/libs/ui/CMakeLists.txt +++ b/libs/ui/CMakeLists.txt @@ -6,3 +6,5 @@ target_include_directories(psemek-ui PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include target_link_libraries(psemek-ui PUBLIC psemek-util psemek-log psemek-geom psemek-cg psemek-gfx psemek-async psemek-sdl2) psemek_glob_resources(psemek-ui resources psemek/ui/resources) + +psemek_glob_tests(psemek-ui tests) diff --git a/libs/ui/include/psemek/ui/tagged_text.hpp b/libs/ui/include/psemek/ui/tagged_text.hpp new file mode 100644 index 00000000..c049471b --- /dev/null +++ b/libs/ui/include/psemek/ui/tagged_text.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace psemek::ui +{ + + // Doesn't own the actual text. Instead, + // it is supposed to reference a string + // stored elsewhere. Beware of SSO! + struct tagged_text + { + struct opening_tag + { + std::string_view type; + std::optional attribute; + }; + + struct closing_tag + { + std::string_view type; + }; + + using token = std::variant; + + std::vector tokens; + + struct parse_error + : std::runtime_error + { + parse_error(std::string error, std::size_t position); + + std::size_t position() const noexcept { return position_; } + + private: + std::size_t position_; + }; + + static tagged_text parse(std::string_view text); + }; + +} diff --git a/libs/ui/source/tagged_text.cpp b/libs/ui/source/tagged_text.cpp new file mode 100644 index 00000000..7d3f15a8 --- /dev/null +++ b/libs/ui/source/tagged_text.cpp @@ -0,0 +1,106 @@ +#include +#include + +namespace psemek::ui +{ + + tagged_text::parse_error::parse_error(std::string error, std::size_t position) + : std::runtime_error(std::move(error) + util::to_string(" at symbol ", position)) + , position_(position) + {} + + tagged_text tagged_text::parse(std::string_view text) + { + tagged_text result; + + auto current = text.begin(); + + auto error = [&](std::string message) + { + throw parse_error(message, current - text.begin()); + }; + + bool in_tag = false; + + while (current < text.end()) + { + if (*current == '[') + { + if (in_tag) + error("cannot open a tag inside another tag"); + if (current + 1 == text.end()) + error("unexpected end"); + if (current[1] == '/') + { + ++current; + result.tokens.push_back(closing_tag{}); + } + else + result.tokens.push_back(opening_tag{}); + in_tag = true; + } + else if (*current == ']') + { + if (!in_tag) + error("closing a tag without opening one"); + in_tag = false; + } + else if (in_tag && *current == ':') + { + if (std::get_if(&result.tokens.back())) + error("closing tags cannot have attributes"); + auto & attr = std::get(result.tokens.back()).attribute; + if (attr) + error("a tag can have at most one attribute"); + attr = std::string_view{}; + } + else + { + if (*current == '\\') + { + ++current; + if (current == text.end()) + error("unexpected end"); + + if (*current != '[' && *current != ']' && *current != '\\') + error("unknown escape sequence \\" + std::string(1, *current)); + } + + auto append = [&](std::string_view & target) + { + if (target.empty()) + target = {current, current + 1}; + else + target = {target.data(), current + 1}; + }; + + if (in_tag) + { + if (auto token = std::get_if(&result.tokens.back())) + { + if (token->attribute) + append(*(token->attribute)); + else + append(token->type); + } + else + append(std::get(result.tokens.back()).type); + } + else + { + if (result.tokens.empty() || !std::get_if(&result.tokens.back())) + result.tokens.push_back(std::string_view{}); + append(std::get(result.tokens.back())); + } + } + + ++current; + } + + if (in_tag) + error("unexpected end"); + + return result; + } + +} diff --git a/libs/ui/tests/tagged_text.cpp b/libs/ui/tests/tagged_text.cpp new file mode 100644 index 00000000..87d1739d --- /dev/null +++ b/libs/ui/tests/tagged_text.cpp @@ -0,0 +1,164 @@ +#include + +#include +#include +#include + +#include + +using namespace psemek::ui; + +static void compare(tagged_text const & result, tagged_text const & expected) +{ + expect_equal(result.tokens.size(), expected.tokens.size()); + for (std::size_t i = 0; i < result.tokens.size(); ++i) + { + expect_equal(result.tokens[i].index(), expected.tokens[i].index()); + if (auto token = std::get_if(&result.tokens[i])) + { + expect_equal(*token, std::get(expected.tokens[i])); + } + else if (auto token = std::get_if(&result.tokens[i])) + { + expect_equal(token->type, std::get(expected.tokens[i]).type); + expect_equal(token->attribute, std::get(expected.tokens[i]).attribute); + } + else + { + expect_equal(std::get(result.tokens[i]).type, std::get(expected.tokens[i]).type); + } + } +} + +static void test(std::string_view text, tagged_text const & expected) +{ + compare(tagged_text::parse(text), expected); +} + +test_case(ui_tagged__text_empty) +{ + test("", {}); +} + +test_case(ui_tagged__text_notags) +{ + test("text", {{"text"}}); +} + +test_case(ui_tagged__text_whitespace) +{ + test("text text", {{"text text"}}); +} + +test_case(ui_tagged__text_newline) +{ + test("text\ntext", {{"text\ntext"}}); +} + +test_case(ui_tagged__text_tag__open) +{ + test("[tag]", {{tagged_text::opening_tag{"tag", std::nullopt}}}); +} + +test_case(ui_tagged__text_tag__close) +{ + test("[/tag]", {{tagged_text::closing_tag{"tag"}}}); +} + +test_case(ui_tagged__text_tag__open__close) +{ + test("[tag][/tag]", {{tagged_text::opening_tag{"tag", std::nullopt}, tagged_text::closing_tag{"tag"}}}); +} + +test_case(ui_tagged__text_tag__text) +{ + test("abc[tag][/tag]", {{"abc", tagged_text::opening_tag{"tag", std::nullopt}, tagged_text::closing_tag{"tag"}}}); + test("[tag]def[/tag]", {{tagged_text::opening_tag{"tag", std::nullopt}, "def", tagged_text::closing_tag{"tag"}}}); + test("abc[tag]def[/tag]", {{"abc", tagged_text::opening_tag{"tag", std::nullopt}, "def", tagged_text::closing_tag{"tag"}}}); + test("[tag][/tag]ghi", {{tagged_text::opening_tag{"tag", std::nullopt}, tagged_text::closing_tag{"tag"}, "ghi"}}); + test("abc[tag][/tag]ghi", {{"abc", tagged_text::opening_tag{"tag", std::nullopt}, tagged_text::closing_tag{"tag"}, "ghi"}}); + test("[tag]def[/tag]ghi", {{tagged_text::opening_tag{"tag", std::nullopt}, "def", tagged_text::closing_tag{"tag"}, "ghi"}}); + test("abc[tag]def[/tag]ghi", {{"abc", tagged_text::opening_tag{"tag", std::nullopt}, "def", tagged_text::closing_tag{"tag"}, "ghi"}}); +} + +test_case(ui_tagged__text_tag__attribute) +{ + test("[tag:attr][/tag]", {{tagged_text::opening_tag{"tag", "attr"}, tagged_text::closing_tag{"tag"}}}); +} + +test_case(ui_tagged__text_escape) +{ + test("\\[", {{"["}}); + test("\\]", {{"]"}}); + test("\\\\", {{"\\"}}); +} + +test_case(ui_tagged__text_random) +{ + using namespace psemek::random; + + generator rng; + + std::vector> string_pool; + + auto random_string = [&rng, &string_pool]() -> std::string const & { + auto & result = string_pool.emplace_back(std::make_unique()); + result->resize(uniform(rng, 1, 10)); + for (char & c : *result) + c = uniform(rng, 'a', 'z'); + return *result; + }; + + std::string text; + tagged_text expected; + + for (int i = 0; i < 100; ++i) + { + auto roll = uniform(rng, 0, 2); + + if (!expected.tokens.empty() && std::get_if(&expected.tokens.back()) && roll == 0) + roll = uniform(rng, 1, 2); + + if (roll == 0) + { + auto const & str = random_string(); + text += str; + expected.tokens.push_back(std::string_view{str}); + } + else if (roll == 1) + { + bool const has_attr = uniform(rng); + + tagged_text::opening_tag token; + + auto const & tag_str = random_string(); + + text += "[" + tag_str; + token.type = tag_str; + + if (has_attr) + { + auto const & attr_str = random_string(); + text += ":" + attr_str; + token.attribute = std::string_view{attr_str}; + } + + text += "]"; + + expected.tokens.push_back(token); + } + else if (roll == 2) + { + tagged_text::closing_tag token; + + auto const & tag_str = random_string(); + + text += "[/" + tag_str + "]"; + token.type = tag_str; + + expected.tokens.push_back(token); + } + } + + test(text, expected); +}