diff --git a/src/Makefile.am b/src/Makefile.am index d322db72..e53c2f96 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -194,6 +194,7 @@ libsub_root_a_SOURCES = \ command_throttle.cc \ command_tracker.cc \ command_scheduler.cc \ + command_string.cc \ command_ui.cc \ control.cc \ control.h \ diff --git a/src/command_helpers.cc b/src/command_helpers.cc index 4ef0755e..512d1a6a 100644 --- a/src/command_helpers.cc +++ b/src/command_helpers.cc @@ -19,6 +19,7 @@ void initialize_command_groups(); void initialize_command_throttle(); void initialize_command_tracker(); void initialize_command_scheduler(); +void initialize_command_string(); void initialize_command_ui(); void @@ -37,4 +38,5 @@ initialize_commands() { initialize_command_throttle(); initialize_command_tracker(); initialize_command_scheduler(); + initialize_command_string(); } diff --git a/src/command_string.cc b/src/command_string.cc new file mode 100644 index 00000000..0a7d7062 --- /dev/null +++ b/src/command_string.cc @@ -0,0 +1,359 @@ +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rpc/parse.h" +#include "rpc/rpc_manager.h" + +#include "globals.h" +#include "control.h" +#include "command_helpers.h" + +namespace { + +const std::string whitespace_characters = " \t\n\r\f\v"; + +// The byte offset of every utf-8 character in 'text', terminated by the offset +// past the last character. Bytes that are not valid utf-8 lead bytes are +// treated as single characters. +std::vector +utf8_offsets(const std::string& text) { + std::vector offsets; + + for (size_t i = 0; i < text.size(); i++) + if (i == 0 || (static_cast(text[i]) & 0xC0) != 0x80) + offsets.push_back(i); + + offsets.push_back(text.size()); + return offsets; +} + +int64_t +utf8_length(const std::string& text) { + int64_t result = 0; + + for (size_t i = 0; i < text.size(); i++) + if (i == 0 || (static_cast(text[i]) & 0xC0) != 0x80) + result++; + + return result; +} + +std::string +utf8_substr(const std::string& text, const std::vector& offsets, size_t first, size_t last) { + return text.substr(offsets[first], offsets[last] - offsets[first]); +} + +std::set +utf8_character_set(const std::string& text) { + auto offsets = utf8_offsets(text); + std::set result; + + for (size_t i = 0; i + 1 < offsets.size(); i++) + result.insert(utf8_substr(text, offsets, i, i + 1)); + + return result; +} + +std::string +ascii_lowercase(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return std::tolower(c); }); + return text; +} + +// A 'max_count' of zero means the command accepts any number of arguments. +void +verify_argument_count(const char* name, const torrent::Object::list_type& args, size_t min_count, size_t max_count) { + if (args.size() < min_count || (max_count != 0 && args.size() > max_count)) + throw torrent::input_error(std::string(name) + ": invalid number of arguments."); +} + +const torrent::Object& +argument_at(const torrent::Object::list_type& args, size_t index) { + return *std::next(args.begin(), index); +} + +void +flatten_argument(const torrent::Object& arg, std::vector* dest) { + if (!arg.is_list()) { + dest->push_back(rpc::convert_to_string(arg)); + return; + } + + for (const auto& child : arg.as_list()) + flatten_argument(child, dest); +} + +// The {old, new} pairs shared by 'string.map' and 'string.replace'. +std::pair +argument_to_pair(const char* name, const torrent::Object& arg) { + if (!arg.is_list() || arg.as_list().size() != 2) + throw torrent::input_error(std::string(name) + ": arguments after the text must be {old, new} pairs."); + + return {rpc::convert_to_string(arg.as_list().front()), rpc::convert_to_string(arg.as_list().back())}; +} + +// Compares the first argument against every remaining argument, returning true +// as soon as one of them matches. +torrent::Object +apply_string_predicate(const char* name, const torrent::Object::list_type& args, bool (*predicate)(const std::string&, const std::string&)) { + verify_argument_count(name, args, 2, 0); + + auto text = rpc::convert_to_string(args.front()); + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) + if (predicate(text, rpc::convert_to_string(*itr))) + return int64_t(1); + + return int64_t(0); +} + +bool +text_equals(const std::string& text, const std::string& other) { + return text == other; +} + +bool +text_starts_with(const std::string& text, const std::string& prefix) { + return text.size() >= prefix.size() && text.compare(0, prefix.size(), prefix) == 0; +} + +bool +text_ends_with(const std::string& text, const std::string& tail) { + return text.size() >= tail.size() && text.compare(text.size() - tail.size(), tail.size(), tail) == 0; +} + +bool +text_contains(const std::string& text, const std::string& needle) { + return text.find(needle) != std::string::npos; +} + +bool +text_contains_i(const std::string& text, const std::string& needle) { + return ascii_lowercase(text).find(ascii_lowercase(needle)) != std::string::npos; +} + +torrent::Object +apply_string_length(const torrent::Object::list_type& args) { + verify_argument_count("string.length", args, 1, 1); + + return utf8_length(rpc::convert_to_string(args.front())); +} + +torrent::Object +apply_string_substr(const torrent::Object::list_type& args) { + verify_argument_count("string.substr", args, 1, 4); + + auto text = rpc::convert_to_string(args.front()); + auto offsets = utf8_offsets(text); + auto text_length = static_cast(offsets.size() - 1); + + auto position = args.size() > 1 ? rpc::convert_to_value(argument_at(args, 1)) : 0; + auto fallback = args.size() > 3 ? rpc::convert_to_string(argument_at(args, 3)) : std::string(); + + // Negative positions are relative to the end of the string. + if (position < 0) + position += text_length; + + if (position < 0 || position > text_length) + return fallback; + + auto last = text_length; + + if (args.size() > 2) { + auto count = rpc::convert_to_value(argument_at(args, 2)); + + if (count < 0) + throw torrent::input_error("string.substr: the character count cannot be negative."); + + last = std::min(text_length, position + std::min(count, text_length)); + } + + return utf8_substr(text, offsets, position, last); +} + +torrent::Object +apply_string_split(const torrent::Object::list_type& args) { + verify_argument_count("string.split", args, 2, 2); + + auto text = rpc::convert_to_string(args.front()); + auto delim = rpc::convert_to_string(args.back()); + auto result = torrent::Object::create_list(); + + // An empty delimiter splits the text into its utf-8 characters. + if (delim.empty()) { + auto offsets = utf8_offsets(text); + + for (size_t i = 0; i + 1 < offsets.size(); i++) + result.as_list().push_back(utf8_substr(text, offsets, i, i + 1)); + + return result; + } + + size_t first = 0; + + while (true) { + auto pos = text.find(delim, first); + + if (pos == std::string::npos) { + result.as_list().push_back(text.substr(first)); + return result; + } + + result.as_list().push_back(text.substr(first, pos - first)); + first = pos + delim.size(); + } +} + +torrent::Object +apply_string_join(const torrent::Object::list_type& args) { + verify_argument_count("string.join", args, 1, 0); + + auto delim = rpc::convert_to_string(args.front()); + + std::vector parts; + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) + flatten_argument(*itr, &parts); + + std::string result; + + for (auto itr = parts.begin(); itr != parts.end(); itr++) { + if (itr != parts.begin()) + result += delim; + + result += *itr; + } + + return result; +} + +torrent::Object +apply_string_pad(const char* name, const torrent::Object::list_type& args, bool pad_start) { + verify_argument_count(name, args, 2, 3); + + auto text = rpc::convert_to_string(args.front()); + auto pad_size = rpc::convert_to_value(argument_at(args, 1)); + auto padding = args.size() > 2 ? rpc::convert_to_string(argument_at(args, 2)) : std::string(" "); + + auto text_length = utf8_length(text); + + if (pad_size <= text_length || padding.empty()) + return text; + + auto padding_offsets = utf8_offsets(padding); + auto padding_length = static_cast(padding_offsets.size() - 1); + + std::string result; + + for (int64_t i = 0; i < pad_size - text_length; i++) { + auto index = static_cast(i % padding_length); + result += utf8_substr(padding, padding_offsets, index, index + 1); + } + + return pad_start ? result + text : text + result; +} + +torrent::Object +apply_string_strip(const char* name, const torrent::Object::list_type& args, bool strip_start, bool strip_end) { + verify_argument_count(name, args, 1, 0); + + auto text = rpc::convert_to_string(args.front()); + + std::string strippable; + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) + strippable += rpc::convert_to_string(*itr); + + if (args.size() == 1) + strippable = whitespace_characters; + + auto characters = utf8_character_set(strippable); + auto offsets = utf8_offsets(text); + + size_t first = 0; + size_t last = offsets.size() - 1; + + while (strip_start && first < last && characters.count(utf8_substr(text, offsets, first, first + 1)) != 0) + first++; + + while (strip_end && last > first && characters.count(utf8_substr(text, offsets, last - 1, last)) != 0) + last--; + + return utf8_substr(text, offsets, first, last); +} + +torrent::Object +apply_string_map(const torrent::Object::list_type& args) { + verify_argument_count("string.map", args, 2, 0); + + auto text = rpc::convert_to_string(args.front()); + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) { + auto pair = argument_to_pair("string.map", *itr); + + if (text == pair.first) + return pair.second; + } + + return text; +} + +torrent::Object +apply_string_replace(const torrent::Object::list_type& args) { + verify_argument_count("string.replace", args, 2, 0); + + auto text = rpc::convert_to_string(args.front()); + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) { + auto pair = argument_to_pair("string.replace", *itr); + + if (pair.first.empty()) + throw torrent::input_error("string.replace: the replaced text cannot be empty."); + + for (auto pos = text.find(pair.first); pos != std::string::npos; pos = text.find(pair.first, pos + pair.second.size())) + text.replace(pos, pair.first.size(), pair.second); + } + + return text; +} + +} + +void +initialize_command_string() { + // clang-format off + CMD2_ANY_LIST("string.length", [](auto, const auto& args) { return apply_string_length(args); }); + CMD2_ANY_LIST("string.substr", [](auto, const auto& args) { return apply_string_substr(args); }); + CMD2_ANY_LIST("string.split", [](auto, const auto& args) { return apply_string_split(args); }); + CMD2_ANY_LIST("string.join", [](auto, const auto& args) { return apply_string_join(args); }); + CMD2_ANY_LIST("string.map", [](auto, const auto& args) { return apply_string_map(args); }); + CMD2_ANY_LIST("string.replace", [](auto, const auto& args) { return apply_string_replace(args); }); + + CMD2_ANY_LIST("string.equals", [](auto, const auto& args) { return apply_string_predicate("string.equals", args, &text_equals); }); + CMD2_ANY_LIST("string.starts_with", [](auto, const auto& args) { return apply_string_predicate("string.starts_with", args, &text_starts_with); }); + CMD2_ANY_LIST("string.ends_with", [](auto, const auto& args) { return apply_string_predicate("string.ends_with", args, &text_ends_with); }); + CMD2_ANY_LIST("string.contains", [](auto, const auto& args) { return apply_string_predicate("string.contains", args, &text_contains); }); + CMD2_ANY_LIST("string.contains_i", [](auto, const auto& args) { return apply_string_predicate("string.contains_i", args, &text_contains_i); }); + + CMD2_ANY_LIST("string.lpad", [](auto, const auto& args) { return apply_string_pad("string.lpad", args, true); }); + CMD2_ANY_LIST("string.rpad", [](auto, const auto& args) { return apply_string_pad("string.rpad", args, false); }); + + CMD2_ANY_LIST("string.strip", [](auto, const auto& args) { return apply_string_strip("string.strip", args, true, true); }); + CMD2_ANY_LIST("string.lstrip", [](auto, const auto& args) { return apply_string_strip("string.lstrip", args, true, false); }); + CMD2_ANY_LIST("string.rstrip", [](auto, const auto& args) { return apply_string_strip("string.rstrip", args, false, true); }); + // clang-format on + + for (const auto name : {"string.length", "string.substr", "string.split", "string.join", "string.map", "string.replace", + "string.equals", "string.starts_with", "string.ends_with", "string.contains", "string.contains_i", + "string.lpad", "string.rpad", "string.strip", "string.lstrip", "string.rstrip"}) + rpc::rpc.mark_safe(name); +} diff --git a/test/Makefile.am b/test/Makefile.am index d6eb396e..94b4d919 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -51,6 +51,8 @@ rtorrent_Test_Src_SOURCES = $(rtorrent_Test_Common) \ src/test_command_dynamic.h \ src/test_command_local.cc \ src/test_command_local.h \ + src/test_command_string.cc \ + src/test_command_string.h \ src/test_watch_ready_queue.cc \ src/test_watch_ready_queue.h diff --git a/test/src/test_command_string.cc b/test/src/test_command_string.cc new file mode 100644 index 00000000..640ca8bc --- /dev/null +++ b/test/src/test_command_string.cc @@ -0,0 +1,261 @@ +#include "config.h" + +#include "test/src/test_command_string.h" + +#include "rpc/parse_commands.h" + +CPPUNIT_TEST_SUITE_REGISTRATION(TestCommandString); + +void initialize_command_string(); + +namespace { + +torrent::Object +args(std::initializer_list objects) { + auto result = torrent::Object::create_list(); + + for (const auto& object : objects) + result.as_list().push_back(object); + + return result; +} + +std::string +call_string(const char* key, std::initializer_list objects) { + return rpc::commands.call_command(key, args(objects)).as_string(); +} + +int64_t +call_value(const char* key, std::initializer_list objects) { + return rpc::commands.call_command(key, args(objects)).as_value(); +} + +torrent::Object::list_type +call_list(const char* key, std::initializer_list objects) { + return rpc::commands.call_command(key, args(objects)).as_list(); +} + +// Runs a command the way a line in the configuration file would. +torrent::Object +parse(const char* command) { + return rpc::parse_command_single(rpc::make_target(), command); +} + +} + +void +TestCommandString::setUp() { + if (!rpc::commands.has("string.length")) + initialize_command_string(); +} + +void +TestCommandString::tearDown() { +} + +void +TestCommandString::test_length() { + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.length", {""})); + CPPUNIT_ASSERT_EQUAL(int64_t(3), call_value("string.length", {"abc"})); + + // The length is counted in utf-8 characters, not bytes. + CPPUNIT_ASSERT_EQUAL(int64_t(5), call_value("string.length", {"héllo"})); + CPPUNIT_ASSERT_EQUAL(int64_t(3), call_value("string.length", {"日本語"})); + + // Values are converted to their string representation. + CPPUNIT_ASSERT_EQUAL(int64_t(4), call_value("string.length", {int64_t(1234)})); +} + +void +TestCommandString::test_equals() { + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.equals", {"abc", "abc"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.equals", {"abc", "abd"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.equals", {"abc", "ab"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.equals", {"abc", "x", "abc"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.equals", {"abc", "x", "y"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.equals", {int64_t(42), "42"})); +} + +void +TestCommandString::test_starts_with() { + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.starts_with", {"abcdef", "abc"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.starts_with", {"abcdef", ""})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.starts_with", {"abcdef", "bcd"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.starts_with", {"ab", "abc"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.starts_with", {"abcdef", "x", "ab"})); +} + +void +TestCommandString::test_ends_with() { + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.ends_with", {"abcdef", "def"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.ends_with", {"abcdef", ""})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.ends_with", {"abcdef", "cde"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.ends_with", {"ef", "def"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.ends_with", {"a.torrent", ".rar", ".torrent"})); +} + +void +TestCommandString::test_contains() { + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.contains", {"abcdef", "cde"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.contains", {"abcdef", "ace"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.contains", {"abcdef", "x", "bcd"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.contains", {"retracker.local", "RETRACKER"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.contains_i", {"retracker.local", "RETRACKER"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.contains_i", {"RETRACKER.LOCAL", "retracker"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.contains_i", {"abcdef", "xyz"})); +} + +void +TestCommandString::test_substr() { + CPPUNIT_ASSERT_EQUAL(std::string("abcdef"), call_string("string.substr", {"abcdef"})); + CPPUNIT_ASSERT_EQUAL(std::string("cdef"), call_string("string.substr", {"abcdef", int64_t(2)})); + CPPUNIT_ASSERT_EQUAL(std::string("cde"), call_string("string.substr", {"abcdef", int64_t(2), int64_t(3)})); + CPPUNIT_ASSERT_EQUAL(std::string("cdef"), call_string("string.substr", {"abcdef", int64_t(2), int64_t(100)})); + CPPUNIT_ASSERT_EQUAL(std::string(""), call_string("string.substr", {"abcdef", int64_t(2), int64_t(0)})); + + // Negative positions are relative to the end of the string. + CPPUNIT_ASSERT_EQUAL(std::string("ef"), call_string("string.substr", {"abcdef", int64_t(-2)})); + CPPUNIT_ASSERT_EQUAL(std::string("e"), call_string("string.substr", {"abcdef", int64_t(-2), int64_t(1)})); + + // Out-of-bounds positions return the default value. + CPPUNIT_ASSERT_EQUAL(std::string(""), call_string("string.substr", {"abcdef", int64_t(10)})); + CPPUNIT_ASSERT_EQUAL(std::string("n/a"), call_string("string.substr", {"abcdef", int64_t(10), int64_t(1), "n/a"})); + CPPUNIT_ASSERT_EQUAL(std::string("n/a"), call_string("string.substr", {"abcdef", int64_t(-10), int64_t(1), "n/a"})); + + // Positions and counts are in utf-8 characters, not bytes. + CPPUNIT_ASSERT_EQUAL(std::string("本"), call_string("string.substr", {"日本語", int64_t(1), int64_t(1)})); + CPPUNIT_ASSERT_EQUAL(std::string("本語"), call_string("string.substr", {"日本語", int64_t(-2)})); +} + +void +TestCommandString::test_split() { + auto parts = call_list("string.split", {"a,b,c", ","}); + + CPPUNIT_ASSERT_EQUAL(size_t(3), parts.size()); + CPPUNIT_ASSERT_EQUAL(std::string("a"), parts.front().as_string()); + CPPUNIT_ASSERT_EQUAL(std::string("c"), parts.back().as_string()); + + // Empty fields are preserved. + CPPUNIT_ASSERT_EQUAL(size_t(3), call_list("string.split", {"a,,b", ","}).size()); + CPPUNIT_ASSERT_EQUAL(size_t(1), call_list("string.split", {"abc", ","}).size()); + + // A multi-character delimiter is matched as a whole. + CPPUNIT_ASSERT_EQUAL(size_t(2), call_list("string.split", {"a::b", "::"}).size()); + + // An empty delimiter splits the text into its utf-8 characters. + auto characters = call_list("string.split", {"日本語", ""}); + + CPPUNIT_ASSERT_EQUAL(size_t(3), characters.size()); + CPPUNIT_ASSERT_EQUAL(std::string("日"), characters.front().as_string()); +} + +void +TestCommandString::test_join() { + CPPUNIT_ASSERT_EQUAL(std::string("a, b"), call_string("string.join", {", ", "a", "b"})); + CPPUNIT_ASSERT_EQUAL(std::string("ab"), call_string("string.join", {"", "a", "b"})); + CPPUNIT_ASSERT_EQUAL(std::string(""), call_string("string.join", {", "})); + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.join", {", ", "a"})); + CPPUNIT_ASSERT_EQUAL(std::string("1-2"), call_string("string.join", {"-", int64_t(1), int64_t(2)})); + + // Lists are flattened, so the output of string.split can be joined again. + CPPUNIT_ASSERT_EQUAL(std::string("a-b-c"), call_string("string.join", {"-", args({"a", "b"}), "c"})); +} + +void +TestCommandString::test_pad() { + CPPUNIT_ASSERT_EQUAL(std::string(" 7"), call_string("string.lpad", {"7", int64_t(3)})); + CPPUNIT_ASSERT_EQUAL(std::string("7 "), call_string("string.rpad", {"7", int64_t(3)})); + CPPUNIT_ASSERT_EQUAL(std::string("007"), call_string("string.lpad", {"7", int64_t(3), "0"})); + CPPUNIT_ASSERT_EQUAL(std::string("700"), call_string("string.rpad", {"7", int64_t(3), "0"})); + + // Text that is already long enough is returned unchanged. + CPPUNIT_ASSERT_EQUAL(std::string("abcd"), call_string("string.lpad", {"abcd", int64_t(2)})); + CPPUNIT_ASSERT_EQUAL(std::string("abcd"), call_string("string.rpad", {"abcd", int64_t(4)})); + + // Multi-character padding is repeated, and an empty padding is a no-op. + CPPUNIT_ASSERT_EQUAL(std::string("axyx"), call_string("string.rpad", {"a", int64_t(4), "xy"})); + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.rpad", {"a", int64_t(4), ""})); + + // Padding is counted in utf-8 characters, not bytes. + CPPUNIT_ASSERT_EQUAL(std::string("00日"), call_string("string.lpad", {"日", int64_t(3), "0"})); +} + +void +TestCommandString::test_strip() { + CPPUNIT_ASSERT_EQUAL(std::string("a b"), call_string("string.strip", {" a b "})); + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.strip", {"\t\n a \r\n"})); + CPPUNIT_ASSERT_EQUAL(std::string("a "), call_string("string.lstrip", {" a "})); + CPPUNIT_ASSERT_EQUAL(std::string(" a"), call_string("string.rstrip", {" a "})); + + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.strip", {"xxaxx", "x"})); + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.strip", {"/x/a/x/", "/", "x"})); + CPPUNIT_ASSERT_EQUAL(std::string(""), call_string("string.strip", {"aaa", "a"})); + CPPUNIT_ASSERT_EQUAL(std::string(" a "), call_string("string.strip", {" a ", ""})); + + // The strippable argument is a set of utf-8 characters. + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.strip", {"日a日", "日"})); +} + +void +TestCommandString::test_map() { + CPPUNIT_ASSERT_EQUAL(std::string("b"), call_string("string.map", {"a", args({"a", "b"})})); + CPPUNIT_ASSERT_EQUAL(std::string("c"), call_string("string.map", {"c", args({"a", "b"})})); + + // Only whole-string matches are replaced. + CPPUNIT_ASSERT_EQUAL(std::string("ab"), call_string("string.map", {"ab", args({"a", "b"})})); + + // The first matching pair wins. + CPPUNIT_ASSERT_EQUAL(std::string("y"), call_string("string.map", {"x", args({"a", "b"}), args({"x", "y"}), args({"x", "z"})})); +} + +void +TestCommandString::test_replace() { + CPPUNIT_ASSERT_EQUAL(std::string("a+b+c"), call_string("string.replace", {"a-b-c", args({"-", "+"})})); + CPPUNIT_ASSERT_EQUAL(std::string("abc"), call_string("string.replace", {"a-b-c", args({"-", ""})})); + CPPUNIT_ASSERT_EQUAL(std::string("a-b-c"), call_string("string.replace", {"a-b-c", args({"x", "y"})})); + + // Pairs are applied in order, left to right. + CPPUNIT_ASSERT_EQUAL(std::string("xby"), call_string("string.replace", {"abc", args({"a", "x"}), args({"c", "y"})})); + + // A replacement that contains the replaced text terminates. + CPPUNIT_ASSERT_EQUAL(std::string("aaaa"), call_string("string.replace", {"aa", args({"a", "aa"})})); +} + +void +TestCommandString::test_invalid_arguments() { + CPPUNIT_ASSERT_THROW(rpc::commands.call_command("string.length", torrent::Object()), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_string("string.length", {"a", "b"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_value("string.equals", {"a"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_value("string.contains", {"a"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_list("string.split", {"a"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_string("string.lpad", {"a"}), torrent::input_error); + + // The character count of string.substr cannot be negative. + CPPUNIT_ASSERT_THROW(call_string("string.substr", {"abc", int64_t(0), int64_t(-1)}), torrent::input_error); + + // Both string.map and string.replace require {old, new} pairs. + CPPUNIT_ASSERT_THROW(call_string("string.map", {"a", "b"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_string("string.replace", {"a", args({"a", "b", "c"})}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_string("string.replace", {"a", args({"", "b"})}), torrent::input_error); +} + +void +TestCommandString::test_config_syntax() { + CPPUNIT_ASSERT_EQUAL(int64_t(3), parse("string.length=abc").as_value()); + CPPUNIT_ASSERT_EQUAL(int64_t(1), parse("string.contains=retracker.local,retracker").as_value()); + CPPUNIT_ASSERT_EQUAL(int64_t(1), parse("string.starts_with=udp://tracker.example.com,http://,udp://").as_value()); + CPPUNIT_ASSERT_EQUAL(std::string("cde"), parse("string.substr=abcdef,2,3").as_string()); + CPPUNIT_ASSERT_EQUAL(std::string("padded"), parse("string.strip=\" padded \"").as_string()); + + // The {old, new} pairs are written as a block in the configuration file. + CPPUNIT_ASSERT_EQUAL(std::string("a+b+c"), parse("string.replace=a-b-c,{-,+}").as_string()); + CPPUNIT_ASSERT_EQUAL(std::string("y"), parse("string.map=x,{a,b},{x,y}").as_string()); + + // Commands nest, so a split can be joined back together. + CPPUNIT_ASSERT_EQUAL(std::string("a-b-c"), parse("string.join=-,(string.split,a.b.c,.)").as_string()); +} diff --git a/test/src/test_command_string.h b/test/src/test_command_string.h new file mode 100644 index 00000000..10f49d6a --- /dev/null +++ b/test/src/test_command_string.h @@ -0,0 +1,41 @@ +#include "test/helpers/test_fixture.h" + +class TestCommandString : public test_fixture { + CPPUNIT_TEST_SUITE(TestCommandString); + + CPPUNIT_TEST(test_length); + CPPUNIT_TEST(test_equals); + CPPUNIT_TEST(test_starts_with); + CPPUNIT_TEST(test_ends_with); + CPPUNIT_TEST(test_contains); + CPPUNIT_TEST(test_substr); + CPPUNIT_TEST(test_split); + CPPUNIT_TEST(test_join); + CPPUNIT_TEST(test_pad); + CPPUNIT_TEST(test_strip); + CPPUNIT_TEST(test_map); + CPPUNIT_TEST(test_replace); + CPPUNIT_TEST(test_invalid_arguments); + CPPUNIT_TEST(test_config_syntax); + + CPPUNIT_TEST_SUITE_END(); + +public: + void setUp(); + void tearDown(); + + void test_length(); + void test_equals(); + void test_starts_with(); + void test_ends_with(); + void test_contains(); + void test_substr(); + void test_split(); + void test_join(); + void test_pad(); + void test_strip(); + void test_map(); + void test_replace(); + void test_invalid_arguments(); + void test_config_syntax(); +};