package config import ( "bytes" "errors" "fmt" "reflect" "regexp" "sort" "strconv" "strings" "github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2/unstable" ) var ( indexPattern = regexp.MustCompile(`\[\d+\]`) bareKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) conflictPattern = regexp.MustCompile(`( is already defined| already exists| already exists as an? [a-z ]+| should be a table, not an? [a-z ]+|, but should be an array table)$`) ) const ( kindTable = "table" kindArray = "array" kindValue = "value" ) func renderSegment(s string) string { if bareKeyPattern.MatchString(s) { return s } return strconv.Quote(s) } func schemaPaths(t reflect.Type, prefix string, out map[string]string) { for t.Kind() == reflect.Pointer { t = t.Elem() } for i := 0; i < t.NumField(); i++ { f := t.Field(i) name, _, _ := strings.Cut(f.Tag.Get("toml"), ",") if name == "" || name == "-" { continue } path := name if prefix != "" { path = prefix + "." + name } out[path] = kindValue ft := f.Type for ft.Kind() == reflect.Pointer { ft = ft.Elem() } switch { case ft.Kind() == reflect.Struct: out[path] = kindTable schemaPaths(ft, path, out) case ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.Struct: out[path] = kindArray out[path+"[]"] = kindTable schemaPaths(ft.Elem(), path+"[]", out) } } } func allowedPaths() map[string]string { out := map[string]string{} schemaPaths(reflect.TypeOf(rawFile{}), "", out) return out } func pathPattern(path string) string { return indexPattern.ReplaceAllString(path, "[]") } type rangeEntry struct { path string start, end int line int } type keyIndex struct { present map[string]bool kinds map[string]string entries []rangeEntry lineStarts []int header string } func (k *keyIndex) has(path string) bool { return k.present[path] } func (k *keyIndex) line(path string) int { best := 0 for _, e := range k.entries { if e.path == path { return e.line } if (strings.HasPrefix(e.path, path+".") || strings.HasPrefix(e.path, path+"[")) && (best == 0 || e.line < best) { best = e.line } } return best } func (k *keyIndex) duplicateUnder(key string) (string, int) { seen := map[string]int{} best, line := "", 0 for _, e := range k.entries { seen[e.path]++ if seen[e.path] == 2 && (e.path == key || strings.HasPrefix(e.path, key+".") || strings.HasPrefix(e.path, key+"[")) && len(e.path) > len(best) { best, line = e.path, e.line } } return best, line } func (k *keyIndex) offset(row, col int) int { if row < 1 || row > len(k.lineStarts) { return -1 } return k.lineStarts[row-1] + col - 1 } func (k *keyIndex) keyAt(row, col int) string { off := k.offset(row, col) best, bestLen := "", -1 for _, e := range k.entries { if off >= e.start && off < e.end && (bestLen < 0 || e.end-e.start < bestLen) { best, bestLen = e.path, e.end-e.start } } return best } func lineStarts(data []byte) []int { starts := []int{0} for i, b := range data { if b == '\n' { starts = append(starts, i+1) } } return starts } func buildRangeMap(data []byte) *keyIndex { idx := &keyIndex{present: map[string]bool{}, kinds: map[string]string{}, lineStarts: lineStarts(data)} arrays := map[string]int{} var p unstable.Parser p.Reset(data) record := func(path string, n *unstable.Node) { start := int(n.Raw.Offset) end := start + int(n.Raw.Length) idx.entries = append(idx.entries, rangeEntry{path: path, start: start, end: end, line: p.Shape(n.Raw).Start.Line}) } var keyValue func(prefix string, kv *unstable.Node) var value func(path string, v *unstable.Node) keyValue = func(prefix string, kv *unstable.Node) { segs, _ := keySegments(kv.Key()) path := strings.Join(segs, ".") if prefix != "" { path = prefix + "." + path } record(path, kv) value(path, kv.Value()) } value = func(path string, v *unstable.Node) { if v == nil { return } switch v.Kind { case unstable.InlineTable: it := v.Children() for it.Next() { if c := it.Node(); c.Kind == unstable.KeyValue { keyValue(path, c) } } case unstable.Array: it := v.Children() i := 0 for it.Next() { if c := it.Node(); c.Kind == unstable.InlineTable { value(fmt.Sprintf("%s[%d]", path, i), c) } i++ } } } for p.NextExpression() { e := p.Expression() switch e.Kind { case unstable.Table, unstable.ArrayTable: segs, first := keySegments(e.Key()) idx.header = indexedHeader(segs, arrays, e.Kind == unstable.ArrayTable) if first != nil { record(idx.header, first) } case unstable.KeyValue: keyValue(idx.header, e) } } return idx } func keySegments(it unstable.Iterator) ([]string, *unstable.Node) { var segs []string var first *unstable.Node for it.Next() { n := it.Node() if first == nil { first = n } segs = append(segs, renderSegment(string(n.Data))) } return segs, first } func indexedHeader(segs []string, arrays map[string]int, isArray bool) string { parts := make([]string, 0, len(segs)) for i, s := range segs { counter := strings.Join(append(parts[:i:i], s), ".") if isArray && i == len(segs)-1 { arrays[counter]++ } if n := arrays[counter]; n > 0 { s = fmt.Sprintf("%s[%d]", s, n-1) } parts = append(parts, s) } return strings.Join(parts, ".") } func documentKind(v any) string { switch t := v.(type) { case map[string]any: return kindTable case []any: for _, e := range t { if _, ok := e.(map[string]any); !ok { return kindValue } } return kindArray } return kindValue } func walkDocument(prefix string, v any, idx *keyIndex) { switch t := v.(type) { case map[string]any: for key, val := range t { path := renderSegment(key) if prefix != "" { path = prefix + "." + path } idx.present[path] = true idx.kinds[path] = documentKind(val) walkDocument(path, val, idx) } case []any: for i, e := range t { if _, ok := e.(map[string]any); ok { path := fmt.Sprintf("%s[%d]", prefix, i) idx.present[path] = true idx.kinds[path] = kindTable walkDocument(path, e, idx) } } } } func decodeProblem(file string, err error, idx *keyIndex, typed bool) Problem { p := Problem{File: file, Reason: reasonSyntax} var de *toml.DecodeError if !errors.As(err, &de) { return p } row, col := de.Position() p.Line = row msg := de.Error() switch { case typed && (strings.HasPrefix(msg, "toml: cannot decode TOML ") || strings.HasPrefix(msg, "toml: cannot store ")): p.Reason = reasonWrongType case !typed && conflictPattern.MatchString(msg): p.Reason = reasonDuplicate } p.Key = idx.keyAt(row, col) if p.Reason == reasonDuplicate { if dup, line := idx.duplicateUnder(p.Key); dup != "" { p.Key, p.Line = dup, line } } if p.Key == "" && p.Reason == reasonSyntax { p.Key = idx.header } return p } func indexKeys(file string, data []byte) (*keyIndex, []Problem) { idx := buildRangeMap(data) var doc map[string]any if err := toml.NewDecoder(bytes.NewReader(data)).Decode(&doc); err != nil { return nil, []Problem{decodeProblem(file, err, idx, false)} } walkDocument("", doc, idx) allowed := allowedPaths() paths := make([]string, 0, len(idx.present)) for path := range idx.present { paths = append(paths, path) } sort.Strings(paths) var problems []Problem var rejected []string for _, path := range paths { pattern := pathPattern(path) if underRejected(path, rejected) { continue } if want, ok := allowed[pattern]; ok { got := idx.kinds[path] if (want == kindTable || want == kindArray) && got != want && !strings.HasSuffix(path, "]") { rejected = append(rejected, path) problems = append(problems, Problem{File: file, Line: idx.line(path), Key: path, Reason: reasonWrongType}) } continue } if strings.HasSuffix(path, "]") { continue } rejected = append(rejected, path) reason := reasonUnknownKey for a := range allowed { if strings.EqualFold(a, pattern) { reason = reasonUnknownKeyCase break } } problems = append(problems, Problem{File: file, Line: idx.line(path), Key: path, Reason: reason}) } return idx, problems } func underRejected(path string, rejected []string) bool { for _, r := range rejected { if strings.HasPrefix(path, r+".") || strings.HasPrefix(path, r+"[") { return true } } return false }