cuelang.org/go@v0.13.0/internal/encoding/yaml/decode.go (about) 1 package yaml 2 3 import ( 4 "bytes" 5 "encoding/base64" 6 "errors" 7 "fmt" 8 "io" 9 "regexp" 10 "slices" 11 "strconv" 12 "strings" 13 "sync" 14 15 "gopkg.in/yaml.v3" 16 17 "cuelang.org/go/cue/ast" 18 "cuelang.org/go/cue/literal" 19 "cuelang.org/go/cue/token" 20 "cuelang.org/go/internal" 21 ) 22 23 // TODO(mvdan): we should sanity check that the decoder always produces valid CUE, 24 // as it is possible to construct a cue/ast syntax tree with invalid literals 25 // or with expressions that will always error, such as `float & 123`. 26 // 27 // One option would be to do this as part of the tests; a more general approach 28 // may be fuzzing, which would find more bugs and work for any decoder, 29 // although it may be slow as we need to involve the evaluator. 30 31 // Decoder is a temporary interface compatible with both the old and new yaml decoders. 32 type Decoder interface { 33 // Decode consumes a YAML value and returns it in CUE syntax tree node. 34 Decode() (ast.Expr, error) 35 } 36 37 // decoder wraps a [yaml.Decoder] to extract CUE syntax tree nodes. 38 type decoder struct { 39 yamlDecoder yaml.Decoder 40 41 // yamlNonEmpty is true once yamlDecoder tells us the input YAML wasn't empty. 42 // Useful so that we can extract "null" when the input is empty. 43 yamlNonEmpty bool 44 45 // decodeErr is returned by any further calls to Decode when not nil. 46 decodeErr error 47 48 tokFile *token.File 49 tokLines []int 50 51 // pendingHeadComments collects the head (preceding) comments 52 // from the YAML nodes we are extracting. 53 // We can't add comments to a CUE syntax tree node until we've created it, 54 // but we need to extract these comments first since they have earlier positions. 55 pendingHeadComments []*ast.Comment 56 57 // extractingAliases ensures we don't loop forever when expanding YAML anchors. 58 extractingAliases map[*yaml.Node]bool 59 60 // lastPos is the last YAML node position that we decoded, 61 // used for working out relative positions such as token.NewSection. 62 // This position can only increase, moving forward in the file. 63 lastPos token.Position 64 65 // forceNewline ensures that the next position will be on a new line. 66 forceNewline bool 67 } 68 69 // TODO(mvdan): this can be io.Reader really, except that token.Pos is offset-based, 70 // so the only way to really have true Offset+Line+Col numbers is to know 71 // the size of the entire YAML node upfront. 72 // With json we can use RawMessage to know the size of the input 73 // before we extract into ast.Expr, but unfortunately, yaml.Node has no size. 74 75 // NewDecoder creates a decoder for YAML values to extract CUE syntax tree nodes. 76 // 77 // The filename is used for position information in CUE syntax tree nodes 78 // as well as any errors encountered while decoding YAML. 79 func NewDecoder(filename string, b []byte) *decoder { 80 // Note that yaml.v3 can insert a null node just past the end of the input 81 // in some edge cases, so we pretend that there's an extra newline 82 // so that we don't panic when handling such a position. 83 tokFile := token.NewFile(filename, 0, len(b)+1) 84 tokFile.SetLinesForContent(b) 85 return &decoder{ 86 tokFile: tokFile, 87 tokLines: append(tokFile.Lines(), len(b)), 88 yamlDecoder: *yaml.NewDecoder(bytes.NewReader(b)), 89 } 90 } 91 92 // Decode consumes a YAML value and returns it in CUE syntax tree node. 93 // 94 // A nil node with an io.EOF error is returned once no more YAML values 95 // are available for decoding. 96 func (d *decoder) Decode() (ast.Expr, error) { 97 if err := d.decodeErr; err != nil { 98 return nil, err 99 } 100 var yn yaml.Node 101 if err := d.yamlDecoder.Decode(&yn); err != nil { 102 if err == io.EOF { 103 // Any further Decode calls must return EOF to avoid an endless loop. 104 d.decodeErr = io.EOF 105 106 // If the input is empty, we produce `*null | _` followed by EOF. 107 // Note that when the input contains "---", we get an empty document 108 // with a null scalar value inside instead. 109 if !d.yamlNonEmpty { 110 // Attach positions which at least point to the filename. 111 pos := d.tokFile.Pos(0, token.NoRelPos) 112 return &ast.BinaryExpr{ 113 Op: token.OR, 114 OpPos: pos, 115 X: &ast.UnaryExpr{ 116 Op: token.MUL, 117 OpPos: pos, 118 X: &ast.BasicLit{ 119 Kind: token.NULL, 120 ValuePos: pos, 121 Value: "null", 122 }, 123 }, 124 Y: &ast.Ident{ 125 Name: "_", 126 NamePos: pos, 127 }, 128 }, nil 129 } 130 // If the input wasn't empty, we already decoded some CUE syntax nodes, 131 // so here we should just return io.EOF to stop. 132 return nil, io.EOF 133 } 134 // Unfortunately, yaml.v3's syntax errors are opaque strings, 135 // and they only include line numbers in some but not all cases. 136 // TODO(mvdan): improve upstream's errors so they are structured 137 // and always contain some position information. 138 e := err.Error() 139 if s, ok := strings.CutPrefix(e, "yaml: line "); ok { 140 // From "yaml: line 3: some issue" to "foo.yaml:3: some issue". 141 e = d.tokFile.Name() + ":" + s 142 } else if s, ok := strings.CutPrefix(e, "yaml:"); ok { 143 // From "yaml: some issue" to "foo.yaml: some issue". 144 e = d.tokFile.Name() + ":" + s 145 } else { 146 return nil, err 147 } 148 err = errors.New(e) 149 // Any further Decode calls repeat this error. 150 d.decodeErr = err 151 return nil, err 152 } 153 d.yamlNonEmpty = true 154 return d.extract(&yn) 155 } 156 157 // Unmarshal parses a single YAML value to a CUE expression. 158 func Unmarshal(filename string, data []byte) (ast.Expr, error) { 159 d := NewDecoder(filename, data) 160 n, err := d.Decode() 161 if err != nil { 162 if err == io.EOF { 163 return nil, nil // empty input 164 } 165 return nil, err 166 } 167 // TODO(mvdan): decoding the entire next value is unnecessary; 168 // consider either a "More" or "Done" method to tell if we are at EOF, 169 // or splitting the Decode method into two variants. 170 // This should use proper error values with positions as well. 171 if n2, err := d.Decode(); err == nil { 172 return nil, fmt.Errorf("%s: expected a single YAML document", n2.Pos()) 173 } else if err != io.EOF { 174 return nil, fmt.Errorf("expected a single YAML document: %v", err) 175 } 176 return n, nil 177 } 178 179 func (d *decoder) extract(yn *yaml.Node) (ast.Expr, error) { 180 d.addHeadCommentsToPending(yn) 181 var expr ast.Expr 182 var err error 183 switch yn.Kind { 184 case yaml.DocumentNode: 185 expr, err = d.document(yn) 186 case yaml.SequenceNode: 187 expr, err = d.sequence(yn) 188 case yaml.MappingNode: 189 expr, err = d.mapping(yn) 190 case yaml.ScalarNode: 191 expr, err = d.scalar(yn) 192 case yaml.AliasNode: 193 expr, err = d.alias(yn) 194 default: 195 return nil, d.posErrorf(yn, "unknown yaml node kind: %d", yn.Kind) 196 } 197 if err != nil { 198 return nil, err 199 } 200 d.addCommentsToNode(expr, yn, 1) 201 return expr, nil 202 } 203 204 // comments parses a newline-delimited list of YAML "#" comments 205 // and turns them into a list of cue/ast comments. 206 func (d *decoder) comments(src string) []*ast.Comment { 207 if src == "" { 208 return nil 209 } 210 var comments []*ast.Comment 211 for _, line := range strings.Split(src, "\n") { 212 if line == "" { 213 continue // yaml.v3 comments have a trailing newline at times 214 } 215 comments = append(comments, &ast.Comment{ 216 // Trim the leading "#". 217 // Note that yaml.v3 does not give us comment positions. 218 Text: "//" + line[1:], 219 }) 220 } 221 return comments 222 } 223 224 // addHeadCommentsToPending parses a node's head comments and adds them to a pending list, 225 // to be used later by addComments once a cue/ast node is constructed. 226 func (d *decoder) addHeadCommentsToPending(yn *yaml.Node) { 227 comments := d.comments(yn.HeadComment) 228 // TODO(mvdan): once yaml.v3 records comment positions, 229 // we can better ensure that sections separated by empty lines are kept that way. 230 // For now, all we can do is approximate by counting lines, 231 // and assuming that head comments are not separated from their node. 232 // This will be wrong in some cases, moving empty lines, but is better than nothing. 233 if len(d.pendingHeadComments) == 0 && len(comments) > 0 { 234 c := comments[0] 235 if d.lastPos.IsValid() && (yn.Line-len(comments))-d.lastPos.Line >= 2 { 236 c.Slash = c.Slash.WithRel(token.NewSection) 237 } 238 } 239 d.pendingHeadComments = append(d.pendingHeadComments, comments...) 240 } 241 242 // addCommentsToNode adds any pending head comments, plus a YAML node's line 243 // and foot comments, to a cue/ast node. 244 func (d *decoder) addCommentsToNode(n ast.Node, yn *yaml.Node, linePos int8) { 245 // cue/ast and cue/format are not able to attach a comment to a node 246 // when the comment immediately follows the node. 247 // For some nodes like fields, the best we can do is move the comments up. 248 // For the root-level struct, we do want to leave comments 249 // at the end of the document to be left at the very end. 250 // 251 // TODO(mvdan): can we do better? for example, support attaching trailing comments to a cue/ast.Node? 252 footComments := d.comments(yn.FootComment) 253 if _, ok := n.(*ast.StructLit); !ok { 254 d.pendingHeadComments = append(d.pendingHeadComments, footComments...) 255 footComments = nil 256 } 257 if comments := d.pendingHeadComments; len(comments) > 0 { 258 ast.AddComment(n, &ast.CommentGroup{ 259 Doc: true, 260 Position: 0, 261 List: comments, 262 }) 263 } 264 if comments := d.comments(yn.LineComment); len(comments) > 0 { 265 ast.AddComment(n, &ast.CommentGroup{ 266 Line: true, 267 Position: linePos, 268 List: comments, 269 }) 270 } 271 if comments := footComments; len(comments) > 0 { 272 ast.AddComment(n, &ast.CommentGroup{ 273 // After 100 tokens, so that the comment goes after the entire node. 274 // TODO(mvdan): this is hacky, can the cue/ast API support trailing comments better? 275 Position: 100, 276 List: comments, 277 }) 278 } 279 d.pendingHeadComments = nil 280 } 281 282 func (d *decoder) posErrorf(yn *yaml.Node, format string, args ...any) error { 283 // TODO(mvdan): use columns as well; for now they are left out to avoid test churn 284 // return fmt.Errorf(d.pos(n).String()+" "+format, args...) 285 return fmt.Errorf(d.tokFile.Name()+":"+strconv.Itoa(yn.Line)+": "+format, args...) 286 } 287 288 // pos converts a YAML node position to a cue/ast position. 289 // Note that this method uses and updates the last position in lastPos, 290 // so it should be called on YAML nodes in increasing position order. 291 func (d *decoder) pos(yn *yaml.Node) token.Pos { 292 // Calculate the position's offset via the line and column numbers. 293 offset := d.tokLines[yn.Line-1] + (yn.Column - 1) 294 pos := d.tokFile.Pos(offset, token.NoRelPos) 295 296 if d.forceNewline { 297 d.forceNewline = false 298 pos = pos.WithRel(token.Newline) 299 } else if d.lastPos.IsValid() { 300 switch { 301 case yn.Line-d.lastPos.Line >= 2: 302 pos = pos.WithRel(token.NewSection) 303 case yn.Line-d.lastPos.Line == 1: 304 pos = pos.WithRel(token.Newline) 305 case yn.Column-d.lastPos.Column > 0: 306 pos = pos.WithRel(token.Blank) 307 default: 308 pos = pos.WithRel(token.NoSpace) 309 } 310 // If for any reason the node's position is before the last position, 311 // give up and return an empty position. Akin to: yn.Pos().Before(d.lastPos) 312 // 313 // TODO(mvdan): Brought over from the old decoder; when does this happen? 314 // Can we get rid of those edge cases and this bit of logic? 315 if yn.Line < d.lastPos.Line || (yn.Line == d.lastPos.Line && yn.Column < d.lastPos.Column) { 316 return token.NoPos 317 } 318 } 319 d.lastPos = token.Position{Line: yn.Line, Column: yn.Column} 320 return pos 321 } 322 323 func (d *decoder) document(yn *yaml.Node) (ast.Expr, error) { 324 if n := len(yn.Content); n != 1 { 325 return nil, d.posErrorf(yn, "yaml document nodes are meant to have one content node but have %d", n) 326 } 327 return d.extract(yn.Content[0]) 328 } 329 330 func (d *decoder) sequence(yn *yaml.Node) (ast.Expr, error) { 331 list := &ast.ListLit{ 332 Lbrack: d.pos(yn).WithRel(token.Blank), 333 } 334 multiline := false 335 if len(yn.Content) > 0 { 336 multiline = yn.Line < yn.Content[len(yn.Content)-1].Line 337 } 338 339 // If a list is empty, or ends with a struct, the closing `]` is on the same line. 340 closeSameLine := true 341 for _, c := range yn.Content { 342 d.forceNewline = multiline 343 elem, err := d.extract(c) 344 if err != nil { 345 return nil, err 346 } 347 list.Elts = append(list.Elts, elem) 348 // A list of structs begins with `[{`, so let it end with `}]`. 349 _, closeSameLine = elem.(*ast.StructLit) 350 } 351 if multiline && !closeSameLine { 352 list.Rbrack = list.Rbrack.WithRel(token.Newline) 353 } 354 return list, nil 355 } 356 357 func (d *decoder) mapping(yn *yaml.Node) (ast.Expr, error) { 358 strct := &ast.StructLit{} 359 multiline := false 360 if len(yn.Content) > 0 { 361 multiline = yn.Line < yn.Content[len(yn.Content)-1].Line 362 } 363 364 if err := d.insertMap(yn, strct, multiline, false); err != nil { 365 return nil, err 366 } 367 // TODO(mvdan): moving these positions above insertMap breaks a few tests, why? 368 strct.Lbrace = d.pos(yn).WithRel(token.Blank) 369 if multiline { 370 strct.Rbrace = strct.Lbrace.WithRel(token.Newline) 371 } else { 372 strct.Rbrace = strct.Lbrace 373 } 374 return strct, nil 375 } 376 377 func (d *decoder) insertMap(yn *yaml.Node, m *ast.StructLit, multiline, mergeValues bool) error { 378 l := len(yn.Content) 379 outer: 380 for i := 0; i < l; i += 2 { 381 if multiline { 382 d.forceNewline = true 383 } 384 yk, yv := yn.Content[i], yn.Content[i+1] 385 d.addHeadCommentsToPending(yk) 386 if isMerge(yk) { 387 mergeValues = true 388 if err := d.merge(yv, m, multiline); err != nil { 389 return err 390 } 391 continue 392 } 393 394 field := &ast.Field{} 395 label, err := d.label(yk) 396 if err != nil { 397 return err 398 } 399 d.addCommentsToNode(field, yk, 2) 400 field.Label = label 401 402 if mergeValues { 403 key := labelStr(label) 404 for _, decl := range m.Elts { 405 f := decl.(*ast.Field) 406 name, _, err := ast.LabelName(f.Label) 407 if err == nil && name == key { 408 f.Value, err = d.extract(yv) 409 if err != nil { 410 return err 411 } 412 continue outer 413 } 414 } 415 } 416 417 value, err := d.extract(yv) 418 if err != nil { 419 return err 420 } 421 field.Value = value 422 423 m.Elts = append(m.Elts, field) 424 } 425 return nil 426 } 427 428 func (d *decoder) merge(yn *yaml.Node, m *ast.StructLit, multiline bool) error { 429 switch yn.Kind { 430 case yaml.MappingNode: 431 return d.insertMap(yn, m, multiline, true) 432 case yaml.AliasNode: 433 return d.insertMap(yn.Alias, m, multiline, true) 434 case yaml.SequenceNode: 435 // Step backwards as earlier nodes take precedence. 436 for _, c := range slices.Backward(yn.Content) { 437 if err := d.merge(c, m, multiline); err != nil { 438 return err 439 } 440 } 441 return nil 442 default: 443 return d.posErrorf(yn, "map merge requires map or sequence of maps as the value") 444 } 445 } 446 447 func (d *decoder) label(yn *yaml.Node) (ast.Label, error) { 448 pos := d.pos(yn) 449 450 var expr ast.Expr 451 var err error 452 var value string 453 switch yn.Kind { 454 case yaml.ScalarNode: 455 expr, err = d.scalar(yn) 456 value = yn.Value 457 case yaml.AliasNode: 458 if yn.Alias.Kind != yaml.ScalarNode { 459 return nil, d.posErrorf(yn, "invalid map key: %v", yn.Alias.ShortTag()) 460 } 461 expr, err = d.alias(yn) 462 value = yn.Alias.Value 463 default: 464 return nil, d.posErrorf(yn, "invalid map key: %v", yn.ShortTag()) 465 } 466 if err != nil { 467 return nil, err 468 } 469 470 switch expr := expr.(type) { 471 case *ast.BasicLit: 472 if expr.Kind == token.STRING { 473 if ast.IsValidIdent(value) && !internal.IsDefOrHidden(value) { 474 return &ast.Ident{ 475 NamePos: pos, 476 Name: value, 477 }, nil 478 } 479 ast.SetPos(expr, pos) 480 return expr, nil 481 } 482 483 return &ast.BasicLit{ 484 ValuePos: pos, 485 Kind: token.STRING, 486 Value: literal.Label.Quote(expr.Value), 487 }, nil 488 489 default: 490 return nil, d.posErrorf(yn, "invalid label "+value) 491 } 492 } 493 494 const ( 495 // TODO(mvdan): The strings below are from yaml.v3; should we be relying on upstream somehow? 496 nullTag = "!!null" 497 boolTag = "!!bool" 498 strTag = "!!str" 499 intTag = "!!int" 500 floatTag = "!!float" 501 timestampTag = "!!timestamp" 502 seqTag = "!!seq" 503 mapTag = "!!map" 504 binaryTag = "!!binary" 505 mergeTag = "!!merge" 506 ) 507 508 // rxAnyOctalYaml11 uses the implicit tag resolution regular expression for base-8 integers 509 // from YAML's 1.1 spec, but including the 8 and 9 digits which aren't valid for octal integers. 510 var rxAnyOctalYaml11 = sync.OnceValue(func() *regexp.Regexp { 511 return regexp.MustCompile(`^[-+]?0[0-9_]+$`) 512 }) 513 514 func (d *decoder) scalar(yn *yaml.Node) (ast.Expr, error) { 515 tag := yn.ShortTag() 516 // If the YAML scalar has no explicit tag, yaml.v3 infers a float tag, 517 // and the value looks like a YAML 1.1 octal literal, 518 // that means the input value was like `01289` and not a valid octal integer. 519 // The safest thing to do, and what most YAML decoders do, is to interpret as a string. 520 if yn.Style&yaml.TaggedStyle == 0 && tag == floatTag && rxAnyOctalYaml11().MatchString(yn.Value) { 521 tag = strTag 522 } 523 switch tag { 524 // TODO: use parse literal or parse expression instead. 525 case timestampTag: 526 return &ast.BasicLit{ 527 ValuePos: d.pos(yn), 528 Kind: token.STRING, 529 Value: literal.String.Quote(yn.Value), 530 }, nil 531 case strTag: 532 return &ast.BasicLit{ 533 ValuePos: d.pos(yn), 534 Kind: token.STRING, 535 Value: literal.String.WithOptionalTabIndent(1).Quote(yn.Value), 536 }, nil 537 538 case binaryTag: 539 data, err := base64.StdEncoding.DecodeString(yn.Value) 540 if err != nil { 541 return nil, d.posErrorf(yn, "!!binary value contains invalid base64 data") 542 } 543 return &ast.BasicLit{ 544 ValuePos: d.pos(yn), 545 Kind: token.STRING, 546 Value: literal.Bytes.Quote(string(data)), 547 }, nil 548 549 case boolTag: 550 t := false 551 switch yn.Value { 552 // TODO(mvdan): The strings below are from yaml.v3; should we be relying on upstream somehow? 553 case "true", "True", "TRUE": 554 t = true 555 } 556 lit := ast.NewBool(t) 557 lit.ValuePos = d.pos(yn) 558 return lit, nil 559 560 case intTag: 561 // Convert YAML octal to CUE octal. If YAML accepted an invalid 562 // integer, just convert it as well to ensure CUE will fail. 563 value := yn.Value 564 if len(value) > 1 && value[0] == '0' && value[1] <= '9' { 565 value = "0o" + value[1:] 566 } 567 var info literal.NumInfo 568 // We make the assumption that any valid YAML integer literal will be a valid 569 // CUE integer literal as well, with the only exception of octal numbers above. 570 // Note that `!!int 123.456` is not allowed. 571 if err := literal.ParseNum(value, &info); err != nil { 572 return nil, d.posErrorf(yn, "cannot decode %q as %s: %v", value, tag, err) 573 } else if !info.IsInt() { 574 return nil, d.posErrorf(yn, "cannot decode %q as %s: not a literal number", value, tag) 575 } 576 return d.makeNum(yn, value, token.INT), nil 577 578 case floatTag: 579 value := yn.Value 580 // TODO(mvdan): The strings below are from yaml.v3; should we be relying on upstream somehow? 581 switch value { 582 case ".inf", ".Inf", ".INF", "+.inf", "+.Inf", "+.INF": 583 value = "+Inf" 584 case "-.inf", "-.Inf", "-.INF": 585 value = "-Inf" 586 case ".nan", ".NaN", ".NAN": 587 value = "NaN" 588 default: 589 var info literal.NumInfo 590 // We make the assumption that any valid YAML float literal will be a valid 591 // CUE float literal as well, with the only exception of Inf/NaN above. 592 // Note that `!!float 123` is allowed. 593 if err := literal.ParseNum(value, &info); err != nil { 594 return nil, d.posErrorf(yn, "cannot decode %q as %s: %v", value, tag, err) 595 } 596 // If the decoded YAML scalar was explicitly or implicitly a float, 597 // and the scalar literal looks like an integer, 598 // unify it with "number" to record the fact that it was represented as a float. 599 // Don't unify with float, as `float & 123` is invalid, and there's no need 600 // to forbid representing the number as an integer either. 601 if yn.Tag != "" { 602 if p := strings.IndexAny(value, ".eEiInN"); p == -1 { 603 // TODO: number(v) when we have conversions 604 // TODO(mvdan): don't shove the unification inside a BasicLit.Value string 605 // 606 // TODO(mvdan): would it be better to do turn `!!float 123` into `123.0` 607 // rather than `number & 123`? Note that `float & 123` is an error. 608 value = fmt.Sprintf("number & %s", value) 609 } 610 } 611 } 612 return d.makeNum(yn, value, token.FLOAT), nil 613 614 case nullTag: 615 return &ast.BasicLit{ 616 ValuePos: d.pos(yn).WithRel(token.Blank), 617 Kind: token.NULL, 618 Value: "null", 619 }, nil 620 default: 621 return nil, d.posErrorf(yn, "cannot unmarshal tag %q", tag) 622 } 623 } 624 625 func (d *decoder) makeNum(yn *yaml.Node, val string, kind token.Token) (expr ast.Expr) { 626 val, negative := strings.CutPrefix(val, "-") 627 expr = &ast.BasicLit{ 628 ValuePos: d.pos(yn), 629 Kind: kind, 630 Value: val, 631 } 632 if negative { 633 expr = &ast.UnaryExpr{ 634 OpPos: d.pos(yn), 635 Op: token.SUB, 636 X: expr, 637 } 638 } 639 return expr 640 } 641 642 func (d *decoder) alias(yn *yaml.Node) (ast.Expr, error) { 643 if d.extractingAliases[yn] { 644 // TODO this could actually be allowed in some circumstances. 645 return nil, d.posErrorf(yn, "anchor %q value contains itself", yn.Value) 646 } 647 if d.extractingAliases == nil { 648 d.extractingAliases = make(map[*yaml.Node]bool) 649 } 650 d.extractingAliases[yn] = true 651 var node ast.Expr 652 node, err := d.extract(yn.Alias) 653 delete(d.extractingAliases, yn) 654 return node, err 655 } 656 657 func labelStr(l ast.Label) string { 658 switch l := l.(type) { 659 case *ast.Ident: 660 return l.Name 661 case *ast.BasicLit: 662 s, _ := literal.Unquote(l.Value) 663 return s 664 } 665 return "" 666 } 667 668 func isMerge(yn *yaml.Node) bool { 669 // TODO(mvdan): The boolean logic below is from yaml.v3; should we be relying on upstream somehow? 670 return yn.Kind == yaml.ScalarNode && yn.Value == "<<" && (yn.Tag == "" || yn.Tag == "!" || yn.ShortTag() == mergeTag) 671 }