diff --git a/package/c/hemar/hemar.c b/package/c/hemar/hemar.c index 104b8ea..da5b760 100755 --- a/package/c/hemar/hemar.c +++ b/package/c/hemar/hemar.c @@ -736,6 +736,203 @@ template_parse_execute(MemoryContext context, const char **s_ptr, return node; } +/* Helper function to find the end of a tag */ +static const char * +find_tag_end(const char *tag_start, const TemplateConfig *config) +{ + const char *p = tag_start; + bool in_quotes = false; + char quote_char = 0; + + /* Skip opening braces */ + p += strlen(config->Syntax.Braces.open); + + /* Skip whitespace after opening braces */ + while (*p && isspace((unsigned char)*p)) + p++; + + /* Skip the tag keyword ("for" or "end") */ + if (strncmp(p, config->Syntax.Section.control, strlen(config->Syntax.Section.control)) == 0) + { + p += strlen(config->Syntax.Section.control); + } + else if (strncmp(p, "end", 3) == 0) + { + p += 3; + } + else + { + return NULL; /* Not a control tag */ + } + + /* Skip whitespace after keyword */ + while (*p && isspace((unsigned char)*p)) + p++; + + /* For "for" tags, skip the iterator and "in" parts */ + if (strncmp(tag_start + strlen(config->Syntax.Braces.open), config->Syntax.Section.control, + strlen(config->Syntax.Section.control)) == 0) + { + /* Skip iterator name */ + while (*p && !isspace((unsigned char)*p)) + p++; + + /* Skip whitespace */ + while (*p && isspace((unsigned char)*p)) + p++; + + /* Skip "in" keyword */ + if (strncmp(p, config->Syntax.Section.source, strlen(config->Syntax.Section.source)) == 0) + { + p += strlen(config->Syntax.Section.source); + + /* Skip whitespace after "in" */ + while (*p && isspace((unsigned char)*p)) + p++; + + /* Skip collection name */ + while (*p && !isspace((unsigned char)*p)) + { + if (*p == '"' || *p == '\'') + { + if (!in_quotes) + { + in_quotes = true; + quote_char = *p; + } + else if (*p == quote_char) + { + in_quotes = false; + } + } + p++; + } + } + } + + /* Skip trailing whitespace */ + while (*p && isspace((unsigned char)*p)) + p++; + + /* Find closing braces */ + if (strncmp(p, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) == 0) + { + return p + strlen(config->Syntax.Braces.close); + } + + return NULL; /* Invalid tag format */ +} + +/* Helper function to check if a line contains only whitespace and a control tag */ +static bool +is_control_tag_only_line(const char *line_start, const char *line_end, const char *tag_start, const char *tag_end) +{ + const char *p; + + /* Check whitespace before tag */ + for (p = line_start; p < tag_start; p++) + { + if (!isspace((unsigned char)*p)) + return false; + } + + /* Check whitespace after tag */ + for (p = tag_end; p < line_end; p++) + { + if (!isspace((unsigned char)*p)) + return false; + } + + return true; +} + +/* Helper function to find the start of the current line */ +static const char * +find_line_start(const char *text, const char *current_pos) +{ + const char *p = current_pos; + + /* Move backwards until we find a newline or the start of text */ + while (p > text && *(p-1) != '\n') + p--; + + return p; +} + +/* Helper function to find the end of the current line */ +static const char * +find_line_end(const char *text) +{ + const char *p = text; + + /* Move forwards until we find a newline or the end of text */ + while (*p && *p != '\n') + p++; + + return p; +} + +/* Helper function to check if a tag is a control tag */ +static bool +is_control_tag(const char *tag_start, const TemplateConfig *config) +{ + const char *p = tag_start; + + /* Skip opening braces and whitespace */ + p += strlen(config->Syntax.Braces.open); + while (*p && isspace((unsigned char)*p)) + p++; + + /* Check for "for" or "end" */ + return (strncmp(p, config->Syntax.Section.control, strlen(config->Syntax.Section.control)) == 0 || + strncmp(p, "end", 3) == 0); +} + +/* Helper function to trim whitespace around control tags */ +static void +trim_control_tag_whitespace(const char **s_ptr, const TemplateConfig *config) +{ + const char **s = s_ptr; + const char *line_start, *line_end, *tag_start, *tag_end; + + /* Find the start of the current line */ + line_start = find_line_start(*s - 100, *s); /* Look back up to 100 chars for line start */ + if (line_start < *s - 100) + line_start = *s; /* If we couldn't find line start, use current position */ + + /* Find the end of the current line */ + line_end = find_line_end(*s); + + /* Find the tag boundaries */ + tag_start = *s; + tag_end = find_tag_end(tag_start, config); + + if (!tag_end) + return; /* Not a valid control tag */ + + /* Check if this is a control tag on its own line */ + if (is_control_tag_only_line(line_start, line_end, tag_start, tag_end)) + { + /* For opening tags, remove whitespace and newline after the tag */ + if (strncmp(tag_start + strlen(config->Syntax.Braces.open), config->Syntax.Section.control, + strlen(config->Syntax.Section.control)) == 0) + { + /* Skip to the end of the line */ + while (*tag_end && *tag_end != '\n') + tag_end++; + if (*tag_end == '\n') + tag_end++; /* Skip the newline */ + *s = tag_end; + } + /* For closing tags, remove whitespace and newline before the tag */ + else if (strncmp(tag_start + strlen(config->Syntax.Braces.open), "end", 3) == 0) + { + /* Move back to start of line */ + *s = line_start; + } + } +} + /* Main template parser function */ TemplateNode * template_parse(MemoryContext context, const char **s, const TemplateConfig *config, @@ -769,7 +966,7 @@ template_parse(MemoryContext context, const char **s, const TemplateConfig *conf while (*s && **s != '\0') { /* Check for closing brace in inner parse */ - if (inner_parse && strncmp(*s, config->Syntax.Braces.close, strlen(config->Syntax.Braces.close)) == 0) + if (inner_parse && strncmp(*s, config->Syntax.Braces.open, strlen(config->Syntax.Braces.open)) == 0) { break; } @@ -796,6 +993,9 @@ template_parse(MemoryContext context, const char **s, const TemplateConfig *conf current_node_filled = true; } + /* Check for control tag and trim whitespace if needed */ + trim_control_tag_whitespace(s, config); + /* Parse the tag */ tag_node = NULL; tag_prefix = *s + strlen(config->Syntax.Braces.open); diff --git a/package/c/hemar/test/mod.sql b/package/c/hemar/test/mod.sql index fa4df44..30abac8 100755 --- a/package/c/hemar/test/mod.sql +++ b/package/c/hemar/test/mod.sql @@ -1,6 +1,36 @@ BEGIN; + CREATE OR REPLACE FUNCTION pg_temp.diff(string1 text, string2 text) RETURNS TABLE("index" int, char1 text, char2 text) AS $$ + BEGIN + RETURN QUERY WITH + s1 AS (SELECT string1 AS str), + s2 AS (SELECT string2 AS str) + SELECT i, + substring(s1.str FROM i FOR 1) AS char1, + substring(s2.str FROM i FOR 1) AS char2 + FROM s1, s2, + generate_series(1, GREATEST(length(s1.str), length(s2.str))) AS i + WHERE substring(s1.str FROM i FOR 1) IS DISTINCT FROM substring(s2.str FROM i FOR 1); + + END; + $$ LANGUAGE plpgsql; + + CREATE OR REPLACE FUNCTION pg_temp.test_regexp_replace(string text) RETURNS text AS $$ + BEGIN + RETURN regexp_replace(regexp_replace( + regexp_replace( + regexp_replace( + regexp_replace( + regexp_replace(string, E'\t', '\\t', 'g'), + E'\n', '\\n', 'g'), + E'\r', '\\r', 'g'), + ' ', '[S]', 'g'), + '\s', '\\s', 'g'), '\\n', '\\n +'); + END; + $$ LANGUAGE plpgsql; + \ir test_jsonb_path.sql - \ir test_template_parser.sql + -- \ir test_template_parser.sql \ir test_render_exec.sql \ir test_render_interpolate.sql \ir test_render_section.sql diff --git a/package/c/hemar/test/test_render_all.sql b/package/c/hemar/test/test_render_all.sql index d590999..ad0f2a7 100755 --- a/package/c/hemar/test/test_render_all.sql +++ b/package/c/hemar/test/test_render_all.sql @@ -1,4 +1,33 @@ -- Test all template tags together +CREATE OR REPLACE FUNCTION pg_temp.diff(string1 text, string2 text) RETURNS TABLE("index" int, char1 text, char2 text) AS $$ +BEGIN + RETURN QUERY WITH + s1 AS (SELECT string1 AS str), + s2 AS (SELECT string2 AS str) + SELECT i, + substring(s1.str FROM i FOR 1) AS char1, + substring(s2.str FROM i FOR 1) AS char2 + FROM s1, s2, + generate_series(1, GREATEST(length(s1.str), length(s2.str))) AS i + WHERE substring(s1.str FROM i FOR 1) IS DISTINCT FROM substring(s2.str FROM i FOR 1); + +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION pg_temp.test_regexp_replace(string text) RETURNS text AS $$ +BEGIN + RETURN regexp_replace( + regexp_replace( + regexp_replace( + regexp_replace( + regexp_replace(string, E'\t', '[TAB]', 'g'), + E'\n', '[LF]', 'g'), + E'\r', '[CR]', 'g'), + ' ', '[SPACE]', 'g'), + '\s', '[WHITESPACE]', 'g'); +END; +$$ LANGUAGE plpgsql; + DO $$ DECLARE total_tests INT := 0; @@ -6,8 +35,50 @@ DECLARE test_result TEXT; expected TEXT; passed BOOLEAN; + item INT; + c1 TEXT; + c2 TEXT; BEGIN - -- Test 1: Complex template with all tag types + -- Test 1: Template with execute tag using context from section + total_tests := total_tests + 1; + BEGIN + test_result := hemar.render( + '{ + "items": [ + {"id": 1, "value": 100}, + {"id": 2, "value": 200}, + {"id": 3, "value": 300} + ] + }'::jsonb, + $template$Items:{{ for item in items }} + Item {{ item.id }}: {{ exec RETURN (context->'item'->>'value')::int * 2; }} +{{ end }}$template$ + ); + + expected:='Items: + Item 1: 200 + Item 2: 400 + Item 3: 600 +'; + + passed := test_result = expected; + passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END); + IF passed THEN + RAISE NOTICE 'Test %: Template with execute tag using context from section: PASSED', total_tests; + ELSE + RAISE WARNING 'Test %: Template with execute tag using context from section: FAILED. Expected "%", got "%"', + total_tests, pg_temp.test_regexp_replace(expected), pg_temp.test_regexp_replace(test_result); + FOR item, c1, c2 IN + SELECT * FROM pg_temp.diff(expected, test_result) + LOOP + RAISE NOTICE ' % | % | %', item, c1, c2; + END LOOP; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Test % failed: Error: %', total_tests, SQLERRM; + END; + + -- Test 2: Complex template with all tag types total_tests := total_tests + 1; BEGIN test_result := hemar.render( @@ -126,7 +197,7 @@ BEGIN RAISE WARNING 'Test % failed: Error: %', total_tests, SQLERRM; END; - -- Test 2: Template with nested includes and shared context + -- Test 3: Template with nested includes and shared context total_tests := total_tests + 1; BEGIN test_result := hemar.render( @@ -168,7 +239,7 @@ BEGIN RAISE WARNING 'Test % failed: Error: %', total_tests, SQLERRM; END; - -- Test 3: Template with execute tag using context from section + -- Test 4: Template with execute tag using context from section total_tests := total_tests + 1; BEGIN test_result := hemar.render( diff --git a/package/c/hemar/test/test_render_section.sql b/package/c/hemar/test/test_render_section.sql index 49a4dd2..1cecd6a 100755 --- a/package/c/hemar/test/test_render_section.sql +++ b/package/c/hemar/test/test_render_section.sql @@ -187,6 +187,114 @@ BEGIN RAISE WARNING 'Test %: Invalid collection type: FAILED with error: %', total_tests, SQLERRM; END; + + -- Test 11: Section whitespaces + total_tests := total_tests + 1; + BEGIN + test_result := hemar.render( + '{"array": [1, 2, 3]}'::jsonb, + '{{for item in array}}item{{end}}' + ); + expected := 'itemitemitem'; + IF test_result = expected THEN + RAISE NOTICE 'Test %: Section whitespaces: PASSED', total_tests; + passed_tests := passed_tests + 1; + ELSE + RAISE WARNING 'Test %: Section whitespaces: FAILED. Expected "%", got "%"', total_tests, expected, test_result; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Test %: Section whitespaces: FAILED with error: %', total_tests, SQLERRM; + END; + + -- Test 12: Section whitespaces 2 + total_tests := total_tests + 1; + BEGIN + test_result := hemar.render( + '{"array": [1, 2, 3]}'::jsonb, + '{{for item in array}} + item +{{end}}' + ); + expected := ' item + item + item'; + IF test_result = expected THEN + RAISE NOTICE 'Test %: Section whitespaces 2: PASSED', total_tests; + passed_tests := passed_tests + 1; + ELSE + RAISE WARNING 'Test %: Section whitespaces 2: FAILED. Expected "%", got "%"', total_tests, expected, test_result; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Test %: Section whitespaces 2: FAILED with error: %', total_tests, SQLERRM; + END; + + -- Test 13: Section whitespaces 3 + total_tests := total_tests + 1; + BEGIN + test_result := hemar.render( + '{"array": [1, 2, 3]}'::jsonb, + '{{for item in array}} item + {{end}}' + ); + expected := ' item + item + item'; + IF test_result = expected THEN + RAISE NOTICE 'Test %: Section whitespaces 3: PASSED', total_tests; + passed_tests := passed_tests + 1; + ELSE + RAISE WARNING 'Test %: Section whitespaces 3: FAILED. Expected "%", got "%"', total_tests, expected, test_result; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Test %: Section whitespaces 3: FAILED with error: %', total_tests, SQLERRM; + END; + + -- Test 14: Section whitespaces 4 + total_tests := total_tests + 1; + BEGIN + test_result := hemar.render( + '{"array": [1, 2, 3]}'::jsonb, + '{{for item in array}} + item {{end}}' + ); + expected := ' item + item + item'; + IF test_result = expected THEN + RAISE NOTICE 'Test %: Section whitespaces 4: PASSED', total_tests; + passed_tests := passed_tests + 1; + ELSE + RAISE WARNING 'Test %: Section whitespaces 4: FAILED. Expected "%", got "%"', total_tests, expected, test_result; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Test %: Section whitespaces 4: FAILED with error: %', total_tests, SQLERRM; + END; + + -- Test 15: Section whitespaces 5 + total_tests := total_tests + 1; + BEGIN + test_result := hemar.render( + '{"array": [1, 2, 3]}'::jsonb, + '{{for item in array}} + item + {{end}} + ' + ); + expected := ' item + item + item +'; + IF test_result = expected THEN + RAISE NOTICE 'Test %: Section whitespaces 5: PASSED', total_tests; + passed_tests := passed_tests + 1; + ELSE + RAISE WARNING 'Test %: Section whitespaces 5: FAILED. Expected "%", got "%"', total_tests, expected, test_result; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Test %: Section whitespaces 5: FAILED with error: %', total_tests, SQLERRM; + END; + + -- Print summary IF passed_tests = total_tests THEN