/* * MIT License * * Copyright (c) 2026 dev@ptrace.dev * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * */ #import "Basic"; #import "File"; #import "String"; #import "File_Utilities"; #import "Hash_Table"; #import "Sort"; #import "Math"; Exit_Codes :: enum s32 #specified { OK :: 0; ERR_FILE :: 1; ERR_TOKENIZER :: 2; ERR_ACTION :: 3; ERR_REPLACE :: 4; } Action :: struct { id: string; using value: Value; } generate :: ( queue_action: [..]Action, template_fp_or_string: string, input_kind: enum { STRING; FILE; }, html_fp: string = "", loc := #caller_location ) -> (success: bool, html: string, exit_code: Exit_Codes, error_message: string) { return_if_err :: (exit_code: Exit_Codes) #expand { if had_error { error_string = sprint("%: %", loc, error_string); `return false, "", exit_code, error_string; } } had_error = false; error_string = ""; line_no = 1; tokens: [..]Token; source: string; queue_replace: [..]Replace_Token; defer { array_free(tokens); array_free(queue_replace); } if #complete input_kind == { case .STRING; source = template_fp_or_string; case .FILE; source = file_acquire(template_fp_or_string); return_if_err(.ERR_FILE); } token_success := tokenize(source, *tokens); return_if_err(.ERR_TOKENIZER); token_lookup := token_lookup_table_create(tokens); defer deinit(*token_lookup); #if false { log("-- Tokens ----------------------"); for tokens log("%", it); log("--------------------------------\n\n"); } template_action(*queue_replace, queue_action, token_lookup); return_if_err(.ERR_ACTION); html_new := template_replace(queue_replace, source); return_if_err(.ERR_REPLACE); if html_fp { fw_success := file_write(html_new, html_fp); return_if_err(.ERR_FILE); } return true, html_new, .OK, ""; } commit :: (queue_action: *[..]Action, id: string, value: [][]string) { action: Action = { id = copy_string(id), nested = array_copy(value), kind = .LOOP }; array_add(queue_action, action); } commit :: (queue_action: *[..]Action, id: string, value: []string) { action: Action = { id = copy_string(id), flat = array_copy(value), kind = .REPLACE }; array_add(queue_action, action); } commit :: (queue_action: *[..]Action, id: string, value: string) { commit(queue_action, id, .[value]); } commit :: (queue_action: *[..]Action, id: string, value: ..string) { commit(queue_action, id, value); } commit :: (id: string, value: [][]string) #expand { commit(*`queue_action, id, value); } commit :: (id: string, value: ..string) #expand { commit(*`queue_action, id, ..value); } // Needed for unit tests commit :: (id: string, value: []string) #expand { commit(*`queue_action, id, ..value); } #scope_file had_error: bool; error_string: string; line_no: int; ERR_PARSE_INTEGER :: #string STR_END Could not parse integer. Character in question: %1 The HTML snippet: %2 STR_END; ERR_VALUE_OOB :: #string STR_END Cannot access your value, since it's out of bounds. The index: %1 The array: %2 STR_END; ERR_TOO_MANY_PLACEHOLDERS :: #string STR_END There are more placeholders than provided values. The HTML snippet: %1 Your values: %2 STR_END; ERR_REPLACE_WRONG_TYPE :: #string STR_END Wrong value type commited. Expected type: `..string` or `[]string`. STR_END; ERR_LOOP_WRONG_TYPE :: #string STR_END Wrong value type commited. Maybe you tried to pass a single string, or an one-dimensional array instead of a nested list. Expected type: `[][]string`. STR_END; /** Note(adam): To reduce confusion why Token_Kind is set on two places: When using `commit()` we set a Token_Kind. When creating a token, we also set a Token_Kind. The reason is, that we're able to check the syntax in the template versus the supplied data structure in `commit()`. If it does not match, we bail. See `assert_kind()`. */ Token_Kind :: enum u8 { NONE :: 0; REPLACE; LOOP; } Token :: struct { kind: Token_Kind; id: string; html: string; position_start: int; position_end: int; } Value :: union kind: Token_Kind { .REPLACE ,, flat: []string; .LOOP ,, nested: [][]string; } Replace_Token :: struct { position_start: int; position_end: int; text: string; } template_replace :: (replace_q: [..]Replace_Token, source: string) -> string { compare_pos_descending :: (a: Replace_Token, b: Replace_Token) -> s64 { aa := a.position_start; bb := b.position_start; return xx compare_floats(xx bb, xx aa); } replacemes := quick_sort(replace_q, compare_pos_descending); idx: int; buf: String_Builder; init_string_builder(*buf); while idx < source.count { if replacemes.count != 0 && peek(replacemes).position_start == idx { replaceme := peek(replacemes); for replaceme.text append(*buf, it); idx = replaceme.position_end; pop(*replacemes); } else { append(*buf, source[idx]); idx += 1; } } out := builder_to_string(*buf); return out; } template_action :: ( replace_q: *[..]Replace_Token, action_q: [..]Action, lookup: Table(string, Token) ) { assert_kind :: (msg: string) #expand { if `token.kind != `it.kind { had_error = true; error_string = msg; `return; } } for action_q { bail(); success, token := table_find(*lookup, it.id); if !success continue; rp: Replace_Token; rp.position_start = token.position_start; rp.position_end = token.position_end; if token.kind == { case .REPLACE; assert_kind(ERR_REPLACE_WRONG_TYPE); rp.text = handle_replace(it.flat, token,, temp); case .LOOP; assert_kind(ERR_LOOP_WRONG_TYPE); rp.text = handle_loop(it.nested, token,, temp); } array_add(replace_q, rp); } } handle_loop :: (value: [][]string, token: Token) -> string { buf: String_Builder; init_string_builder(*buf); for value { replaced := handle_replace(it, token); bail(""); append(*buf, replaced); if it_index != value.count-1 then append(*buf, "\n"); } out := builder_to_string(*buf); return out; } handle_replace :: (value: []string, token: Token) -> string { buf: String_Builder; init_string_builder(*buf); if token.html { html_scan(value, token, *buf); bail(""); } else { append(*buf, value[0]); } out := builder_to_string(*buf); return out; } html_scan :: (value: []string, token: Token, buffer: *String_Builder) { idx: int; iteration_skip := false; guard_next_char := false; using token; for html { if iteration_skip { iteration_skip = false; continue; } prev := clamp(it_index-1, 0, html.count); next := ifx (it_index+1 > html.count-1) then 0 else it_index+1; // Add the next char without processing if it == "\\" && html[next] == "%" { guard_next_char = true; continue; } if !guard_next_char && it == "%" { // Populate the same value to every placeholder if value.count == 1 { if is_digit(html[next]) then iteration_skip = true; append(buffer, value[0]); // If user used %n, populate the values by index of `value` } else if is_digit(html[next]) { html_arguments_numbered(html, next, value, buffer); bail(); iteration_skip = true; // Populate the value by position of the placeholders } else { if idx > value.count-1 { error_string = sprint(ERR_TOO_MANY_PLACEHOLDERS, html, value); had_error = true; return; } append(buffer, value[idx]); idx += 1; } } else { guard_next_char = false; append(buffer, it); } } } html_arguments_numbered :: inline ( html: string, next: int, value: []string, buffer: *String_Builder ) { str := string.{ 1, *html[next] }; _num, success := parse_int(*str); if !success { // Note: `string.{ 1, foo }` Does not work with non-ASCII srings error_string = sprint(ERR_PARSE_INTEGER, string.{ 1, *html[next] }, html); had_error = true; return; } num := ifx _num == 0 then 1 else _num - 1; if num > value.count-1 { error_string = sprint(ERR_VALUE_OOB, num, value); had_error = true; return; } append(buffer, value[num]); } bail :: (value: $T) #expand { if had_error `return value; } bail :: () #expand { if had_error `return; } file_acquire :: (fp: string) -> data: string { data, success := read_entire_file(fp); if !success { error_string = sprint("Could not read file: %", fp); had_error = true; } return data; } file_write :: (data: string, fp: string) { success := write_entire_file(fp, data); if !success { error_string = sprint("Could not write into file: %", fp); had_error = true; } } // ------------------------------------ // -- "Tokenizer" --------------------- // ------------------------------------ adv :: () -> u8 #expand { value := `source[`current]; `current += 1; return value; } adv :: (current: *int) -> u8 #expand { value := `source[current.*]; current.* += 1; return value; } peek :: () -> u8 #expand { return `source[`current]; } peek :: (current: int) -> u8 #expand { return `source[current]; } peek_next :: () -> u8 #expand { if `current + 1 >= `source.count-1 then return "\0"; return `source[`current+1]; } is_at_end :: () -> bool #expand { return `current >= `source.count-1; } count_newlines :: inline (s: string) { for s { if s == "\n" then line_no += 1; } } consume_till :: (terminator: u8) -> (success: bool, slice: string, pos: int) #expand { idx := find_index_from_left(`source, terminator, `current.*); if idx == -1 { // string.{ 1, *x } will explode if character is non-ASCII error_string = sprint( "Line %: Could not find terminator: '%'", line_no, string.{ 1, *terminator } ); return false, "", 0; } offset := idx - `current.*; out := slice(`source, `current.*, offset); count_newlines(out); `current.* = idx + 1; return true, out, idx; } consume_till :: (terminator: string) -> (success: bool, slice: string, pos: int) #expand { idx := find_index_from_left(`source, terminator, `current.*); if idx == -1 { error_string = sprint( "Line %: Could not find terminator: '%'", line_no, terminator ); return false, "", 0; } offset := idx - `current.*; out := slice(`source, `current.*, offset); count_newlines(out); `current.* = idx + terminator.count - 1; return true, out, idx; } tokenize :: (source: string, tokens: *[..]Token) { current: int; while !is_at_end() && !had_error { c := adv(); if c == "\n" { line_no += 1; } else if c == "{" && peek() == "{" { position_start := current - 1; adv(); // consume second '{' if peek() == " " || peek() == "\t" then adv(); token_kind := token_determine_kind(source, *current); token_create(tokens, token_kind, source, *current, position_start); } } } token_determine_kind :: (source: string, current: *int) -> Token_Kind { match :: (m: string) -> bool #expand { temp := `current.*; for m { if it != `source[temp] return false; temp += 1; } `current.* = temp; return true; } if match(":") { return .REPLACE; } if match("loop:") { return .LOOP; } had_error = true; error_string = sprint("Line %: Expected `:` or `loop` here", line_no); return .NONE; } token_create :: ( tokens: *[..]Token, kind: Token_Kind, source: string, current: *int, position_start: int ) { create :: (kind: Token_Kind) #expand { token := token_consume(`source, `current, kind, position_start); array_add(`tokens, token); } if kind == { case .REPLACE; create(.REPLACE); case .LOOP; create(.LOOP); } } token_consume :: ( source: string, current: *int, kind: Token_Kind, position_start: int ) -> Token { term: u8 = ":"; success, id := consume_till(term); id_trimmed := trim(id); if !id_trimmed { error_string = sprint("Line %: Expected an identifier here", line_no); had_error = true; return {}; } if !success { had_error = true; return {}; } terminator_end :: "}}"; success2, html, position_end := consume_till(terminator_end); html_trimmed := trim(html); if !success2 { had_error = true; return {}; } return .{ kind = kind, id = id_trimmed, html = html_trimmed, position_start = position_start, position_end = position_end + terminator_end.count, }; } token_lookup_table_create :: (tokens: [..]Token) -> Table(string, Token) { action_map: Table(string, Token); for token: tokens { table_set(*action_map, token.id, token); } return action_map; }