Merge string utils to one file

Reviewers: buda

Reviewed By: buda

Subscribers: pullbot

Differential Revision: https://phabricator.memgraph.io/D281
This commit is contained in:
Mislav Bradac 2017-04-18 12:11:25 +02:00
parent 541c3f0af7
commit 5434e79ea6
28 changed files with 194 additions and 482 deletions

View File

@ -324,9 +324,6 @@ set(memgraph_src_files
${src_dir}/config/config.cpp
${src_dir}/dbms/dbms.cpp
# ${src_dir}/dbms/cleaner.cpp
${src_dir}/utils/string/transform.cpp
${src_dir}/utils/string/join.cpp
${src_dir}/utils/string/file.cpp
${src_dir}/utils/numerics/saturate.cpp
${src_dir}/io/network/addrinfo.cpp
${src_dir}/io/network/network_endpoint.cpp

View File

@ -10,23 +10,31 @@ namespace fs = std::experimental::filesystem;
#include "utils/command_line/arguments.hpp"
#include "utils/exceptions/basic_exception.hpp"
#include "utils/file.hpp"
#include "utils/string/file.hpp"
#include "utils/string/trim.hpp"
#include "utils/string.hpp"
std::string extract_query(const fs::path &path) {
/**
* Reads a query from the file specified by the path argument.
* The first line of a query should start with "// Query: ". Query can be
* in more than one line but every line has to start with "//".
*
* @param path to the query file.
* @return query as a string.
*/
std::string ExtractQuery(const fs::path &path) {
auto comment_mark = std::string("// ");
auto query_mark = comment_mark + std::string("Query: ");
auto lines = utils::read_lines(path);
auto lines = utils::ReadLines(path);
// find the line with a query (the query can be split across multiple
// lines)
for (int i = 0; i < (int)lines.size(); ++i) {
for (int i = 0; i < static_cast<int>(lines.size()); ++i) {
// find query in the line
auto &line = lines[i];
auto pos = line.find(query_mark);
// if query doesn't exist pass
if (pos == std::string::npos) continue;
auto query = utils::trim(line.substr(pos + query_mark.size()));
while (i + 1 < (int)lines.size() &&
auto query = utils::Trim(line.substr(pos + query_mark.size()));
while (i + 1 < static_cast<int>(lines.size()) &&
lines[i + 1].find(comment_mark) != std::string::npos) {
query += lines[i + 1].substr(lines[i + 1].find(comment_mark) +
comment_mark.length());
@ -61,7 +69,7 @@ int main(int argc, char **argv) {
QueryPreprocessor preprocessor;
for (auto &src_file : src_files) {
auto query = extract_query(src_file);
auto query = ExtractQuery(src_file);
auto query_hash = preprocessor.preprocess(query).hash;
auto dst_file = dst_path / fs::path(std::to_string(query_hash) + ".cpp");
fs::copy(src_file, dst_file, fs::copy_options::overwrite_existing);

View File

@ -9,10 +9,10 @@ namespace fs = std::experimental::filesystem;
#include "logging/loggable.hpp"
#include "query/exceptions.hpp"
#include "query/frontend/opencypher/parser.hpp"
#include "query/interpreter.hpp"
#include "query/plan_compiler.hpp"
#include "query/plan_interface.hpp"
#include "query/preprocessor.hpp"
#include "query/interpreter.hpp"
#include "utils/dynamic_lib.hpp"
/**
@ -26,11 +26,12 @@ namespace fs = std::experimental::filesystem;
* the results should be returned (more optimal then just return
* the whole result set)
*/
template <typename Stream> class QueryEngine : public Loggable {
private:
template <typename Stream>
class QueryEngine : public Loggable {
private:
using QueryPlanLib = DynamicLib<QueryPlanTrait<Stream>>;
public:
public:
QueryEngine() : Loggable("QueryEngine") {}
/**
@ -63,7 +64,6 @@ public:
*/
auto Run(const std::string &query, GraphDbAccessor &db_accessor,
Stream &stream) {
if (CONFIG_BOOL(config::INTERPRET)) {
query::Interpret(query, db_accessor, stream);
return true;
@ -109,12 +109,12 @@ public:
*
* @return size_t the number of loaded query plans
*/
auto Size() { // TODO: const once whan ConcurrentMap::Accessor becomes const
auto Size() { // TODO: const once whan ConcurrentMap::Accessor becomes const
return query_plans.access().size();
}
// return query_plans.access().size(); }
private:
private:
/**
* Loads query plan eather from hardcoded folder or from the file that is
* generated in this method.
@ -175,13 +175,13 @@ private:
auto path_so = CONFIG(config::COMPILE_PATH) + std::to_string(hash) + "_" +
(std::string)Timestamp::now() + ".so";
plan_compiler.compile(path_cpp, path_so);
plan_compiler.Compile(path_cpp, path_so);
auto query_plan = std::make_unique<QueryPlanLib>(path_so);
// TODO: underlying object has to be live during query execution
// fix that when Antler will be introduced into the database
auto query_plan_instance = query_plan->instance(); // because of move
auto query_plan_instance = query_plan->instance(); // because of move
plans_accessor.insert(hash, std::move(query_plan));
// return an instance of runnable code (PlanInterface)

View File

@ -6,7 +6,7 @@
#include "logging/loggable.hpp"
#include "query/exceptions.hpp"
#include "query/plan_compiler_flags.hpp"
#include "utils/string/join.hpp"
#include "utils/string.hpp"
// TODO:
// * all libraries have to be compiled in the server compile time
@ -27,19 +27,18 @@ class PlanCompiler : public Loggable {
*
* @return void
*/
void compile(const std::string &in_file, const std::string &out_file) {
void Compile(const std::string &in_file, const std::string &out_file) {
// generate compile command
auto compile_command = utils::prints(
"clang++", compile_flags,
auto compile_command =
utils::Join({"clang++", compile_flags,
#ifdef HARDCODED_OUTPUT_STREAM
"-DHARDCODED_OUTPUT_STREAM",
"-DHARDCODED_OUTPUT_STREAM",
#endif
in_file, // input file
"-o", out_file, // ouput file
include_dirs,
link_dirs, "-lmemgraph_pic",
"-shared -fPIC" // shared library flags
);
in_file, // input file
"-o", out_file, // ouput file
include_dirs, link_dirs, "-lmemgraph_pic",
"-shared -fPIC"}, // shared library flags
" ");
logger.debug("compile command -> {}", compile_command);

View File

@ -1,17 +1,25 @@
#include "template_engine/engine.hpp"
#include "utils/string/replace.hpp"
#include "utils/string.hpp"
namespace template_engine {
string render(const string& form, const data& partials) {
/**
* Replaces all placeholders in the form string marked as {{ key }} with values
* defined in the partials dictionary.
*
* @param form template string.
* @param partials values to inject into the template string.
* @return string rendered based on template string and values.
*/
std::string Render(const std::string &form, const data &partials) {
// TODO more optimal implementation
// another option is something like https://github.com/no1msd/mstch
// but it has to be wrapped
string rendered = form;
for (auto partial : partials) {
for (const auto &partial : partials) {
string key = "{{" + partial.first + "}}";
rendered = utils::replace(rendered, key, partial.second);
rendered = utils::Replace(rendered, key, partial.second);
}
return rendered;
}

View File

@ -6,7 +6,7 @@
namespace template_engine {
using std::string;
using data = std::unordered_map<string, string>;
using data = std::unordered_map<std::string, std::string>;
string render(const string& form, const data& partials);
std::string Render(const string& form, const data& partials);
}

View File

@ -1,7 +1,7 @@
#pragma once
#include <fstream>
#include <experimental/filesystem>
#include <fstream>
namespace fs = std::experimental::filesystem;
namespace utils {
@ -50,4 +50,36 @@ inline auto LoadFilePaths(const fs::path &directory,
return file_paths;
}
// TODO: add error checking
/**
* Reads all lines from the file specified by path.
*
* @param path file path.
* @return vector of all lines from the file.
*/
std::vector<std::string> ReadLines(const fs::path &path) {
std::vector<std::string> lines;
std::ifstream stream(path.c_str());
std::string line;
while (std::getline(stream, line)) {
lines.emplace_back(line);
}
return lines;
}
/**
* Writes test into the file specified by path.
*
* @param text content which will be written in the file.
* @param path a path to the file.
*/
void Write(const std::string &text, const fs::path &path) {
std::ofstream stream;
stream.open(path.c_str());
stream << text;
stream.close();
}
}

View File

@ -12,7 +12,6 @@
#include <limits.h>
#include <sys/inotify.h>
#include <unistd.h>
#include <unistd.h>
#include <atomic>
#include <chrono>
#include <mutex>

95
src/utils/string.hpp Normal file
View File

@ -0,0 +1,95 @@
#pragma once
#include <algorithm>
#include <cctype>
#include <string>
#include <iterator>
#include <sstream>
#include <string>
#include <vector>
namespace utils {
/**
* Removes whitespace characters from the start and from the end of a string.
*
* @param str string that is going to be trimmed
*
* @return trimmed string
*/
std::string Trim(const std::string& s) {
auto begin = s.begin();
auto end = s.end();
if (begin == end) {
// Need to check this to be sure that prev(end) exists.
return s;
}
while (begin != end && isspace(*begin)) {
++begin;
}
while (prev(end) != begin && isspace(*prev(end))) {
--end;
}
return std::string(begin, end);
}
/**
* Return string with all lowercased characters (locale independent).
*/
std::string ToLowerCase(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](char c) { return tolower(c); });
return s;
}
/**
* Return string with all uppercased characters (locale independent).
*/
std::string ToUpperCase(std::string s) {
std::string s2(s.size(), ' ');
std::transform(s.begin(), s.end(), s.begin(),
[](char c) { return toupper(c); });
return s;
}
/**
* Join strings in vector separated by a given separator.
*/
std::string Join(const std::vector<std::string>& strings,
const char* separator) {
std::ostringstream oss;
std::copy(strings.begin(), strings.end(),
std::ostream_iterator<std::string>(oss, separator));
return oss.str();
}
/**
* Replaces all occurences of <match> in <src> with <replacement>.
*/
// TODO: This could be implemented much more efficient.
std::string Replace(std::string src, const std::string& match,
const std::string& replacement) {
for (size_t pos = src.find(match); pos != std::string::npos;
pos = src.find(match, pos + replacement.size())) {
src.erase(pos, match.length()).insert(pos, replacement);
}
return src;
}
/**
* Split string by delimeter and return vector of results.
*/
std::vector<std::string> Split(const std::string& src,
const std::string& delimiter) {
size_t index = 0;
std::vector<std::string> res;
size_t n = src.find(delimiter, index);
while (n != std::string::npos) {
n = src.find(delimiter, index);
res.push_back(src.substr(index, n - index));
index = n + delimiter.size();
}
return res;
}
}

View File

@ -1,6 +0,0 @@
#pragma once
#include "intercalate.hpp"
#include "linereader.hpp"
#include "replace.hpp"
#include "split.hpp"

View File

@ -1,38 +0,0 @@
#include "utils/string/file.hpp"
#include <iterator>
namespace utils {
namespace fs = std::experimental::filesystem;
Text read_text(const fs::path &path) {
std::ifstream in(path, std::ios::in | std::ios::binary);
if (in)
return Text(std::string(std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>()));
auto error_message = fmt::format("{0}{1}", "Fail to read: ", path.c_str());
throw std::runtime_error(error_message);
}
std::vector<std::string> read_lines(const fs::path &path) {
std::vector<std::string> lines;
std::ifstream stream(path.c_str());
std::string line;
while (std::getline(stream, line)) {
lines.emplace_back(line);
}
return lines;
}
void write(const Text &text, const fs::path &path) {
std::ofstream stream;
stream.open(path.c_str());
stream << text.str();
stream.close();
}
}

View File

@ -1,61 +0,0 @@
#pragma once
#include <cerrno>
#include <fstream>
#include <ostream>
#include <stdexcept>
#include <streambuf>
#include <string>
#include <fmt/format.h>
// TODO: remove experimental from here once that becomes possible
#include <experimental/filesystem>
namespace fs = std::experimental::filesystem;
namespace utils {
/*
* Type safe text object.
*/
class Text {
public:
Text() = default;
explicit Text(const std::string& text) : text_(text) {}
// text could be huge and copy operationt would be too expensive
Text(const Text& other) = delete;
Text& operator=(const Text& other) = delete;
// the object is movable
Text(Text&& other) = default;
Text& operator=(Text&& other) {
text_ = std::move(other.text_);
return *this;
}
const std::string& str() const { return text_; }
private:
std::string text_;
};
/*
* Reads the whole text from a file at the path.
*/
Text read_text(const fs::path& path);
/*
* Reads all the lines from a file at the path.
*/
std::vector<std::string> read_lines(const fs::path& path);
// TODO: lazy implementation of read_lines functionality (line by line)
// TODO: read word by word + lazy implementation
/*
* Write text in a file at the path.
*/
void write(const Text& text, const fs::path& path);
}

View File

@ -1,23 +0,0 @@
#pragma once
#include <sstream>
#include <string>
namespace utils {
template <typename It>
std::string intercalate(It first, It last, const std::string& separator) {
if (first == last) return "";
std::stringstream ss;
It second(first);
// append the first N-1 elements with a separator
for (second++; second != last; ++first, ++second) ss << *first << separator;
// append the last element
ss << *first;
return ss.str();
}
}

View File

@ -1,12 +0,0 @@
#include "utils/string/join.hpp"
namespace utils {
std::string join(const std::vector<std::string>& strings,
const char* separator) {
std::ostringstream oss;
std::copy(strings.begin(), strings.end(),
std::ostream_iterator<std::string>(oss, separator));
return oss.str();
}
}

View File

@ -1,18 +0,0 @@
#pragma once
#include <iterator>
#include <sstream>
#include <string>
#include <vector>
namespace utils {
std::string join(const std::vector<std::string>& strings,
const char* separator);
template <typename... Args>
std::string prints(const Args&... args) {
std::vector<std::string> strings = {args...};
return join(strings, " ");
}
}

View File

@ -1,27 +0,0 @@
#pragma once
#include <fstream>
#include <functional>
#include <string>
namespace utils {
void linereader(std::istream& stream,
std::function<void(const std::string&)> cb) {
std::string line;
while (std::getline(stream, line)) cb(line);
}
void linereader(const std::string& filename,
std::function<void(const std::string&)> cb) {
std::fstream fs(filename.c_str());
// should this throw an error? figure out how to handle this TODO
if (fs.is_open() == false)
throw std::runtime_error("[ERROR] can't open file " + filename);
linereader(fs, cb);
}
}

View File

@ -1,17 +0,0 @@
#pragma once
#include <string>
namespace utils {
// replaces all occurences of <match> in <src> with <replacement>
std::string replace(std::string src, const std::string& match,
const std::string& replacement) {
for (size_t pos = src.find(match); pos != std::string::npos;
pos = src.find(match, pos + replacement.size()))
src.erase(pos, match.length()).insert(pos, replacement);
return src;
}
}

View File

@ -1,35 +0,0 @@
#pragma once
#include <regex>
#include <vector>
namespace utils {
std::vector<std::string> split(const std::string& src,
const std::string& delimiter) {
size_t index = 0;
std::vector<std::string> res;
size_t n = src.find(delimiter, index);
while (n != std::string::npos) {
n = src.find(delimiter, index);
res.push_back(src.substr(index, n - index));
index = n + delimiter.size();
}
return res;
}
// doesn't work with gcc even though it's only c++11...
// and it's slow as hell compared to the split implementation above
// (more powerful though)
std::vector<std::string> regex_split(const std::string& input,
const std::string& regex) {
auto rgx = std::regex(regex);
std::sregex_token_iterator last, first{input.begin(), input.end(), rgx, -1};
return {first, last};
}
}

View File

@ -1,29 +0,0 @@
#pragma once
#include <x86intrin.h>
namespace sse42 {
constexpr int strcmp_mode = _SIDD_CMP_EQUAL_EACH | _SIDD_NEGATIVE_POLARITY;
constexpr unsigned CF = 0x1;
constexpr unsigned ZF = 0x40;
bool streq(const char* lhs, const char* rhs) {
int idx, eflags;
while (true) {
auto lhs_mm = _mm_loadu_si128((__m128i*)lhs);
auto rhs_mm = _mm_loadu_si128((__m128i*)rhs);
idx = _mm_cmpistri(lhs_mm, rhs_mm, strcmp_mode);
eflags = __readeflags();
if (idx != 0x10) return false;
if ((eflags & (ZF | CF)) != 0) return true;
lhs += 16;
rhs += 16;
}
}
}

View File

@ -1,12 +0,0 @@
#include "utils/string/transform.hpp"
namespace utils {
// TODO CPPCheck -> function never used
void str_tolower(std::string& s) {
// en_US.utf8 localization
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return std::tolower(c, std::locale("en_US.utf8"));
});
}
}

View File

@ -1,10 +0,0 @@
#pragma once
#include <algorithm>
#include <locale>
#include <string>
namespace utils {
void str_tolower(std::string& s);
}

View File

@ -1,22 +0,0 @@
#pragma once
#include <string>
namespace utils {
/**
* Removes whitespace characters from the start and from the end of a string.
*
* @param str string that is going to be trimmed
*
* @return trimmed string
*/
std::string trim(const std::string &str) {
size_t first = str.find_first_not_of(' ');
if (std::string::npos == first) {
return str;
}
size_t last = str.find_last_not_of(' ');
return str.substr(first, (last - first + 1));
}
}

View File

@ -1,62 +0,0 @@
#pragma once
#include <cstring>
#include <string>
#include "utils/assert.hpp"
#include "utils/total_ordering.hpp"
#include "utils/total_ordering_with.hpp"
class WeakString {
public:
constexpr WeakString() : str(nullptr), len(0) {}
WeakString(const std::string& str) : str(str.c_str()), len(str.size()) {}
WeakString(const char* str) : str(str), len(strlen(str)) {}
constexpr WeakString(const char* str, size_t len) : str(str), len(len) {}
const char& operator[](size_t idx) const {
debug_assert(idx < len, "Index not smaller than length.");
return str[idx];
}
const char& front() const {
debug_assert(len > 0, "String is empty.");
return str[0];
}
const char& back() const {
debug_assert(len > 0, "String is empty.");
return str[len - 1];
}
const char* data() const { return str; }
bool empty() const { return len == 0; }
size_t size() const { return len; }
size_t length() const { return size(); }
std::string to_string() const { return std::string(str, len); }
friend bool operator==(const WeakString& lhs, const WeakString rhs) {
// oh dear god, make this better with custom iterators
if (lhs.size() != rhs.size()) return false;
for (size_t i = 0; i < lhs.size(); ++i)
if (lhs[i] != rhs[i]) return false;
return true;
}
friend bool operator!=(const WeakString& lhs, const WeakString& rhs) {
return !(lhs == rhs);
}
private:
const char* str;
size_t len;
};

View File

@ -1,50 +0,0 @@
#pragma once
#include <iostream>
namespace utils {
/*
* Variadic argument print
*/
template <class Head>
void print_vargs(std::ostream& s, Head&& head) {
s << std::forward<Head>(head);
}
template <class Head, class... Tail>
void print_vargs(std::ostream& s, Head&& head, Tail&&... tail) {
s << std::forward<Head>(head);
print_vargs(s, std::forward<Tail>(tail)...);
}
/*
* Compile time print line.
*
* USAGE:
* RUN: utils::printer("ONE ", "TWO");
* OUTPUT: "ONE TWO\n"
*
* TODO: reimplament with C++17 fold expressions
*/
template <class... Args>
void println(Args&&... args) {
print_vargs(std::cout, std::forward<Args>(args)...);
std::cout << std::endl;
}
// value equality with any of variadic argument
// example: value == varg[0] OR value == varg[1] OR ...
template <class Value, class Head>
bool _or_vargs(Value&& value, Head&& head) {
return value == head;
}
template <class Value, class Head, class... Tail>
bool _or_vargs(Value&& value, Head&& head, Tail&&... tail) {
return value == head || _or_vargs(std::forward<Value>(value), tail...);
}
template <class Value, class... Array>
bool or_vargs(Value&& value, Array&&... array) {
return _or_vargs(std::forward<Value>(value), std::forward<Array>(array)...);
}
}

View File

@ -12,8 +12,7 @@ namespace fs = std::experimental::filesystem;
#include "stream/print_record_stream.hpp"
#include "utils/command_line/arguments.hpp"
#include "utils/file.hpp"
#include "utils/string/file.hpp"
#include "utils/string/trim.hpp"
#include "utils/string.hpp"
namespace tests {
namespace integration {
@ -55,7 +54,7 @@ auto LoadQueryHashes(Logger &log, const fs::path &path) {
// hashes calculated from all queries in queries file
QueryHashesT query_hashes;
// fill the above set
auto queries = utils::read_lines(path);
auto queries = utils::ReadLines(path);
for (auto &query : queries) {
if (query.empty()) continue;
query_hashes.insert(preprocessor.preprocess(query).hash);
@ -86,7 +85,7 @@ auto LoadQueryPlans(Logger &log, QueryEngineT &engine,
auto comment = std::string("// ");
auto query_mark = comment + std::string("Query: ");
for (auto &plan_path : plan_paths) {
auto lines = read_lines(plan_path);
auto lines = utils::ReadLines(plan_path);
// find the line with a query in order
// be able to place it in the dynamic libs container (base on query
// hash)
@ -96,7 +95,7 @@ auto LoadQueryPlans(Logger &log, QueryEngineT &engine,
auto pos = line.find(query_mark);
// if query doesn't exist pass
if (pos == std::string::npos) continue;
auto query = trim(line.substr(pos + query_mark.size()));
auto query = utils::Trim(line.substr(pos + query_mark.size()));
while (i + 1 < (int)lines.size() &&
lines[i + 1].find(comment) != std::string::npos) {
query +=
@ -131,10 +130,10 @@ auto ExecuteQueryPlans(Logger &log, QueryEngineT &engine, Dbms &dbms,
const fs::path &path, StreamT &stream) {
log.info("*** Execute the queries from the queries_file ***");
// execute all queries from queries_file
auto queries = utils::read_lines(path);
auto queries = utils::ReadLines(path);
for (auto &query : queries) {
if (query.empty()) continue;
permanent_assert(engine.Loaded(trim(query)),
permanent_assert(engine.Loaded(utils::Trim(query)),
"Implementation wasn't loaded");
// Create new db_accessor since one query is associated with one
// transaction.

View File

@ -41,14 +41,14 @@ int main(int argc, char* argv[]) {
auto comment = std::string("// ");
auto query_mark = comment + std::string("Query: ");
auto lines = read_lines(event.path);
auto lines = utils::ReadLines(event.path);
for (int i = 0; i < (int)lines.size(); ++i) {
// find query in the line
auto& line = lines[i];
auto pos = line.find(query_mark);
// if query doesn't exist pass
if (pos == std::string::npos) continue;
auto query = trim(line.substr(pos + query_mark.size()));
auto query = utils::Trim(line.substr(pos + query_mark.size()));
while (i + 1 < (int)lines.size() &&
lines[i + 1].find(comment) != std::string::npos) {
query += lines[i + 1].substr(lines[i + 1].find(comment) +

View File

@ -5,11 +5,7 @@
#include "logging/streams/stdout.hpp"
#include "query/preprocessor.hpp"
#include "utils/command_line/arguments.hpp"
#include "utils/string/file.hpp"
#include "utils/type_discovery.hpp"
#include "utils/variadic/variadic.hpp"
using utils::println;
/**
* Useful when somebody wants to get a hash for some query.
@ -32,13 +28,14 @@ int main(int argc, char **argv) {
auto preprocessed = preprocessor.preprocess(query);
// print query, stripped query, hash and variable values (propertie values)
println("Query: ", query);
println("Stripped query: ", preprocessed.query);
println("Query hash: ", preprocessed.hash);
println("Property values:");
for (int i = 0; i < preprocessed.arguments.Size(); ++i)
println(" ", preprocessed.arguments.At(i));
println("");
std::cout << fmt::format("Query: {}\n", query);
std::cout << fmt::format("Stripped query: {}\n", preprocessed.query);
std::cout << fmt::format("Query hash: {}\n", preprocessed.hash);
std::cout << fmt::format("Property values:\n");
for (int i = 0; i < static_cast<int>(preprocessed.arguments.Size()); ++i) {
fmt::format(" {}", preprocessed.arguments.At(i));
}
std::cout << std::endl;
return 0;
}

View File

@ -3,7 +3,7 @@
#include "template_engine/engine.hpp"
TEST(TemplateEngine, BasicPlaceholderReplacement) {
auto rendered = template_engine::render("{{one}} {{two}}",
auto rendered = template_engine::Render("{{one}} {{two}}",
{{"one", "two"}, {"two", "one"}});
ASSERT_EQ(rendered, "two one");