Skip to content

Commit

Permalink
feat: mustache specs
Browse files Browse the repository at this point in the history
  • Loading branch information
alandefreitas committed Sep 9, 2023
1 parent d10a921 commit f63df18
Show file tree
Hide file tree
Showing 13 changed files with 2,398 additions and 28 deletions.
3 changes: 3 additions & 0 deletions include/mrdox/Support/Handlebars.hpp
Expand Up @@ -77,6 +77,9 @@ struct HandlebarsOptions
When enabled, fields will be looked up recursively in objects
and arrays.
This mode should be used to enable complete compatibility
with Mustache templates.
*/
bool compat = false;

Expand Down
90 changes: 81 additions & 9 deletions src/lib/Support/Handlebars.cpp
Expand Up @@ -1203,11 +1203,13 @@ parseTag(
// ==============================================================
if (tagStr.starts_with('^')) {
t.type = '^';
t.type2 = '^';
tagStr.remove_prefix(1);
tagStr = trim_spaces(tagStr);
t.content = tagStr;
} else if (tagStr.starts_with("else")) {
t.type = '^';
t.type2 = 'e';
tagStr.remove_prefix(4);
tagStr = trim_spaces(tagStr);
t.content = tagStr;
Expand Down Expand Up @@ -1273,10 +1275,10 @@ parseTag(
// ==============================================================
// Check if tag is standalone
// ==============================================================
static constexpr std::array<char, 5> block_tag_types({'#', '^', '/', '>', '*'});
bool const isBlock = std::ranges::find(
block_tag_types, t.type) != block_tag_types.end();
if (isBlock)
static constexpr std::array<char, 6> standalone_tag_types({'#', '^', '/', '>', '*', '!'});
bool const checkStandalone = std::ranges::find(
standalone_tag_types, t.type) != standalone_tag_types.end();
if (checkStandalone)
{
MRDOX_ASSERT(t.buffer.data() >= context.data());
MRDOX_ASSERT(t.buffer.data() + t.buffer.size() <= context.data() + context.size());
Expand All @@ -1285,13 +1287,22 @@ parseTag(
std::string_view beforeTag = context.substr(
0, t.buffer.data() - context.data());
auto posL = beforeTag.find_last_not_of(' ');
bool const isStandaloneL =
bool isStandaloneL =
posL == std::string_view::npos || beforeTag[posL] == '\n';
if (!isStandaloneL && posL != 0)
{
isStandaloneL = beforeTag[posL - 1] == '\r' && beforeTag[posL] == '\n';
}
std::string_view afterTag = context.substr(
t.buffer.data() + t.buffer.size() - context.data());
auto posR = afterTag.find_first_not_of(' ');
bool const isStandaloneR =
bool isStandaloneR =
posR == std::string_view::npos || afterTag[posR] == '\n';
if (!isStandaloneR && posR != afterTag.size() - 1)
{
isStandaloneR = afterTag[posR] == '\r' && afterTag[posR + 1] == '\n';
}

t.isStandalone = isStandaloneL && isStandaloneR;

// Get standalone indent
Expand Down Expand Up @@ -1359,7 +1370,7 @@ render_to(
}
else if (!opt.ignoreStandalone && tag.isStandalone)
{
if (tag.type == '#' || tag.type == '^' || tag.type == '/')
if (tag.type == '#' || tag.type == '^' || tag.type == '/' || tag.type == '!')
{
beforeTag = trim_rdelimiters(beforeTag, " ");
}
Expand All @@ -1384,7 +1395,7 @@ render_to(
// ==============================================================
// Advance template text
// ==============================================================
if (tag.removeRWhitespace)
if (tag.removeRWhitespace && tag.type != '#')
{
state.templateText = trim_lspaces(state.templateText);
}
Expand Down Expand Up @@ -1829,6 +1840,32 @@ evalExpr(
// ==============================================================
if (opt.compat)
{
// Dotted names should be resolved against former resolutions
bool isDotted = isPathedValue;
std::string_view firstSeg;
if (!isDotted)
{
std::string_view expression0 = expression;
firstSeg = popFirstSegment(expression);
isDotted = !expression.empty();
expression = expression0;
}

if (isDotted)
{
if (context.kind() == dom::Kind::Object)
{
// Context has first segment of dotted object.
// -> Context has priority even if result is undefined.
auto& obj = context.getObject();
if (obj.exists(firstSeg))
{
return {r, false, false};
}
}
}

// Find in parent contexts
auto parentContexts = std::ranges::views::reverse(state.parentContext);
for (auto parentContext: parentContexts)
{
Expand Down Expand Up @@ -1926,6 +1963,10 @@ parseBlock(
{
fnBlock.remove_prefix(1);
}
else if (fnBlock.starts_with("\r\n"))
{
fnBlock.remove_prefix(2);
}
}

// ==============================================================
Expand Down Expand Up @@ -1956,7 +1997,14 @@ parseBlock(
// Update section level
// ==============================================================
if (!tag.rawBlock) {
if (curTag.type == '#' || curTag.type2 == '#') {
bool isRegularBlock = curTag.type == '#' || curTag.type2 == '#';
// Nested invert blocks are blocks considered inside the current
// block rather than a new "else" block.
// {{^bool}}A{{^bool}}B{{/bool}}C{{/bool}} -> nested
// {{^bool}}A{{else if bool}}B{{/bool}} -> not nested
bool isNestedInvert =
curTag.type == '^' && curTag.type2 == '^' && !curTag.content.empty();
if (isRegularBlock || isNestedInvert) {
// Opening a child section tag
++l;
} else if (curTag.type == '/') {
Expand Down Expand Up @@ -2084,6 +2132,10 @@ parseBlock(
{
templateText.remove_prefix(1);
}
else if (templateText.starts_with("\r\n"))
{
templateText.remove_prefix(2);
}
}

return true;
Expand Down Expand Up @@ -2115,6 +2167,22 @@ renderTag(
{
renderExpression(tag, out, context, opt, state);
}
else if ('!' == tag.type)
{
// Remove standalone whitespace
if (!opt.ignoreStandalone && tag.isStandalone)
{
state.templateText = trim_ldelimiters(state.templateText, " ");
if (state.templateText.starts_with('\n'))
{
state.templateText.remove_prefix(1);
}
else if (state.templateText.starts_with("\r\n"))
{
state.templateText.remove_prefix(2);
}
}
}
}

void
Expand Down Expand Up @@ -2623,6 +2691,10 @@ renderPartial(
{
state.templateText.remove_prefix(1);
}
else if (state.templateText.starts_with("\r\n"))
{
state.templateText.remove_prefix(2);
}
}
}

Expand Down
174 changes: 174 additions & 0 deletions src/test/lib/Support/Handlebars.cpp
Expand Up @@ -13,6 +13,9 @@
#include <mrdox/Support/Handlebars.hpp>
#include <mrdox/Support/Path.hpp>
#include <mrdox/Support/String.hpp>
#include <llvm/Support/JSON.h>
#include <llvm/Support/MemoryBuffer.h>
#include <filesystem>

namespace clang {
namespace mrdox {
Expand Down Expand Up @@ -1282,6 +1285,14 @@ whitespace_control()
ctx.set("foo", "bar");
BOOST_TEST(hbs.render(" {{~foo~}} {{foo}} {{foo}} ", ctx) == "barbar bar ");
}

// remove block right whitespace
{
std::string string = "{{#unless z ~}}\na\n{{~/unless}}\nb";
BOOST_TEST(hbs.render(string) == "ab");
string = "{{#unless z ~}}\na\n{{~/unless}}\n\nb";
BOOST_TEST(hbs.render(string) == "a\nb");
}
}

void
Expand Down Expand Up @@ -5218,10 +5229,173 @@ utils()
}
}

static
dom::Value
to_dom(llvm::json::Value& val)
{
dom::Value res;
// val is llvm::json::Object
llvm::json::Object* obj_ptr = val.getAsObject();
if (obj_ptr)
{
dom::Object obj;
auto it = obj_ptr->begin();
while (it != obj_ptr->end())
{
obj.set(it->first.str(), to_dom(it->second));
++it;
}
res = obj;
return res;
}

// val is array
llvm::json::Array* arr_ptr = val.getAsArray();
if (arr_ptr)
{
dom::Array arr;
for (auto& item: *arr_ptr)
{
arr.emplace_back(to_dom(item));
}
res = arr;
return res;
}

// val is string
std::optional<llvm::StringRef> str_opt = val.getAsString();
if (str_opt) {
return dom::Value(str_opt.value().str());
}

// val is integer
std::optional<std::int64_t> int_opt = val.getAsInteger();
if (int_opt) {
return dom::Value(int_opt.value());
}

// val is double (convert to string)
std::optional<double> num_opt = val.getAsNumber();
if (num_opt) {
std::string double_str = std::to_string(num_opt.value());
double_str.erase(double_str.find_last_not_of('0') + 1, std::string::npos);
return dom::Value(double_str);
}

// val is bool
std::optional<bool> bool_opt = val.getAsBoolean();
if (bool_opt) {
return dom::Value(bool_opt.value());
}

return res;
};

void
mustache_compat_spec()
{
// https://github.com/handlebars-lang/handlebars.js/blob/4.x/spec/spec.js
std::string_view mustache_specs_dir =
MRDOX_TEST_FILES_DIR "/handlebars/mustache/";
std::vector<std::string> spec_files;
for (auto& p: std::filesystem::directory_iterator(mustache_specs_dir))
{
if (p.is_regular_file())
{
spec_files.emplace_back(p.path().filename().string());
}
}

for (auto spec_file: spec_files) {
// Skip mustache extensions (handlebars knowingly deviates from these)
if (spec_file.starts_with('~'))
{
continue;
}

// Load JSON file
std::string spec_path = std::string(mustache_specs_dir) + std::string(spec_file);
llvm::ErrorOr<std::unique_ptr<llvm::MemoryBuffer>> FileOrErr =
llvm::MemoryBuffer::getFile(spec_path, true);
BOOST_TEST(FileOrErr);
std::unique_ptr<llvm::MemoryBuffer> Buffer = std::move(*FileOrErr);

// Parse the JSON content
llvm::Expected<llvm::json::Value> jsonObj =
llvm::json::parse(Buffer->getBuffer());
BOOST_TEST(jsonObj);
llvm::json::Value jsonData = std::move(*jsonObj);
BOOST_TEST(jsonData.getAsObject());
llvm::json::Object data = std::move(*jsonData.getAsObject());

// Iterate tests
llvm::json::Array tests = std::move(*data.get("tests")->getAsArray());
for (auto testPtr: tests) {
llvm::json::Object test = *testPtr.getAsObject();
// Skip invalid partial tests
llvm::StringRef test_name =
*test.get("name")->getAsString();
if (
// Handlebars throws if partials are not found
(spec_file == "partials.json" && test_name == "Failed Lookup") ||
// Handlebars nests the entire response from partials, not just the literals
(spec_file == "partials.json" && test_name == "Standalone Indentation"))
{
continue;
}

// Get template
std::string template_str =test.get("template")->getAsString()->str();
if (template_str.find("{{=") != std::string::npos)
{
// "{{=" not supported by handlebars
continue;
}

// Get partials
std::vector<std::pair<std::string, std::string>> partials;
llvm::json::Value* partialsPtr = test.get("partials");
llvm::json::Object* partialsObj = partialsPtr ? partialsPtr->getAsObject() : nullptr;
if (partialsObj)
{
auto it = partialsObj->begin();
bool incompatiblePartial = false;
while (it != partialsObj->end())
{
llvm::StringRef partial_string = *it->second.getAsString();
if (partial_string.find("{{=") != llvm::StringRef::npos)
{
// "{{=" not supported by handlebars
incompatiblePartial = true;
break;
}
else
{
partials.emplace_back(it->first.str(), partial_string.str());
}
++it;
}
if (incompatiblePartial) {
continue;
}
}

// Render
Handlebars hbs;
for (auto [name, partial]: partials)
{
hbs.registerPartial(name, partial);
}
dom::Value context = to_dom(*test.get("data"));
HandlebarsOptions opt;
opt.compat = true;
std::string expected = test.get("expected")->getAsString().value().str();
std::string rendered = hbs.render(template_str, context, opt);
if (!BOOST_TEST(rendered == expected)) {
return;
}
}
}
}

void
Expand Down

0 comments on commit f63df18

Please sign in to comment.