Add tagged_text description, parser & tests
This commit is contained in:
parent
abb95f87ae
commit
b524600da0
4 changed files with 318 additions and 0 deletions
|
|
@ -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)
|
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_resources(psemek-ui resources psemek/ui/resources)
|
||||||
|
|
||||||
|
psemek_glob_tests(psemek-ui tests)
|
||||||
|
|
|
||||||
46
libs/ui/include/psemek/ui/tagged_text.hpp
Normal file
46
libs/ui/include/psemek/ui/tagged_text.hpp
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string_view>
|
||||||
|
#include <variant>
|
||||||
|
#include <vector>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
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<std::string_view> attribute;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct closing_tag
|
||||||
|
{
|
||||||
|
std::string_view type;
|
||||||
|
};
|
||||||
|
|
||||||
|
using token = std::variant<std::string_view, opening_tag, closing_tag>;
|
||||||
|
|
||||||
|
std::vector<token> 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
106
libs/ui/source/tagged_text.cpp
Normal file
106
libs/ui/source/tagged_text.cpp
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
#include <psemek/ui/tagged_text.hpp>
|
||||||
|
#include <psemek/util/to_string.hpp>
|
||||||
|
|
||||||
|
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<closing_tag>(&result.tokens.back()))
|
||||||
|
error("closing tags cannot have attributes");
|
||||||
|
auto & attr = std::get<opening_tag>(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<opening_tag>(&result.tokens.back()))
|
||||||
|
{
|
||||||
|
if (token->attribute)
|
||||||
|
append(*(token->attribute));
|
||||||
|
else
|
||||||
|
append(token->type);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
append(std::get<closing_tag>(result.tokens.back()).type);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (result.tokens.empty() || !std::get_if<std::string_view>(&result.tokens.back()))
|
||||||
|
result.tokens.push_back(std::string_view{});
|
||||||
|
append(std::get<std::string_view>(result.tokens.back()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
++current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_tag)
|
||||||
|
error("unexpected end");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
164
libs/ui/tests/tagged_text.cpp
Normal file
164
libs/ui/tests/tagged_text.cpp
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
#include <psemek/test/test.hpp>
|
||||||
|
|
||||||
|
#include <psemek/ui/tagged_text.hpp>
|
||||||
|
#include <psemek/random/generator.hpp>
|
||||||
|
#include <psemek/random/uniform.hpp>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
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<std::string_view>(&result.tokens[i]))
|
||||||
|
{
|
||||||
|
expect_equal(*token, std::get<std::string_view>(expected.tokens[i]));
|
||||||
|
}
|
||||||
|
else if (auto token = std::get_if<tagged_text::opening_tag>(&result.tokens[i]))
|
||||||
|
{
|
||||||
|
expect_equal(token->type, std::get<tagged_text::opening_tag>(expected.tokens[i]).type);
|
||||||
|
expect_equal(token->attribute, std::get<tagged_text::opening_tag>(expected.tokens[i]).attribute);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
expect_equal(std::get<tagged_text::closing_tag>(result.tokens[i]).type, std::get<tagged_text::closing_tag>(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<std::unique_ptr<std::string>> string_pool;
|
||||||
|
|
||||||
|
auto random_string = [&rng, &string_pool]() -> std::string const & {
|
||||||
|
auto & result = string_pool.emplace_back(std::make_unique<std::string>());
|
||||||
|
result->resize(uniform<int>(rng, 1, 10));
|
||||||
|
for (char & c : *result)
|
||||||
|
c = uniform<char>(rng, 'a', 'z');
|
||||||
|
return *result;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string text;
|
||||||
|
tagged_text expected;
|
||||||
|
|
||||||
|
for (int i = 0; i < 100; ++i)
|
||||||
|
{
|
||||||
|
auto roll = uniform<int>(rng, 0, 2);
|
||||||
|
|
||||||
|
if (!expected.tokens.empty() && std::get_if<std::string_view>(&expected.tokens.back()) && roll == 0)
|
||||||
|
roll = uniform<int>(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<bool>(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);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue