Repository: goccy/go-yaml
Pull request #727 (2025-05-08 14:06:26)

Support implicit null
resolve https://github.com/goccy/go-yaml/issues/710

Add `ImplicitNullType` as a token type.
`ImplicitNullType` is used during the parser phase when inserting a null token. If ast.NullNode holds this token, the "null" string will be omitted when converted to a string. This enables reversible conversion of implicit nulls.

codecov-commenter: :warning: Please install the !['codecov app svg image'](https://github.com/codecov/engineering-team/assets/152432831/e90313f4-9d3a-4b63-8b54-cfe14e7ec20d) to ensure uploads and comments are reliably processed by Codecov.

## [Codecov](https://app.codecov.io/gh/goccy/go-yaml/pull/727?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Masaaki+Goshima) Report
Attention: Patch coverage is `73.33333%` with `4 lines` in your changes missing coverage. Please review.
> Project coverage is 77.94%. Comparing base [(`e61dcd0`)](https://app.codecov.io/gh/goccy/go-yaml/commit/e61dcd0829750b3b82ab1300c629751c525cd23a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Masaaki+Goshima) to head [(`3491b9f`)](https://app.codecov.io/gh/goccy/go-yaml/commit/3491b9fb17d1fea80b6d2d2a4473a0870d296a36?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Masaaki+Goshima).

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #727      +/-   ##
==========================================
- Coverage   77.95%   77.94%   -0.02%     
==========================================
  Files          22       22              
  Lines        7993     7998       +5     
==========================================
+ Hits         6231     6234       +3     
- Misses       1347     1349       +2     
  Partials      415      415              
```

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/ast/ast.go b/ast/ast.go
index 3882850..a1a7de1 100644
--- a/ast/ast.go
+++ b/ast/ast.go
@@ -317,7 +317,7 @@ func checkLineBreak(t *token.Token) bool {
 			//  bar: null # comment
 			//
 			//  baz: 1
-			if prev.Type == token.NullType {
+			if prev.Type == token.NullType || prev.Type == token.ImplicitNullType {
 				return strings.Count(prev.Origin, lbc) > 0
 			}
 			if lineDiff-adjustment > 0 {
@@ -627,6 +627,12 @@ func (n *NullNode) GetValue() interface{} {
 
 // String returns `null` text
 func (n *NullNode) String() string {
+	if n.Token.Type == token.ImplicitNullType {
+		if n.Comment != nil {
+			return n.Comment.String()
+		}
+		return ""
+	}
 	if n.Comment != nil {
 		return addCommentString("null", n.Comment)
 	}
@@ -1437,7 +1443,12 @@ func (n *MappingValueNode) toString() string {
 	valueIndentLevel := n.Value.GetToken().Position.IndentLevel
 	keyComment := n.Key.GetComment()
 	if _, ok := n.Value.(ScalarNode); ok {
-		return fmt.Sprintf("%s%s: %s", space, n.Key.String(), n.Value.String())
+		value := n.Value.String()
+		if value == "" {
+			// implicit null value.
+			return fmt.Sprintf("%s%s:", space, n.Key.String())
+		}
+		return fmt.Sprintf("%s%s: %s", space, n.Key.String(), value)
 	} else if keyIndentLevel < valueIndentLevel && !n.IsFlowStyle {
 		if keyComment != nil {
 			return fmt.Sprintf(
@@ -1812,13 +1823,18 @@ func (n *AnchorNode) AddColumn(col int) {
 
 // String anchor to text
 func (n *AnchorNode) String() string {
+	anchor := "&" + n.Name.String()
 	value := n.Value.String()
 	if s, ok := n.Value.(*SequenceNode); ok && !s.IsFlowStyle {
-		return fmt.Sprintf("&%s\n%s", n.Name.String(), value)
+		return fmt.Sprintf("%s\n%s", anchor, value)
 	} else if m, ok := n.Value.(*MappingNode); ok && !m.IsFlowStyle {
-		return fmt.Sprintf("&%s\n%s", n.Name.String(), value)
+		return fmt.Sprintf("%s\n%s", anchor, value)
+	}
+	if value == "" {
+		// implicit null value.
+		return anchor
 	}
-	return fmt.Sprintf("&%s %s", n.Name.String(), value)
+	return fmt.Sprintf("%s %s", anchor, value)
 }
 
 // MarshalYAML encodes to a YAML text
diff --git a/parser/context.go b/parser/context.go
index 76a3795..1584b3e 100644
--- a/parser/context.go
+++ b/parser/context.go
@@ -120,7 +120,7 @@ func (c *context) next() bool {
 }
 
 func (c *context) insertNullToken(tk *Token) *Token {
-	nullToken := c.createNullToken(tk)
+	nullToken := c.createImplicitNullToken(tk)
 	c.insertToken(nullToken)
 	c.goNext()
 
@@ -128,7 +128,7 @@ func (c *context) insertNullToken(tk *Token) *Token {
 }
 
 func (c *context) addNullValueToken(tk *Token) *Token {
-	nullToken := c.createNullToken(tk)
+	nullToken := c.createImplicitNullToken(tk)
 	rawTk := nullToken.RawToken()
 
 	// add space for map or sequence value.
@@ -140,10 +140,12 @@ func (c *context) addNullValueToken(tk *Token) *Token {
 	return nullToken
 }
 
-func (c *context) createNullToken(base *Token) *Token {
+func (c *context) createImplicitNullToken(base *Token) *Token {
 	pos := *(base.RawToken().Position)
 	pos.Column++
-	return &Token{Token: token.New("null", " null", &pos)}
+	tk := token.New("null", " null", &pos)
+	tk.Type = token.ImplicitNullType
+	return &Token{Token: tk}
 }
 
 func (c *context) insertToken(tk *Token) {
diff --git a/parser/parser.go b/parser/parser.go
index b3319f1..af34988 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -304,7 +304,7 @@ func (p *parser) parseScalarValue(ctx *context, tk *Token) (ast.ScalarNode, erro
 	switch tk.Type() {
 	case token.MergeKeyType:
 		return newMergeKeyNode(ctx, tk)
-	case token.NullType:
+	case token.NullType, token.ImplicitNullType:
 		return newNullNode(ctx, tk)
 	case token.BoolType:
 		return newBoolNode(ctx, tk)
@@ -747,7 +747,7 @@ func (p *parser) parseMapValue(ctx *context, key ast.MapKeyNode, colonTk *Token)
 		// next
 		group := &TokenGroup{
 			Type:   TokenGroupAnchor,
-			Tokens: []*Token{tk, ctx.createNullToken(tk)},
+			Tokens: []*Token{tk, ctx.createImplicitNullToken(tk)},
 		}
 		anchor, err := p.parseAnchor(ctx.withGroup(group), group)
 		if err != nil {
@@ -784,7 +784,7 @@ func (p *parser) parseMapValue(ctx *context, key ast.MapKeyNode, colonTk *Token)
 		// next
 		group := &TokenGroup{
 			Type:   TokenGroupAnchor,
-			Tokens: []*Token{tk, ctx.createNullToken(tk)},
+			Tokens: []*Token{tk, ctx.createImplicitNullToken(tk)},
 		}
 		anchor, err := p.parseAnchor(ctx.withGroup(group), group)
 		if err != nil {
@@ -976,7 +976,7 @@ func (p *parser) parseTag(ctx *context) (*ast.TagNode, error) {
 
 func (p *parser) parseTagValue(ctx *context, tagRawTk *token.Token, tk *Token) (ast.Node, error) {
 	if tk == nil {
-		return newNullNode(ctx, ctx.createNullToken(&Token{Token: tagRawTk}))
+		return newNullNode(ctx, ctx.createImplicitNullToken(&Token{Token: tagRawTk}))
 	}
 	switch token.ReservedTagKeyword(tagRawTk.Value) {
 	case token.MappingTag, token.SetTag:
@@ -1141,7 +1141,7 @@ func (p *parser) parseSequenceValue(ctx *context, seqTk *Token) (ast.Node, error
 		// -
 		group := &TokenGroup{
 			Type:   TokenGroupAnchor,
-			Tokens: []*Token{tk, ctx.createNullToken(tk)},
+			Tokens: []*Token{tk, ctx.createImplicitNullToken(tk)},
 		}
 		anchor, err := p.parseAnchor(ctx.withGroup(group), group)
 		if err != nil {
@@ -1178,7 +1178,7 @@ func (p *parser) parseSequenceValue(ctx *context, seqTk *Token) (ast.Node, error
 		// next
 		group := &TokenGroup{
 			Type:   TokenGroupAnchor,
-			Tokens: []*Token{tk, ctx.createNullToken(tk)},
+			Tokens: []*Token{tk, ctx.createImplicitNullToken(tk)},
 		}
 		anchor, err := p.parseAnchor(ctx.withGroup(group), group)
 		if err != nil {
diff --git a/parser/token.go b/parser/token.go
index b72de4f..b07c018 100644
--- a/parser/token.go
+++ b/parser/token.go
@@ -709,6 +709,7 @@ func isScalarType(tk *Token) bool {
 		typ == token.LiteralType ||
 		typ == token.FoldedType ||
 		typ == token.NullType ||
+		typ == token.ImplicitNullType ||
 		typ == token.BoolType ||
 		typ == token.IntegerType ||
 		typ == token.BinaryIntegerType ||
diff --git a/token/token.go b/token/token.go
index 4fe300f..e887356 100644
--- a/token/token.go
+++ b/token/token.go
@@ -102,6 +102,10 @@ const (
 	SpaceType
 	// NullType type for Null token
 	NullType
+	// ImplicitNullType type for implicit Null token.
+	// This is used when explicit keywords such as null or ~ are not specified.
+	// It is distinguished during encoding and output as an empty string.
+	ImplicitNullType
 	// InfinityType type for Infinity token
 	InfinityType
 	// NanType type for Nan token
@@ -187,6 +191,8 @@ func (t Type) String() string {
 		return "Float"
 	case NullType:
 		return "Null"
+	case ImplicitNullType:
+		return "ImplicitNull"
 	case InfinityType:
 		return "Infinity"
 	case NanType:


Test patch:
diff --git a/parser/parser_test.go b/parser/parser_test.go
index 85aeb99..8fdc6a7 100644
--- a/parser/parser_test.go
+++ b/parser/parser_test.go
@@ -311,7 +311,7 @@ a: 0 - 1
 - a:
    b: c
    d: e
-- f: null
+- f:
   g: h
 `,
 		},
@@ -351,7 +351,7 @@ a:
 -     a     :
       b: c
 `, `
-- a: null
+- a:
   b: c
 `,
 		},
@@ -740,8 +740,8 @@ d: e
 `,
 			`
 a:
- b: &anchor null
- c: &anchor2 null
+ b: &anchor
+ c: &anchor2
 d: e
 `,
 		},
@@ -1567,7 +1567,7 @@ foo:
 `
 		expected := `
 foo:
-  bar: null # comment
+  bar: # comment
   baz: 1`
 		f, err := parser.ParseBytes([]byte(content), parser.ParseComments)
 		if err != nil {
@@ -1590,7 +1590,7 @@ foo:
 `
 		expected := `
 foo:
-  bar: null
+  bar:
   # comment
   baz: 1`
 		f, err := parser.ParseBytes([]byte(content), parser.ParseComments)
@@ -1620,7 +1620,7 @@ baz: 1`
 		}
 		expected := `
 foo:
-  bar: null
+  bar:
 # comment
 baz: 1`
 		got := f.Docs[0].String()