Fix trivia slot shift that drops comments in literals#745
Conversation
The trivia walker records one detached-trivia slot per element in a literal scope, and the printers look those slots up by element index. An element whose value is a brace ended at the brace rather than at its own comma, so that comma opened a slot of its own and shifted every later slot by one. The effect is silent content loss. A comment separated from its element by a blank line drifts one element per format pass, and is discarded once the shift shoves it past the final slot -- the file then formats stably with the comment gone. Blank lines are tracked by the same index, so they move too, with no comment involved: a blank line before an element reappears before the next one, and falls off the end entirely when it shifts past the last element. End the element at its comma when it has one. Separators are optional between message literal fields, so a brace with no following comma must still end the element. Since the element now runs through to its comma, trivia that used to land on the closing brace can land on the comma instead. A dict elides its commas when printing and emits only their trailing trivia, so an inline comment there would be dropped; hoist it onto the brace, which is where it was attached before. Bracketed lists print their commas and keep emitting that trivia themselves, so the two literal flavors are now distinguished. The format golden corpus already asserts two-pass idempotence, so a fixture covering array elements, compact options, and separator-less fields is enough to catch this class of shift.
emcfarlane
left a comment
There was a problem hiding this comment.
Thanks for the fix and detailed response. Fix looks good to me, just some small comments.
| // (option x = { ... };); with `,` it detects whether a literal element | ||
| // has a separator of its own. The cursor is restored to its original | ||
| // position after peeking. | ||
| func (*triviaIndex) nextNonSkippableIs(cursor *token.Cursor, kw keyword.Keyword) bool { |
There was a problem hiding this comment.
From lint, nextNonSkippableIs is no longer needed.
There was a problem hiding this comment.
Removed in d5aa3bb. It lost its only caller when the brace lookahead moved to Cursor.Peek, and I missed it because my local lint run predated that refactor. Sorry for the red CI.
| // scopeModeLiteral is a bracketed list: `[...]`, whether a compact | ||
| // option list or an array literal. Commas are printed. | ||
| scopeModeLiteral |
There was a problem hiding this comment.
| // scopeModeLiteral is a bracketed list: `[...]`, whether a compact | |
| // option list or an array literal. Commas are printed. | |
| scopeModeLiteral | |
| // scopeModeList is a bracketed list: `[...]`, whether a compact | |
| // option list or an array literal. Commas are printed. | |
| scopeModeList |
This is maybe a nicer name now we have the division.
There was a problem hiding this comment.
Agreed, applied in d5aa3bb — scopeModeLiteral read as the umbrella term for both halves of the division.
Worth noting it caught something: walkFused stated its bracket-to-mode table twice, once in the doc comment and again inline, and the two had drifted (the inline copy still described the dict case as "dict literal (literal mode)"). I dropped the inline copy rather than syncing it.
| // isLiteral reports whether the scope holds a comma-separated element | ||
| // list, in either of the two literal flavors. | ||
| func (m scopeMode) isLiteral() bool { | ||
| return m == scopeModeLiteral || m == scopeModeDict |
There was a problem hiding this comment.
| // isLiteral reports whether the scope holds a comma-separated element | |
| // list, in either of the two literal flavors. | |
| func (m scopeMode) isLiteral() bool { | |
| return m == scopeModeLiteral || m == scopeModeDict | |
| // isLiteral reports whether the scope holds a comma-separated element | |
| // list, in either of the two literal flavors. | |
| func (m scopeMode) isLiteral() bool { | |
| return m == scopeModeList || m == scopeModeDict |
There was a problem hiding this comment.
Applied in d5aa3bb. I kept the isLiteral name, since "literal" is still the accurate umbrella for list-plus-dict and renaming it would widen the diff without changing behavior — happy to rename it too if you would rather it echoed the constants.
Now that a literal scope is either a bracketed list or a dict, the umbrella name for one half of that division reads as the whole. Call it what it is. nextNonSkippableIs lost its only caller when the brace lookahead moved to Cursor.Peek, which does the same job without unwinding the cursor by hand. walkFused stated its bracket-to-mode table twice, in the doc comment and again inline, and the two had drifted apart. Keep the doc comment.
Follow up to #745. Fixes two issues. Message literal fields separated by `;` instead of `,` hit the same slot-shift bug. Empty declarations `;` also didn't emit the trivia in format mode.
Fixes a case where
buf formatsilently deletes a comment, and a related case where it silently moves or deletes blank lines. Reported downstream as bufbuild/buf#4620.The bug
The trivia walker records one detached-trivia slot per element in a literal scope, and the printers look those slots up by element index. An element whose value is a brace ends at the brace rather than at its own comma, so that comma opens a slot of its own and shifts every later slot by one.
Dumping the trivia index for the repro below, a two-element array produces four slots, with the comment in
slot[2]— the index the printer treats as "final slot, emit before]". On the next pass it lands inslot[3], which no printer path reads, so it is dropped. Each brace-valued element adds one slot, so a longer list delays the loss by a pass.Pass 1 moves the comment past the element it annotated, down to the closing bracket and dedented to the bracket's level. Pass 2 deletes it. Pass 3 onward is stable, so the file settles with the comment permanently gone.
buf format -don the unmodified input shows the first step:@@ items: [ {name: "first"}, - /* detached comment */ - {name: "second"} + /* detached comment */ + ]Same defect, no comments involved
Blank lines are tracked by the same shifted index. With the blank line before
{name: "b"}:items: [ {name: "a"}, {name: "b"}, {name: "c"}, {name: "d"} ]the blank line comes back before
{name: "c"}. Move it one element further down in the input and it is deleted outright, because it shifts past the last element. This needs no repeated passes and no comments — a singlebuf formatreflows the file.The fix
End the element at its comma when it has one. Separators are optional between message literal fields, so a brace with no following comma must still end the element.
Because the element now runs through to its comma, trivia that previously landed on the closing brace can land on the comma instead. A dict elides its commas when printing and emits only their trailing trivia, so an inline comment between the brace and the comma would be dropped; it is hoisted onto the brace, which is where it was attached before. Bracketed lists print their commas and keep emitting that trivia themselves, so the two literal flavors are now distinguished by scope mode.
I also replaced the local peek-and-rewind helper with the existing
Cursor.Peek(). The hand-rolled rewind advanced withNextSkippableand unwound withPrevSkippable, which returns the close token for a fused token and parks the cursor there —Peek()copies the cursor by value and has no such state.Testing
TestFormatalready asserts two-pass idempotence, so the new fixture is enough to catch this class of shift; with thetrivia.gochange reverted it fails on both thelegacyanddefaultgoldens. It covers array elements, compact options, and separator-less fields.I also formatted every
.protoin this repo and inbufbuild/buf(~1,500 files) with a stockv1.72.0binary and a patched one. Three files differ, and in all three the patched output matches the source's blank-line structure where the current output shifts every blank line in a message literal down by one element. Comparing comment-marker counts before and after, the set of files that lose a marker is byte-identical between the two binaries, so this introduces no new loss.Noted but not fixed here
;in place of,. Pre-existing and unchanged by this PR.testdata/bufformat/proto2/option/v1/option_message_field.protohas a comment namedLeading comment on ','that the current golden drops: an elided comma's leading trivia is never emitted. An earlier revision of this patch repaired that incidentally, but not idempotently, so I narrowed the change to only what it disturbed. Happy to file it separately.emitTriviaSlotreturns silently when the slot index exceeds the slot count, which is why this desync lost comments instead of failing a test. An assertion at the printer's scope entry points, where the element count is known, would turn any future desync into a loud failure.