diff --git a/docs/config-file.md b/docs/config-file.md index 73b78c8d..c079e057 100644 --- a/docs/config-file.md +++ b/docs/config-file.md @@ -88,6 +88,7 @@ The `basic` formatter is the default formatter that takes the data provided, ser | `indentless_arrays` | bool | false | Render `-` array items (block sequence items) without an increased indent. | | `drop_merge_tag` | bool | false | Assume that any well formed merge using just a `<<` token will be a merge, and drop the `!!merge` tag from the formatted result. | | `pad_line_comments` | int | 1 | The number of padding spaces to insert before line comments. | +| `preserve_comment_indents` | bool | false | Keep each line comment at the same column it had in the source. If the reformatted content would collid with the comment, use `pad_line_comments` spaces instead. | | `trim_trailing_whitespace` | bool | false | Trim trailing whitespace from lines. | | `eof_newline` | bool | false | Always add a newline at end of file. Useful in the scenario where `retain_line_breaks` is disabled but the trailing newline is still needed. | | `strip_directives` | bool | false | [YAML Directives](https://yaml.org/spec/1.2.2/#3234-directives) are not supported by this formatter. This feature will attempt to strip the directives before formatting and put them back. [Use this feature at your own risk.](#strip_directives) | diff --git a/formatters/basic/config.go b/formatters/basic/config.go index b72d2df9..81ae2b37 100644 --- a/formatters/basic/config.go +++ b/formatters/basic/config.go @@ -33,6 +33,7 @@ type Config struct { IndentlessArrays bool `mapstructure:"indentless_arrays"` DropMergeTag bool `mapstructure:"drop_merge_tag"` PadLineComments int `mapstructure:"pad_line_comments"` + PreserveCommentIndents bool `mapstructure:"preserve_comment_indents"` TrimTrailingWhitespace bool `mapstructure:"trim_trailing_whitespace"` EOFNewline bool `mapstructure:"eof_newline"` StripDirectives bool `mapstructure:"strip_directives"` diff --git a/formatters/basic/formatter.go b/formatters/basic/formatter.go index 1e69bdb0..bfed4ff6 100644 --- a/formatters/basic/formatter.go +++ b/formatters/basic/formatter.go @@ -118,6 +118,7 @@ func (f *BasicFormatter) getNewEncoder(buf *bytes.Buffer) *yaml.Encoder { e.SetIndentlessBlockSequence(f.Config.IndentlessArrays) e.SetDropMergeTag(f.Config.DropMergeTag) e.SetPadLineComments(f.Config.PadLineComments) + e.SetPreserveCommentIndents(f.Config.PreserveCommentIndents) if f.Config.ArrayIndent > 0 { e.SetArrayIndent(f.Config.ArrayIndent) diff --git a/formatters/basic/formatter_test.go b/formatters/basic/formatter_test.go index 4e32ce4c..83797057 100644 --- a/formatters/basic/formatter_test.go +++ b/formatters/basic/formatter_test.go @@ -115,6 +115,77 @@ a:`, input: `a: 1 # line comment`, expect: `a: 1 # line comment`, }, + { + name: "preserve comment indents keeps manual column alignment", + config: map[string]any{ + "preserve_comment_indents": true, + }, + input: `steps: + - a # first + - bb # second`, + expect: `steps: + - a # first + - bb # second`, + }, + { + name: "without preserve comment indents alignment collapses", + input: `steps: + - a # first + - bb # second`, + expect: `steps: + - a # first + - bb # second`, + }, + { + name: "preserve comment indents falls back when content overflows original column", + config: map[string]any{ + "indent": 4, + "preserve_comment_indents": true, + }, + input: `a: + b: c # x`, + expect: `a: + b: c # x`, + }, + { + // Content grows (indent 2 -> 4) but the '#' column (11) is still + // reachable: the column is held and the gap SHRINKS from 4 to 2. + name: "preserve comment indents holds column when content grows within reach", + config: map[string]any{ + "indent": 4, + "preserve_comment_indents": true, + }, + input: `a: + bb: c # x`, + expect: `a: + bb: c # x`, + }, + { + // Content shrinks (indent 4 -> 2): the column (11) is held and the + // gap WIDENS from 2 to 4. + name: "preserve comment indents holds column when content shrinks", + config: map[string]any{ + "indent": 2, + "preserve_comment_indents": true, + }, + input: `a: + bb: c # x`, + expect: `a: + bb: c # x`, + }, + { + // Overflow fallback uses the pad_line_comments floor (3), not 1. + name: "preserve comment indents fallback honors pad_line_comments", + config: map[string]any{ + "indent": 4, + "pad_line_comments": 3, + "preserve_comment_indents": true, + }, + input: `a: + b: c # x`, + expect: `a: + b: c # x`, + }, { name: "trim trailing whitespace", config: map[string]any{ @@ -268,6 +339,51 @@ map: } } +// TestPreserveCommentIndentsIdempotent guards the property lint/check mode +// relies on: formatting an already-formatted file must be a no-op. After the +// first pass each comment's '#' sits at a column the parser reads back as its +// new source column, so a second pass must produce identical bytes. +func TestPreserveCommentIndentsIdempotent(t *testing.T) { + testCases := []struct { + name string + config map[string]any + input string + }{ + { + name: "aligned columns", + config: map[string]any{"preserve_comment_indents": true}, + input: `steps: + - a # first + - bb # second`, + }, + { + name: "content grew, gap shrank", + config: map[string]any{"indent": 4, "preserve_comment_indents": true}, + input: `a: + bb: c # x`, + }, + { + name: "overflow fallback", + config: map[string]any{"indent": 4, "preserve_comment_indents": true}, + input: `a: + b: c # x`, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + f, err := factory.NewFormatter(tc.config) + require.NoError(t, err) + + once, err := f.Format([]byte(tc.input)) + require.NoError(t, err) + twice, err := f.Format(once) + require.NoError(t, err) + + require.Equal(t, string(once), string(twice), "second format pass changed the output") + }) + } +} + func TestRetainLineBreaks(t *testing.T) { testCases := []struct { name string diff --git a/integrationtest/command/testdata/print_conf_file/stdout/stdout.txt b/integrationtest/command/testdata/print_conf_file/stdout/stdout.txt index 685f3f47..8384ca32 100644 --- a/integrationtest/command/testdata/print_conf_file/stdout/stdout.txt +++ b/integrationtest/command/testdata/print_conf_file/stdout/stdout.txt @@ -27,6 +27,7 @@ formatter: line_ending: crlf max_line_length: 0 pad_line_comments: 1 + preserve_comment_indents: false retain_line_breaks: false retain_line_breaks_single: true scan_folded_as_literal: false diff --git a/integrationtest/command/testdata/print_conf_flags/stdout/stdout.txt b/integrationtest/command/testdata/print_conf_flags/stdout/stdout.txt index e8e5e418..1183d741 100644 --- a/integrationtest/command/testdata/print_conf_flags/stdout/stdout.txt +++ b/integrationtest/command/testdata/print_conf_flags/stdout/stdout.txt @@ -26,6 +26,7 @@ formatter: line_ending: lf max_line_length: 0 pad_line_comments: 1 + preserve_comment_indents: false retain_line_breaks: true retain_line_breaks_single: false scan_folded_as_literal: false diff --git a/integrationtest/command/testdata/print_conf_flags_and_file/stdout/stdout.txt b/integrationtest/command/testdata/print_conf_flags_and_file/stdout/stdout.txt index 5ff6a51d..0823dbf9 100644 --- a/integrationtest/command/testdata/print_conf_flags_and_file/stdout/stdout.txt +++ b/integrationtest/command/testdata/print_conf_flags_and_file/stdout/stdout.txt @@ -27,6 +27,7 @@ formatter: line_ending: crlf max_line_length: 0 pad_line_comments: 1 + preserve_comment_indents: false retain_line_breaks: true retain_line_breaks_single: true scan_folded_as_literal: false diff --git a/pkg/yaml/apic.go b/pkg/yaml/apic.go index e58b481a..504a1466 100644 --- a/pkg/yaml/apic.go +++ b/pkg/yaml/apic.go @@ -214,6 +214,11 @@ func yaml_emitter_set_pad_line_comments(emitter *yaml_emitter_t, pad_line_commen emitter.pad_line_comments = pad_line_comments } +// Set preserve comment indents. +func yaml_emitter_set_preserve_comment_indents(emitter *yaml_emitter_t, preserve bool) { + emitter.preserve_comment_indents = preserve +} + // Set correct alias keys. func yaml_emitter_set_correct_alias_keys(emitter *yaml_emitter_t, correct_alias_keys bool) { emitter.correct_alias_keys = correct_alias_keys diff --git a/pkg/yaml/decode.go b/pkg/yaml/decode.go index 0173b698..2c87edcd 100644 --- a/pkg/yaml/decode.go +++ b/pkg/yaml/decode.go @@ -188,6 +188,7 @@ func (p *parser) node(kind Kind, defaultTag, tag, value string) *Node { n.Column = p.event.start_mark.column + 1 n.HeadComment = string(p.event.head_comment) n.LineComment = string(p.event.line_comment) + n.LineCommentColumn = p.event.line_comment_column n.FootComment = string(p.event.foot_comment) } return n @@ -262,6 +263,7 @@ func (p *parser) sequence() *Node { p.parseChild(n) } n.LineComment = string(p.event.line_comment) + n.LineCommentColumn = p.event.line_comment_column n.FootComment = string(p.event.foot_comment) p.expect(yaml_SEQUENCE_END_EVENT) return n @@ -298,6 +300,7 @@ func (p *parser) mapping() *Node { } } n.LineComment = string(p.event.line_comment) + n.LineCommentColumn = p.event.line_comment_column n.FootComment = string(p.event.foot_comment) if n.Style&FlowStyle == 0 && n.FootComment != "" && len(n.Content) > 1 { n.Content[len(n.Content)-2].FootComment = n.FootComment diff --git a/pkg/yaml/emitterc.go b/pkg/yaml/emitterc.go index 3a1aede9..c4d8c767 100644 --- a/pkg/yaml/emitterc.go +++ b/pkg/yaml/emitterc.go @@ -812,7 +812,9 @@ func yaml_emitter_emit_block_mapping_key(emitter *yaml_emitter_t, event *yaml_ev // scanner associates line comments with the value. Either way, // save the line comment and render it appropriately later. emitter.key_line_comment = emitter.line_comment + emitter.key_line_comment_column = emitter.line_comment_column emitter.line_comment = nil + emitter.line_comment_column = 0 } if yaml_emitter_check_simple_key(emitter) { emitter.states = append(emitter.states, yaml_EMIT_BLOCK_MAPPING_SIMPLE_VALUE_STATE) @@ -849,15 +851,19 @@ func yaml_emitter_emit_block_mapping_value(emitter *yaml_emitter_t, event *yaml_ // so just let it handle the line comment as usual. If it has a // line comment, we can't have both so the one from the key is lost. emitter.line_comment = emitter.key_line_comment + emitter.line_comment_column = emitter.key_line_comment_column emitter.key_line_comment = nil + emitter.key_line_comment_column = 0 } } else if event.sequence_style() != yaml_FLOW_SEQUENCE_STYLE && (event.typ == yaml_MAPPING_START_EVENT || event.typ == yaml_SEQUENCE_START_EVENT) { // An indented block follows, so write the comment right now. emitter.line_comment, emitter.key_line_comment = emitter.key_line_comment, emitter.line_comment + emitter.line_comment_column, emitter.key_line_comment_column = emitter.key_line_comment_column, emitter.line_comment_column if !yaml_emitter_process_line_comment(emitter) { return false } emitter.line_comment, emitter.key_line_comment = emitter.key_line_comment, emitter.line_comment + emitter.line_comment_column, emitter.key_line_comment_column = emitter.key_line_comment_column, emitter.line_comment_column } } emitter.states = append(emitter.states, yaml_EMIT_BLOCK_MAPPING_KEY_STATE) @@ -1181,8 +1187,17 @@ func yaml_emitter_process_line_comment(emitter *yaml_emitter_t) bool { return true } if !emitter.whitespace { + pad := emitter.pad_line_comments + // Restore the author's alignment if asked: keep the '#' at the column + // it had in the source. + // If the reformatted content would collide, fallsback to pad_line_comments. + if emitter.preserve_comment_indents && emitter.line_comment_column > emitter.column { + if gap := emitter.line_comment_column - emitter.column; gap > pad { + pad = gap + } + } // Insert as many spaces before the line comment as requested. - for i := 0; i < emitter.pad_line_comments; i++ { + for i := 0; i < pad; i++ { if !put(emitter, ' ') { return false } @@ -1192,6 +1207,7 @@ func yaml_emitter_process_line_comment(emitter *yaml_emitter_t) bool { return false } emitter.line_comment = emitter.line_comment[:0] + emitter.line_comment_column = 0 return true } @@ -1453,6 +1469,7 @@ func yaml_emitter_analyze_event(emitter *yaml_emitter_t, event *yaml_event_t) bo } if len(event.line_comment) > 0 { emitter.line_comment = event.line_comment + emitter.line_comment_column = event.line_comment_column } if len(event.foot_comment) > 0 { emitter.foot_comment = event.foot_comment diff --git a/pkg/yaml/encode.go b/pkg/yaml/encode.go index ba1c854f..e42cb84b 100644 --- a/pkg/yaml/encode.go +++ b/pkg/yaml/encode.go @@ -369,7 +369,7 @@ func (e *encoder) stringv(tag string, in reflect.Value) { default: style = yaml_DOUBLE_QUOTED_SCALAR_STYLE } - e.emitScalar(s, "", tag, style, nil, nil, nil, nil) + e.emitScalar(s, "", tag, style, nil, nil, nil, nil, 0) } func (e *encoder) boolv(tag string, in reflect.Value) { @@ -379,23 +379,23 @@ func (e *encoder) boolv(tag string, in reflect.Value) { } else { s = "false" } - e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil, 0) } func (e *encoder) intv(tag string, in reflect.Value) { s := strconv.FormatInt(in.Int(), 10) - e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil, 0) } func (e *encoder) uintv(tag string, in reflect.Value) { s := strconv.FormatUint(in.Uint(), 10) - e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil, 0) } func (e *encoder) timev(tag string, in reflect.Value) { t := in.Interface().(time.Time) s := t.Format(time.RFC3339Nano) - e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil, 0) } func (e *encoder) floatv(tag string, in reflect.Value) { @@ -414,14 +414,14 @@ func (e *encoder) floatv(tag string, in reflect.Value) { case "NaN": s = ".nan" } - e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil, 0) } func (e *encoder) nilv() { - e.emitScalar("null", "", "", yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) + e.emitScalar("null", "", "", yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil, 0) } -func (e *encoder) emitScalar(value, anchor, tag string, style yaml_scalar_style_t, head, line, foot, tail []byte) { +func (e *encoder) emitScalar(value, anchor, tag string, style yaml_scalar_style_t, head, line, foot, tail []byte, lineColumn int) { // TODO Kill this function. Replace all initialize calls by their underlining Go literals. implicit := tag == "" if !implicit { @@ -430,6 +430,7 @@ func (e *encoder) emitScalar(value, anchor, tag string, style yaml_scalar_style_ e.must(yaml_scalar_event_initialize(&e.event, []byte(anchor), []byte(tag), []byte(value), implicit, implicit, style)) e.event.head_comment = head e.event.line_comment = line + e.event.line_comment_column = lineColumn e.event.foot_comment = foot e.event.tail_comment = tail e.emit() @@ -507,6 +508,7 @@ func (e *encoder) node(node *Node, tail string) { } e.must(yaml_sequence_end_event_initialize(&e.event)) e.event.line_comment = []byte(node.LineComment) + e.event.line_comment_column = node.LineCommentColumn e.event.foot_comment = []byte(node.FootComment) e.emit() @@ -543,6 +545,7 @@ func (e *encoder) node(node *Node, tail string) { yaml_mapping_end_event_initialize(&e.event) e.event.tail_comment = []byte(tail) e.event.line_comment = []byte(node.LineComment) + e.event.line_comment_column = node.LineCommentColumn e.event.foot_comment = []byte(node.FootComment) e.emit() @@ -550,6 +553,7 @@ func (e *encoder) node(node *Node, tail string) { yaml_alias_event_initialize(&e.event, []byte(node.Value)) e.event.head_comment = []byte(node.HeadComment) e.event.line_comment = []byte(node.LineComment) + e.event.line_comment_column = node.LineCommentColumn e.event.foot_comment = []byte(node.FootComment) e.emit() @@ -584,7 +588,7 @@ func (e *encoder) node(node *Node, tail string) { style = yaml_DOUBLE_QUOTED_SCALAR_STYLE } - e.emitScalar(value, node.Anchor, tag, style, []byte(node.HeadComment), []byte(node.LineComment), []byte(node.FootComment), []byte(tail)) + e.emitScalar(value, node.Anchor, tag, style, []byte(node.HeadComment), []byte(node.LineComment), []byte(node.FootComment), []byte(tail), node.LineCommentColumn) default: failf("cannot encode node with unknown kind %d", node.Kind) } diff --git a/pkg/yaml/parserc.go b/pkg/yaml/parserc.go index 23f3d6ae..e606cbd7 100644 --- a/pkg/yaml/parserc.go +++ b/pkg/yaml/parserc.go @@ -99,6 +99,10 @@ func yaml_parser_unfold_comments(parser *yaml_parser_t, token *yaml_token_t) { if len(comment.line) > 0 { if len(parser.line_comment) > 0 { parser.line_comment = append(parser.line_comment, '\n') + } else { + // Remember the column of the first '#' so the emitter can + // restore the author's alignment. + parser.line_comment_column = comment.start_mark.column } parser.line_comment = append(parser.line_comment, comment.line...) } @@ -422,9 +426,11 @@ func yaml_parser_parse_document_end(parser *yaml_parser_t, event *yaml_event_t) func yaml_parser_set_event_comments(parser *yaml_parser_t, event *yaml_event_t) { event.head_comment = parser.head_comment event.line_comment = parser.line_comment + event.line_comment_column = parser.line_comment_column event.foot_comment = parser.foot_comment parser.head_comment = nil parser.line_comment = nil + parser.line_comment_column = 0 parser.foot_comment = nil parser.tail_comment = nil parser.stem_comment = nil diff --git a/pkg/yaml/yaml.go b/pkg/yaml/yaml.go index 5fcdc1a3..00d3acc2 100644 --- a/pkg/yaml/yaml.go +++ b/pkg/yaml/yaml.go @@ -343,6 +343,15 @@ func (e *Encoder) SetPadLineComments(padLineComments int) { yaml_emitter_set_pad_line_comments(&e.encoder.emitter, padLineComments) } +// SetPreserveCommentIndents controls whether each line comment is kept at the +// column it had in the source (Node.LineCommentColumn), padding to reach it so +// manually aligned comments stay so. When the reformatted content reaches, +// passes, or comes within SetPadLineComments of that column, the comment can't +// sit there without colliding, so it falls back to the SetPadLineComments spacing. +func (e *Encoder) SetPreserveCommentIndents(preserve bool) { + yaml_emitter_set_preserve_comment_indents(&e.encoder.emitter, preserve) +} + // SetCorrectAliasKeys enables alias key syntax correction. func (e *Encoder) SetCorrectAliasKeys(correctAliasKeys bool) { yaml_emitter_set_correct_alias_keys(&e.encoder.emitter, correctAliasKeys) @@ -475,6 +484,12 @@ type Node struct { // LineComment holds any comments at the end of the line where the node is in. LineComment string + // LineCommentColumn holds the source column (0-based) of the '#' of + // LineComment, letting an encoder restore manual comment alignment via + // Encoder.SetPreserveCommentIndents. Zero when unknown. Only meaningful + // together with LineComment. + LineCommentColumn int + // FootComment holds any comments following the node and before empty lines. FootComment string @@ -487,7 +502,7 @@ type Node struct { // IsZero returns whether the node has all of its fields unset. func (n *Node) IsZero() bool { return n.Kind == 0 && n.Style == 0 && n.Tag == "" && n.Value == "" && n.Anchor == "" && n.Alias == nil && n.Content == nil && - n.HeadComment == "" && n.LineComment == "" && n.FootComment == "" && n.Line == 0 && n.Column == 0 + n.HeadComment == "" && n.LineComment == "" && n.LineCommentColumn == 0 && n.FootComment == "" && n.Line == 0 && n.Column == 0 } // LongTag returns the long form of the tag that indicates the data type for diff --git a/pkg/yaml/yamlh.go b/pkg/yaml/yamlh.go index a8ccef79..0dc296d6 100644 --- a/pkg/yaml/yamlh.go +++ b/pkg/yaml/yamlh.go @@ -309,6 +309,11 @@ type yaml_event_t struct { foot_comment []byte tail_comment []byte + // line_comment_column is the source column of the '#' of line_comment, + // carried so the emitter can restore manual comment alignment (see + // preserve_comment_indents on the emitter). Zero when unknown. + line_comment_column int + // The anchor (for yaml_SCALAR_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT, yaml_ALIAS_EVENT). anchor []byte @@ -608,6 +613,8 @@ type yaml_parser_t struct { tail_comment []byte // Foot comment that happens at the end of a block. stem_comment []byte // Comment in item preceding a nested structure (list inside list item, etc) + line_comment_column int // Source column of the '#' of line_comment (see the field of the same name on the event). + comments []yaml_comment_t // The folded comments for all parsed tokens comments_head int @@ -741,6 +748,7 @@ type yaml_emitter_t struct { explicit_document_start bool // Force an explicit document start assume_folded_as_literal bool // Assume blocks were scanned as literals pad_line_comments int // The number of spaces to insert before line comments. + preserve_comment_indents bool // Restore each line comment to its original source column when possible. correct_alias_keys bool // Whether to correct alias nodes used as map keys. state yaml_emitter_state_t // The current emitter state. @@ -802,6 +810,11 @@ type yaml_emitter_t struct { key_line_comment []byte + // Source columns of the '#' for line_comment / key_line_comment, used by + // preserve_comment_indents to restore the original alignment. 0 when unknown. + line_comment_column int + key_line_comment_column int + // Dumper stuff opened bool // If the stream was already opened?