Files
krow_backend/go-api/internal/definition/jsvalue.go
2026-08-24 13:06:29 +05:30

211 lines
5.7 KiB
Go

package definition
import (
"math"
"strconv"
"strings"
)
// JavaScript value semantics, reproduced exactly.
//
// The frontend parser is JavaScript, and the compatibility contract is with
// THAT parser, not with an idealised YAML. Three of its behaviours are load
// bearing and none of them are Go's defaults:
//
// - `\s` and `String.prototype.trim` cover a different set of code points
// than `unicode.IsSpace`. JS treats U+FEFF as whitespace and U+0085 as
// not; Go is the other way round. A definition is trimmed on the way
// through the parser at least four times, so the difference is reachable.
// - Truthiness decides whether `id:` is used or derived, whether a name
// falls back to `Untitled skill`, and what `prompt:` becomes. `0`, `false`
// and `""` are falsy; `"0"` and `[]` are not.
// - `String(x)` and `Number(x)` have defined results for every type, and the
// validator interpolates them into messages an author reads. `[1,2]`
// stringifies to `1,2`, an object to `[object Object]`.
//
// Reimplementing these is not gold-plating: each one is exercised by a case in
// the conformance suite because each one is reachable from a definition an
// author could write.
// jsIsSpace reports whether r is whitespace to JavaScript — the union of
// WhiteSpace and LineTerminator in the specification.
//
// Deliberately NOT unicode.IsSpace: that set includes U+0085 (NEL), which JS
// does not, and excludes U+FEFF, which JS does.
func jsIsSpace(r rune) bool {
switch r {
case '\t', '\n', '\v', '\f', '\r', ' ',
0x00A0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF:
return true
}
return r >= 0x2000 && r <= 0x200A
}
// jsTrim is String.prototype.trim.
func jsTrim(s string) string { return strings.TrimFunc(s, jsIsSpace) }
// jsTrimStart is String.prototype.trimStart.
func jsTrimStart(s string) string { return strings.TrimLeftFunc(s, jsIsSpace) }
// jsTruthy is the `!!x` of a parsed YAML value.
//
// The parser produces only nil, bool, float64, string, []any and
// map[string]any, so those are the only cases that can arise. An empty array
// and an empty object are both truthy in JavaScript, which is why they are not
// listed alongside the empty string.
func jsTruthy(v any) bool {
switch x := v.(type) {
case nil:
return false
case bool:
return x
case float64:
return x != 0 && !math.IsNaN(x)
case string:
return x != ""
default:
return true
}
}
// jsNumberToString is JavaScript's Number → String conversion for the values
// this parser can produce.
//
// `-0` prints as `0`, integers print without a decimal point, and everything
// else takes the shortest representation that round-trips — which is what
// strconv's 'g' with precision -1 gives, in the range a definition can reach.
func jsNumberToString(f float64) string {
switch {
case math.IsNaN(f):
return "NaN"
case math.IsInf(f, 1):
return "Infinity"
case math.IsInf(f, -1):
return "-Infinity"
case f == 0:
return "0" // collapses -0
}
if f == math.Trunc(f) && math.Abs(f) < 1e21 {
return strconv.FormatFloat(f, 'f', -1, 64)
}
return strconv.FormatFloat(f, 'g', -1, 64)
}
// jsString is the `String(x)` of a parsed YAML value.
//
// Arrays join on `,` with nil rendering as the empty string, which is
// Array.prototype.toString; a mapping renders as `[object Object]`. Both are
// reachable: `trigger:` may be written as a list, and the message an author
// reads interpolates the result.
func jsString(v any) string {
switch x := v.(type) {
case nil:
return "null"
case bool:
if x {
return "true"
}
return "false"
case float64:
return jsNumberToString(x)
case string:
return x
case []any:
parts := make([]string, len(x))
for i, item := range x {
if item == nil {
parts[i] = ""
continue
}
parts[i] = jsString(item)
}
return strings.Join(parts, ",")
case map[string]any:
return "[object Object]"
}
return ""
}
// jsTrimmed is the frontend's `trimmed()` helper: String(value ?? empty).trim().
//
// The nullish coalescing matters: a nil renders as the empty string here,
// where a bare String(null) would render as the four characters `null`.
func jsTrimmed(v any) string {
if v == nil {
return ""
}
return jsTrim(jsString(v))
}
// jsNumber is the `Number(x)` of a parsed YAML value, NaN where JavaScript
// gives NaN.
//
// Only reached from `version:`, where the result is checked with
// Number.isInteger. The string cases below are the ones a YAML scalar can
// still be carrying at that point: a quoted `"3"` stays a string, and so does
// anything the numeric patterns in toScalar declined.
func jsNumber(v any) float64 {
switch x := v.(type) {
case nil:
return 0
case bool:
if x {
return 1
}
return 0
case float64:
return x
case string:
return jsNumberFromString(x)
case []any:
// Number([]) is 0 and Number([3]) is 3, via the same String()
// conversion; anything longer stringifies with a comma and fails.
if len(x) == 0 {
return 0
}
if len(x) == 1 {
return jsNumberFromString(jsString(x[0]))
}
}
return math.NaN()
}
func jsNumberFromString(s string) float64 {
s = jsTrim(s)
if s == "" {
return 0
}
switch s {
case "Infinity", "+Infinity":
return math.Inf(1)
case "-Infinity":
return math.Inf(-1)
}
// The radix prefixes JavaScript accepts in a numeric string literal. Signs
// are not permitted with them, which ParseUint enforces by rejecting the
// leading character.
if len(s) > 2 && s[0] == '0' {
var base int
switch s[1] {
case 'x', 'X':
base = 16
case 'o', 'O':
base = 8
case 'b', 'B':
base = 2
}
if base != 0 {
n, err := strconv.ParseUint(s[2:], base, 64)
if err != nil {
return math.NaN()
}
return float64(n)
}
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return math.NaN()
}
return f
}