mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
fix(mustache): Got whitespace management to pass the tests.
This commit is contained in:
@@ -28,7 +28,7 @@ public/ ← build output (generated)
|
|||||||
| `sectionate.odin` | `wrap_sections` proc — splits HTML at `<h2` into `<section>` wrappers |
|
| `sectionate.odin` | `wrap_sections` proc — splits HTML at `<h2` into `<section>` wrappers |
|
||||||
| `render.odin` | Mustache template rendering, all page types, RSS, sitemap, robots.txt, `load_partials` recursive scan |
|
| `render.odin` | Mustache template rendering, all page types, RSS, sitemap, robots.txt, `load_partials` recursive scan |
|
||||||
| `feed.odin` | RSS feed + sitemap XML generation |
|
| `feed.odin` | RSS feed + sitemap XML generation |
|
||||||
| `mustache/` | Vendored [odin-mustache](https://github.com/benjamindblock/odin-mustache) library |
|
| `mustache/` | Mustache template engine (spec-compliant, replaces vendored odin-mustache) |
|
||||||
|
|
||||||
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
|
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
|
||||||
|
|
||||||
@@ -131,19 +131,39 @@ odin test . # site + mustache smoke tests
|
|||||||
odin test mustache # mustache spec + targeted tests (37 total)
|
odin test mustache # mustache spec + targeted tests (37 total)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Vendored mustache patches
|
## Mustache engine
|
||||||
|
|
||||||
Four modifications to `mustache/mustache.odin`:
|
Spec-compliant Mustache implementation at `mustache/`. Passes all 170 tests across 7 official spec files (interpolation, sections, inverted, comments, partials, dynamic-names, inheritance).
|
||||||
|
|
||||||
1. **`any` unwrapping** in `map_get` and `data_type` — when values come from `map[string]any`, the inner `any` wrapper is unwrapped so type detection works correctly for nested maps and lists.
|
### Files
|
||||||
|
|
||||||
2. **Layout partials** — `layout_template.partials = tmpl.partials` added so partials (`{{> nav}}`, `{{> footer}}`) work inside the base layout template.
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), post-parse de-indent (`deindent_blocks`), renderer (`render_nodes`), indent helpers |
|
||||||
|
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), two-pass standalone whitespace detection (`trim_standalone_whitespace`) |
|
||||||
|
| `data.odin` | Reflection-based data model: `effective` (union/distinct peeling), `lookup_in`, `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `write_value`, `format_f64` |
|
||||||
|
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case |
|
||||||
|
|
||||||
3. **Inline partial rendering** — replaced `template_insert_partial` (which injected tokens into the main token list, breaking section iteration) with `template_render_partial` (which lexes the partial, temporarily swaps `tmpl.lexer`, and recursively processes via `template_process_tokens`). Fixes partials inside sections. Standalone indentation applied to partial source before lexing.
|
### Architecture
|
||||||
|
|
||||||
4. **Dynamic Names** — `{{>*key}}` support. When a partial token value starts with `*`, the remaining key is resolved from the data context stack, and the resolved string is used as the partial name. Enables per-item partial selection inside sections (e.g., `{{>* icon}}` resolves `icon` from each social link).
|
```
|
||||||
|
parse(source) → tokenize → trim_standalone_whitespace → parse_section → deindent_blocks → Template
|
||||||
|
render(tmpl, data, partials) → render_nodes (walks flat node array against context stack) → string
|
||||||
|
```
|
||||||
|
|
||||||
Extracted `template_process_tokens` from `template_eat_tokens` to separate ROOT initialization + skip pass from the core token loop, allowing partials to reuse the loop.
|
- **Flat `[dynamic]Node` array** with `first_child`/`child_count` indices — one allocation, one `delete`. Pre-order layout: children stored contiguously after their parent.
|
||||||
|
- **`render_nodes`** takes `all_nodes` (full array, for absolute child access) + `nodes` (current slice). Index-based loop, skips children after sections/blocks via `i += 1 + child_count`.
|
||||||
|
- **Context stack**: `^[dynamic]any` with `append`/`pop` for section push/pop.
|
||||||
|
- **`effective(a)`** peels Named/Distinct/Union layers (including `json.Value`) so all downstream operations can switch on base `Type_Info` variant directly.
|
||||||
|
- **Two-pass standalone detection**: detect using original token values, then trim left-to-right with `left_done`/`right_done` tracking to prevent double-trims. Cascading `check_left`/`check_right` skip adjacent standalone-eligible tags.
|
||||||
|
- **Block indent**: `deindent_blocks` runs post-parse — finds common indent of direct text children, sets block's intrinsic indent if empty, removes common indent. Renderer applies block indent at output level via `write_indented`.
|
||||||
|
- **Partial/parent indent**: `Template` stores `source` for indent re-parse. `render_template` calls `indent_lines(source, indent)` then re-parses with temp allocator when indent is non-empty.
|
||||||
|
- **Block overrides** (`Block_Override` struct): carries `all_nodes` + child range so override content from different templates renders correctly. `merge_block_overrides` propagates overrides through multi-level inheritance chains.
|
||||||
|
|
||||||
|
### Not implemented
|
||||||
|
|
||||||
|
- Lambdas (`~lambdas.json`)
|
||||||
|
- Set delimiters (`{{= =}}`, `delimiters.json`)
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
@@ -152,12 +172,15 @@ Extracted `template_process_tokens` from `template_eat_tokens` to separate ROOT
|
|||||||
- `json.Value` params require 64-byte aligned arena (workaround for `dynamic_arena_allocator_proc` ignoring per-allocation alignment).
|
- `json.Value` params require 64-byte aligned arena (workaround for `dynamic_arena_allocator_proc` ignoring per-allocation alignment).
|
||||||
- Tree-sitter grammar/query paths hardcoded in `tree_sitter.odin` (Nix store hashes, Helix-version-dependent).
|
- Tree-sitter grammar/query paths hardcoded in `tree_sitter.odin` (Nix store hashes, Helix-version-dependent).
|
||||||
- `TSQueryCapture` needs explicit `_padding: u32` field for C ABI compatibility (40-byte sizeof).
|
- `TSQueryCapture` needs explicit `_padding: u32` field for C ABI compatibility (40-byte sizeof).
|
||||||
- `{{&content}}` in layout template must have no leading whitespace — `template_insert_content_into_layout` indents all lines by preceding whitespace, which corrupts `<pre>` blocks.
|
|
||||||
- highlight.js removed; syntax highlighting is build-time only (no fallback if Tree-sitter fails).
|
- highlight.js removed; syntax highlighting is build-time only (no fallback if Tree-sitter fails).
|
||||||
|
- `format_f64` in mustache brute-forces shortest float representation (Odin's `strconv` doesn't produce shortest round-trip for all values like `3.3`).
|
||||||
|
- Block indent uses output-level indentation (`write_indented`), not source-level re-parse. Multi-line interpolated content inside a standalone block would get incorrectly indented. No spec test exercises this.
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
|
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
|
||||||
|
See `mustache/PARTIAL_INDENT.md` for whitespace handling analysis.
|
||||||
|
See `mustache/SPEC.md` for the original implementation specification.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
# Partial Indentation — Problem & Solutions
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
When a partial tag `{{> name}}` is standalone (only non-whitespace on its line), the mustache spec requires that its leading whitespace be treated as indentation and **prepended to each line of the partial source before rendering**.
|
|
||||||
|
|
||||||
This is a source-level transformation, not output post-processing. The distinction matters when interpolated content contains newlines:
|
|
||||||
|
|
||||||
```
|
|
||||||
partial source: "|\n{{{content}}}\n|\n"
|
|
||||||
content value: "<\n->"
|
|
||||||
indent: " "
|
|
||||||
|
|
||||||
Expected output: " |\n <\n->\n |\n"
|
|
||||||
```
|
|
||||||
|
|
||||||
The line `->` gets NO indent — it comes from expanded content (`<\n->`), not from a partial source line. Post-processing the output would incorrectly indent it.
|
|
||||||
|
|
||||||
### Spec tests that require this
|
|
||||||
|
|
||||||
- **Standalone Without Previous Line** — indent at start of template
|
|
||||||
- **Standalone Without Newline** — indent at end of template
|
|
||||||
- **Standalone Indentation** — indent with multi-line interpolated content
|
|
||||||
|
|
||||||
3 of 14 tests in `partials.json`. The other 11 (basic lookup, context, recursion, nesting, inline usage, failed lookup, padding) work without indentation handling.
|
|
||||||
|
|
||||||
### thor's real usage
|
|
||||||
|
|
||||||
Thor's partials (`{{> nav}}`, `{{> footer}}`, `{{>* icon}}`) are typically standalone with indentation. Without indentation handling, HTML output has wrong indentation — ugly but functional since HTML ignores whitespace.
|
|
||||||
|
|
||||||
## Solution 1: Store source, re-parse with indent (Recommended)
|
|
||||||
|
|
||||||
Add `source: string` to `Template`. When rendering a standalone partial with non-empty indent:
|
|
||||||
|
|
||||||
1. Prepend indent to each line of `partial.source`
|
|
||||||
2. Re-tokenize + re-parse with `context.temp_allocator`
|
|
||||||
3. Render the re-parsed nodes
|
|
||||||
|
|
||||||
When indent is empty, render the pre-parsed nodes directly (no re-parse).
|
|
||||||
|
|
||||||
### Pros
|
|
||||||
- Correct by construction — exactly matches spec ("prepended to each line before rendering")
|
|
||||||
- ~20 lines of code
|
|
||||||
- Nested partials accumulate indentation naturally
|
|
||||||
- No line-start state tracking
|
|
||||||
|
|
||||||
### Cons
|
|
||||||
- Template gains a `source: string` field
|
|
||||||
- Standalone partials with indent get re-parsed at render time (negligible for small fragments)
|
|
||||||
|
|
||||||
### Implementation sketch
|
|
||||||
|
|
||||||
```odin
|
|
||||||
// tokenizer: capture indent during trim_standalone_whitespace
|
|
||||||
// for Partial tokens, before stripping left whitespace:
|
|
||||||
tokens[i].indent = text[nl+1:] // capture indentation
|
|
||||||
|
|
||||||
// mustache.odin: Template gains source field
|
|
||||||
Template :: struct {
|
|
||||||
nodes: [dynamic]Node,
|
|
||||||
source: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse: store source
|
|
||||||
tmpl.source = source
|
|
||||||
|
|
||||||
// renderer: re-parse if indent
|
|
||||||
case .Partial:
|
|
||||||
pt, found := partials[name]
|
|
||||||
if !found do break
|
|
||||||
if len(node.indent) > 0 {
|
|
||||||
indented := indent_lines(pt.source, node.indent)
|
|
||||||
reparse, err := parse(indented, context.temp_allocator, context.temp_allocator)
|
|
||||||
if err == nil {
|
|
||||||
defer delete(reparse.nodes)
|
|
||||||
render_nodes(reparse.nodes[:], reparse.nodes[:], ctx, partials, b)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Solution 2: Modify text nodes during rendering
|
|
||||||
|
|
||||||
Track "at line start" state while rendering the partial's nodes. Insert indent before text-node content that begins a new line. Variables/sections render normally — their output is NOT indented.
|
|
||||||
|
|
||||||
### Pros
|
|
||||||
- No source storage
|
|
||||||
- No re-parsing
|
|
||||||
|
|
||||||
### Cons
|
|
||||||
- Complex: must track line-start state across nodes
|
|
||||||
- Variables producing multi-line output need careful handling (their newlines don't create indented lines)
|
|
||||||
- Trailing newline edge case (partial ending with `\n` shouldn't leave trailing indent)
|
|
||||||
- Nested partials with accumulated indentation need extra logic
|
|
||||||
- ~60+ lines of fiddly code
|
|
||||||
|
|
||||||
### Implementation sketch
|
|
||||||
|
|
||||||
```odin
|
|
||||||
render_partial :: proc(nodes, ctx, partials, b, indent) {
|
|
||||||
at_line_start := true
|
|
||||||
for node in nodes {
|
|
||||||
switch node.kind {
|
|
||||||
case .Text:
|
|
||||||
// Walk text, prepend indent at line starts
|
|
||||||
// Insert indent after each \n
|
|
||||||
// Don't indent after final \n of last text node
|
|
||||||
...
|
|
||||||
at_line_start = (text ends with \n)
|
|
||||||
case .Variable, .Unescaped:
|
|
||||||
// Render normally — no indent applied to output
|
|
||||||
write_value(b, val, ...)
|
|
||||||
at_line_start = false // can't know if output ends with \n
|
|
||||||
case .Section, .Inverted:
|
|
||||||
// Complex: nested text nodes need indent too?
|
|
||||||
...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The "can't know if variable output ends with \n" problem makes `at_line_start` unreliable, requiring heuristics or output buffering.
|
|
||||||
|
|
||||||
## Adjacent Tag Standalone Detection
|
|
||||||
|
|
||||||
**Problem:** When a block-type tag and its close tag are adjacent (no text between them), like `{{<include}}{{/include}}\n`, neither tag is individually detected as standalone. The current check looks at the immediately adjacent token — if it's another tag (not text), the check fails. So the trailing `\n` isn't consumed.
|
|
||||||
|
|
||||||
**Affected spec tests:**
|
|
||||||
- `~inheritance.json`: "Inherit", "Override parent with newlines"
|
|
||||||
- Potentially any `{{<name}}{{/name}}` or `{{#name}}{{/name}}` on its own line
|
|
||||||
|
|
||||||
**Solution:** When scanning left/right for the line boundary, skip over adjacent standalone-eligible tags (they're transparent). Two helper procs replace the current inline checks:
|
|
||||||
- `check_left(tokens, i)` — scans backwards through adjacent eligible tags until finding text or start of template
|
|
||||||
- `check_right(tokens, i)` — scans forwards through adjacent eligible tags until finding text or end of template
|
|
||||||
|
|
||||||
Both return `(ok: bool, text_idx: int)` where `text_idx` is the text token to trim (or -1 if none).
|
|
||||||
+5
-2
@@ -1,3 +1,6 @@
|
|||||||
- [ ] Initialize string builder size based on filesize?
|
- [ ] Initialize string builder size based on filesize?
|
||||||
- [ ] Review [whitespace handling code](./tokenizer.odin)
|
- [x] Review [whitespace handling code](./tokenizer.odin)
|
||||||
- [ ] Add leak checks for public procs.
|
- [x] Add leak checks for public procs.
|
||||||
|
- [ ] Simplify `trim_standalone_whitespace` — four temp arrays (`li_buf`, `ri_buf`, `left_done`, `right_done`) could potentially be reduced to two by merging the done-tracking into the index buffers using sentinel values (e.g. `-2` = already trimmed).
|
||||||
|
- [ ] Adjacent standalone tags don't capture indent for the second tag (e.g. ` {{>a}}\n {{>b}}` — `{{>b}}` gets `indent=""` because its left text was consumed by `{{>a}}`'s right-trim). Blocks recover via `deindent_blocks`, but partials/parents have no fallback.
|
||||||
|
- [ ] lazy load partials ? (with mutex)
|
||||||
|
|||||||
+74
-33
@@ -9,19 +9,27 @@ import "core:strings"
|
|||||||
// effective unwraps union variants and strips Named/Distinct layers,
|
// effective unwraps union variants and strips Named/Distinct layers,
|
||||||
// returning the "peeled" any value and its base type info.
|
// returning the "peeled" any value and its base type info.
|
||||||
effective :: proc(a: any) -> (val: any, info: ^runtime.Type_Info) {
|
effective :: proc(a: any) -> (val: any, info: ^runtime.Type_Info) {
|
||||||
|
if a == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val = a
|
val = a
|
||||||
info = nil
|
info = nil
|
||||||
if val == nil do return
|
|
||||||
|
|
||||||
ti := type_info_of(val.id)
|
ti := type_info_of(val.id)
|
||||||
if ti == nil do return
|
if ti == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
base := runtime.type_info_base(ti)
|
base := runtime.type_info_base(ti)
|
||||||
|
|
||||||
if _, ok := base.variant.(runtime.Type_Info_Union); ok {
|
if _, ok := base.variant.(runtime.Type_Info_Union); ok {
|
||||||
variant := reflect.get_union_variant(val)
|
variant := reflect.get_union_variant(val)
|
||||||
if variant == nil do return {}, nil
|
if variant == nil {
|
||||||
return effective(variant)
|
return {}, nil
|
||||||
|
} else {
|
||||||
|
return effective(variant)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info = base
|
info = base
|
||||||
@@ -31,35 +39,36 @@ effective :: proc(a: any) -> (val: any, info: ^runtime.Type_Info) {
|
|||||||
// lookup_in resolves a single key in a container (struct or map).
|
// lookup_in resolves a single key in a container (struct or map).
|
||||||
// Returns found=false if the key doesn't exist or the container is not a struct/map.
|
// Returns found=false if the key doesn't exist or the container is not a struct/map.
|
||||||
lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
|
lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
|
||||||
if container == nil do return nil, false
|
if container == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val, info := effective(container)
|
val, info := effective(container)
|
||||||
if info == nil do return nil, false
|
if info == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
#partial switch v in info.variant {
|
#partial switch v in info.variant {
|
||||||
case runtime.Type_Info_Struct:
|
case runtime.Type_Info_Struct:
|
||||||
result = reflect.struct_field_value_by_name(val, key, allow_using = true)
|
result = reflect.struct_field_value_by_name(val, key, allow_using = true)
|
||||||
found = result != nil
|
found = result != nil
|
||||||
return
|
|
||||||
|
|
||||||
case runtime.Type_Info_Map:
|
case runtime.Type_Info_Map:
|
||||||
mi := v
|
mi := v
|
||||||
rm_ptr := (^runtime.Raw_Map)(val.data)
|
rm_ptr := (^runtime.Raw_Map)(val.data)
|
||||||
if rm_ptr.len == 0 do return nil, false
|
if rm_ptr.len == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
k := key
|
k := key
|
||||||
seed := runtime.map_seed(rm_ptr^)
|
seed := runtime.map_seed(rm_ptr^)
|
||||||
h := mi.map_info.key_hasher(&k, seed)
|
h := mi.map_info.key_hasher(&k, seed)
|
||||||
value_ptr := runtime.__dynamic_map_get(rm_ptr, mi.map_info, h, &k)
|
value_ptr := runtime.__dynamic_map_get(rm_ptr, mi.map_info, h, &k)
|
||||||
if value_ptr == nil do return nil, false
|
if value_ptr != nil {
|
||||||
|
result = any{value_ptr, mi.value.id}
|
||||||
result = any{value_ptr, mi.value.id}
|
found = true
|
||||||
found = true
|
}
|
||||||
return
|
|
||||||
|
|
||||||
case:
|
|
||||||
return nil, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +77,11 @@ lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
|
|||||||
// resolve against the prior result only.
|
// resolve against the prior result only.
|
||||||
resolve_name :: proc(name: string, ctx: []any) -> any {
|
resolve_name :: proc(name: string, ctx: []any) -> any {
|
||||||
if name == "." {
|
if name == "." {
|
||||||
if len(ctx) > 0 do return ctx[len(ctx) - 1]
|
if len(ctx) > 0 {
|
||||||
return nil
|
return ctx[len(ctx) - 1]
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parts: [16]string
|
parts: [16]string
|
||||||
@@ -89,22 +101,33 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
|
|||||||
part_count += 1
|
part_count += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if part_count == 0 do return nil
|
if part_count == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
dot_parts := parts[:part_count]
|
dot_parts := parts[:part_count]
|
||||||
|
|
||||||
result: any = nil
|
result: any = nil
|
||||||
found := false
|
found := false
|
||||||
for i := len(ctx) - 1; i >= 0; i -= 1 {
|
for i := len(ctx) - 1; i >= 0; i -= 1 {
|
||||||
result, found = lookup_in(ctx[i], dot_parts[0])
|
result, found = lookup_in(ctx[i], dot_parts[0])
|
||||||
if found do break
|
if found {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found do return nil
|
if !found {
|
||||||
if len(dot_parts) == 1 do return result
|
return nil
|
||||||
|
}
|
||||||
|
if len(dot_parts) == 1 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
for i := 1; i < len(dot_parts); i += 1 {
|
for i := 1; i < len(dot_parts); i += 1 {
|
||||||
result, found = lookup_in(result, dot_parts[i])
|
result, found = lookup_in(result, dot_parts[i])
|
||||||
if !found do return nil
|
if !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -112,11 +135,17 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
|
|||||||
|
|
||||||
// is_truthy checks mustache truthiness.
|
// is_truthy checks mustache truthiness.
|
||||||
is_truthy :: proc(a: any) -> bool {
|
is_truthy :: proc(a: any) -> bool {
|
||||||
if a == nil do return false
|
if a == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
val, info := effective(a)
|
val, info := effective(a)
|
||||||
if info == nil do return false
|
if info == nil {
|
||||||
if reflect.is_nil(val) do return false
|
return false
|
||||||
|
}
|
||||||
|
if reflect.is_nil(val) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
#partial switch _ in info.variant {
|
#partial switch _ in info.variant {
|
||||||
case runtime.Type_Info_Slice, runtime.Type_Info_Dynamic_Array, runtime.Type_Info_Map:
|
case runtime.Type_Info_Slice, runtime.Type_Info_Dynamic_Array, runtime.Type_Info_Map:
|
||||||
@@ -130,7 +159,9 @@ is_truthy :: proc(a: any) -> bool {
|
|||||||
// Returns elem_info=nil if the value is not a list.
|
// Returns elem_info=nil if the value is not a list.
|
||||||
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
|
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
|
||||||
val, info := effective(a)
|
val, info := effective(a)
|
||||||
if info == nil do return nil, 0, nil
|
if info == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
#partial switch v in info.variant {
|
#partial switch v in info.variant {
|
||||||
case runtime.Type_Info_Slice:
|
case runtime.Type_Info_Slice:
|
||||||
@@ -148,7 +179,9 @@ list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: r
|
|||||||
|
|
||||||
// any_to_string converts a scalar value to a string using the temp allocator.
|
// any_to_string converts a scalar value to a string using the temp allocator.
|
||||||
any_to_string :: proc(a: any) -> string {
|
any_to_string :: proc(a: any) -> string {
|
||||||
if a == nil do return ""
|
if a == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
val, _ := effective(a)
|
val, _ := effective(a)
|
||||||
|
|
||||||
switch v in val {
|
switch v in val {
|
||||||
@@ -174,14 +207,18 @@ format_f64 :: proc(v: f64) -> string {
|
|||||||
buf: [64]byte
|
buf: [64]byte
|
||||||
for prec in 1 ..= 17 {
|
for prec in 1 ..= 17 {
|
||||||
s := strconv.write_float(buf[:], v, 'g', prec, 64)
|
s := strconv.write_float(buf[:], v, 'g', prec, 64)
|
||||||
if len(s) > 0 && s[0] == '+' do s = s[1:]
|
if len(s) > 0 && s[0] == '+' {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
parsed, ok := strconv.parse_f64(s)
|
parsed, ok := strconv.parse_f64(s)
|
||||||
if ok && parsed == v {
|
if ok && parsed == v {
|
||||||
return strings.clone(s, context.temp_allocator)
|
return strings.clone(s, context.temp_allocator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s := strconv.write_float(buf[:], v, 'g', -1, 64)
|
s := strconv.write_float(buf[:], v, 'g', -1, 64)
|
||||||
if len(s) > 0 && s[0] == '+' do s = s[1:]
|
if len(s) > 0 && s[0] == '+' {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
return strings.clone(s, context.temp_allocator)
|
return strings.clone(s, context.temp_allocator)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +226,9 @@ format_f64 :: proc(v: f64) -> string {
|
|||||||
// optionally HTML-escaped.
|
// optionally HTML-escaped.
|
||||||
write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
||||||
s := any_to_string(a)
|
s := any_to_string(a)
|
||||||
if len(s) == 0 do return
|
if len(s) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !escape {
|
if !escape {
|
||||||
strings.write_string(b, s)
|
strings.write_string(b, s)
|
||||||
@@ -200,7 +239,9 @@ write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
|||||||
for i in 0 ..< len(s) {
|
for i in 0 ..< len(s) {
|
||||||
switch s[i] {
|
switch s[i] {
|
||||||
case '&', '<', '>', '"':
|
case '&', '<', '>', '"':
|
||||||
if i > start do strings.write_string(b, s[start:i])
|
if i > start {
|
||||||
|
strings.write_string(b, s[start:i])
|
||||||
|
}
|
||||||
switch s[i] {
|
switch s[i] {
|
||||||
case '&':
|
case '&':
|
||||||
strings.write_string(b, "&")
|
strings.write_string(b, "&")
|
||||||
|
|||||||
+250
-40
@@ -50,8 +50,21 @@ Node :: struct {
|
|||||||
child_count: int,
|
child_count: int,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// node_span returns the number of flat-array entries a node occupies:
|
||||||
|
// 1 for leaf nodes, 1 + child_count for container nodes (whose children
|
||||||
|
// are stored contiguously after them in the array).
|
||||||
|
node_span :: proc(n: Node) -> int {
|
||||||
|
#partial switch n.kind {
|
||||||
|
case .Section, .Inverted, .Parent, .Block:
|
||||||
|
return 1 + n.child_count
|
||||||
|
case:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Template :: struct {
|
Template :: struct {
|
||||||
nodes: [dynamic]Node,
|
nodes: [dynamic]Node,
|
||||||
|
source: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
Block_Override :: struct {
|
Block_Override :: struct {
|
||||||
@@ -66,6 +79,13 @@ template_free :: proc(tmpl: ^Template) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete_partials :: proc(partials: map[string]Template) {
|
||||||
|
for _, &p in partials {
|
||||||
|
template_free(&p)
|
||||||
|
}
|
||||||
|
delete(partials)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -86,8 +106,11 @@ parse :: proc(
|
|||||||
tmpl.nodes, err = parse_tokens(tokens[:], allocator)
|
tmpl.nodes, err = parse_tokens(tokens[:], allocator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
delete(tmpl.nodes)
|
delete(tmpl.nodes)
|
||||||
|
return {}, err
|
||||||
}
|
}
|
||||||
return tmpl, err
|
tmpl.source = source
|
||||||
|
deindent_blocks(tmpl.nodes[:], 0, len(tmpl.nodes), allocator)
|
||||||
|
return tmpl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
render :: proc(
|
render :: proc(
|
||||||
@@ -165,8 +188,7 @@ parse_section :: proc(
|
|||||||
pos^ += 1
|
pos^ += 1
|
||||||
idx := len(nodes)
|
idx := len(nodes)
|
||||||
append(nodes, Node{kind = .Section, key = tok.value, first_child = -1})
|
append(nodes, Node{kind = .Section, key = tok.value, first_child = -1})
|
||||||
err := parse_section(tokens, pos, nodes, tok.value)
|
parse_section(tokens, pos, nodes, tok.value) or_return
|
||||||
if err != nil do return err
|
|
||||||
nodes[idx].first_child = idx + 1
|
nodes[idx].first_child = idx + 1
|
||||||
nodes[idx].child_count = len(nodes) - idx - 1
|
nodes[idx].child_count = len(nodes) - idx - 1
|
||||||
|
|
||||||
@@ -174,8 +196,7 @@ parse_section :: proc(
|
|||||||
pos^ += 1
|
pos^ += 1
|
||||||
idx := len(nodes)
|
idx := len(nodes)
|
||||||
append(nodes, Node{kind = .Inverted, key = tok.value, first_child = -1})
|
append(nodes, Node{kind = .Inverted, key = tok.value, first_child = -1})
|
||||||
err := parse_section(tokens, pos, nodes, tok.value)
|
parse_section(tokens, pos, nodes, tok.value) or_return
|
||||||
if err != nil do return err
|
|
||||||
nodes[idx].first_child = idx + 1
|
nodes[idx].first_child = idx + 1
|
||||||
nodes[idx].child_count = len(nodes) - idx - 1
|
nodes[idx].child_count = len(nodes) - idx - 1
|
||||||
|
|
||||||
@@ -202,6 +223,7 @@ parse_section :: proc(
|
|||||||
kind = .Partial,
|
kind = .Partial,
|
||||||
key = tok.value,
|
key = tok.value,
|
||||||
is_dynamic = tok.is_dynamic,
|
is_dynamic = tok.is_dynamic,
|
||||||
|
indent = tok.indent,
|
||||||
first_child = -1,
|
first_child = -1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -210,18 +232,22 @@ parse_section :: proc(
|
|||||||
case .Parent:
|
case .Parent:
|
||||||
pos^ += 1
|
pos^ += 1
|
||||||
idx := len(nodes)
|
idx := len(nodes)
|
||||||
append(nodes, Node{kind = .Parent, key = tok.value, first_child = -1})
|
append(
|
||||||
err := parse_section(tokens, pos, nodes, tok.value)
|
nodes,
|
||||||
if err != nil do return err
|
Node{kind = .Parent, key = tok.value, indent = tok.indent, first_child = -1},
|
||||||
|
)
|
||||||
|
parse_section(tokens, pos, nodes, tok.value) or_return
|
||||||
nodes[idx].first_child = idx + 1
|
nodes[idx].first_child = idx + 1
|
||||||
nodes[idx].child_count = len(nodes) - idx - 1
|
nodes[idx].child_count = len(nodes) - idx - 1
|
||||||
|
|
||||||
case .Block_Open:
|
case .Block_Open:
|
||||||
pos^ += 1
|
pos^ += 1
|
||||||
idx := len(nodes)
|
idx := len(nodes)
|
||||||
append(nodes, Node{kind = .Block, key = tok.value, first_child = -1})
|
append(
|
||||||
err := parse_section(tokens, pos, nodes, tok.value)
|
nodes,
|
||||||
if err != nil do return err
|
Node{kind = .Block, key = tok.value, indent = tok.indent, first_child = -1},
|
||||||
|
)
|
||||||
|
parse_section(tokens, pos, nodes, tok.value) or_return
|
||||||
nodes[idx].first_child = idx + 1
|
nodes[idx].first_child = idx + 1
|
||||||
nodes[idx].child_count = len(nodes) - idx - 1
|
nodes[idx].child_count = len(nodes) - idx - 1
|
||||||
}
|
}
|
||||||
@@ -233,6 +259,172 @@ parse_section :: proc(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Post-parse: de-indent block content
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
deindent_blocks :: proc(all_nodes: []Node, start: int, end: int, allocator := context.allocator) {
|
||||||
|
i := start
|
||||||
|
for i < end {
|
||||||
|
#partial switch all_nodes[i].kind {
|
||||||
|
case .Block:
|
||||||
|
if all_nodes[i].child_count > 0 {
|
||||||
|
cs := all_nodes[i].first_child
|
||||||
|
ce := cs + all_nodes[i].child_count
|
||||||
|
deindent_blocks(all_nodes, cs, ce, allocator)
|
||||||
|
|
||||||
|
children := all_nodes[cs:ce]
|
||||||
|
common := find_common_indent(children)
|
||||||
|
if len(common) > 0 {
|
||||||
|
if len(all_nodes[i].indent) == 0 {
|
||||||
|
all_nodes[i].indent = common
|
||||||
|
}
|
||||||
|
for j := cs; j < ce; {
|
||||||
|
if all_nodes[j].kind == .Text && len(all_nodes[j].text) > 0 {
|
||||||
|
all_nodes[j].text = remove_line_indent(
|
||||||
|
all_nodes[j].text,
|
||||||
|
common,
|
||||||
|
allocator,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
j += node_span(all_nodes[j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .Section, .Inverted, .Parent:
|
||||||
|
if all_nodes[i].child_count > 0 {
|
||||||
|
cs := all_nodes[i].first_child
|
||||||
|
ce := cs + all_nodes[i].child_count
|
||||||
|
deindent_blocks(all_nodes, cs, ce, allocator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += node_span(all_nodes[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
find_common_indent :: proc(children: []Node) -> string {
|
||||||
|
common: string
|
||||||
|
found := false
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(children) {
|
||||||
|
if children[i].kind == .Text {
|
||||||
|
text := children[i].text
|
||||||
|
if len(text) > 0 {
|
||||||
|
line_start := 0
|
||||||
|
for j in 0 ..= len(text) {
|
||||||
|
if j == len(text) || text[j] == '\n' {
|
||||||
|
line := text[line_start:j]
|
||||||
|
if len(strings.trim_space(line)) > 0 {
|
||||||
|
ws := leading_whitespace(line)
|
||||||
|
if !found {
|
||||||
|
common = ws
|
||||||
|
found = true
|
||||||
|
} else if len(ws) < len(common) {
|
||||||
|
common = ws
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line_start = j + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += node_span(children[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if found {
|
||||||
|
return common
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leading_whitespace :: proc(s: string) -> string {
|
||||||
|
for i in 0 ..< len(s) {
|
||||||
|
if s[i] != ' ' && s[i] != '\t' {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_line_indent :: proc(s: string, indent: string, allocator := context.allocator) -> string {
|
||||||
|
if len(indent) == 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([dynamic]u8, 0, len(s), allocator)
|
||||||
|
i := 0
|
||||||
|
at_line_start := true
|
||||||
|
|
||||||
|
for i < len(s) {
|
||||||
|
if at_line_start {
|
||||||
|
if i + len(indent) <= len(s) && s[i:i + len(indent)] == indent {
|
||||||
|
i += len(indent)
|
||||||
|
at_line_start = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at_line_start = false
|
||||||
|
}
|
||||||
|
append(&buf, s[i])
|
||||||
|
if s[i] == '\n' {
|
||||||
|
at_line_start = true
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Indentation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
indent_lines :: proc(source: string, indent: string) -> string {
|
||||||
|
if len(indent) == 0 || len(source) == 0 {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
b: strings.Builder
|
||||||
|
strings.builder_init(&b, context.temp_allocator)
|
||||||
|
write_indented(&b, indent, source)
|
||||||
|
return strings.to_string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
write_indented :: proc(b: ^strings.Builder, indent: string, content: string) {
|
||||||
|
if len(indent) == 0 || len(content) == 0 {
|
||||||
|
strings.write_string(b, content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
at_line_start := true
|
||||||
|
for i in 0 ..< len(content) {
|
||||||
|
if at_line_start {
|
||||||
|
strings.write_string(b, indent)
|
||||||
|
at_line_start = false
|
||||||
|
}
|
||||||
|
strings.write_byte(b, content[i])
|
||||||
|
if content[i] == '\n' {
|
||||||
|
at_line_start = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_template :: proc(
|
||||||
|
pt: Template,
|
||||||
|
ctx: ^[dynamic]any,
|
||||||
|
partials: map[string]Template,
|
||||||
|
b: ^strings.Builder,
|
||||||
|
blocks: map[string]Block_Override,
|
||||||
|
indent: string,
|
||||||
|
) -> Render_Error {
|
||||||
|
if len(indent) > 0 && len(pt.source) > 0 {
|
||||||
|
indented := indent_lines(pt.source, indent)
|
||||||
|
reparse := parse(indented, context.temp_allocator, context.temp_allocator) or_return
|
||||||
|
return render_nodes(reparse.nodes[:], reparse.nodes[:], ctx, partials, b, blocks)
|
||||||
|
}
|
||||||
|
return render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b, blocks)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Renderer — walk node array against context stack, write to builder
|
// Renderer — walk node array against context stack, write to builder
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -269,18 +461,16 @@ render_nodes :: proc(
|
|||||||
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
||||||
elem_info, count, data := list_info(val)
|
elem_info, count, data := list_info(val)
|
||||||
if elem_info != nil {
|
if elem_info != nil {
|
||||||
for j in 0..<count {
|
for j in 0 ..< count {
|
||||||
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
|
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
|
||||||
append(ctx, any{elem_ptr, elem_info.id})
|
append(ctx, any{elem_ptr, elem_info.id})
|
||||||
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
|
defer pop(ctx)
|
||||||
pop(ctx)
|
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
|
||||||
if err != nil do return err
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
append(ctx, val)
|
append(ctx, val)
|
||||||
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
|
defer pop(ctx)
|
||||||
pop(ctx)
|
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
|
||||||
if err != nil do return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
i += 1 + node.child_count
|
i += 1 + node.child_count
|
||||||
@@ -289,8 +479,7 @@ render_nodes :: proc(
|
|||||||
val := resolve_name(node.key, ctx[:])
|
val := resolve_name(node.key, ctx[:])
|
||||||
if !is_truthy(val) {
|
if !is_truthy(val) {
|
||||||
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
||||||
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
|
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
|
||||||
if err != nil do return err
|
|
||||||
}
|
}
|
||||||
i += 1 + node.child_count
|
i += 1 + node.child_count
|
||||||
|
|
||||||
@@ -302,25 +491,49 @@ render_nodes :: proc(
|
|||||||
}
|
}
|
||||||
pt, found := partials[name]
|
pt, found := partials[name]
|
||||||
if found {
|
if found {
|
||||||
err := render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b)
|
render_template(pt, ctx, partials, b, nil, node.indent) or_return
|
||||||
if err != nil do return err
|
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
case .Block:
|
case .Block:
|
||||||
rendered_override := false
|
content_nodes: []Node
|
||||||
|
content_pool: []Node
|
||||||
|
content_blocks := blocks
|
||||||
|
|
||||||
|
found_override := false
|
||||||
if blocks != nil {
|
if blocks != nil {
|
||||||
if o, ok := blocks[node.key]; ok {
|
if o, ok := blocks[node.key]; ok {
|
||||||
children := o.all_nodes[o.first:o.first + o.count]
|
content_nodes = o.all_nodes[o.first:o.first + o.count]
|
||||||
err := render_nodes(o.all_nodes, children, ctx, partials, b)
|
content_pool = o.all_nodes
|
||||||
if err != nil do return err
|
found_override = true
|
||||||
rendered_override = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !rendered_override {
|
if !found_override {
|
||||||
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
content_nodes = all_nodes[node.first_child:node.first_child + node.child_count]
|
||||||
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
|
content_pool = all_nodes
|
||||||
if err != nil do return err
|
}
|
||||||
|
|
||||||
|
if len(node.indent) > 0 {
|
||||||
|
temp: strings.Builder
|
||||||
|
strings.builder_init(&temp, context.temp_allocator)
|
||||||
|
render_nodes(
|
||||||
|
content_pool,
|
||||||
|
content_nodes,
|
||||||
|
ctx,
|
||||||
|
partials,
|
||||||
|
&temp,
|
||||||
|
content_blocks,
|
||||||
|
) or_return
|
||||||
|
write_indented(b, node.indent, strings.to_string(temp))
|
||||||
|
} else {
|
||||||
|
render_nodes(
|
||||||
|
content_pool,
|
||||||
|
content_nodes,
|
||||||
|
ctx,
|
||||||
|
partials,
|
||||||
|
b,
|
||||||
|
content_blocks,
|
||||||
|
) or_return
|
||||||
}
|
}
|
||||||
i += 1 + node.child_count
|
i += 1 + node.child_count
|
||||||
|
|
||||||
@@ -329,8 +542,7 @@ render_nodes :: proc(
|
|||||||
merged := merge_block_overrides(parent_children, all_nodes, blocks)
|
merged := merge_block_overrides(parent_children, all_nodes, blocks)
|
||||||
pt, found := partials[node.key]
|
pt, found := partials[node.key]
|
||||||
if found {
|
if found {
|
||||||
err := render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b, merged)
|
render_template(pt, ctx, partials, b, merged, node.indent) or_return
|
||||||
if err != nil do return err
|
|
||||||
}
|
}
|
||||||
i += 1 + node.child_count
|
i += 1 + node.child_count
|
||||||
}
|
}
|
||||||
@@ -354,16 +566,14 @@ merge_block_overrides :: proc(
|
|||||||
child := children[i]
|
child := children[i]
|
||||||
if child.kind == .Block {
|
if child.kind == .Block {
|
||||||
if _, exists := result[child.key]; !exists {
|
if _, exists := result[child.key]; !exists {
|
||||||
result[child.key] = Block_Override{
|
result[child.key] = Block_Override {
|
||||||
all_nodes = all_nodes,
|
all_nodes = all_nodes,
|
||||||
first = child.first_child,
|
first = child.first_child,
|
||||||
count = child.child_count,
|
count = child.child_count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
i += 1 + child.child_count
|
|
||||||
} else {
|
|
||||||
i += 1
|
|
||||||
}
|
}
|
||||||
|
i += node_span(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#+test
|
||||||
|
package mustache
|
||||||
|
|
||||||
|
import "core:mem"
|
||||||
|
import "core:testing"
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
leak_parse_free :: proc(t: ^testing.T) {
|
||||||
|
tmpl, err := parse("Hello {{name}}!")
|
||||||
|
testing.expect(t, err == nil)
|
||||||
|
defer template_free(&tmpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
leak_parse_free_tokens :: proc(t: ^testing.T) {
|
||||||
|
arena: mem.Dynamic_Arena
|
||||||
|
backing: [256]byte
|
||||||
|
mem.dynamic_arena_init(&arena)
|
||||||
|
defer mem.dynamic_arena_destroy(&arena)
|
||||||
|
|
||||||
|
tmpl, err := parse("Hello {{name}}!", context.allocator, mem.dynamic_arena_allocator(&arena))
|
||||||
|
testing.expect(t, err == nil)
|
||||||
|
defer template_free(&tmpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
leak_parse_error :: proc(t: ^testing.T) {
|
||||||
|
tmpl, err := parse("Hello {{name")
|
||||||
|
testing.expect(t, err != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
leak_render :: proc(t: ^testing.T) {
|
||||||
|
Data :: struct {
|
||||||
|
name: string,
|
||||||
|
}
|
||||||
|
tmpl, err := parse("Hello {{name}}!")
|
||||||
|
testing.expect(t, err == nil)
|
||||||
|
defer template_free(&tmpl)
|
||||||
|
|
||||||
|
result, rerr := render(tmpl, Data{name = "World"})
|
||||||
|
testing.expect(t, rerr == nil)
|
||||||
|
defer delete(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
leak_render_partials :: proc(t: ^testing.T) {
|
||||||
|
partials := make_map(map[string]Template)
|
||||||
|
defer {
|
||||||
|
for _, &p in partials {
|
||||||
|
template_free(&p)
|
||||||
|
}
|
||||||
|
delete(partials)
|
||||||
|
}
|
||||||
|
|
||||||
|
pt, perr := parse("world")
|
||||||
|
testing.expect(t, perr == nil)
|
||||||
|
partials["name"] = pt
|
||||||
|
|
||||||
|
tmpl, err := parse("Hello {{> name}}!")
|
||||||
|
testing.expect(t, err == nil)
|
||||||
|
defer template_free(&tmpl)
|
||||||
|
|
||||||
|
Data :: struct {
|
||||||
|
name: string,
|
||||||
|
}
|
||||||
|
result, rerr := render(tmpl, Data{name = "World"}, partials)
|
||||||
|
testing.expect(t, rerr == nil)
|
||||||
|
defer delete(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
leak_render_sections :: proc(t: ^testing.T) {
|
||||||
|
tmpl, err := parse("{{#items}}{{.}}{{/items}}")
|
||||||
|
testing.expect(t, err == nil)
|
||||||
|
defer template_free(&tmpl)
|
||||||
|
|
||||||
|
Data :: struct {
|
||||||
|
items: [3]string,
|
||||||
|
}
|
||||||
|
result, rerr := render(tmpl, Data{items = {"a", "b", "c"}})
|
||||||
|
testing.expect(t, rerr == nil)
|
||||||
|
defer delete(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
leak_render_inheritance :: proc(t: ^testing.T) {
|
||||||
|
partials := make_map(map[string]Template)
|
||||||
|
defer delete_partials(partials)
|
||||||
|
|
||||||
|
layout_src := "{{$title}}default{{/title}}"
|
||||||
|
layout, lerr := parse(layout_src)
|
||||||
|
testing.expect(t, lerr == nil)
|
||||||
|
partials["layout"] = layout
|
||||||
|
|
||||||
|
tmpl_src := "{{<layout}}{{$title}}custom{{/title}}{{/layout}}"
|
||||||
|
tmpl, err := parse(tmpl_src)
|
||||||
|
testing.expect(t, err == nil)
|
||||||
|
defer template_free(&tmpl)
|
||||||
|
|
||||||
|
result, rerr := render(tmpl, {}, partials)
|
||||||
|
testing.expect(t, rerr == nil)
|
||||||
|
defer delete(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
leak_repeated_render :: proc(t: ^testing.T) {
|
||||||
|
tmpl, err := parse("Hello {{name}}!")
|
||||||
|
testing.expect(t, err == nil)
|
||||||
|
defer template_free(&tmpl)
|
||||||
|
|
||||||
|
Data :: struct {
|
||||||
|
name: string,
|
||||||
|
}
|
||||||
|
for _ in 0 ..< 3 {
|
||||||
|
result, rerr := render(tmpl, Data{name = "World"})
|
||||||
|
testing.expect(t, rerr == nil)
|
||||||
|
defer delete(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,9 +4,12 @@ package mustache
|
|||||||
import "core:encoding/json"
|
import "core:encoding/json"
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
import "core:os"
|
import "core:os"
|
||||||
|
import "core:path/filepath"
|
||||||
import "core:testing"
|
import "core:testing"
|
||||||
|
|
||||||
run_spec_file :: proc(t: ^testing.T, path: string) {
|
run_spec_file :: proc(t: ^testing.T, path: string) {
|
||||||
|
dirs := [?]string{#directory, path}
|
||||||
|
path, _ := filepath.join(dirs[:], context.temp_allocator)
|
||||||
raw_data, err := os.read_entire_file(path, context.allocator)
|
raw_data, err := os.read_entire_file(path, context.allocator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
testing.expectf(t, false, "Failed to read %s", path)
|
testing.expectf(t, false, "Failed to read %s", path)
|
||||||
|
|||||||
+74
-14
@@ -19,6 +19,7 @@ Token :: struct {
|
|||||||
kind: Token_Kind,
|
kind: Token_Kind,
|
||||||
value: string,
|
value: string,
|
||||||
is_dynamic: bool,
|
is_dynamic: bool,
|
||||||
|
indent: string,
|
||||||
pos: int,
|
pos: int,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,24 +142,83 @@ tokenize :: proc(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
trim_standalone_whitespace :: proc(tokens: ^[dynamic]Token) {
|
trim_standalone_whitespace :: proc(tokens: ^[dynamic]Token) {
|
||||||
for i := 0; i < len(tokens); i += 1 {
|
n := len(tokens)
|
||||||
if !should_trim_whitespace(tokens[i].kind) do continue
|
|
||||||
|
|
||||||
left_ok, left_idx := check_left(tokens[:], i)
|
// First pass: detect standalone status using original token values
|
||||||
right_ok, right_idx := check_right(tokens[:], i)
|
li_buf := make([dynamic]int, n, context.temp_allocator)
|
||||||
|
defer delete(li_buf)
|
||||||
|
ri_buf := make([dynamic]int, n, context.temp_allocator)
|
||||||
|
defer delete(ri_buf)
|
||||||
|
|
||||||
if !left_ok || !right_ok do continue
|
for i := 0; i < n; i += 1 {
|
||||||
|
li_buf[i] = -1
|
||||||
|
ri_buf[i] = -1
|
||||||
|
}
|
||||||
|
|
||||||
if left_idx >= 0 {
|
for i := 0; i < n; i += 1 {
|
||||||
text := tokens[left_idx].value
|
should_trim_whitespace(tokens[i].kind) or_continue
|
||||||
nl := strings.last_index_byte(text, '\n')
|
|
||||||
tokens[left_idx].value = text[:nl+1] if nl >= 0 else ""
|
left_ok, li := check_left(tokens[:], i)
|
||||||
|
right_ok, ri := check_right(tokens[:], i)
|
||||||
|
|
||||||
|
if left_ok && right_ok {
|
||||||
|
li_buf[i] = li
|
||||||
|
ri_buf[i] = ri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: apply trims left-to-right
|
||||||
|
// Track which text tokens have been trimmed to avoid double-trimming
|
||||||
|
left_done := make([dynamic]bool, n, context.temp_allocator)
|
||||||
|
defer delete(left_done)
|
||||||
|
right_done := make([dynamic]bool, n, context.temp_allocator)
|
||||||
|
defer delete(right_done)
|
||||||
|
|
||||||
|
for i := 0; i < n; i += 1 {
|
||||||
|
should_trim_whitespace(tokens[i].kind) or_continue
|
||||||
|
li := li_buf[i]
|
||||||
|
ri := ri_buf[i]
|
||||||
|
if li < 0 && ri < 0 {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if right_idx >= 0 {
|
if li >= 0 && !left_done[li] {
|
||||||
text := tokens[right_idx].value
|
left_done[li] = true
|
||||||
nl := strings.index_byte(text, '\n')
|
text := tokens[li].value
|
||||||
tokens[right_idx].value = text[nl+1:] if nl >= 0 else ""
|
nl := strings.last_index_byte(text, '\n')
|
||||||
|
if tokens[i].kind == .Partial ||
|
||||||
|
tokens[i].kind == .Parent ||
|
||||||
|
tokens[i].kind == .Block_Open {
|
||||||
|
tokens[i].indent = text[nl + 1:] if nl >= 0 else text
|
||||||
|
}
|
||||||
|
tokens[li].value = text[:nl + 1] if nl >= 0 else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if ri >= 0 && !right_done[ri] {
|
||||||
|
right_done[ri] = true
|
||||||
|
// When a Block_Open and its Section_Close are adjacent (e.g.
|
||||||
|
// {{$block}}{{/block}}\n), the close tag is the one that
|
||||||
|
// should consume the trailing newline — not the block open.
|
||||||
|
// Skip the right-trim if a Section_Close sits between this
|
||||||
|
// tag and its right text, or if this IS the Section_Close
|
||||||
|
// immediately following a Block_Open (the close handles it).
|
||||||
|
skip := false
|
||||||
|
if tokens[i].kind == .Block_Open {
|
||||||
|
for k := i + 1; k < ri; k += 1 {
|
||||||
|
if tokens[k].kind == .Section_Close {
|
||||||
|
skip = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tokens[i].kind == .Section_Close && i > 0 && tokens[i - 1].kind == .Block_Open {
|
||||||
|
skip = true
|
||||||
|
}
|
||||||
|
if !skip {
|
||||||
|
text := tokens[ri].value
|
||||||
|
nl := strings.index_byte(text, '\n')
|
||||||
|
tokens[ri].value = text[nl + 1:] if nl >= 0 else ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +234,7 @@ check_left :: proc(tokens: []Token, i: int) -> (ok: bool, text_idx: int) {
|
|||||||
}
|
}
|
||||||
nl := strings.last_index_byte(text, '\n')
|
nl := strings.last_index_byte(text, '\n')
|
||||||
if nl >= 0 {
|
if nl >= 0 {
|
||||||
return strings.trim_space(text[nl+1:]) == "", j
|
return strings.trim_space(text[nl + 1:]) == "", j
|
||||||
}
|
}
|
||||||
if j == 0 {
|
if j == 0 {
|
||||||
return strings.trim_space(text) == "", j
|
return strings.trim_space(text) == "", j
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
#+feature dynamic-literals
|
|
||||||
#+test
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "core:testing"
|
|
||||||
import "mustache"
|
|
||||||
|
|
||||||
@(test)
|
|
||||||
test_simple_substitution :: proc(t: ^testing.T) {
|
|
||||||
data := map[string]string {
|
|
||||||
"name" = "World",
|
|
||||||
}
|
|
||||||
tpl, _ := mustache.parse("Hello, {{name}}!")
|
|
||||||
result, err := mustache.render(tpl, data)
|
|
||||||
testing.expect(t, err == nil)
|
|
||||||
testing.expect(t, result == "Hello, World!")
|
|
||||||
}
|
|
||||||
|
|
||||||
@(test)
|
|
||||||
test_bool_section :: proc(t: ^testing.T) {
|
|
||||||
data := map[string]bool {
|
|
||||||
"show" = true,
|
|
||||||
}
|
|
||||||
tpl, _ := mustache.parse("{{#show}}visible{{/show}}")
|
|
||||||
result, err := mustache.render(tpl, data)
|
|
||||||
testing.expect(t, err == nil)
|
|
||||||
testing.expect(t, result == "visible")
|
|
||||||
}
|
|
||||||
|
|
||||||
@(test)
|
|
||||||
test_inverted_section :: proc(t: ^testing.T) {
|
|
||||||
data := map[string]bool {
|
|
||||||
"show" = false,
|
|
||||||
}
|
|
||||||
tpl, _ := mustache.parse("{{^show}}hidden{{/show}}")
|
|
||||||
result, err := mustache.render(tpl, data)
|
|
||||||
testing.expect(t, err == nil)
|
|
||||||
testing.expect(t, result == "hidden")
|
|
||||||
}
|
|
||||||
|
|
||||||
@(test)
|
|
||||||
test_unescaped :: proc(t: ^testing.T) {
|
|
||||||
data := map[string]string {
|
|
||||||
"html" = "<b>bold</b>",
|
|
||||||
}
|
|
||||||
tpl, _ := mustache.parse("{{{html}}}")
|
|
||||||
result, err := mustache.render(tpl, data)
|
|
||||||
testing.expect(t, err == nil)
|
|
||||||
testing.expect(t, result == "<b>bold</b>")
|
|
||||||
}
|
|
||||||
|
|
||||||
@(test)
|
|
||||||
test_array_iteration :: proc(t: ^testing.T) {
|
|
||||||
items := make([dynamic]map[string]string)
|
|
||||||
defer delete(items)
|
|
||||||
append(&items, map[string]string{"name" = "Alice"})
|
|
||||||
append(&items, map[string]string{"name" = "Bob"})
|
|
||||||
|
|
||||||
data := map[string][dynamic]map[string]string {
|
|
||||||
"items" = items,
|
|
||||||
}
|
|
||||||
tpl, _ := mustache.parse("{{#items}}{{name}} {{/items}}")
|
|
||||||
result, err := mustache.render(tpl, data)
|
|
||||||
testing.expect(t, err == nil)
|
|
||||||
testing.expect(t, result == "Alice Bob ")
|
|
||||||
}
|
|
||||||
|
|
||||||
@(test)
|
|
||||||
test_partial :: proc(t: ^testing.T) {
|
|
||||||
data := map[string]string {
|
|
||||||
"name" = "Test",
|
|
||||||
}
|
|
||||||
greeting_tpl, _ := mustache.parse("Hello, {{name}}!")
|
|
||||||
partials := map[string]mustache.Template {
|
|
||||||
"greeting" = greeting_tpl,
|
|
||||||
}
|
|
||||||
main_tpl, _ := mustache.parse("{{>greeting}}")
|
|
||||||
result, err := mustache.render(main_tpl, data, partials)
|
|
||||||
testing.expect(t, err == nil)
|
|
||||||
testing.expect(t, result == "Hello, Test!")
|
|
||||||
}
|
|
||||||
|
|
||||||
@(test)
|
|
||||||
test_mixed_types :: proc(t: ^testing.T) {
|
|
||||||
data := map[string]any {
|
|
||||||
"site_title" = "One Idiot Developer",
|
|
||||||
"has_date" = true,
|
|
||||||
"date" = "17 Jul 2025",
|
|
||||||
}
|
|
||||||
|
|
||||||
tpl, _ := mustache.parse("{{site_title}}: {{#has_date}}{{date}}{{/has_date}}")
|
|
||||||
result, err := mustache.render(tpl, data)
|
|
||||||
testing.expect(t, err == nil)
|
|
||||||
testing.expect(t, result == "One Idiot Developer: 17 Jul 2025")
|
|
||||||
}
|
|
||||||
|
|
||||||
@(test)
|
|
||||||
test_nested_context :: proc(t: ^testing.T) {
|
|
||||||
page := map[string]string {
|
|
||||||
"title" = "My Post",
|
|
||||||
}
|
|
||||||
data := map[string]any {
|
|
||||||
"site_title" = "One Idiot Developer",
|
|
||||||
"page" = page,
|
|
||||||
}
|
|
||||||
|
|
||||||
tpl, _ := mustache.parse("{{site_title}}: {{#page}}{{title}}{{/page}}")
|
|
||||||
result, err := mustache.render(tpl, data)
|
|
||||||
testing.expect(t, err == nil)
|
|
||||||
testing.expect(t, result == "One Idiot Developer: My Post")
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user