fix: stabilize runtime follow-up regressions

This commit is contained in:
udo
2026-06-11 23:03:07 +00:00
parent 7f757654b6
commit 20db669589
37 changed files with 906 additions and 144 deletions
+8
View File
@@ -10,11 +10,19 @@ dtree_map
dtree_omit
dtree_pick
dtree_values
each
get_by_path
has
is_array
is_list
set_status
String
StringList
StringMap
to_bool
to_f64
to_json
to_s64
to_string
to_stringmap
to_u64
+14 -7
View File
@@ -42,11 +42,18 @@ You will encounter `DTree` throughout the runtime, especially in:
- `.key("key")` returns a child pointer when it already exists.
- `.get_or_create("key")` returns a child pointer and creates it when missing.
- `.get_by_path("a/b/c")` traverses nested children without creating missing keys.
- `.to_string()` reads scalar content as text.
- `.to_s64()`, `.to_u64()`, and `.to_f64()` perform best-effort numeric conversion.
- `.to_bool()` performs best-effort boolean conversion.
- `.to_string(default)` reads scalar content as text.
- `.to_s64(default)`, `.to_u64(default)`, and `.to_f64(default)` perform best-effort numeric conversion.
- `.to_bool(default)` performs best-effort boolean conversion.
- `.to_stringmap()` converts a map-shaped tree into `StringMap`.
All read accessors are `const` and never modify the tree; they work directly on `const DTree&` values such as `each()` callback parameters. Every `to_*` conversion takes an optional default that is returned when the value is missing or cannot be converted — see the individual pages (`to_string`, `to_s64`, `to_u64`, `to_f64`, `to_bool`) for the exact rules:
```cpp
String title = context.props["title"].to_string("Untitled");
s64 page_size = context.cfg.get_by_path("app/page_size").to_s64(25);
```
`operator[]` creates missing entries, just like `std::map`. `.has()` and `.key()` are the non-mutating lookup helpers, and `.get_by_path()` is the non-creating traversal helper.
`json_decode()` currently stores JSON numbers as string-valued `DTree` nodes, so typed numeric conversion is the normal way to consume those values.
@@ -60,7 +67,7 @@ References are dereferenced automatically in most normal reads.
- `bool` values convert numerically to `1` and `0`.
- Pointer values convert numerically when read as numbers.
- Single-value maps can act as scalar wrappers for numeric and boolean conversion.
- Invalid numeric input falls back to `0`.
- Missing values and invalid numeric input fall back to the accessor's `default_value` argument (`0`, `""`, or `false` when not given).
- `.to_stringmap()` converts map children key-by-key using each child's `to_string()`.
## Writing Values
@@ -93,11 +100,11 @@ Useful inspection helpers include:
## each()
`each(std::function<void (DTree t, String key)> f)` iterates over the current tree value.
`each(std::function<void (const DTree& t, String key)> f)` iterates over the current tree value (see the `each` page for details).
For map-shaped `DTree` values, the callback runs once per child entry and receives:
- `t` as the child value
- `t` as the child value, by const reference (no copy)
- `key` as the child key
For non-map values, `each()` still invokes the callback once:
@@ -106,7 +113,7 @@ For non-map values, `each()` still invokes the callback once:
- `key` is an empty string
```cpp
context.connection["items"].each([&](DTree item, String key) {
context.connection["items"].each([&](const DTree& item, String key) {
print(key, ": ", item.to_string(), "\n");
});
```
+36
View File
@@ -0,0 +1,36 @@
:sig
void DTree::each(function<void (const DTree& item, String key)> f) const
:params
f : callback invoked per child with the child node and its key
return value : none
:see
>types
0_DTree
dtree_map
dtree_filter
dtree_keys
is_list
:content
Iterates a `DTree`. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
For map-shaped nodes the callback runs once per child, in key order, receiving the child and its key. For scalar nodes it runs exactly once with the node itself and an empty key.
The callback receives the child as `const DTree&` — no copy is made, and all read accessors (`to_string()`, `to_s64()`, `get_by_path()`, ...) work on it directly:
```cpp
rows.each([&](const DTree& row, String key) {
out("<li>", html_escape(row.get_by_path("title").to_string("Untitled")), "</li>");
});
```
Declaring the callback parameter as plain `DTree` also works but deep-copies every child; prefer `const DTree&`. To transform or filter into a new tree, use `dtree_map()` / `dtree_filter()` instead of mutating inside the callback.
List-shaped trees (see `is_list`) iterate in numeric index order, matching `json_encode()` and the other serializers. Keyed maps iterate in string key order.
## Related Concepts
- PHP: `foreach ($tree as $key => $item)`
- JavaScript: `Object.entries(obj).forEach(([key, item]) => ...)`
+8 -3
View File
@@ -1,5 +1,5 @@
:sig
DTree DTree::get_by_path(String path, String delim = "/")
DTree DTree::get_by_path(String path, String delim = "/") const
:params
path : slash-delimited path to traverse
@@ -11,16 +11,21 @@ return value : the resolved child node, or an empty `DTree` when the path cannot
0_Request
>types
json_decode
has
to_string
:content
Traverses a nested `DTree` without creating missing keys.
Traverses a nested `DTree` without creating missing keys. This is a read accessor: it is `const` and never modifies the tree, unlike `operator[]`, which creates missing entries.
Empty path segments are ignored, so leading and trailing `/` characters are harmless. If any intermediate node is not a map or a segment is missing, `get_by_path()` returns an empty `DTree`.
A missing path therefore reads like an empty value — combine it with the `to_*` default arguments to express a fallback in one call:
## Example
```cpp
context.cfg.get_by_path("theme/options/portal-dark/label").to_string()
String label = context.cfg.get_by_path("theme/options/portal-dark/label").to_string("Portal Dark");
s64 page_size = context.cfg.get_by_path("app/page_size").to_s64(25);
```
## Related Concepts
+36
View File
@@ -0,0 +1,36 @@
:sig
bool DTree::has(String s) const
:params
s : child key to test
return value : true when the node is map-shaped and contains the key
:see
>types
0_DTree
get_by_path
each
is_array
:content
Tests whether a map-shaped `DTree` contains a child key, without creating it. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
Returns `false` for scalar nodes and for missing keys.
This matters because `operator[]` creates missing entries, exactly like `std::map`. Use `has()` (or `get_by_path()`) when you only want to look:
```cpp
if(context.props.has("avatar"))
print(component("components/basic/avatar", context.props["avatar"], context));
```
`has()` only checks one level. For nested checks, combine with `get_by_path()`:
```cpp
bool configured = context.cfg.get_by_path("mail/smtp").is_array();
```
## Related Concepts
- PHP: `array_key_exists()` / `isset()`
- JavaScript: `Object.hasOwn(obj, key)` / `key in obj`
+34
View File
@@ -0,0 +1,34 @@
:sig
bool DTree::is_array() const
:params
return value : true when the node is map-shaped
:see
>types
0_DTree
is_list
has
each
:content
Tests whether a `DTree` node is map-shaped, i.e. holds nested child values rather than a scalar. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
`is_array()` is true for both list-like and keyed containers; use `is_list()` to distinguish the two:
```cpp
DTree rows = sqlite_query(db, "select * from notes");
if(rows.is_array())
{
rows.each([&](const DTree& row, String key) {
// ...
});
}
```
Scalar values (strings, numbers, booleans, pointers) return `false`, as do unresolvable references.
## Related Concepts
- PHP: `is_array()`
- JavaScript: `typeof value === "object"`
+35
View File
@@ -0,0 +1,35 @@
:sig
bool DTree::is_list() const
:params
return value : true when the node is a sequential, numerically indexed container
:see
>types
0_DTree
is_array
push
each
dtree_values
:content
Tests whether a map-shaped `DTree` represents a list: its keys are the numeric strings `"0"`, `"1"`, `"2"`, ... in unbroken sequence. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
Containers built with `push()` are lists. An empty container counts as a list when it was created with `set_array()` or `push()`; a keyed map (or a map with gaps in its numeric keys) is `is_array()` but not `is_list()`.
```cpp
DTree items;
items.push(first_item);
items.push(second_item);
// items.is_list() == true
items["custom"] = "x";
// items.is_list() == false, items.is_array() == true
```
`dtree_map()` and `dtree_filter()` use this distinction to decide whether results re-index from zero or keep their original keys.
## Related Concepts
- PHP: `array_is_list()`
- JavaScript: `Array.isArray()`
+23 -4
View File
@@ -1,5 +1,9 @@
:sig
bool DTree::to_bool()
bool DTree::to_bool(bool default_value = false) const
:params
default_value : returned when the value is missing or empty
return value : the value as a boolean, or `default_value`
:see
>types
@@ -7,12 +11,27 @@ bool DTree::to_bool()
json_decode
to_f64
to_u64
to_string
:content
Reads a `DTree` value as a boolean.
Reads a `DTree` value as a boolean. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
String values such as `true`, `yes`, `on`, and `1` read as true. Values such as `false`, `no`, `off`, `0`, and `null` read as false.
String values such as `true`, `yes`, `on`, and `1` read as true. Values such as `false`, `no`, `off`, `0`, and `null` read as false. Numeric values read as true when non-zero.
Numeric values read as true when non-zero. Empty strings read as false.
`default_value` is returned when the node is unset, holds an empty string, or is an unresolvable reference. Note the truthiness rule: a non-empty string that parses as neither a boolean word nor a number still reads as `true` — only missing/empty values fall back to the default.
A map-shaped node with exactly one entry unwraps to that entry's value; other maps read as true when non-empty.
## Example
```cpp
bool log_requests = context.cfg.get_by_path("app/log_requests").to_bool(true);
bool wants_compact = context.props["compact"].to_bool();
```
Use this when consuming request data, JSON-decoded values, config trees, or component props where the original input may be string-shaped.
## Related Concepts
- PHP: `filter_var($v, FILTER_VALIDATE_BOOLEAN)`
- JavaScript: `Boolean(value)` plus string handling
+28 -3
View File
@@ -1,5 +1,9 @@
:sig
f64 DTree::to_f64()
f64 DTree::to_f64(f64 default_value = 0) const
:params
default_value : returned when the value is missing or cannot be parsed as a number
return value : the value as a floating-point number, or `default_value`
:see
>types
@@ -7,11 +11,32 @@ f64 DTree::to_f64()
json_decode
float_val
to_bool
to_s64
to_u64
:content
Reads a `DTree` value as a floating-point number.
Reads a `DTree` value as a floating-point number. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
String values are parsed using the same permissive conversion rules used by the runtime's scalar helpers. Boolean values become `1.0` or `0.0`.
String values are trimmed and parsed permissively: numeric forms and the boolean words understood by `to_bool()` both convert. Boolean values become `1.0` or `0.0`.
`default_value` is returned when:
- the node is unset or holds an empty string
- the string does not parse as a finite number
- the node is map-shaped with more than one entry, or an unresolvable reference
A map-shaped node with exactly one entry unwraps to that entry's value before converting.
## Example
```cpp
f64 ratio = context.props["ratio"].to_f64(1.0);
f64 threshold = context.cfg.get_by_path("alerts/threshold").to_f64(0.75);
```
Use this for numeric config values, JSON-decoded fields, component props, and request data that should be treated as a number.
## Related Concepts
- PHP: `floatval()` with a fallback
- JavaScript: `parseFloat(x) || fallback`
+34
View File
@@ -0,0 +1,34 @@
:sig
String DTree::to_json(char quote_char = '"') const
:params
quote_char : quote character used around string output
return value : the scalar as a single JSON token
:see
>types
0_DTree
json_encode
json_decode
to_string
:content
Renders a single scalar `DTree` value as a JSON token. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
Strings are escaped and quoted, numbers render as numeric literals, and booleans render as `true` / `false`.
This is **not** a serializer for nested data: map-shaped nodes render as the placeholder string `"(array)"`. Use `json_encode()` to serialize a whole tree.
## Example
```cpp
String token = context.props["label"].to_json(); // "\"Don't panic\""
String body = json_encode(context.props); // full tree serialization
```
The `quote_char` parameter exists for embedding output inside single-quoted contexts; for HTML attributes prefer double quotes plus `html_escape()`.
## Related Concepts
- JavaScript: `JSON.stringify(value)` for a single scalar
- PHP: `json_encode($scalar)`
+41
View File
@@ -0,0 +1,41 @@
:sig
s64 DTree::to_s64(s64 default_value = 0) const
:params
default_value : returned when the value is missing or cannot be parsed as a number
return value : the value as a signed 64-bit integer, or `default_value`
:see
>types
0_DTree
to_u64
to_f64
to_bool
to_string
:content
Reads a `DTree` value as a signed integer. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
String values are trimmed and parsed permissively: plain integers, floating-point forms (truncated toward zero), and the boolean words understood by `to_bool()` (`yes` reads as `1`) all convert. Results outside the `s64` range clamp to the range boundaries. Boolean values become `1` or `0`.
`default_value` is returned when:
- the node is unset or holds an empty string
- the string does not parse as a number (`"not-a-number"`)
- the node is map-shaped with more than one entry, or an unresolvable reference
A map-shaped node with exactly one entry unwraps to that entry's value before converting — this matches how single-value rows from query results read.
## Example
```cpp
s64 page = context.get["page"].to_s64(1);
s64 limit = context.cfg.get_by_path("app/page_size").to_s64(25);
```
`json_decode()` stores JSON numbers as string-valued nodes, so this is the normal way to consume decoded numeric fields.
## Related Concepts
- PHP: `intval()` with a fallback
- JavaScript: `parseInt(x) || fallback`
+40
View File
@@ -0,0 +1,40 @@
:sig
String DTree::to_string(String default_value = "") const
:params
default_value : returned when the node holds no usable text
return value : the scalar content as text, or `default_value`
:see
>types
0_DTree
to_s64
to_f64
to_bool
to_json
get_by_path
:content
Reads a `DTree` value as text. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
`default_value` is returned when the value is missing or not text-convertible:
- the node is unset or holds an empty string (a missing key read via `get_by_path()` looks exactly like this)
- the node is map-shaped (use `json_encode()` or `to_stringmap()` for those)
- the node is an unresolvable reference
Other scalar kinds convert instead of falling back: `f64` values format with six decimal places (`std::to_string`), `bool` values become `(true)` / `(false)`, and pointers format as their numeric address.
## Example
```cpp
String title = context.props["title"].to_string("Untitled");
String theme = context.cfg.get_by_path("theme/key").to_string("light");
```
The default argument replaces the older `first(x.to_string(), "fallback")` idiom.
## Related Concepts
- PHP: `$value ?? 'fallback'` / `strval()`
- JavaScript: `String(value || "fallback")`
+36
View File
@@ -0,0 +1,36 @@
:sig
StringMap DTree::to_stringmap() const
:params
return value : a flat `StringMap` projection of the node
:see
>types
0_DTree
StringMap
to_string
dtree_keys
dtree_values
:content
Converts a `DTree` into a flat `StringMap`. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
- Map-shaped nodes produce one entry per child, with each child read via `to_string()`. Nested maps flatten to empty strings — this is a one-level projection, not a serializer.
- A non-empty scalar produces a single `"value"` entry holding the scalar.
- Empty values and unresolvable references produce an empty map.
Use this when handing request- or config-shaped data to APIs that take `StringMap`, such as `sqlite_query()` / `mysql_query()` parameter maps or `encode_query()`.
## Example
```cpp
StringMap params = context.props["filters"].to_stringmap();
DTree rows = sqlite_query(db, "select * from notes where author = :author", params);
```
For a faithful representation of nested data, use `json_encode()` instead.
## Related Concepts
- PHP: casting a one-level array with `array_map('strval', $a)`
- JavaScript: `Object.fromEntries(Object.entries(o).map(([k, v]) => [k, String(v)]))`
+28 -3
View File
@@ -1,5 +1,9 @@
:sig
u64 DTree::to_u64()
u64 DTree::to_u64(u64 default_value = 0) const
:params
default_value : returned when the value is missing or cannot be parsed as a number
return value : the value as an unsigned 64-bit integer, or `default_value`
:see
>types
@@ -7,11 +11,32 @@ u64 DTree::to_u64()
json_decode
int_val
to_bool
to_s64
to_f64
:content
Reads a `DTree` value as an unsigned integer.
Reads a `DTree` value as an unsigned integer. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
String values are parsed numerically. Boolean values become `1` or `0`. Negative values clamp to `0`.
String values are trimmed and parsed permissively, like `to_s64()`. Boolean values become `1` or `0`. Negative values clamp to `0`, and results above the `u64` range clamp to the maximum.
`default_value` is returned when:
- the node is unset or holds an empty string
- the string does not parse as a number
- the node is map-shaped with more than one entry, or an unresolvable reference
A map-shaped node with exactly one entry unwraps to that entry's value before converting. Note that a *present* negative value clamps to `0` rather than returning the default — the default signals "missing or unparseable", not "out of range".
## Example
```cpp
u64 limit = context.get["limit"].to_u64(50);
u64 owner_id = row.get_by_path("owner/id").to_u64();
```
Use this for counts, identifiers, limits, timestamps, and other non-negative numeric values stored inside a `DTree`.
## Related Concepts
- PHP: `intval()` with a fallback
- JavaScript: `parseInt(x) || fallback` (within unsigned range)
@@ -26,7 +26,7 @@ void app_asset_tags(Request& context, String kind, DTree assets)
app_asset_tag_once(context, kind, scalar);
return;
}
assets.each([&](DTree item, String key) {
assets.each([&](const DTree& item, String key) {
app_asset_tag_once(context, kind, item.to_string());
});
}
@@ -21,5 +21,6 @@ COMPONENT(Request& context)
<script src="<?= app_asset_url("js/morphdom.js", context) ?>"></script>
<script src="<?= app_asset_url("js/site.js", context) ?>"></script>
<?: context.call["fragments"]["head"].to_string() ?>
<?: context.call["fragments"]["once"].to_string() ?>
</>
}
+25
View File
@@ -28,6 +28,8 @@ RENDER(Request& context)
StringMap http_headers = split_http_headers("GET /demo.uce?x=1 HTTP/1.1\r\nHost: example.test\r\nX-Empty:\r\n");
StringMap leading_crlf_headers = split_http_headers("\r\nGET /lead.uce HTTP/1.1\r\nHost: lead.example\r\n");
StringMap header_only = split_http_headers("Host: example.test\nX-Token: abc\n");
StringMap colon_uri_headers = split_http_headers("GET /clock.uce?t=12:30 HTTP/1.1\r\nHost: colon.example\r\n");
check("split_http_headers() request line with colon in URI", colon_uri_headers["REQUEST_METHOD"] == "GET" && colon_uri_headers["DOCUMENT_URI"] == "/clock.uce" && colon_uri_headers["QUERY_STRING"] == "t=12:30" && colon_uri_headers["HTTP_HOST"] == "colon.example", var_dump(colon_uri_headers));
check("trim() / split_kv() / split_http_headers()", trim(" padded value ") == "padded value" && kv["alpha"] == "one" && kv["empty"] == "" && http_headers["REQUEST_METHOD"] == "GET" && http_headers["DOCUMENT_URI"] == "/demo.uce" && http_headers["QUERY_STRING"] == "x=1" && http_headers["HTTP_X_EMPTY"] == "" && leading_crlf_headers["REQUEST_METHOD"] == "GET" && leading_crlf_headers["DOCUMENT_URI"] == "/lead.uce" && leading_crlf_headers["HTTP_HOST"] == "lead.example" && header_only["REQUEST_METHOD"] == "" && header_only["HTTP_HOST"] == "example.test" && header_only["HTTP_X_TOKEN"] == "abc", trim(" padded value ") + " / " + var_dump(kv) + " / " + var_dump(http_headers) + " / " + var_dump(leading_crlf_headers) + " / " + var_dump(header_only));
check("replace()", replace("alpha-beta-beta", "beta", "done") == "alpha-done-done", replace("alpha-beta-beta", "beta", "done"));
check("html_escape() attribute-safe quotes", html_escape("<&>\"Don't") == "&lt;&amp;&gt;&quot;Don&#39;t", html_escape("<&>\"Don't"));
@@ -84,6 +86,29 @@ RENDER(Request& context)
DTree nav_dash_public = dtree_omit(nav_dash, {"section"});
check("DTree collection helpers", empty_pop.to_string() == "" && app_nav.is_list() && nav_titles["0"].to_string() == "Dashboard" && grouped_nav["app"]["1"]["title"].to_string() == "Themes" && join(dtree_keys(nav_dash_summary), ",") == "title" && dtree_values(nav_dash_public)["0"].to_string() == "Dashboard", json_encode(grouped_nav));
DTree conv;
conv["name"] = "ada";
conv["count"] = "12";
conv["junk"] = "not-a-number";
conv["flag"] = "off";
check("DTree to_* defaults", conv.get_by_path("missing/key").to_string("fallback") == "fallback" && conv["name"].to_string("fallback") == "ada" && conv["junk"].to_s64(-7) == -7 && conv["count"].to_s64(-7) == 12 && conv["junk"].to_f64(2.5) == 2.5 && conv["junk"].to_u64(9) == 9 && conv.get_by_path("nope").to_bool(true) && !conv["flag"].to_bool(true), json_encode(conv));
const DTree& conv_read = conv;
String const_each_keys = "";
conv_read.each([&](const DTree& item, String key) { const_each_keys += key + ":" + item.to_string("-") + " "; });
check("DTree const read accessors", conv_read.has("name") && conv_read.get_by_path("name").to_string() == "ada" && conv_read.is_array() && !conv_read.is_list() && conv_read.to_stringmap()["count"] == "12" && conv_read.get_type_name() == "array" && contains(const_each_keys, "count:12"), const_each_keys);
DTree ordered;
for(u32 i = 0; i < 12; i++)
{
DTree ordered_entry;
ordered_entry = "v" + std::to_string(i);
ordered.push(ordered_entry);
}
String ordered_keys = "";
ordered.each([&](const DTree& item, String key) { ordered_keys += key + ","; });
check("DTree list iteration is numeric", ordered_keys == "0,1,2,3,4,5,6,7,8,9,10,11," && dtree_values(ordered)["10"].to_string() == "v10" && dtree_map(ordered, [](const DTree& item, String key) { return(item); })["11"].to_string() == "v11", ordered_keys);
String binary_payload = "core";
binary_payload.push_back((char)0x00);
binary_payload.push_back((char)0xff);