diff --git a/package/c/hemar/hemar.c b/package/c/hemar/hemar.c index a6a537a..33b40de 100755 --- a/package/c/hemar/hemar.c +++ b/package/c/hemar/hemar.c @@ -168,12 +168,9 @@ template_error_to_string(TemplateErrorCode code, TemplateConfig *config) strcat(message, "` keyword in section block"); return message; case TEMPLATE_ERROR_NO_BEGIN_IN_SECTION: - message = "Not found `"; - strcat(message, config->Syntax.Section.begin); - strcat(message, "` keyword in section block"); - return message; + return "Missing end tag for section"; case TEMPLATE_ERROR_UNEXPECTED_SECTION_END: - return "Unexpected section end"; + return "Unexpected section end or missing end tag"; case TEMPLATE_ERROR_NESTED_INCLUDE: return "Nested include"; case TEMPLATE_ERROR_NESTED_EXECUTE: @@ -201,7 +198,7 @@ template_default_config(MemoryContext context) config.Syntax.Braces.close = "}}"; config.Syntax.Section.control = "for "; config.Syntax.Section.source = "in "; - config.Syntax.Section.begin = "do "; + config.Syntax.Section.begin = ""; /* No longer used, but keep for backward compatibility */ config.Syntax.Interpolate.invoke = ""; config.Syntax.Include.invoke = "include "; config.Syntax.Execute.invoke = "exec "; @@ -336,6 +333,11 @@ template_parse_interpolation(MemoryContext context, const char **s_ptr, } key_len = *s - key_start; + + /* Trim trailing whitespace if it was terminated by whitespace */ + while (key_len > 0 && isspace((unsigned char)key_start[key_len - 1])) + key_len--; + node->value->interpolate.key = MemoryContextStrdup(context, pnstrdup(key_start, key_len)); elog(DEBUG1, "TPI: Parsing: %s", node->value->interpolate.key); @@ -423,7 +425,7 @@ template_parse_section(MemoryContext context, const char **s_ptr, while (**s != '\0') { if (isspace((unsigned char)**s) || - strncmp(*s, config->Syntax.Section.begin, strlen(config->Syntax.Section.begin)) == 0) + strncmp(*s, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) == 0) break; if (strncmp(*s, config->Syntax.Braces.open, strlen(config->Syntax.Braces.open)) == 0) @@ -434,14 +436,6 @@ template_parse_section(MemoryContext context, const char **s_ptr, return NULL; } - if (strncmp(*s, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) == 0) - { - if (error_code) - *error_code = TEMPLATE_ERROR_NO_BEGIN_IN_SECTION; - template_free_node(node); - return NULL; - } - (*s)++; } @@ -449,60 +443,72 @@ template_parse_section(MemoryContext context, const char **s_ptr, node->value->section.collection = MemoryContextStrdup(context, pnstrdup(collection_start, collection_len)); elog(DEBUG1, "TPS: Parsed section collection: %s", node->value->section.collection); - /* Check for 'do' keyword */ + /* Skip whitespace before closing brace */ *s = skip_whitespace(*s); - // TODO: why check begin second time, first in while - if (strncmp(*s, config->Syntax.Section.begin, strlen(config->Syntax.Section.begin)) != 0) + + /* Check for closing brace */ + if (strncmp(*s, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) != 0) { if (error_code) - *error_code = TEMPLATE_UNEXPECTED_OPEN_BRACES_AFFTER_SECTION_SOURCE; + *error_code = TEMPLATE_ERROR_UNEXPECTED_SECTION_END; template_free_node(node); return NULL; } - *s += strlen(config->Syntax.Section.begin); - *s = skip_whitespace(*s); + /* Move past the closing brace */ + *s += strlen(config->Syntax.Braces.close); - /* Check if there's a closing brace right after 'do' */ - if (strncmp(*s, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) == 0) - { - /* Empty section body */ - elog(DEBUG1, "TPS: Parsed empty section body"); - *s_ptr = *s + strlen(config->Syntax.Braces.close); - node->value->section.body = NULL; - return node; - } - - /* Parse the body as a normal template */ + /* Start of the body content */ const char *body_start = *s; - const char *original_s = *s; - - int inner_braces_opened_count = 0; + const char *end_tag_start = NULL; + int nesting_level = 1; /* Start at 1, our current section */ - /* Find the end of the section */ - while (**s) { - // s = {% a %} %} - elog(DEBUG2, "TPS: Step, braces opened: %d, s: %s", inner_braces_opened_count, *s); - if (strncmp(*s, config->Syntax.Braces.open, strlen(config->Syntax.Braces.open)) == 0) { - elog(DEBUG2, "TPS: inner_braces_opened_count++"); - inner_braces_opened_count++; - } - if (strncmp(*s, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) == 0) { - if (inner_braces_opened_count > 0) { - elog(DEBUG2, "TPS: inner_braces_opened_count--"); - inner_braces_opened_count--; - } - else { - elog(DEBUG2, "TPS: exit"); - break; - } - } + elog(DEBUG1, "TPS: Starting to parse section body at position: %s", body_start); + elog(DEBUG1, "TPS: Looking for end tag"); + + /* Find the matching end tag, accounting for nested sections */ + while (**s != '\0') + { + if (strncmp(*s, config->Syntax.Braces.open, strlen(config->Syntax.Braces.open)) == 0) + { + /* We found an opening brace */ + const char *tag_start = *s + strlen(config->Syntax.Braces.open); + const char *tag_ptr = tag_start; + + /* Skip whitespace after opening brace */ + while (*tag_ptr && isspace((unsigned char)*tag_ptr)) + tag_ptr++; + + /* Check if this is a new section tag */ + if (strncmp(tag_ptr, config->Syntax.Section.control, strlen(config->Syntax.Section.control)) == 0) + { + /* Found a nested section, increase nesting level */ + nesting_level++; + elog(DEBUG1, "TPS: Found nested section, nesting level: %d, at position: %s", nesting_level, *s); + } + /* Check if this is an end tag */ + else if (strncmp(tag_ptr, "end", 3) == 0) + { + /* Found an end tag, decrease nesting level */ + nesting_level--; + elog(DEBUG1, "TPS: Found end tag, nesting level: %d, at position: %s", nesting_level, *s); + + if (nesting_level == 0) + { + /* This is our matching end tag */ + end_tag_start = *s; + break; + } + } + } + (*s)++; } - if (!**s) + /* Check if we found a matching end tag */ + if (nesting_level > 0 || !end_tag_start) { - /* Unexpected end of string before closing brace */ + elog(WARNING, "TPS: No matching end tag found for section, nesting level: %d", nesting_level); if (error_code) *error_code = TEMPLATE_ERROR_UNEXPECTED_SECTION_END; template_free_node(node); @@ -510,7 +516,7 @@ template_parse_section(MemoryContext context, const char **s_ptr, } /* Extract the body content */ - size_t body_len = *s - body_start; + size_t body_len = end_tag_start - body_start; char *body_content = pnstrdup(body_start, body_len); elog(DEBUG1, "TPS: Section body content: %s", body_content); @@ -530,8 +536,26 @@ template_parse_section(MemoryContext context, const char **s_ptr, pfree(body_content); node->value->section.body = body_node; + /* Skip past the end tag */ + *s = end_tag_start; + *s += strlen(config->Syntax.Braces.open); + *s = skip_whitespace(*s); + *s += 3; /* Skip "end" */ + *s = skip_whitespace(*s); + + /* Check for closing brace of end tag */ + if (strncmp(*s, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) != 0) + { + elog(WARNING, "TPS: No closing brace for end tag at position: %s", *s); + if (error_code) + *error_code = TEMPLATE_ERROR_UNEXPECTED_SECTION_END; + template_free_node(node); + return NULL; + } + /* Set the pointer to after the closing brace */ *s_ptr = *s + strlen(config->Syntax.Braces.close); + elog(DEBUG1, "TPS: Successfully parsed section, returning at position: %s", *s_ptr); return node; } @@ -574,6 +598,11 @@ template_parse_include(MemoryContext context, const char **s_ptr, } include_len = *s - include_start; + + /* Trim trailing whitespace if it was terminated by whitespace */ + while (include_len > 0 && isspace((unsigned char)include_start[include_len - 1])) + include_len--; + node->value->include.key = MemoryContextStrdup(context, pnstrdup(include_start, include_len)); *s = skip_whitespace(*s); @@ -612,23 +641,55 @@ template_parse_execute(MemoryContext context, const char **s_ptr, *s = skip_whitespace(*s); code_start = *s; + /* Track quote state to handle SQL content properly */ + bool in_single_quote = false; + bool in_double_quote = false; + bool escaped = false; + while (**s != '\0') { - if (strncmp(*s, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) == 0) - break; - - if (strncmp(*s, config->Syntax.Braces.open, strlen(config->Syntax.Braces.open)) == 0) - { - if (error_code) - *error_code = TEMPLATE_ERROR_NESTED_EXECUTE; - template_free_node(node); - return NULL; + /* Handle escaping */ + if (**s == '\\') { + escaped = !escaped; + (*s)++; + continue; } + /* Handle quotes - only toggle quote state if not escaped */ + if (!escaped) { + if (**s == '\'') { + in_single_quote = !in_single_quote; + } else if (**s == '"') { + in_double_quote = !in_double_quote; + } + } + + /* Only check for closing braces when not inside quotes */ + if (!in_single_quote && !in_double_quote) { + if (strncmp(*s, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) == 0) { + break; + } + + /* Check for nested opening braces */ + if (strncmp(*s, config->Syntax.Braces.open, strlen(config->Syntax.Braces.open)) == 0) { + if (error_code) + *error_code = TEMPLATE_ERROR_NESTED_EXECUTE; + template_free_node(node); + return NULL; + } + } + + /* Reset escaped flag after processing a character */ + escaped = false; (*s)++; } code_len = *s - code_start; + + /* Trim trailing whitespace */ + while (code_len > 0 && isspace((unsigned char)code_start[code_len - 1])) + code_len--; + node->value->execute.code = MemoryContextStrdup(context, pnstrdup(code_start, code_len)); /* Check for closing brace */ @@ -710,25 +771,52 @@ template_parse(MemoryContext context, const char **s, const TemplateConfig *conf tag_prefix = *s + strlen(config->Syntax.Braces.open); tag_prefix = skip_whitespace(tag_prefix); - /* Determine tag type by prefix */ - if (strncmp(tag_prefix, config->Syntax.Section.control, strlen(config->Syntax.Section.control)) == 0) - { + /* Find the longest matching prefix to determine tag type */ + typedef struct { + const char *prefix; + int tag_type; + } PrefixMatch; + + PrefixMatch matches[] = { + {config->Syntax.Section.control, 1}, + {config->Syntax.Include.invoke, 2}, + {config->Syntax.Execute.invoke, 3}, + {config->Syntax.Interpolate.invoke, 4} + }; + + int matched_type = 0; + size_t max_length = 0; + + /* Find longest match (in case when one prefix is part of another) */ + for (int i = 0; i < 4; i++) { + if (strncmp(tag_prefix, matches[i].prefix, strlen(matches[i].prefix)) == 0) { + /* >= because one of the prefixes may be empty */ + if (strlen(matches[i].prefix) >= max_length) { + max_length = strlen(matches[i].prefix); + matched_type = matches[i].tag_type; + } + } + } + + /* Choose the tag parser based on the matched type */ + if (matched_type == 1) { + /* Section tag */ + elog(LOG, "TPE: Parsing section tag at position: %.50s", *s); tag_node = template_parse_section(context, s, config, error_code); - } - else if (strncmp(tag_prefix, config->Syntax.Include.invoke, strlen(config->Syntax.Include.invoke)) == 0) - { + } else if (matched_type == 2) { + /* Include tag */ + elog(LOG, "TPE: Parsing include tag at position: %.50s", *s); tag_node = template_parse_include(context, s, config, error_code); - } - else if (strncmp(tag_prefix, config->Syntax.Execute.invoke, strlen(config->Syntax.Execute.invoke)) == 0) - { + } else if (matched_type == 3) { + /* Execute tag */ + elog(LOG, "TPE: Parsing include tag at position: %.50s", *s); tag_node = template_parse_execute(context, s, config, error_code); - } - else if (strncmp(tag_prefix, config->Syntax.Interpolate.invoke, strlen(config->Syntax.Interpolate.invoke)) == 0) - { + } else if (matched_type == 4) { + /* Interpolation tag */ + elog(LOG, "TPE: Parsing interpolation tag at position: %.50s", *s); tag_node = template_parse_interpolation(context, s, config, error_code); - } - else - { + } else { + /* Unknown tag type */ if (error_code) *error_code = TEMPLATE_ERROR_UNKNOWN_TAG; template_free_node(root); diff --git a/package/c/hemar/test/test_template_parser.sql b/package/c/hemar/test/test_template_parser.sql index 8d83f44..88606f6 100755 --- a/package/c/hemar/test/test_template_parser.sql +++ b/package/c/hemar/test/test_template_parser.sql @@ -1,405 +1,443 @@ --- Test file for hemar template parser --- Run with: psql -f test_template_parser.sql - --- Load extension if not already loaded --- CREATE EXTENSION IF NOT EXISTS hemar; - --- Create test function to validate template parsing -CREATE OR REPLACE FUNCTION test_template_parse(template_text text, expected_structure text) RETURNS boolean AS $$ -DECLARE - parsed_result text; - passed boolean; -BEGIN - BEGIN - parsed_result := hemar.parse(template_text); - passed := position(expected_structure in parsed_result) > 0; - EXCEPTION - WHEN OTHERS THEN - passed := false; - END; - - IF NOT passed THEN - RAISE WARNING 'Template parsing test failed!'; - RAISE WARNING 'Template: %', template_text; - RAISE WARNING 'Expected to find: %', expected_structure; - RAISE WARNING 'Actual result: %', parsed_result; - END IF; - - RETURN passed; -END; -$$ LANGUAGE plpgsql; - --- Run the tests -DO $$ -DECLARE - total_tests integer := 0; - passed_tests integer := 0; - result boolean; -BEGIN - RAISE NOTICE 'Starting template parser tests...'; - - -- Test 1: Simple interpolation - total_tests := total_tests + 1; - result := test_template_parse( - '{{ simple_var }}', - 'INTERPOLATE: "simple_var"' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Simple interpolation - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Simple interpolation - FAILED', total_tests; - END IF; - - -- Test 2: Interpolation with surrounding text - total_tests := total_tests + 1; - result := test_template_parse( - 'Hello, {{ name }}!', - 'TEXT: "Hello, " -INTERPOLATE: "name" -TEXT: "!"' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Interpolation with surrounding text - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Interpolation with surrounding text - FAILED', total_tests; - END IF; - - -- Test 3: Simple section (for loop) - total_tests := total_tests + 1; - result := test_template_parse( - '{{ for item in items do }}{{ item }}{{ end }}', - 'SECTION: iterator="item", collection="items"' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Simple section (for loop) - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Simple section (for loop) - FAILED', total_tests; - END IF; - - -- Test 4: Section with nested interpolation - total_tests := total_tests + 1; - result := test_template_parse( - '{{ for item in items do }}Name: {{ item.name }}{{ end }}', - 'SECTION: iterator="item", collection="items" - TEXT: "Name: " - INTERPOLATE: "item.name"' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Section with nested interpolation - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Section with nested interpolation - FAILED', total_tests; - END IF; - - -- Test 5: Nested sections - total_tests := total_tests + 1; - result := test_template_parse( - '{{ for item in items do }}{{ for subitem in item.subitems do }}{{ subitem }}{{ end }}{{ end }}', - 'SECTION: iterator="item", collection="items" - SECTION: iterator="subitem", collection="item.subitems" - INTERPOLATE: "subitem"' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Nested sections - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Nested sections - FAILED', total_tests; - END IF; - - -- Test 6: Include tag - total_tests := total_tests + 1; - result := test_template_parse( - '{{ include template_name }}', - 'INCLUDE: "template_name"' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Include tag - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Include tag - FAILED', total_tests; - END IF; - - -- Test 7: Execute tag - total_tests := total_tests + 1; - result := test_template_parse( - '{{ exec RETURN my_function(arg1, arg2) }}', - 'EXECUTE: "RETURN my_function(arg1, arg2)"' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Execute tag - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Execute tag - FAILED', total_tests; - END IF; - - -- Test 8: Complex mixed template - total_tests := total_tests + 1; - result := test_template_parse( - '
{{ item.name }}
{{ include item.template }}{{ end }}" - INTERPOLATE: "item.name" - TEXT: "
" - INCLUDE: "item.template" -TEXT: "{{ item.description }}
- {{ include item.footer }} - {{ end }}', - 'SECTION: iterator="item", collection="items" - TEXT: " -" - INTERPOLATE: "item.description" - TEXT: "
- " - INCLUDE: "item.footer" - TEXT: " - "' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Section with multiple nested elements - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Section with multiple nested elements - FAILED', total_tests; - END IF; - - -- Test 13: Empty template - total_tests := total_tests + 1; - result := test_template_parse( - '', - 'TEXT: ""' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Empty template - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Empty template - FAILED', total_tests; - END IF; - - -- Test 14: Just text, no tags - total_tests := total_tests + 1; - result := test_template_parse( - 'Just plain text, no tags here.', - 'TEXT: "Just plain text, no tags here."' - ); - IF result THEN - passed_tests := passed_tests + 1; - RAISE NOTICE 'Test %: Just text, no tags - PASSED', total_tests; - ELSE - RAISE WARNING 'Test %: Just text, no tags - FAILED', total_tests; - END IF; - - -- Test 15: Complex example from documentation - total_tests := total_tests + 1; - result := test_template_parse( - '{{ item.name }}
{{ include item.template }}{{ end }}" + INTERPOLATE: "item.name" + TEXT: "
" + INCLUDE: "item.template" +TEXT: "{{ item.description }}
+ {{ include item.footer }} + {{ end }}$hemar12$, + $expected12$SECTION: iterator="item", collection="items" + TEXT: " +" + INTERPOLATE: "item.description" + TEXT: "
+ " + INCLUDE: "item.footer" + TEXT: " + "$expected12$ + ); + IF result THEN + passed_tests := passed_tests + 1; + RAISE NOTICE 'Test %: Section with multiple nested elements - PASSED', total_tests; + ELSE + RAISE WARNING 'Test %: Section with multiple nested elements - FAILED', total_tests; + END IF; + + -- Test 13: Empty template + total_tests := total_tests + 1; + result := test_template_parse( + '', + 'TEXT: ""' + ); + IF result THEN + passed_tests := passed_tests + 1; + RAISE NOTICE 'Test %: Empty template - PASSED', total_tests; + ELSE + RAISE WARNING 'Test %: Empty template - FAILED', total_tests; + END IF; + + -- Test 14: Just text, no tags + total_tests := total_tests + 1; + result := test_template_parse( + $hemar14$Just plain text, no tags here.$hemar14$, + $expected14$TEXT: "Just plain text, no tags here."$expected14$ + ); + IF result THEN + passed_tests := passed_tests + 1; + RAISE NOTICE 'Test %: Just text, no tags - PASSED', total_tests; + ELSE + RAISE WARNING 'Test %: Just text, no tags - FAILED', total_tests; + END IF; + + -- Test 15: Complex example from documentation + total_tests := total_tests + 1; + result := test_template_parse( + $template15$