This repository has no description
1package engine
2
3import (
4 "errors"
5 "fmt"
6 "reflect"
7 "strconv"
8 "strings"
9
10 "gopkg.in/yaml.v3"
11 "tangled.org/core/workflow"
12)
13
14// how many lines of context to show on above / below of an offending line.
15const frameContext = 3
16
17type manifestError struct {
18 line int
19 msg string
20}
21
22func (e *manifestError) Error() string { return e.msg }
23
24// codeFrame renders the lines around `line` with a gutter and a `>` marker on
25// the offending line, eg.
26//
27// 4 | image: alpine
28// > 5 | registre:
29// 6 | nixpkgs: github:nixos/nixpkgs/nixos-unstable
30func codeFrame(raw string, line int) string {
31 lines := strings.Split(raw, "\n")
32 if line < 1 || line > len(lines) {
33 return ""
34 }
35 start := max(line-frameContext, 1)
36 end := min(line+frameContext, len(lines))
37 width := len(strconv.Itoa(end))
38
39 var b strings.Builder
40 for n := start; n <= end; n++ {
41 marker := " "
42 if n == line {
43 marker = "> "
44 }
45 fmt.Fprintf(&b, "%s%*d | %s\n", marker, width, n, lines[n-1])
46 }
47 return strings.TrimRight(b.String(), "\n")
48}
49
50var genericWorkflowKeys = ignoredKeys(reflect.TypeFor[workflow.Workflow]())
51
52// ignoredKeys is the set of yaml keys we ignore on field checks for a struct.
53// real, parseable keys come straight from the tags (via fieldsByYAMLName); on
54// top of those we tolerate `yaml:"-"` fields by their conventional spelling.
55// those have no yaml key of their own (the program fills them in itself, eg.
56// `name` from the filename, `raw` from the file bytes), but users sometimes
57// write one in the body anyway, and that's harmless rather than a typo.
58func ignoredKeys(t reflect.Type) map[string]bool {
59 if t.Kind() == reflect.Pointer {
60 t = t.Elem()
61 }
62 keys := make(map[string]bool)
63 for k := range fieldsByYAMLName(t) {
64 keys[k] = true
65 }
66 for i := 0; i < t.NumField(); i++ {
67 f := t.Field(i)
68 if tag, _, _ := strings.Cut(f.Tag.Get("yaml"), ","); tag == "-" {
69 keys[strings.ToLower(f.Name)] = true
70 }
71 }
72 return keys
73}
74
75// this exists because yaml.v3 reports mismatches as "cannot unmarshal !!seq into
76// map[string]interface {}", which is kind of confusing, even if it outputs a line.
77// so we use reflection, walk the node tree alongside the schema type, and point
78// at the field that's actually mis-shaped.
79//
80// returns nil when nothing is structurally wrong.
81func DescribeManifestError(raw string, schema any) error {
82 var doc yaml.Node
83 if err := yaml.Unmarshal([]byte(raw), &doc); err != nil {
84 return nil
85 }
86 if len(doc.Content) == 0 {
87 return nil
88 }
89 err := checkNode(doc.Content[0], reflect.TypeOf(schema), "", genericWorkflowKeys)
90 var me *manifestError
91 if !errors.As(err, &me) {
92 return err // nil
93 }
94 if frame := codeFrame(raw, me.line); frame != "" {
95 return fmt.Errorf("%s\n\n%s", me.msg, frame)
96 }
97 return errors.New(me.msg)
98}
99
100// checkNode walks a yaml node against the type it's expected to decode into,
101// recursing through structs, maps and slices. allowExtra names keys that are
102// valid at this level despite not being in the struct (only the root uses it).
103func checkNode(node *yaml.Node, t reflect.Type, path string, allowExtra map[string]bool) error {
104 if node.Kind == yaml.AliasNode && node.Alias != nil {
105 node = node.Alias
106 }
107 if t == nil {
108 return nil
109 }
110 if t.Kind() == reflect.Pointer {
111 t = t.Elem()
112 }
113 // `any` accepts anything (eg. registry values) so we can't check more
114 if t.Kind() == reflect.Interface {
115 return nil
116 }
117 // an empty value (eg. `registry:` with nothing under it) is harmless
118 if node.Kind == yaml.ScalarNode && (node.Tag == "!!null" || node.Value == "") {
119 return nil
120 }
121
122 want, ok := yamlKindForType(t)
123 if !ok {
124 return nil
125 }
126 if node.Kind != want {
127 return &manifestError{line: node.Line, msg: fmt.Sprintf(
128 "%s must be %s, but got %s (line %d)",
129 describePath(path), yamlKindName(want), yamlKindName(node.Kind), node.Line)}
130 }
131
132 switch t.Kind() {
133 case reflect.Struct:
134 fields := fieldsByYAMLName(t)
135 for i := 0; i+1 < len(node.Content); i += 2 {
136 key, val := node.Content[i], node.Content[i+1]
137 ft, ok := fields[key.Value]
138 if !ok {
139 // a struct has a fixed set of fields, so anything else is a typo.
140 // (maps, take arbitrary user-defined keys and don't count)
141 if allowExtra[key.Value] {
142 continue
143 }
144 return &manifestError{line: key.Line, msg: fmt.Sprintf(
145 "unknown field %s (line %d)",
146 describePath(joinKey(path, key.Value)), key.Line)}
147 }
148 if err := checkNode(val, ft, joinKey(path, key.Value), nil); err != nil {
149 return err
150 }
151 }
152 case reflect.Map:
153 for i := 0; i+1 < len(node.Content); i += 2 {
154 key, val := node.Content[i], node.Content[i+1]
155 if err := checkNode(val, t.Elem(), joinKey(path, key.Value), nil); err != nil {
156 return err
157 }
158 }
159 case reflect.Slice, reflect.Array:
160 for idx, val := range node.Content {
161 if err := checkNode(val, t.Elem(), fmt.Sprintf("%s[%d]", path, idx), nil); err != nil {
162 return err
163 }
164 }
165 }
166 return nil
167}
168
169// fieldsByYAMLName maps a struct's yaml keys to their field types, mirroring how
170// yaml.v3 resolves keys: explicit tag name, else the lowercased field name.
171func fieldsByYAMLName(t reflect.Type) map[string]reflect.Type {
172 fields := make(map[string]reflect.Type)
173 for i := 0; i < t.NumField(); i++ {
174 f := t.Field(i)
175 name, _, _ := strings.Cut(f.Tag.Get("yaml"), ",")
176 if name == "-" {
177 continue
178 }
179 if name == "" {
180 name = strings.ToLower(f.Name)
181 }
182 fields[name] = f.Type
183 }
184 return fields
185}
186
187func joinKey(path, key string) string {
188 if path == "" {
189 return key
190 }
191 return path + "." + key
192}
193
194func describePath(path string) string {
195 if path == "" {
196 return "the manifest"
197 }
198 return "`" + path + "`"
199}
200
201func yamlKindForType(t reflect.Type) (yaml.Kind, bool) {
202 switch t.Kind() {
203 case reflect.Pointer:
204 return yamlKindForType(t.Elem())
205 case reflect.Map, reflect.Struct:
206 return yaml.MappingNode, true
207 case reflect.Slice, reflect.Array:
208 return yaml.SequenceNode, true
209 case reflect.String, reflect.Bool,
210 reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
211 reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
212 reflect.Float32, reflect.Float64:
213 return yaml.ScalarNode, true
214 default:
215 return 0, false
216 }
217}
218
219func yamlKindName(k yaml.Kind) string {
220 switch k {
221 case yaml.MappingNode:
222 return "a mapping"
223 case yaml.SequenceNode:
224 return "a list"
225 case yaml.ScalarNode:
226 return "a scalar value"
227 case yaml.AliasNode:
228 return "an alias"
229 default:
230 return "an unknown value"
231 }
232}