package parser import ( "encoding/json" "fmt" "strconv" "strings" ) func parseYAMLDocuments(input []byte) ([]any, error) { raw := string(input) docsRaw := splitYAMLDocuments(raw) docs := make([]any, 0, len(docsRaw)) for _, doc := range docsRaw { trimmed := strings.TrimSpace(doc) if trimmed == "" { continue } if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { var parsed any if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { return nil, fmt.Errorf("json parse failed: %w", err) } docs = append(docs, parsed) continue } parsed, err := parseYAMLDoc(trimmed) if err != nil { return nil, err } docs = append(docs, parsed) } return docs, nil } func splitYAMLDocuments(input string) []string { lines := strings.Split(input, "\n") parts := []string{} cur := []string{} for _, line := range lines { if strings.TrimSpace(line) == "---" { parts = append(parts, strings.Join(cur, "\n")) cur = cur[:0] continue } cur = append(cur, line) } parts = append(parts, strings.Join(cur, "\n")) return parts } func parseYAMLDoc(doc string) (any, error) { lines := preprocessLines(doc) if len(lines) == 0 { return nil, nil } v, next, err := parseBlock(lines, 0, 0) if err != nil { return nil, err } if next < len(lines) { return nil, fmt.Errorf("unexpected content near line %d", lines[next].num) } return v, nil } type yamlLine struct { num int indent int text string } func preprocessLines(doc string) []yamlLine { raw := strings.Split(doc, "\n") out := make([]yamlLine, 0, len(raw)) for i, line := range raw { if strings.TrimSpace(line) == "" { continue } clean := stripInlineComment(line) if strings.TrimSpace(clean) == "" { continue } indent := countIndent(clean) text := strings.TrimSpace(clean) out = append(out, yamlLine{num: i + 1, indent: indent, text: text}) } return out } func parseBlock(lines []yamlLine, idx int, indent int) (any, int, error) { if idx >= len(lines) { return nil, idx, nil } if lines[idx].indent < indent { return nil, idx, nil } if strings.HasPrefix(lines[idx].text, "- ") || lines[idx].text == "-" { return parseList(lines, idx, indent) } return parseMap(lines, idx, indent) } func parseMap(lines []yamlLine, idx int, indent int) (map[string]any, int, error) { out := map[string]any{} for idx < len(lines) { line := lines[idx] if line.indent < indent { break } if line.indent > indent { break } if strings.HasPrefix(line.text, "- ") || line.text == "-" { break } key, value, valid, hasValue := splitKeyValue(line.text) if !valid { return nil, idx, fmt.Errorf("line %d: invalid mapping", line.num) } if hasValue { out[key] = parseScalar(value) idx++ continue } childIndent := indent + 2 if idx+1 < len(lines) && lines[idx+1].indent >= indent { childIndent = lines[idx+1].indent } nested, next, err := parseBlock(lines, idx+1, childIndent) if err != nil { return nil, idx, err } out[key] = nested idx = next } return out, idx, nil } func parseList(lines []yamlLine, idx int, indent int) ([]any, int, error) { out := []any{} for idx < len(lines) { line := lines[idx] if line.indent < indent { break } if line.indent > indent { break } if !strings.HasPrefix(line.text, "-") { break } itemText := strings.TrimSpace(strings.TrimPrefix(line.text, "-")) idx++ if itemText == "" { nested, next, err := parseBlock(lines, idx, indent+2) if err != nil { return nil, idx, err } out = append(out, nested) idx = next continue } if k, v, valid, hasValue := splitKeyValue(itemText); valid { m := map[string]any{} if hasValue { m[k] = parseScalar(v) if idx < len(lines) && lines[idx].indent > indent { nested, next, err := parseBlock(lines, idx, indent+2) if err != nil { return nil, idx, err } if nestedMap, ok := nested.(map[string]any); ok { for nk, nv := range nestedMap { m[nk] = nv } } idx = next } } else { childIndent := indent + 2 if idx < len(lines) && lines[idx].indent >= indent { childIndent = lines[idx].indent } nested, next, err := parseBlock(lines, idx, childIndent) if err != nil { return nil, idx, err } m[k] = nested idx = next // YAML list items may continue with sibling keys after an inline key without value: // - hosts: // - example.local // secretName: demo-tls if idx < len(lines) && lines[idx].indent >= childIndent && !strings.HasPrefix(lines[idx].text, "-") { extra, nextMap, err := parseMap(lines, idx, childIndent) if err != nil { return nil, idx, err } for ek, ev := range extra { m[ek] = ev } idx = nextMap } } out = append(out, m) continue } out = append(out, parseScalar(itemText)) } return out, idx, nil } func splitKeyValue(text string) (string, string, bool, bool) { parts := strings.SplitN(text, ":", 2) if len(parts) != 2 { return "", "", false, false } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) if key == "" { return "", "", false, false } if value == "" { return key, "", true, false } return key, value, true, true } func parseScalar(s string) any { s = strings.TrimSpace(s) if s == "" { return "" } if strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") && len(s) >= 2 { return strings.Trim(s, "\"") } if strings.HasPrefix(s, "'") && strings.HasSuffix(s, "'") && len(s) >= 2 { return strings.Trim(s, "'") } lower := strings.ToLower(s) if lower == "true" { return true } if lower == "false" { return false } if lower == "null" || s == "~" { return nil } if i, err := strconv.Atoi(s); err == nil { return i } if f, err := strconv.ParseFloat(s, 64); err == nil { return f } if strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[") { var out any if err := json.Unmarshal([]byte(s), &out); err == nil { return out } } return s } func stripInlineComment(line string) string { inSingle := false inDouble := false for i, r := range line { switch r { case '\'': if !inDouble { inSingle = !inSingle } case '"': if !inSingle { inDouble = !inDouble } case '#': if !inSingle && !inDouble { if i == 0 || line[i-1] == ' ' || line[i-1] == '\t' { return strings.TrimRight(line[:i], " \t") } } } } return line } func countIndent(line string) int { count := 0 for _, r := range line { if r == ' ' { count++ continue } break } return count }