Skip to content

Commit

Permalink
refactor string templating to f3d::utils (#1744)
Browse files Browse the repository at this point in the history
Move the core of the variable substitution functionality used to generate screenshot filenames into `f3d::utils`.
This will allow it to be tested independently and possibly reused in other parts of the code.
  • Loading branch information
snoyer authored Dec 6, 2024
1 parent 1d2f6b0 commit 3d30a5f
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 74 deletions.
107 changes: 33 additions & 74 deletions application/F3DStarter.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
#include "interactor.h"
#include "log.h"
#include "options.h"
#include "utils.h"
#include "window.h"

#include <algorithm>
Expand Down Expand Up @@ -262,8 +263,8 @@ class F3DStarter::F3DInternals
fs::path applyFilenameTemplate(const std::string& templateString)
{
constexpr size_t maxNumberingAttempts = 1000000;
const std::regex numberingRe("\\{(n:?([0-9]*))\\}");
const std::regex dateRe("date:?([A-Za-z%]*)");
const std::regex numberingRe("(n:?(.*))");
const std::regex dateRe("date:?(.*)");

/* Return a file related string depending on the currently loaded files, or the empty string if
* a single file is loaded */
Expand Down Expand Up @@ -333,112 +334,70 @@ class F3DStarter::F3DInternals
fmt = "%Y%m%d";
}
std::time_t t = std::time(nullptr);
std::stringstream joined;
joined << std::put_time(std::localtime(&t), fmt.c_str());
return joined.str();
}
throw std::out_of_range(var);
};

/* process template as tokens, keeping track of whether they've been
* substituted or left untouched */
const auto substituteVariables = [&]()
{
const std::string varName = "[\\w_.%:-]+";
const std::string escapedVar = "(\\{(\\{" + varName + "\\})\\})";
const std::string substVar = "(\\{(" + varName + ")\\})";
const std::regex escapedVarRe(escapedVar);
const std::regex substVarRe(substVar);

std::vector<std::pair<std::string, bool>> fragments;
const auto callback = [&](const std::string& m)
{
if (std::regex_match(m, escapedVarRe))
{
fragments.emplace_back(std::regex_replace(m, escapedVarRe, "$2"), true);
}
else if (std::regex_match(m, substVarRe))
{
try
{
fragments.emplace_back(variableLookup(std::regex_replace(m, substVarRe, "$2")), true);
}
catch (std::out_of_range&)
{
fragments.emplace_back(m, false);
}
}
else
std::stringstream ss;
ss << std::put_time(std::localtime(&t), fmt.c_str());
std::string formatted = ss.str();
if (formatted == fmt)
{
fragments.emplace_back(m, false);
f3d::log::warn("invalid date format for \"", var, "\"");
}
};

const std::regex re(escapedVar + "|" + substVar);
std::sregex_token_iterator begin(templateString.begin(), templateString.end(), re, { -1, 0 });
std::for_each(begin, std::sregex_token_iterator(), callback);

return fragments;
return formatted;
}
throw f3d::utils::string_template::lookup_error(var);
};

const auto fragments = substituteVariables();
f3d::utils::string_template stringTemplate(templateString);
stringTemplate.substitute(variableLookup);

/* check the non-substituted fragments for numbering variables */
const auto hasNumbering = [&]()
{
for (const auto& [fragment, processed] : fragments)
for (const auto& variable : stringTemplate.variables())
{
if (!processed && std::regex_search(fragment, numberingRe))
if (std::regex_search(variable, numberingRe))
{
return true;
}
}
return false;
};

/* just join and return if there's no numbering to be done */
/* return if there's no numbering to be done */
if (!hasNumbering())
{
std::stringstream joined;
for (const auto& fragment : fragments)
{
joined << fragment.first;
}
return { joined.str() };
return { stringTemplate.str() };
}

/* apply numbering in the non-substituted fragments and join */
const auto applyNumbering = [&](const size_t i)
const auto numberingLookup = [&](const size_t number)
{
std::stringstream joined;
for (const auto& [fragment, processed] : fragments)
return [&numberingRe, number](const std::string& var)
{
if (!processed && std::regex_match(fragment, numberingRe))
if (std::regex_match(var, numberingRe))
{
std::stringstream formattedNumber;
try
{
const std::string fmt = std::regex_replace(fragment, numberingRe, "$2");
formattedNumber << std::setfill('0') << std::setw(std::stoi(fmt)) << i;
const std::string fmt = std::regex_replace(var, numberingRe, "$2");
formattedNumber << std::setfill('0') << std::setw(std::stoi(fmt)) << number;
}
catch (std::invalid_argument&)
{
formattedNumber << std::setw(0) << i;
if (number == 1) /* avoid spamming the log */
{
f3d::log::warn("ignoring invalid number format for \"", var, "\"");
}
formattedNumber << std::setw(0) << number;
}
joined << std::regex_replace(fragment, numberingRe, formattedNumber.str());
return std::regex_replace(var, numberingRe, formattedNumber.str());
}
else
{
joined << fragment;
}
}
return joined.str();
throw f3d::utils::string_template::lookup_error(var);
};
};

/* apply incrementing numbering until file doesn't exist already */
/* try substituting incrementing number until file doesn't exist already */
for (size_t i = 1; i <= maxNumberingAttempts; ++i)
{
const std::string candidate = applyNumbering(i);
const std::string candidate =
f3d::utils::string_template(stringTemplate).substitute(numberingLookup(i)).str();
if (!fs::exists(candidate))
{
return { candidate };
Expand Down
1 change: 1 addition & 0 deletions application/testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ f3d_ss_test(NAME Model TEMPLATE ${_screenshot_dir}/{model}_{model.ext}_{model_ex
f3d_ss_test(NAME ModelN1 TEMPLATE ${_screenshot_dir}/{model}_{n}_{n:2}.png EXPECTED ${_screenshot_dir}/suzanne_1_01.png)
f3d_ss_test(NAME ModelN2 TEMPLATE ${_screenshot_dir}/{model}_{n}_{n:2}.png EXPECTED ${_screenshot_dir}/suzanne_2_02.png DEPENDS TestScreenshotModelN1)
f3d_ss_template_test(NAME Date TEMPLATE ${_screenshot_dir}/{model}_{date}_{date:%Y}.png EXPECTED_REGEX suzanne_[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9]\.png)
f3d_ss_template_test(NAME InvalidFormats TEMPLATE ${_screenshot_dir}/{model}_{date:blah}_{n:blah}_{unknown}.png EXPECTED_REGEX suzanne_blah_1_\{unknown\}\.png)
f3d_ss_test(NAME Esc TEMPLATE ${_screenshot_dir}/{model}_{{model}}_{}.png EXPECTED ${_screenshot_dir}/suzanne_{model}_{}.png)
f3d_ss_test(NAME Minimal MINIMAL TEMPLATE ${_screenshot_dir}/minimal.png EXPECTED ${_screenshot_dir}/minimal.png)

Expand Down
150 changes: 150 additions & 0 deletions library/public/utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
#include "exception.h"
#include "export.h"

#include <map>
#include <regex>
#include <sstream>
#include <string>
#include <vector>

Expand Down Expand Up @@ -59,7 +62,154 @@ class F3D_EXPORT utils
{
explicit tokenize_exception(const std::string& what = "");
};

/** String template allowing substitution of variables enclosed in curly braces.
```
string_template("{greeting} {name}!")
.substitute({ { "greeting", "hello" }, { "name", "World" } })
.str() == "hello World!"
```
*/
class string_template
{
std::vector<std::pair<std::string, bool>> fragments;

public:
explicit string_template(const std::string& templateString);

/** Substitute variables based on a `std::string(const std::string&)` function.
* Variables for which the function throws a `string_template::lookup_error` exception
* are left untouched.
*/
template<typename F>
string_template& substitute(F lookup);

/** Substitute variables based on a map.
* Variables for which the map does not contain a key are left untouched.
*/
string_template& substitute(const std::map<std::string, std::string>& lookup);

std::string str() const;

/** List the remaining un-substituted variables. */
std::vector<std::string> variables() const;

/**
* Exception to be thrown by substitution functions to let untouched variables through.
*/
struct lookup_error : public std::out_of_range
{
explicit lookup_error(const std::string& what = "")
: std::out_of_range(what)
{
}
};
};
};

//------------------------------------------------------------------------------
inline utils::string_template::string_template(const std::string& templateString)
{
const std::string varName = "[\\w_.%:-]+";
const std::string escapedVar = "(\\{(\\{" + varName + "\\})\\})";
const std::string substVar = "(\\{(" + varName + ")\\})";
const std::regex escapedVarRe(escapedVar);
const std::regex substVarRe(substVar);

const auto callback = [&](const std::string& m)
{
if (std::regex_match(m, escapedVarRe))
{
this->fragments.emplace_back(std::regex_replace(m, escapedVarRe, "$2"), false);
}
else if (std::regex_match(m, substVarRe))
{
this->fragments.emplace_back(std::regex_replace(m, substVarRe, "$2"), true);
}
else
{
this->fragments.emplace_back(m, false);
}
};

const std::regex re(escapedVar + "|" + substVar);
std::sregex_token_iterator begin(templateString.begin(), templateString.end(), re, { -1, 0 });
std::for_each(begin, std::sregex_token_iterator(), callback);
}

//------------------------------------------------------------------------------
template<typename F>
utils::string_template& utils::string_template::substitute(F lookup)
{
for (auto& [fragment, isVariable] : this->fragments)
{
if (isVariable)
{
try
{
fragment = lookup(fragment);
isVariable = false;
}
catch (const lookup_error&)
{
/* leave variable as is */
}
}
}
return *this;
}

//------------------------------------------------------------------------------
inline utils::string_template& utils::string_template::substitute(
const std::map<std::string, std::string>& lookup)
{
return this->substitute(
[&](const std::string& key)
{
try
{
return lookup.at(key);
}
catch (const std::out_of_range&)
{
throw lookup_error(key);
}
});
}

//------------------------------------------------------------------------------
inline std::string utils::string_template::str() const
{
std::ostringstream ss;
// cppcheck-suppress unassignedVariable
// (false positive, fixed in cppcheck 2.8)
for (const auto& [fragment, isVariable] : this->fragments)
{
if (isVariable)
{
ss << "{" << fragment << "}";
}
else
{
ss << fragment;
}
}
return ss.str();
}

//------------------------------------------------------------------------------
inline std::vector<std::string> utils::string_template::variables() const
{
std::vector<std::string> variables;
for (const auto& [fragment, isVariable] : this->fragments)
{
if (isVariable)
{
variables.emplace_back(fragment);
}
}
return variables;
}
}

#endif
33 changes: 33 additions & 0 deletions library/testing/TestSDKUtils.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,38 @@ int TestSDKUtils(int argc, char* argv[])
test.expect<f3d::utils::tokenize_exception>("tokenize_exception with unfinishied escape",
[&]() { f3d::utils::tokenize(R"(set render.hdri.file file path back\)"); });

//

test("string_template: basic substitution",
f3d::utils::string_template("{greeting} {name}!")
.substitute({ { "greeting", "hello" }, { "name", "World" } })
.str(),
"hello World!");

test("string_template: partial substitution",
f3d::utils::string_template("{greeting} {name}!").substitute({ { "greeting", "hello" } }).str(),
"hello {name}!");

test("string_template: multi-step substitution",
f3d::utils::string_template("{greeting} {name}!")
.substitute({ { "greeting", "hello" } })
.substitute({ { "name", "World" } })
.str(),
"hello World!");

test("string_template: escaped variable substitution",
f3d::utils::string_template("{greeting} {{name}}!")
.substitute({ { "greeting", "hello" } })
.substitute({ { "name", "World" } })
.str(),
"hello {name}!");

test("string_template: non-recursive substitution",
f3d::utils::string_template("{greeting} {name}!")
.substitute({ { "greeting", "hello" }, { "name", "{foo}" } })
.substitute({ { "foo", "bar" } })
.str(),
"hello {foo}!");

return test.result();
}

0 comments on commit 3d30a5f

Please sign in to comment.