package definition import ( "fmt" "regexp" "strconv" "strings" ) // The YAML subset a Krow definition is allowed to use. // // A line-for-line port of the frontend's src/lib/skills/yaml.js. That file is // the specification and this is the second implementation of it, so every // decision below is made because the JavaScript makes it — including the ones // a YAML library would make differently. // // Supported, and nothing else: // // - block maps and block sequences, nested to any depth // - scalars: strings, integers, floats, booleans, null // - quoted strings, for values containing `:` or `#` // - `- key: value`, a mapping whose first key sits on the dash // - `#` comments, and blank lines // // Anchors, aliases, merge keys, multi-document files, flow mappings, flow // sequences, block scalars and tags are NOT supported, and are not silently // half-read: an unparseable line is an error carrying the line number, so a // definition either means what it says or is refused with somewhere to look. // // No dependency. A general YAML library would accept a much larger language // than the frontend does, and every construct it accepted and the frontend did // not would be a definition the backend stores and the editor cannot read — // exactly the failure this package exists to prevent. The subset is small // enough to port exactly, so it is ported exactly. // // Nothing here evaluates anything. There is no reflection, no template, no // code path from a definition to execution of any kind: a definition is // configuration, and this is the boundary that keeps it configuration. // Error is a definition that could not be read, carrying the line it failed on. // // Line is 1-based and counts lines of FRONTMATTER, not of the file — which is // what the JavaScript reports, because parseYaml is handed the fenced block // rather than the document. Reproduced rather than improved: an author who // sees one message in the editor and another from the API is being told about // two different problems. type Error struct { Line int Message string } func (e *Error) Error() string { return e.Message } // errIndent and errPair are the two failures the subset has, worded exactly as // the frontend words them. func errIndent(line int) *Error { return &Error{Line: line, Message: fmt.Sprintf("Unexpected indentation on line %d", line)} } func errPair(line int, content string) *Error { return &Error{Line: line, Message: fmt.Sprintf("Line %d is not `key: value`: %s", line, content)} } // keyPair matches `key: value` and is the only shape a mapping entry may take. // The key alphabet is the frontend's: letters, digits, underscore, dot, dash — // which is why `my key: value` is a refusal rather than a key with a space. // jsSpaceClass is the JavaScript `\s` character class. Go's own `\s` is // ASCII-only, and the difference is reachable: a non-breaking space after the // colon is whitespace to the editor's parser and would be part of the value // here. const jsSpaceClass = `[\t\n\v\f\r \x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]` var keyPair = regexp.MustCompile(`^([A-Za-z0-9_.-]+):` + jsSpaceClass + `*([\s\S]*)$`) var ( reInt = regexp.MustCompile(`^-?[0-9]+$`) reFloat = regexp.MustCompile(`^-?[0-9]*\.[0-9]+$`) ) // line is one significant line, reduced to what the parser needs to decide. type line struct { number int // 1-based, within the frontmatter block indent int // leading whitespace, tabs counted as two content string } // readLines drops blank lines and whole-line comments, and measures what is // left. // // Indentation is counted in code points with a tab worth two spaces, which is // what the JavaScript does and is why a tab-indented sequence sits at the same // depth as a two-space one. func readLines(source string) []line { out := []line{} for i, text := range strings.Split(source, "\n") { trimmed := jsTrim(text) if trimmed == "" { continue } // A whole-line comment: `^\s*#`. if strings.HasPrefix(jsTrimStart(text), "#") { continue } indent := 0 for _, r := range text { if !jsIsSpace(r) { break } if r == '\t' { indent += 2 continue } indent++ } out = append(out, line{number: i + 1, indent: indent, content: trimmed}) } return out } // quoted matches a scalar wrapped in one kind of quote, end to end. // // Greedy and anchored at both ends, as in the frontend: `"a" "b"` is therefore // ONE quoted string whose content is `a" "b`, not two. That is a strange // reading, and it is the reading the editor gives, so it is the reading here. func quotedScalar(value string) (string, bool) { if len(value) < 2 { return "", false } q := value[0] if q != '\'' && q != '"' { return "", false } if value[len(value)-1] != q { return "", false } inner := value[1 : len(value)-1] // The only escape the subset has: a doubled quote is one quote. return strings.ReplaceAll(inner, string([]byte{q, q}), string(q)), true } // stripTrailingComment removes an unquoted trailing `#` comment. // // `\s+#.*$` applied once, leftmost — so `ops # a # b` loses everything from // the first spaced hash, and `ops#1` loses nothing, because a hash inside a // word is part of the word. func stripTrailingComment(value string) string { runes := []rune(value) for i := 0; i < len(runes); i++ { if !jsIsSpace(runes[i]) { continue } j := i for j < len(runes) && jsIsSpace(runes[j]) { j++ } if j < len(runes) && runes[j] == '#' { return string(runes[:i]) } i = j - 1 } return value } // toScalar reads one written value: `true`, `false`, `null`, a number, a // quoted string, or the string as written. func toScalar(raw string) any { value := jsTrim(raw) switch value { case "", "~", "null": return nil case "true": return true case "false": return false } // Quoted: taken literally, which is how a value containing `:` or `#` is // written. No escape processing beyond the doubled quote. if inner, ok := quotedScalar(value); ok { return inner } if reInt.MatchString(value) || reFloat.MatchString(value) { if f, err := strconv.ParseFloat(value, 64); err == nil { return f } } return jsTrim(stripTrailingComment(value)) } // cursor is shared down the recursion so a child consumes the lines it owns. type cursor struct{ i int } // parseBlock reads one block at indent or deeper. // // Map or sequence depending on what the first line at this level is, which is // how YAML itself decides. func parseBlock(lines []line, c *cursor, indent int) (any, *Error) { if c.i >= len(lines) { return nil, nil } first := lines[c.i] if strings.HasPrefix(first.content, "- ") || first.content == "-" { return parseSequence(lines, c, indent) } return parseMapping(lines, c, indent) } // dashPrefix is the `-` and the whitespace after it, as `^-\s*` consumes them. func dashPrefix(content string) int { if !strings.HasPrefix(content, "-") { return 0 } n := 1 for _, r := range content[1:] { if !jsIsSpace(r) { break } n += len(string(r)) } return n } func parseSequence(lines []line, c *cursor, indent int) (any, *Error) { out := []any{} for c.i < len(lines) { cur := lines[c.i] if cur.indent < indent { break } if cur.indent > indent { return nil, errIndent(cur.number) } if !strings.HasPrefix(cur.content, "-") { break } cut := dashPrefix(cur.content) rest := cur.content[cut:] c.i++ if rest == "" { // `-` alone: the item is the indented block beneath it. if c.i < len(lines) && lines[c.i].indent > indent { item, err := parseBlock(lines, c, lines[c.i].indent) if err != nil { return nil, err } out = append(out, item) continue } out = append(out, nil) continue } // `- key: value` opens a mapping whose first key sits on the dash. The // remaining keys are indented to where that key started. if m := keyPair.FindStringSubmatch(rest); m != nil { keyIndent := indent + cut item := map[string]any{} key, value := m[1], m[2] if value == "" && c.i < len(lines) && lines[c.i].indent > indent { block, err := parseBlock(lines, c, lines[c.i].indent) if err != nil { return nil, err } item[key] = block } else { item[key] = toScalar(value) } for c.i < len(lines) && lines[c.i].indent == keyIndent && !strings.HasPrefix(lines[c.i].content, "- ") { more, err := parseMapping(lines, c, keyIndent) if err != nil { return nil, err } if m, ok := more.(map[string]any); ok { for k, v := range m { item[k] = v } } } out = append(out, item) continue } out = append(out, toScalar(rest)) } return out, nil } func parseMapping(lines []line, c *cursor, indent int) (any, *Error) { out := map[string]any{} for c.i < len(lines) { cur := lines[c.i] if cur.indent < indent { break } if cur.indent > indent { return nil, errIndent(cur.number) } if strings.HasPrefix(cur.content, "- ") { break } m := keyPair.FindStringSubmatch(cur.content) if m == nil { return nil, errPair(cur.number, cur.content) } key, value := m[1], m[2] c.i++ if value != "" { out[key] = toScalar(value) continue } // An empty value means the value is the block below — or nothing. if c.i < len(lines) && lines[c.i].indent > indent { block, err := parseBlock(lines, c, lines[c.i].indent) if err != nil { return nil, err } out[key] = block continue } out[key] = nil } return out, nil } // ParseYAML reads one document of the subset as plain data. // // Returns map[string]any, []any, or the empty map for an empty document. // Anything it cannot read is an error rather than a guess, so a malformed // definition is reported to its author instead of being registered in a shape // nobody intended. func ParseYAML(source string) (any, error) { lines := readLines(source) if len(lines) == 0 { return map[string]any{}, nil } c := &cursor{} value, err := parseBlock(lines, c, lines[0].indent) if err != nil { return nil, err } if c.i < len(lines) { return nil, errIndent(lines[c.i].number) } return value, nil }