0/*
1 * MIT License
2 *
3 * Copyright (c) 2026 dev@ptrace.dev
4 *
5 * Permission is hereby granted, free of charge, to any person obtaining a copy
6 * of this software and associated documentation files (the "Software"), to deal
7 * in the Software without restriction, including without limitation the rights
8 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 * copies of the Software, and to permit persons to whom the Software is
10 * furnished to do so, subject to the following conditions:
11 *
12 * The above copyright notice and this permission notice shall be included in all
13 * copies or substantial portions of the Software.
14 *
15 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 * SOFTWARE.
22 *
23 */
25#import "Basic";
26#import "File";
27#import "String";
28#import "File_Utilities";
29#import "Hash_Table";
30#import "Sort";
31#import "Math";
34Exit_Codes :: enum s32 #specified {
35 OK :: 0;
37 ERR_FILE :: 1;
38 ERR_TOKENIZER :: 2;
39 ERR_ACTION :: 3;
40 ERR_REPLACE :: 4;
41}
43Action :: struct {
44 id: string;
45 using value: Value;
46}
49generate :: (
50 queue_action: [..]Action,
51 template_fp_or_string: string,
52 input_kind: enum { STRING; FILE; },
53 html_fp: string = "",
54 loc := #caller_location
55) -> (success: bool, html: string, exit_code: Exit_Codes, error_message: string)
56{
57 return_if_err :: (exit_code: Exit_Codes) #expand {
58 if had_error {
59 error_string = sprint("%: %", loc, error_string);
60 `return false, "", exit_code, error_string;
61 }
62 }
64 had_error = false;
65 error_string = "";
66 line_no = 1;
68 tokens: [..]Token;
69 source: string;
70 queue_replace: [..]Replace_Token;
72 defer {
73 array_free(tokens);
74 array_free(queue_replace);
75 }
77 if #complete input_kind == {
78 case .STRING;
79 source = template_fp_or_string;
80 case .FILE;
81 source = file_acquire(template_fp_or_string);
82 return_if_err(.ERR_FILE);
83 }
85 token_success := tokenize(source, *tokens);
86 return_if_err(.ERR_TOKENIZER);
88 token_lookup := token_lookup_table_create(tokens);
89 defer deinit(*token_lookup);
91 #if false {
92 log("-- Tokens ----------------------");
93 for tokens log("%", it);
94 log("--------------------------------\n\n");
95 }
97 template_action(*queue_replace, queue_action, token_lookup);
98 return_if_err(.ERR_ACTION);
100 html_new := template_replace(queue_replace, source);
101 return_if_err(.ERR_REPLACE);
103 if html_fp {
104 fw_success := file_write(html_new, html_fp);
105 return_if_err(.ERR_FILE);
106 }
108 return true, html_new, .OK, "";
109}
111commit :: (queue_action: *[..]Action, id: string, value: [][]string) {
112 action: Action = {
113 id = copy_string(id),
114 nested = array_copy(value),
115 kind = .LOOP
116 };
117 array_add(queue_action, action);
118}
120commit :: (queue_action: *[..]Action, id: string, value: []string) {
121 action: Action = {
122 id = copy_string(id),
123 flat = array_copy(value),
124 kind = .REPLACE
125 };
126 array_add(queue_action, action);
127}
129commit :: (queue_action: *[..]Action, id: string, value: string) {
130 commit(queue_action, id, .[value]);
131}
133commit :: (queue_action: *[..]Action, id: string, value: ..string) {
134 commit(queue_action, id, value);
135}
137commit :: (id: string, value: [][]string) #expand {
138 commit(*`queue_action, id, value);
139}
141commit :: (id: string, value: ..string) #expand {
142 commit(*`queue_action, id, ..value);
143}
145// Needed for unit tests
146commit :: (id: string, value: []string) #expand {
147 commit(*`queue_action, id, ..value);
148}
151#scope_file
154had_error: bool;
155error_string: string;
156line_no: int;
159ERR_PARSE_INTEGER :: #string STR_END
160Could not parse integer. Character in question: %1
161The HTML snippet: %2
162STR_END;
164ERR_VALUE_OOB :: #string STR_END
165Cannot access your value, since it's out of bounds.
166The index: %1
167The array: %2
168STR_END;
170ERR_TOO_MANY_PLACEHOLDERS :: #string STR_END
171There are more placeholders than provided values.
172The HTML snippet: %1
173Your values: %2
174STR_END;
176ERR_REPLACE_WRONG_TYPE :: #string STR_END
177Wrong value type commited. Expected type: `..string` or `[]string`.
178STR_END;
180ERR_LOOP_WRONG_TYPE :: #string STR_END
181Wrong value type commited. Maybe you tried to pass a single string,
182or an one-dimensional array instead of a nested list.
183Expected type: `[][]string`.
184STR_END;
187/** Note(adam): To reduce confusion why Token_Kind is set on two places:
188 When using `commit()` we set a Token_Kind.
189 When creating a token, we also set a Token_Kind.
191 The reason is, that we're able to check the syntax in the template
192 versus the supplied data structure in `commit()`.
194 If it does not match, we bail. See `assert_kind()`.
195*/
197Token_Kind :: enum u8 {
198 NONE :: 0;
200 REPLACE;
201 LOOP;
202}
204Token :: struct {
205 kind: Token_Kind;
206 id: string;
207 html: string;
208 position_start: int;
209 position_end: int;
210}
212Value :: union kind: Token_Kind {
213 .REPLACE ,, flat: []string;
214 .LOOP ,, nested: [][]string;
215}
217Replace_Token :: struct {
218 position_start: int;
219 position_end: int;
220 text: string;
221}
224template_replace :: (replace_q: [..]Replace_Token, source: string) -> string {
226 compare_pos_descending :: (a: Replace_Token, b: Replace_Token) -> s64 {
227 aa := a.position_start;
228 bb := b.position_start;
229 return xx compare_floats(xx bb, xx aa);
230 }
232 replacemes := quick_sort(replace_q, compare_pos_descending);
234 idx: int;
235 buf: String_Builder;
236 init_string_builder(*buf);
238 while idx < source.count {
239 if replacemes.count != 0 && peek(replacemes).position_start == idx {
240 replaceme := peek(replacemes);
242 for replaceme.text append(*buf, it);
243 idx = replaceme.position_end;
245 pop(*replacemes);
246 } else {
247 append(*buf, source[idx]);
248 idx += 1;
249 }
250 }
252 out := builder_to_string(*buf);
253 return out;
254}
256template_action :: (
257 replace_q: *[..]Replace_Token,
258 action_q: [..]Action,
259 lookup: Table(string, Token)
260) {
261 assert_kind :: (msg: string) #expand {
262 if `token.kind != `it.kind {
263 had_error = true;
264 error_string = msg;
265 `return;
266 }
267 }
269 for action_q {
270 bail();
271 success, token := table_find(*lookup, it.id);
272 if !success continue;
274 rp: Replace_Token;
275 rp.position_start = token.position_start;
276 rp.position_end = token.position_end;
278 if token.kind == {
279 case .REPLACE;
280 assert_kind(ERR_REPLACE_WRONG_TYPE);
281 rp.text = handle_replace(it.flat, token,, temp);
282 case .LOOP;
283 assert_kind(ERR_LOOP_WRONG_TYPE);
284 rp.text = handle_loop(it.nested, token,, temp);
285 }
287 array_add(replace_q, rp);
288 }
289}
291handle_loop :: (value: [][]string, token: Token) -> string {
292 buf: String_Builder;
293 init_string_builder(*buf);
295 for value {
296 replaced := handle_replace(it, token);
297 bail("");
298 append(*buf, replaced);
300 if it_index != value.count-1 then append(*buf, "\n");
301 }
303 out := builder_to_string(*buf);
304 return out;
305}
307handle_replace :: (value: []string, token: Token) -> string {
308 buf: String_Builder;
309 init_string_builder(*buf);
311 if token.html {
312 html_scan(value, token, *buf);
313 bail("");
314 } else {
315 append(*buf, value[0]);
316 }
318 out := builder_to_string(*buf);
319 return out;
320}
322html_scan :: (value: []string, token: Token, buffer: *String_Builder) {
323 idx: int;
324 iteration_skip := false;
325 guard_next_char := false;
327 using token;
329 for html {
330 if iteration_skip {
331 iteration_skip = false;
332 continue;
333 }
335 prev := clamp(it_index-1, 0, html.count);
336 next := ifx (it_index+1 > html.count-1) then 0 else it_index+1;
338 // Add the next char without processing
339 if it == "\\" && html[next] == "%" {
340 guard_next_char = true;
341 continue;
342 }
344 if !guard_next_char && it == "%" {
346 // Populate the same value to every placeholder
347 if value.count == 1 {
348 if is_digit(html[next]) then iteration_skip = true;
349 append(buffer, value[0]);
351 // If user used %n, populate the values by index of `value`
352 } else if is_digit(html[next]) {
353 html_arguments_numbered(html, next, value, buffer);
354 bail();
355 iteration_skip = true;
357 // Populate the value by position of the placeholders
358 } else {
359 if idx > value.count-1 {
360 error_string = sprint(ERR_TOO_MANY_PLACEHOLDERS, html, value);
361 had_error = true;
362 return;
363 }
365 append(buffer, value[idx]);
366 idx += 1;
367 }
368 } else {
369 guard_next_char = false;
370 append(buffer, it);
371 }
372 }
373}
377html_arguments_numbered :: inline (
378 html: string,
379 next: int,
380 value: []string,
381 buffer: *String_Builder
382) {
383 str := string.{ 1, *html[next] };
384 _num, success := parse_int(*str);
386 if !success {
387 // Note: `string.{ 1, foo }` Does not work with non-ASCII srings
388 error_string = sprint(ERR_PARSE_INTEGER, string.{ 1, *html[next] }, html);
389 had_error = true;
390 return;
391 }
393 num := ifx _num == 0 then 1 else _num - 1;
395 if num > value.count-1 {
396 error_string = sprint(ERR_VALUE_OOB, num, value);
397 had_error = true;
398 return;
399 }
401 append(buffer, value[num]);
402}
405bail :: (value: $T) #expand {
406 if had_error `return value;
407}
409bail :: () #expand {
410 if had_error `return;
411}
414file_acquire :: (fp: string) -> data: string {
415 data, success := read_entire_file(fp);
416 if !success {
417 error_string = sprint("Could not read file: %", fp);
418 had_error = true;
419 }
420 return data;
421}
423file_write :: (data: string, fp: string) {
424 success := write_entire_file(fp, data);
426 if !success {
427 error_string = sprint("Could not write into file: %", fp);
428 had_error = true;
429 }
430}
433// ------------------------------------
434// -- "Tokenizer" ---------------------
435// ------------------------------------
438adv :: () -> u8 #expand {
439 value := `source[`current];
440 `current += 1;
441 return value;
442}
444adv :: (current: *int) -> u8 #expand {
445 value := `source[current.*];
446 current.* += 1;
447 return value;
448}
450peek :: () -> u8 #expand {
451 return `source[`current];
452}
454peek :: (current: int) -> u8 #expand {
455 return `source[current];
456}
458peek_next :: () -> u8 #expand {
459 if `current + 1 >= `source.count-1 then return "\0";
460 return `source[`current+1];
461}
463is_at_end :: () -> bool #expand {
464 return `current >= `source.count-1;
465}
467count_newlines :: inline (s: string) {
468 for s {
469 if s == "\n" then line_no += 1;
470 }
471}
473consume_till :: (terminator: u8) -> (success: bool, slice: string, pos: int) #expand {
474 idx := find_index_from_left(`source, terminator, `current.*);
476 if idx == -1 {
477 // string.{ 1, *x } will explode if character is non-ASCII
478 error_string = sprint(
479 "Line %: Could not find terminator: '%'", line_no, string.{ 1, *terminator }
480 );
481 return false, "", 0;
482 }
484 offset := idx - `current.*;
485 out := slice(`source, `current.*, offset);
487 count_newlines(out);
489 `current.* = idx + 1;
491 return true, out, idx;
492}
494consume_till :: (terminator: string) -> (success: bool, slice: string, pos: int) #expand {
495 idx := find_index_from_left(`source, terminator, `current.*);
497 if idx == -1 {
498 error_string = sprint(
499 "Line %: Could not find terminator: '%'", line_no, terminator
500 );
501 return false, "", 0;
502 }
504 offset := idx - `current.*;
505 out := slice(`source, `current.*, offset);
507 count_newlines(out);
509 `current.* = idx + terminator.count - 1;
511 return true, out, idx;
512}
514tokenize :: (source: string, tokens: *[..]Token) {
515 current: int;
517 while !is_at_end() && !had_error {
518 c := adv();
520 if c == "\n" { line_no += 1; }
521 else if c == "{" && peek() == "{" {
522 position_start := current - 1;
523 adv(); // consume second '{'
525 if peek() == " " || peek() == "\t" then adv();
527 token_kind := token_determine_kind(source, *current);
528 token_create(tokens, token_kind, source, *current, position_start);
529 }
530 }
531}
533token_determine_kind :: (source: string, current: *int) -> Token_Kind {
535 match :: (m: string) -> bool #expand {
536 temp := `current.*;
538 for m {
539 if it != `source[temp] return false;
540 temp += 1;
541 }
543 `current.* = temp;
544 return true;
545 }
547 if match(":") { return .REPLACE; }
548 if match("loop:") { return .LOOP; }
550 had_error = true;
551 error_string = sprint("Line %: Expected `:` or `loop` here", line_no);
553 return .NONE;
554}
556token_create :: (
557 tokens: *[..]Token,
558 kind: Token_Kind,
559 source: string,
560 current: *int,
561 position_start: int
562) {
563 create :: (kind: Token_Kind) #expand {
564 token := token_consume(`source, `current, kind, position_start);
565 array_add(`tokens, token);
566 }
568 if kind == {
569 case .REPLACE; create(.REPLACE);
570 case .LOOP; create(.LOOP);
571 }
572}
574token_consume :: (
575 source: string,
576 current: *int,
577 kind: Token_Kind,
578 position_start: int
579) -> Token
580{
581 term: u8 = ":";
582 success, id := consume_till(term);
583 id_trimmed := trim(id);
585 if !id_trimmed {
586 error_string = sprint("Line %: Expected an identifier here", line_no);
587 had_error = true;
588 return {};
589 }
591 if !success {
592 had_error = true;
593 return {};
594 }
596 terminator_end :: "}}";
598 success2, html, position_end := consume_till(terminator_end);
599 html_trimmed := trim(html);
601 if !success2 {
602 had_error = true;
603 return {};
604 }
606 return .{
607 kind = kind,
608 id = id_trimmed,
609 html = html_trimmed,
610 position_start = position_start,
611 position_end = position_end + terminator_end.count,
612 };
613}
615token_lookup_table_create :: (tokens: [..]Token) -> Table(string, Token) {
616 action_map: Table(string, Token);
618 for token: tokens {
619 table_set(*action_map, token.id, token);
620 }
622 return action_map;
623}
index : htmltemplate
---