package config import ( "bufio" "bytes" "fmt" "strings" ) // parseYAML reads the flat YAML subset used by probe.yaml: // // key: value # scalar (quotes optional, comments allowed) // key: [a, "b", c] # inline list // key: # block list // - a // - b // key: [] # empty list // // Values are returned as string or []string; "~" / "null" / empty become "". Nested mappings are rejected. func parseYAML(data []byte) (map[string]any, error) { doc := map[string]any{} sc := bufio.NewScanner(bytes.NewReader(data)) var ( curKey string // key awaiting a block list curList []string // accumulated block list items inList bool lineNo int ) flush := func() { if inList { doc[curKey] = curList inList = false curList = nil } } for sc.Scan() { lineNo++ raw := sc.Text() if lineNo == 1 { raw = strings.TrimPrefix(raw, "\uFEFF") // UTF-8 BOM } line := stripComment(raw) if strings.TrimSpace(line) == "" || strings.TrimSpace(line) == "---" { continue } trimmed := strings.TrimSpace(line) indented := line[0] == ' ' || line[0] == '\t' if indented { if inList && strings.HasPrefix(trimmed, "- ") || inList && trimmed == "-" { curList = append(curList, unquote(strings.TrimSpace(strings.TrimPrefix(trimmed, "-")))) continue } return nil, fmt.Errorf("line %d: nested structures are not supported", lineNo) } flush() key, val, ok := strings.Cut(trimmed, ":") if !ok || strings.ContainsAny(strings.TrimSpace(key), " \t\"'") { return nil, fmt.Errorf("line %d: expected `key: value`", lineNo) } key = strings.TrimSpace(key) val = strings.TrimSpace(val) if _, dup := doc[key]; dup { return nil, fmt.Errorf("line %d: duplicate key %q", lineNo, key) } switch { case val == "": // Either a block list follows or the value is null; decide on the next lines. curKey, inList, curList = key, true, []string{} case strings.HasPrefix(val, "["): if !strings.HasSuffix(val, "]") { return nil, fmt.Errorf("line %d: unterminated inline list", lineNo) } doc[key] = splitInline(val[1 : len(val)-1]) default: doc[key] = unquote(val) } } if err := sc.Err(); err != nil { return nil, err } flush() // Keys that had no value and no list items are null scalars → "". for k, v := range doc { if l, ok := v.([]string); ok && len(l) == 0 { // keep empty list for list-typed keys, but scalar keys get "" (set() handles both). if k != "resolvers_override" { doc[k] = "" } } } return doc, nil } // stripComment removes a trailing "# …" comment that is not inside quotes. func stripComment(line string) string { var quote byte for i := 0; i < len(line); i++ { c := line[i] switch { case quote != 0: if c == quote { quote = 0 } case c == '"' || c == '\'': quote = c case c == '#' && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t'): return line[:i] } } return line } func unquote(s string) string { s = strings.TrimSpace(s) if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) { inner := s[1 : len(s)-1] if s[0] == '"' { inner = strings.NewReplacer(`\"`, `"`, `\\`, `\`, `\n`, "\n", `\t`, "\t").Replace(inner) } return inner } if s == "~" || s == "null" || s == "Null" || s == "NULL" { return "" } return s } func splitInline(s string) []string { var out []string var cur strings.Builder var quote byte for i := 0; i < len(s); i++ { c := s[i] switch { case quote != 0: cur.WriteByte(c) if c == quote { quote = 0 } case c == '"' || c == '\'': quote = c cur.WriteByte(c) case c == ',': if v := unquote(cur.String()); v != "" { out = append(out, v) } cur.Reset() default: cur.WriteByte(c) } } if v := unquote(cur.String()); v != "" { out = append(out, v) } if out == nil { out = []string{} } return out }