mirror of
https://github.com/mikefarah/yq.git
synced 2026-07-08 06:45:38 +00:00
239 lines
9.2 KiB
Go
239 lines
9.2 KiB
Go
//go:build !yq_noyaml
|
|
|
|
// Package yqlib provides YAML processing functionality using the goccy/go-yaml parser.
|
|
// This decoder implementation provides compatibility with the legacy yaml.v3 parser
|
|
// while leveraging the actively maintained goccy/go-yaml library.
|
|
package yqlib
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
|
|
yaml "github.com/goccy/go-yaml"
|
|
"github.com/goccy/go-yaml/ast"
|
|
)
|
|
|
|
// Constants for document processing
|
|
const (
|
|
// docSeparatorPlaceholder is used internally to mark document separators during preprocessing.
|
|
docSeparatorPlaceholder = "$yqDocSeparator$"
|
|
)
|
|
|
|
type goccyYamlDecoder struct {
|
|
decoder yaml.Decoder
|
|
cm yaml.CommentMap
|
|
|
|
prefs YamlPreferences
|
|
|
|
// leadingContent stores comments and directives found before the actual YAML content.
|
|
leadingContent string
|
|
// bufferRead is used with a TeeReader when LeadingContentPreProcessing is off,
|
|
// to capture initial bytes that might only contain comments.
|
|
bufferRead bytes.Buffer
|
|
|
|
// anchorMap stores anchors encountered within the current YAML stream.
|
|
// It is reset by Init() for each new stream.
|
|
anchorMap map[string]*CandidateNode
|
|
|
|
// dateTimePreprocessor handles automatic timestamp tagging for datetime arithmetic compatibility
|
|
dateTimePreprocessor *DateTimePreprocessor
|
|
|
|
readAnything bool // Flag to track if any actual YAML node (or synthesized comment node) has been decoded.
|
|
firstFile bool // Flag for 'evaluateTogether' mode to handle leading content only once.
|
|
documentIndex uint // Index of the current document within the stream.
|
|
}
|
|
|
|
// NewGoccyYAMLDecoder creates a new YAML decoder using the goccy/go-yaml library,
|
|
// configured with the given preferences.
|
|
func NewGoccyYAMLDecoder(prefs YamlPreferences) Decoder {
|
|
return &goccyYamlDecoder{
|
|
prefs: prefs,
|
|
firstFile: true,
|
|
dateTimePreprocessor: NewDateTimePreprocessor(true), // Enable datetime preprocessing for Goccy
|
|
}
|
|
}
|
|
|
|
// processReadStream pre-processes the input stream to extract leading comments,
|
|
// directives, and document separators before the main YAML content is parsed.
|
|
// It returns a reader for the remaining stream and the extracted leading content.
|
|
func (dec *goccyYamlDecoder) processReadStream(reader *bufio.Reader) (io.Reader, string, error) {
|
|
var commentLineRegEx = regexp.MustCompile(`^\s*#`)
|
|
var yamlDirectiveLineRegEx = regexp.MustCompile(`^\s*%YA`) // e.g., %YAML
|
|
var sb strings.Builder
|
|
for {
|
|
peekBytes, err := reader.Peek(4) // Peek up to 4 bytes to identify line types.
|
|
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) { // EOF encountered while peeking.
|
|
// No more full lines can be definitively processed as leading content.
|
|
return reader, sb.String(), nil
|
|
}
|
|
return reader, sb.String(), err // Other errors during Peek.
|
|
}
|
|
|
|
peekString := string(peekBytes) // Convert peeked bytes to string once per iteration.
|
|
|
|
if strings.HasPrefix(peekString, "\n") { // Line is blank.
|
|
_, readErr := reader.ReadString('\n')
|
|
sb.WriteString("\n")
|
|
if readErr != nil {
|
|
if errors.Is(readErr, io.EOF) {
|
|
return reader, sb.String(), nil
|
|
}
|
|
return reader, sb.String(), readErr
|
|
}
|
|
} else if strings.HasPrefix(peekString, "--- ") { // Document separator "--- "
|
|
_, readErr := reader.ReadString(' ') // Consume "--- "
|
|
sb.WriteString(docSeparatorPlaceholder + "\n")
|
|
if readErr != nil {
|
|
if errors.Is(readErr, io.EOF) {
|
|
return reader, sb.String(), nil
|
|
}
|
|
return reader, sb.String(), readErr
|
|
}
|
|
} else if strings.HasPrefix(peekString, "---\n") { // Document separator "---\n"
|
|
_, readErr := reader.ReadString('\n') // Consume "---\n"
|
|
sb.WriteString(docSeparatorPlaceholder + "\n")
|
|
if readErr != nil {
|
|
if errors.Is(readErr, io.EOF) {
|
|
return reader, sb.String(), nil
|
|
}
|
|
return reader, sb.String(), readErr
|
|
}
|
|
} else if commentLineRegEx.MatchString(peekString) || yamlDirectiveLineRegEx.MatchString(peekString) {
|
|
// Line is a comment or a YAML directive.
|
|
line, readErr := reader.ReadString('\n')
|
|
sb.WriteString(line)
|
|
if readErr != nil {
|
|
if errors.Is(readErr, io.EOF) {
|
|
return reader, sb.String(), nil
|
|
}
|
|
return reader, sb.String(), readErr
|
|
}
|
|
} else {
|
|
// Line does not match any leading content pattern, so actual YAML content begins.
|
|
return reader, sb.String(), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Init initializes the decoder with a new reader.
|
|
// It handles leading content preprocessing according to preferences.
|
|
func (dec *goccyYamlDecoder) Init(reader io.Reader) error {
|
|
readerToUse := reader
|
|
processedLeadingContent := ""
|
|
dec.bufferRead = bytes.Buffer{} // Reset buffer
|
|
var err error
|
|
|
|
if dec.prefs.LeadingContentPreProcessing && (!dec.prefs.EvaluateTogether || dec.firstFile) {
|
|
// Only process leading content for the first file if 'evaluateTogether' is active.
|
|
readerToUse, processedLeadingContent, err = dec.processReadStream(bufio.NewReader(reader))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Apply datetime preprocessing to the remaining content
|
|
if dec.dateTimePreprocessor != nil {
|
|
remainingBytes, err := io.ReadAll(readerToUse)
|
|
if err != nil && errors.Is(err, io.EOF) {
|
|
return err
|
|
}
|
|
preprocessedContent := dec.dateTimePreprocessor.PreprocessDocument(string(remainingBytes))
|
|
readerToUse = strings.NewReader(preprocessedContent)
|
|
}
|
|
} else if !dec.prefs.LeadingContentPreProcessing {
|
|
// If not preprocessing, TeeReader captures initial bytes in case it's a comment-only document.
|
|
if dec.dateTimePreprocessor != nil {
|
|
// Read all content, apply datetime preprocessing, then create new reader
|
|
allBytes, err := io.ReadAll(reader)
|
|
if err != nil && errors.Is(err, io.EOF) {
|
|
return err
|
|
}
|
|
preprocessedContent := dec.dateTimePreprocessor.PreprocessDocument(string(allBytes))
|
|
readerToUse = io.TeeReader(strings.NewReader(preprocessedContent), &dec.bufferRead)
|
|
} else {
|
|
readerToUse = io.TeeReader(reader, &dec.bufferRead)
|
|
}
|
|
}
|
|
|
|
dec.leadingContent = processedLeadingContent
|
|
dec.readAnything = false
|
|
dec.cm = yaml.CommentMap{} // Reset comment map for the new stream.
|
|
dec.decoder = *yaml.NewDecoder(readerToUse, yaml.CommentToMap(dec.cm), yaml.UseOrderedMap())
|
|
dec.firstFile = false // Subsequent Init calls are not for the 'firstFile' in 'evaluateTogether' context.
|
|
dec.documentIndex = 0 // Reset document index for the new stream.
|
|
dec.anchorMap = make(map[string]*CandidateNode) // Reset anchor map for the new stream.
|
|
return nil
|
|
}
|
|
|
|
// Decode reads the next YAML document from the stream and decodes it into a CandidateNode.
|
|
// It handles EOF conditions and the synthesis of nodes for comment-only content.
|
|
func (dec *goccyYamlDecoder) Decode() (*CandidateNode, error) {
|
|
var astNode ast.Node
|
|
err := dec.decoder.Decode(&astNode)
|
|
|
|
if err != nil { // An error occurred (could be EOF).
|
|
if errors.Is(err, io.EOF) {
|
|
// Handle EOF: potentially return a comment-only node, or propagate EOF.
|
|
// Case 1: Leading content was processed, and nothing else has been successfully decoded from this stream yet.
|
|
if dec.leadingContent != "" && !dec.readAnything {
|
|
dec.readAnything = true // Mark that we are consuming the leadingContent as a node.
|
|
return dec.blankNodeWithComment(), nil
|
|
}
|
|
// Case 2: Leading content preprocessing was disabled, nothing successfully decoded yet,
|
|
// so check the TeeReader buffer for comments.
|
|
if !dec.prefs.LeadingContentPreProcessing && !dec.readAnything {
|
|
dec.readAnything = true // Mark that we are attempting to consume buffered content.
|
|
dec.leadingContent = dec.bufferRead.String() // Transfer buffered content to leadingContent.
|
|
if dec.leadingContent != "" {
|
|
return dec.blankNodeWithComment(), nil
|
|
}
|
|
}
|
|
// If neither of the above conditions for a comment-only node is met, it's a genuine EOF.
|
|
return nil, io.EOF
|
|
}
|
|
|
|
// Provide more informative error messages for known goccy limitations
|
|
errorMsg := err.Error()
|
|
if strings.Contains(errorMsg, "could not find flow map content") {
|
|
return nil, fmt.Errorf("flow maps with alias keys are not supported in this parser. Consider using block map syntax instead. Original error: %w", err)
|
|
}
|
|
if strings.Contains(errorMsg, "sequence was used where mapping is expected") {
|
|
return nil, fmt.Errorf("merge anchor only supports maps, got !!seq instead")
|
|
}
|
|
|
|
// Non-EOF error.
|
|
return nil, err
|
|
}
|
|
|
|
// No error from dec.decoder.Decode(&astNode) means a successful decode of a YAML structure.
|
|
dec.readAnything = true
|
|
|
|
candidateNode := &CandidateNode{document: dec.documentIndex}
|
|
if errUnmarshal := candidateNode.UnmarshalGoccyYAML(astNode, dec.cm, dec.anchorMap); errUnmarshal != nil {
|
|
return nil, errUnmarshal
|
|
}
|
|
|
|
// If there was leading content processed before this node, attach it.
|
|
if dec.leadingContent != "" {
|
|
candidateNode.LeadingContent = dec.leadingContent
|
|
dec.leadingContent = "" // Clear after attaching.
|
|
}
|
|
|
|
dec.documentIndex++
|
|
return candidateNode, nil
|
|
}
|
|
|
|
// blankNodeWithComment creates an empty scalar node and attaches the current leadingContent as its comment.
|
|
// This is used for documents that only contain comments or directives.
|
|
func (dec *goccyYamlDecoder) blankNodeWithComment() *CandidateNode {
|
|
node := createScalarNode(nil, "") // Create an empty scalar node.
|
|
node.LeadingContent = dec.leadingContent
|
|
return node
|
|
}
|