diff --git a/README.md b/README.md index 8156469..e9b9236 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,18 @@ render("{{ isArray(guests) }}", data); // "true" // Implemented type checks: isArray, isBoolean, isFloat, isInteger, isNumber, isObject, isString, ``` +### Whitespace Control + +In the default configuration, no whitespace is removed while rendering the file. To support a more readable template style, you can configure the environment to control whitespaces before and after a statement automatically. While enabling `set_trim` removes the first newline after a statement, `set_lstrip` strips tabs and spaces from the beginning of a line to the start of a block. + +```c++ +Environment env; +env.set_trim(true); +env.set_lstrip(true); +``` + +With both `trim` and `lstrip` enabled, you can put statements on their own lines. + ### Callbacks You can create your own and more complex functions with callbacks. diff --git a/single_include/inja/inja.hpp b/single_include/inja/inja.hpp index 0cb64f2..80b3d68 100644 --- a/single_include/inja/inja.hpp +++ b/single_include/inja/inja.hpp @@ -1370,6 +1370,9 @@ struct LexerConfig { std::string comment_close {"#}"}; std::string open_chars {"#{"}; + bool trim_blocks {false}; + bool lstrip_blocks {false}; + void update_open_chars() { open_chars = ""; if (open_chars.find(line_statement[0]) == std::string::npos) { @@ -1793,12 +1796,15 @@ class Lexer { // try to match one of the opening sequences, and get the close nonstd::string_view open_str = m_in.substr(m_pos); + bool must_lstrip = false; if (inja::string_view::starts_with(open_str, m_config.expression_open)) { m_state = State::ExpressionStart; } else if (inja::string_view::starts_with(open_str, m_config.statement_open)) { m_state = State::StatementStart; + must_lstrip = m_config.lstrip_blocks; } else if (inja::string_view::starts_with(open_str, m_config.comment_open)) { m_state = State::CommentStart; + must_lstrip = m_config.lstrip_blocks; } else if ((m_pos == 0 || m_in[m_pos - 1] == '\n') && inja::string_view::starts_with(open_str, m_config.line_statement)) { m_state = State::LineStart; @@ -1806,8 +1812,13 @@ class Lexer { m_pos += 1; // wasn't actually an opening sequence goto again; } - if (m_pos == m_tok_start) goto again; // don't generate empty token - return make_token(Token::Kind::Text); + + nonstd::string_view text = string_view::slice(m_in, m_tok_start, m_pos); + if (must_lstrip) + text = clear_final_line_if_whitespace(text); + + if (text.empty()) goto again; // don't generate empty token + return Token(Token::Kind::Text, text); } case State::ExpressionStart: { m_state = State::ExpressionBody; @@ -1834,7 +1845,7 @@ class Lexer { case State::LineBody: return scan_body("\n", Token::Kind::LineStatementClose); case State::StatementBody: - return scan_body(m_config.statement_close, Token::Kind::StatementClose); + return scan_body(m_config.statement_close, Token::Kind::StatementClose, m_config.trim_blocks); case State::CommentBody: { // fast-scan to comment close size_t end = m_in.substr(m_pos).find(m_config.comment_close); @@ -1845,7 +1856,10 @@ class Lexer { // return the entire comment in the close token m_state = State::Text; m_pos += end + m_config.comment_close.size(); - return make_token(Token::Kind::CommentClose); + Token tok = make_token(Token::Kind::CommentClose); + if (m_config.trim_blocks) + skip_newline(); + return tok; } } } @@ -1853,7 +1867,7 @@ class Lexer { const LexerConfig& get_config() const { return m_config; } private: - Token scan_body(nonstd::string_view close, Token::Kind closeKind) { + Token scan_body(nonstd::string_view close, Token::Kind closeKind, bool trim = false) { again: // skip whitespace (except for \n as it might be a close) if (m_tok_start >= m_in.size()) return make_token(Token::Kind::Eof); @@ -1867,7 +1881,10 @@ class Lexer { if (inja::string_view::starts_with(m_in.substr(m_tok_start), close)) { m_state = State::Text; m_pos = m_tok_start + close.size(); - return make_token(closeKind); + Token tok = make_token(closeKind); + if (trim) + skip_newline(); + return tok; } // skip \n @@ -1988,6 +2005,34 @@ class Lexer { Token make_token(Token::Kind kind) const { return Token(kind, string_view::slice(m_in, m_tok_start, m_pos)); } + + void skip_newline() { + if (m_pos < m_in.size()) { + char ch = m_in[m_pos]; + if (ch == '\n') + m_pos += 1; + else if (ch == '\r') { + m_pos += 1; + if (m_pos < m_in.size() && m_in[m_pos] == '\n') + m_pos += 1; + } + } + } + + static nonstd::string_view clear_final_line_if_whitespace(nonstd::string_view text) + { + nonstd::string_view result = text; + while (!result.empty()) { + char ch = result.back(); + if (ch == ' ' || ch == '\t') + result.remove_suffix(1); + else if (ch == '\n' || ch == '\r') + break; + else + return text; + } + return result; + } }; } @@ -3261,6 +3306,16 @@ class Environment { m_impl->lexer_config.update_open_chars(); } + /// Sets whether to remove the first newline after a block + void set_trim_blocks(bool trim_blocks) { + m_impl->lexer_config.trim_blocks = trim_blocks; + } + + /// Sets whether to strip the spaces and tabs from the start of a line to a block + void set_lstrip_blocks(bool lstrip_blocks) { + m_impl->lexer_config.lstrip_blocks = lstrip_blocks; + } + /// Sets the element notation syntax void set_element_notation(ElementNotation notation) { m_impl->parser_config.notation = notation; diff --git a/test/data/nested-whitespace/data.json b/test/data/nested-whitespace/data.json new file mode 100755 index 0000000..f7e4949 --- /dev/null +++ b/test/data/nested-whitespace/data.json @@ -0,0 +1,4 @@ +{ + "xarray": [0, 1], + "yarray": [0, 1, 2] +} \ No newline at end of file diff --git a/test/data/nested-whitespace/result.txt b/test/data/nested-whitespace/result.txt new file mode 100755 index 0000000..109ebdb --- /dev/null +++ b/test/data/nested-whitespace/result.txt @@ -0,0 +1,6 @@ +0-0 +0-1 +0-2 +1-0 +1-1 +1-2 diff --git a/test/data/nested-whitespace/template.txt b/test/data/nested-whitespace/template.txt new file mode 100755 index 0000000..254dce2 --- /dev/null +++ b/test/data/nested-whitespace/template.txt @@ -0,0 +1,3 @@ +{% for x in xarray %}{% for y in yarray %} +{{x}}-{{y}} +{% endfor %}{% endfor %} \ No newline at end of file diff --git a/test/unit-files.cpp b/test/unit-files.cpp index afd7614..0c6747d 100644 --- a/test/unit-files.cpp +++ b/test/unit-files.cpp @@ -41,6 +41,18 @@ TEST_CASE("complete-files") { } } +TEST_CASE("complete-files-whitespace-control") { + inja::Environment env {test_file_directory}; + env.set_trim_blocks(true); + env.set_lstrip_blocks(true); + + for (std::string test_name : {"nested-whitespace"}) { + SECTION(test_name) { + CHECK( env.render_file_with_json_file(test_name + "/template.txt", test_name + "/data.json") == env.load_file(test_name + "/result.txt") ); + } + } +} + TEST_CASE("global-path") { inja::Environment env {test_file_directory, "./"}; inja::Environment env_result {"./"}; diff --git a/test/unit-renderer.cpp b/test/unit-renderer.cpp index 310eb41..1243470 100644 --- a/test/unit-renderer.cpp +++ b/test/unit-renderer.cpp @@ -7,8 +7,9 @@ using json = nlohmann::json; TEST_CASE("dot-to-pointer") { std::string buffer; - CHECK( inja::convert_dot_to_json_pointer("person.names.surname", buffer) == "/person/names/surname" ); + CHECK( inja::convert_dot_to_json_pointer("test", buffer) == "/test" ); CHECK( inja::convert_dot_to_json_pointer("guests.2", buffer) == "/guests/2" ); + CHECK( inja::convert_dot_to_json_pointer("person.names.surname", buffer) == "/person/names/surname" ); } TEST_CASE("types") { @@ -172,7 +173,6 @@ TEST_CASE("functions") { SECTION("length") { CHECK( env.render("{{ length(names) }}", data) == "4" ); // Length of array CHECK( env.render("{{ length(name) }}", data) == "5" ); // Length of string - // CHECK_THROWS_WITH( env.render("{{ length(5) }}", data), "[inja.exception.json_error] [json.exception.type_error.302] type must be array, but is number" ); }