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, renamed ...map[string]string) 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 var renames map[string]string
90 if len(renamed) > 0 {
91 renames = renamed[0]
92 }
93 err := checkNode(doc.Content[0], reflect.TypeOf(schema), "", genericWorkflowKeys, renames)
94 var me *manifestError
95 if !errors.As(err, &me) {
96 return err // nil
97 }
98 if frame := codeFrame(raw, me.line); frame != "" {
99 return fmt.Errorf("%s\n\n%s", me.msg, frame)
100 }
101 return errors.New(me.msg)
102}
103
104// checkNode walks a yaml node against the type it's expected to decode into,
105// recursing through structs, maps and slices. allowExtra names keys that are
106// valid at this level despite not being in the struct (only the root uses it).
107// renamed maps removed root-level keys to their new names, for better errors.
108func checkNode(node *yaml.Node, t reflect.Type, path string, allowExtra map[string]bool, renamed map[string]string) error {
109 if node.Kind == yaml.AliasNode && node.Alias != nil {
110 node = node.Alias
111 }
112 if t == nil {
113 return nil
114 }
115 if t.Kind() == reflect.Pointer {
116 t = t.Elem()
117 }
118 // `any` accepts anything (eg. registry values) so we can't check more
119 if t.Kind() == reflect.Interface {
120 return nil
121 }
122 // an empty value (eg. `registry:` with nothing under it) is harmless
123 if node.Kind == yaml.ScalarNode && (node.Tag == "!!null" || node.Value == "") {
124 return nil
125 }
126
127 want, ok := yamlKindForType(t)
128 if !ok {
129 return nil
130 }
131 if node.Kind != want {
132 return &manifestError{line: node.Line, msg: fmt.Sprintf(
133 "%s must be %s, but got %s (line %d)",
134 describePath(path), yamlKindName(want), yamlKindName(node.Kind), node.Line)}
135 }
136
137 switch t.Kind() {
138 case reflect.Struct:
139 fields := fieldsByYAMLName(t)
140 for i := 0; i+1 < len(node.Content); i += 2 {
141 key, val := node.Content[i], node.Content[i+1]
142 ft, ok := fields[key.Value]
143 if !ok {
144 // a struct has a fixed set of fields, so anything else is a typo.
145 // (maps, take arbitrary user-defined keys and don't count)
146 if allowExtra[key.Value] {
147 continue
148 }
149 if newName, wasRenamed := renamed[key.Value]; wasRenamed && path == "" {
150 return &manifestError{line: key.Line, msg: fmt.Sprintf(
151 "field %s was renamed to %s (line %d)",
152 describePath(joinKey(path, key.Value)), newName, key.Line)}
153 }
154 return &manifestError{line: key.Line, msg: fmt.Sprintf(
155 "unknown field %s (line %d)",
156 describePath(joinKey(path, key.Value)), key.Line)}
157 }
158 if err := checkNode(val, ft, joinKey(path, key.Value), nil, nil); err != nil {
159 return err
160 }
161 }
162 case reflect.Map:
163 for i := 0; i+1 < len(node.Content); i += 2 {
164 key, val := node.Content[i], node.Content[i+1]
165 if err := checkNode(val, t.Elem(), joinKey(path, key.Value), nil, nil); err != nil {
166 return err
167 }
168 }
169 case reflect.Slice, reflect.Array:
170 for idx, val := range node.Content {
171 if err := checkNode(val, t.Elem(), fmt.Sprintf("%s[%d]", path, idx), nil, nil); err != nil {
172 return err
173 }
174 }
175 }
176 return nil
177}
178
179// fieldsByYAMLName maps a struct's yaml keys to their field types, mirroring how
180// yaml.v3 resolves keys: explicit tag name, else the lowercased field name.
181func fieldsByYAMLName(t reflect.Type) map[string]reflect.Type {
182 fields := make(map[string]reflect.Type)
183 for i := 0; i < t.NumField(); i++ {
184 f := t.Field(i)
185 name, _, _ := strings.Cut(f.Tag.Get("yaml"), ",")
186 if name == "-" {
187 continue
188 }
189 if name == "" {
190 name = strings.ToLower(f.Name)
191 }
192 fields[name] = f.Type
193 }
194 return fields
195}
196
197func joinKey(path, key string) string {
198 if path == "" {
199 return key
200 }
201 return path + "." + key
202}
203
204func describePath(path string) string {
205 if path == "" {
206 return "the manifest"
207 }
208 return "`" + path + "`"
209}
210
211func yamlKindForType(t reflect.Type) (yaml.Kind, bool) {
212 switch t.Kind() {
213 case reflect.Pointer:
214 return yamlKindForType(t.Elem())
215 case reflect.Map, reflect.Struct:
216 return yaml.MappingNode, true
217 case reflect.Slice, reflect.Array:
218 return yaml.SequenceNode, true
219 case reflect.String, reflect.Bool,
220 reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
221 reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
222 reflect.Float32, reflect.Float64:
223 return yaml.ScalarNode, true
224 default:
225 return 0, false
226 }
227}
228
229func yamlKindName(k yaml.Kind) string {
230 switch k {
231 case yaml.MappingNode:
232 return "a mapping"
233 case yaml.SequenceNode:
234 return "a list"
235 case yaml.ScalarNode:
236 return "a scalar value"
237 case yaml.AliasNode:
238 return "an alias"
239 default:
240 return "an unknown value"
241 }
242}