1package config
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "reflect"
8 "regexp"
9 "sort"
10 "strconv"
11 "strings"
12
13 "github.com/pelletier/go-toml/v2"
14 "github.com/pelletier/go-toml/v2/unstable"
15)
16
17var (
18 indexPattern = regexp.MustCompile(`\[\d+\]`)
19 bareKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
20 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)$`)
21)
22
23const (
24 kindTable = "table"
25 kindArray = "array"
26 kindValue = "value"
27)
28
29func renderSegment(s string) string {
30 if bareKeyPattern.MatchString(s) {
31 return s
32 }
33 return strconv.Quote(s)
34}
35
36func schemaPaths(t reflect.Type, prefix string, out map[string]string) {
37 for t.Kind() == reflect.Pointer {
38 t = t.Elem()
39 }
40 for i := 0; i < t.NumField(); i++ {
41 f := t.Field(i)
42 name, _, _ := strings.Cut(f.Tag.Get("toml"), ",")
43 if name == "" || name == "-" {
44 continue
45 }
46 path := name
47 if prefix != "" {
48 path = prefix + "." + name
49 }
50 out[path] = kindValue
51 ft := f.Type
52 for ft.Kind() == reflect.Pointer {
53 ft = ft.Elem()
54 }
55 switch {
56 case ft.Kind() == reflect.Struct:
57 out[path] = kindTable
58 schemaPaths(ft, path, out)
59 case ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.Struct:
60 out[path] = kindArray
61 out[path+"[]"] = kindTable
62 schemaPaths(ft.Elem(), path+"[]", out)
63 }
64 }
65}
66
67func allowedPaths() map[string]string {
68 out := map[string]string{}
69 schemaPaths(reflect.TypeOf(rawFile{}), "", out)
70 return out
71}
72
73func pathPattern(path string) string {
74 return indexPattern.ReplaceAllString(path, "[]")
75}
76
77type rangeEntry struct {
78 path string
79 start, end int
80 line int
81}
82
83type keyIndex struct {
84 present map[string]bool
85 kinds map[string]string
86 entries []rangeEntry
87 lineStarts []int
88 header string
89}
90
91func (k *keyIndex) has(path string) bool {
92 return k.present[path]
93}
94
95func (k *keyIndex) line(path string) int {
96 best := 0
97 for _, e := range k.entries {
98 if e.path == path {
99 return e.line
100 }
101 if (strings.HasPrefix(e.path, path+".") || strings.HasPrefix(e.path, path+"[")) && (best == 0 || e.line < best) {
102 best = e.line
103 }
104 }
105 return best
106}
107
108func (k *keyIndex) duplicateUnder(key string) (string, int) {
109 seen := map[string]int{}
110 best, line := "", 0
111 for _, e := range k.entries {
112 seen[e.path]++
113 if seen[e.path] == 2 && (e.path == key || strings.HasPrefix(e.path, key+".") || strings.HasPrefix(e.path, key+"[")) && len(e.path) > len(best) {
114 best, line = e.path, e.line
115 }
116 }
117 return best, line
118}
119
120func (k *keyIndex) offset(row, col int) int {
121 if row < 1 || row > len(k.lineStarts) {
122 return -1
123 }
124 return k.lineStarts[row-1] + col - 1
125}
126
127func (k *keyIndex) keyAt(row, col int) string {
128 off := k.offset(row, col)
129 best, bestLen := "", -1
130 for _, e := range k.entries {
131 if off >= e.start && off < e.end && (bestLen < 0 || e.end-e.start < bestLen) {
132 best, bestLen = e.path, e.end-e.start
133 }
134 }
135 return best
136}
137
138func lineStarts(data []byte) []int {
139 starts := []int{0}
140 for i, b := range data {
141 if b == '\n' {
142 starts = append(starts, i+1)
143 }
144 }
145 return starts
146}
147
148func buildRangeMap(data []byte) *keyIndex {
149 idx := &keyIndex{present: map[string]bool{}, kinds: map[string]string{}, lineStarts: lineStarts(data)}
150 arrays := map[string]int{}
151 var p unstable.Parser
152 p.Reset(data)
153 record := func(path string, n *unstable.Node) {
154 start := int(n.Raw.Offset)
155 end := start + int(n.Raw.Length)
156 idx.entries = append(idx.entries, rangeEntry{path: path, start: start, end: end, line: p.Shape(n.Raw).Start.Line})
157 }
158 var keyValue func(prefix string, kv *unstable.Node)
159 var value func(path string, v *unstable.Node)
160 keyValue = func(prefix string, kv *unstable.Node) {
161 segs, _ := keySegments(kv.Key())
162 path := strings.Join(segs, ".")
163 if prefix != "" {
164 path = prefix + "." + path
165 }
166 record(path, kv)
167 value(path, kv.Value())
168 }
169 value = func(path string, v *unstable.Node) {
170 if v == nil {
171 return
172 }
173 switch v.Kind {
174 case unstable.InlineTable:
175 it := v.Children()
176 for it.Next() {
177 if c := it.Node(); c.Kind == unstable.KeyValue {
178 keyValue(path, c)
179 }
180 }
181 case unstable.Array:
182 it := v.Children()
183 i := 0
184 for it.Next() {
185 if c := it.Node(); c.Kind == unstable.InlineTable {
186 value(fmt.Sprintf("%s[%d]", path, i), c)
187 }
188 i++
189 }
190 }
191 }
192 for p.NextExpression() {
193 e := p.Expression()
194 switch e.Kind {
195 case unstable.Table, unstable.ArrayTable:
196 segs, first := keySegments(e.Key())
197 idx.header = indexedHeader(segs, arrays, e.Kind == unstable.ArrayTable)
198 if first != nil {
199 record(idx.header, first)
200 }
201 case unstable.KeyValue:
202 keyValue(idx.header, e)
203 }
204 }
205 return idx
206}
207
208func keySegments(it unstable.Iterator) ([]string, *unstable.Node) {
209 var segs []string
210 var first *unstable.Node
211 for it.Next() {
212 n := it.Node()
213 if first == nil {
214 first = n
215 }
216 segs = append(segs, renderSegment(string(n.Data)))
217 }
218 return segs, first
219}
220
221func indexedHeader(segs []string, arrays map[string]int, isArray bool) string {
222 parts := make([]string, 0, len(segs))
223 for i, s := range segs {
224 counter := strings.Join(append(parts[:i:i], s), ".")
225 if isArray && i == len(segs)-1 {
226 arrays[counter]++
227 }
228 if n := arrays[counter]; n > 0 {
229 s = fmt.Sprintf("%s[%d]", s, n-1)
230 }
231 parts = append(parts, s)
232 }
233 return strings.Join(parts, ".")
234}
235
236func documentKind(v any) string {
237 switch t := v.(type) {
238 case map[string]any:
239 return kindTable
240 case []any:
241 for _, e := range t {
242 if _, ok := e.(map[string]any); !ok {
243 return kindValue
244 }
245 }
246 return kindArray
247 }
248 return kindValue
249}
250
251func walkDocument(prefix string, v any, idx *keyIndex) {
252 switch t := v.(type) {
253 case map[string]any:
254 for key, val := range t {
255 path := renderSegment(key)
256 if prefix != "" {
257 path = prefix + "." + path
258 }
259 idx.present[path] = true
260 idx.kinds[path] = documentKind(val)
261 walkDocument(path, val, idx)
262 }
263 case []any:
264 for i, e := range t {
265 if _, ok := e.(map[string]any); ok {
266 path := fmt.Sprintf("%s[%d]", prefix, i)
267 idx.present[path] = true
268 idx.kinds[path] = kindTable
269 walkDocument(path, e, idx)
270 }
271 }
272 }
273}
274
275func decodeProblem(file string, err error, idx *keyIndex, typed bool) Problem {
276 p := Problem{File: file, Reason: reasonSyntax}
277 var de *toml.DecodeError
278 if !errors.As(err, &de) {
279 return p
280 }
281 row, col := de.Position()
282 p.Line = row
283 msg := de.Error()
284 switch {
285 case typed && (strings.HasPrefix(msg, "toml: cannot decode TOML ") || strings.HasPrefix(msg, "toml: cannot store ")):
286 p.Reason = reasonWrongType
287 case !typed && conflictPattern.MatchString(msg):
288 p.Reason = reasonDuplicate
289 }
290 p.Key = idx.keyAt(row, col)
291 if p.Reason == reasonDuplicate {
292 if dup, line := idx.duplicateUnder(p.Key); dup != "" {
293 p.Key, p.Line = dup, line
294 }
295 }
296 if p.Key == "" && p.Reason == reasonSyntax {
297 p.Key = idx.header
298 }
299 return p
300}
301
302func indexKeys(file string, data []byte) (*keyIndex, []Problem) {
303 idx := buildRangeMap(data)
304 var doc map[string]any
305 if err := toml.NewDecoder(bytes.NewReader(data)).Decode(&doc); err != nil {
306 return nil, []Problem{decodeProblem(file, err, idx, false)}
307 }
308 walkDocument("", doc, idx)
309
310 allowed := allowedPaths()
311 paths := make([]string, 0, len(idx.present))
312 for path := range idx.present {
313 paths = append(paths, path)
314 }
315 sort.Strings(paths)
316 var problems []Problem
317 var rejected []string
318 for _, path := range paths {
319 pattern := pathPattern(path)
320 if underRejected(path, rejected) {
321 continue
322 }
323 if want, ok := allowed[pattern]; ok {
324 got := idx.kinds[path]
325 if (want == kindTable || want == kindArray) && got != want && !strings.HasSuffix(path, "]") {
326 rejected = append(rejected, path)
327 problems = append(problems, Problem{File: file, Line: idx.line(path), Key: path, Reason: reasonWrongType})
328 }
329 continue
330 }
331 if strings.HasSuffix(path, "]") {
332 continue
333 }
334 rejected = append(rejected, path)
335 reason := reasonUnknownKey
336 for a := range allowed {
337 if strings.EqualFold(a, pattern) {
338 reason = reasonUnknownKeyCase
339 break
340 }
341 }
342 problems = append(problems, Problem{File: file, Line: idx.line(path), Key: path, Reason: reason})
343 }
344 return idx, problems
345}
346
347func underRejected(path string, rejected []string) bool {
348 for _, r := range rejected {
349 if strings.HasPrefix(path, r+".") || strings.HasPrefix(path, r+"[") {
350 return true
351 }
352 }
353 return false
354}