diff --git a/changes/json-escape-linear.md b/changes/json-escape-linear.md new file mode 100644 index 00000000..278ec9b6 --- /dev/null +++ b/changes/json-escape-linear.md @@ -0,0 +1,6 @@ +bump: patch +type: fix +**`Json.parse` decodes an escaped string in one pass.** A string with escapes in it was joined a character +at a time, and every shorter copy was kept: a long escaped text took gigabytes (ui-preview's story model +reached 6.5 GB in three seconds). It is now decoded into one buffer sized from the text. ui-preview's +protocol-v1.md states how an `@import` path is resolved and which key a `file` override answers to. diff --git a/runtime/native/value.ludic b/runtime/native/value.ludic index 4ed5243a..3297b8ff 100644 --- a/runtime/native/value.ludic +++ b/runtime/native/value.ludic @@ -605,36 +605,67 @@ function jp_string(p: JP) -> string { return p.s[a..p.n] } -# the same with escapes in it, the rare case, a character at a time +# the same with escapes in it: decoded in one pass into one buffer, sized by the text up to the closing +# quote: an escape decodes to at most one and a half times its bytes (\uXXXX is six bytes and at most +# three out; a \u with bad hex is U+FFFD, three for two). +# Joining the result a character at a time kept every shorter copy - a long escaped text made +# gigabytes of them (ui-preview's model, 6.5 GB in three seconds). function jp_string_esc(p: JP) -> string { - var out = "" + var end = p.i + while end < p.n and p.s[end] != '"' { + if p.s[end] == '\\' { end += 1 } + end += 1 + } + if end > p.n { end = p.n } + let out = bytes((end - p.i) * 3 / 2 + 4) # a \u with bad hex is U+FFFD: three bytes for two + var w = 0 while p.i < p.n { let c = p.s[p.i] - if c == '"' { p.i += 1; return out } # closing quote + if c == '"' { # closing quote + p.i += 1 + out[w] = 0 + let done: string = out + return done + } if c == '\\' { # escape p.i += 1 if p.i < p.n { let e = p.s[p.i] - if e == 'n' { out += "\n" } - else if e == 't' { out += "\t" } - else if e == 'r' { out += "\r" } - else if e == 'b' or e == 'f' or e == 'u' { - var code = 8 - if e == 'f' { code = 12 } - if e == 'u' { code = jp_code(p) } # leaves p.i on the escape's last hex digit - let u = jp_utf8(code) - out += u - free(u) - } - else { out += p.s[p.i..p.i + 1] } # \" \\ \/ -> the literal char + if e == 'n' { out[w] = 10; w += 1 } + else if e == 't' { out[w] = 9; w += 1 } + else if e == 'r' { out[w] = 13; w += 1 } + else if e == 'b' { out[w] = 8; w += 1 } + else if e == 'f' { out[w] = 12; w += 1 } + else if e == 'u' { w = jp_put_utf8(out, w, jp_code(p)) } # leaves p.i on the escape's last hex digit + else { out[w] = e; w += 1 } # \" \\ \/ -> the literal char p.i += 1 } } else { - out += p.s[p.i..p.i + 1] + out[w] = c + w += 1 p.i += 1 } } - return out + out[w] = 0 + let rest: string = out + return rest +} +# one code point's UTF-8 bytes into out at w; where the next one goes +function jp_put_utf8(out: bytes, w: int, c: int) -> int { + if c < 128 { + out[w] = c + return w + 1 + } + if c < 2048 { + out[w] = 192 | (c >> 6); out[w + 1] = 128 | (c & 63) + return w + 2 + } + if c < 65536 { + out[w] = 224 | (c >> 12); out[w + 1] = 128 | ((c >> 6) & 63); out[w + 2] = 128 | (c & 63) + return w + 3 + } + out[w] = 240 | (c >> 18); out[w + 1] = 128 | ((c >> 12) & 63); out[w + 2] = 128 | ((c >> 6) & 63); out[w + 3] = 128 | (c & 63) + return w + 4 } # \uXXXX, p.i on the 'u': the code point, a surrogate pair joined (\ud83d\ude00 is one emoji); a @@ -671,18 +702,6 @@ function jp_hex4(p: JP, at: int) -> int { } return v } -# one code point as a UTF-8 string -function jp_utf8(c: int) -> string { - let out = bytes(5) - var n = 0 - if c < 128 { out[0] = c; n = 1 } - else if c < 2048 { out[0] = 192 | (c >> 6); out[1] = 128 | (c & 63); n = 2 } - else if c < 65536 { out[0] = 224 | (c >> 12); out[1] = 128 | ((c >> 6) & 63); out[2] = 128 | (c & 63); n = 3 } - else { out[0] = 240 | (c >> 18); out[1] = 128 | ((c >> 12) & 63); out[2] = 128 | ((c >> 6) & 63); out[3] = 128 | (c & 63); n = 4 } - out[n] = 0 - let t: string = out - return t -} # read a number; a '.' makes it a fixed node, otherwise an int node. function jp_number(p: JP) -> Val { diff --git a/tools/ui-preview/protocol-v1.md b/tools/ui-preview/protocol-v1.md index 30e239a5..541f03e9 100644 --- a/tools/ui-preview/protocol-v1.md +++ b/tools/ui-preview/protocol-v1.md @@ -64,7 +64,7 @@ A template error is NOT a `fail`: the request succeeded and the error is reporte | `font ` | none. Reads `/font.json` (§5.3); `fail` if it has no `adv` list | | `viewport []` | none. The frame's size in screen pixels, and screen pixels a design pixel; with no scale, `h / 1080` (the game's own rule). Default 1920 1080 | | `locale ` | none. Every text a template shows is looked up in that gettext file as the game looks it up (ludic.i18n: exact, then patterns with holes, then sentence by sentence). `""` or `en` is English. The path is read through `file` overrides first | -| `load [ []]` | events only (`err`). Registers component class `Name`, or replaces its template and styles in place if it exists. Paths default to `Name.xml` and `Name.lss`; they name the files in errors and rules, and an `@import` in the styles is read from beside the xml path. Every capitalised tag in the template naming a class not yet loaded gets an empty class (``) of that name, filled in place when the studio loads it; the studio sends every class a story names before it `show`s the root | +| `load [ []]` | events only (`err`). Registers component class `Name`, or replaces its template and styles in place if it exists. Paths default to `Name.xml` and `Name.lss`; they name the files in errors and rules, and an `@import` in the styles is resolved beside the lss path (§4.1). Every capitalised tag in the template naming a class not yet loaded gets an empty class (``) of that name, filled in place when the studio loads it; the studio sends every class a story names before it `show`s the root | | `model ` | none. The class's model: every key is a name its template reads. Props from the parent's tag and values the template `set`s are laid over it | | `calls ` | none. How the class's functions answer (§7) | | `show ` | none. The class a `frame` shows, as a whole screen; `fail` if it is not loaded | @@ -87,6 +87,15 @@ A template error is NOT a `fail`: the request succeeded and the error is reporte Input takes effect in the next `frame`; a press's events are reported by that frame. +### 4.1 Paths + +This is the rule v1 keeps. A later version will not change it silently. + +- **The rule is for `@import` only.** A `locale` path and an `image` source are read exactly as written, relative to the host's working directory, and a `file` override for either uses that same text. +- **A leading `/` in an `@import`** means the game's root, which is the host's working directory. The slash is dropped: `@import "/src/ui/kit/theme.lss"` reads `src/ui/kit/theme.lss`. So an absolute filesystem path cannot be imported; start the host in the game's folder and write the path from there. +- **Any other `@import`** is joined to the directory of the sheet that imports it. For a class's own styles, that is its lss path as `load` gave it. For an imported sheet, it is that sheet's resolved path. So a sheet loaded as `src/ui/hud/Hud.lss` that imports `../kit/theme.lss` reads `src/ui/hud/../kit/theme.lss`, with no normalising of `..`. A class loaded under an absolute lss path imports under absolute paths. +- **A `file` override** applies to exactly the resolved path, byte for byte: `src/ui/kit/theme.lss` for the first example, and `src/ui/hud/../kit/theme.lss` for the second. Neither `/src/...` nor a normalised form matches. + ## 5. The frame ```