This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / spindle / engine / manifest.go
7.0 kB 242 lines
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}