Skip to content

Commit 64e6ad0

Browse files
authored
Fix whitespace handling for synthetic tokens (#731)
This PR makes a fix to the `ast/printer` to account for synthetic tokens when checking for whitespace trivia, skipping over them when consulting for blank space for non-synthetic tokens. It also expands the `Edit` API for adding declarations before, so that we can test the behaviour of adding synthetic tokens before non-synthetic definitions/declarations.
1 parent ce6b290 commit 64e6ad0

5 files changed

Lines changed: 225 additions & 23 deletions

File tree

experimental/ast/edit/edit.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,13 @@ type Edit struct {
9090
// - oneof, extend, group: message body
9191
Insertions []ast.DeclAny
9292

93-
// Before is the destination anchor for KindMove: the moved decl
94-
// is reinserted immediately before Before. Honored only by
95-
// KindMove.
93+
// Before is the destination anchor for positional inserts:
94+
// - KindAdd: when non-zero, Insertions are placed immediately
95+
// before Before in Target's decl list (in the order
96+
// given). Before must be a current member of Target.
97+
// When zero, Insertions are appended (default).
98+
// - KindMove: the moved decl is reinserted immediately before
99+
// Before. Required.
96100
Before ast.DeclAny
97101
}
98102

@@ -128,21 +132,34 @@ func applyEdit(file *ast.File, edit Edit) error {
128132
}
129133
}
130134

131-
// applyAdd appends insertions to the target's decl list, validating
132-
// each insertion against the target container.
135+
// applyAdd appends insertions to the target's decl list (or inserts
136+
// them before edit.Before when set), validating each insertion against
137+
// the target container.
133138
func applyAdd(file *ast.File, edit Edit) error {
134139
decls, container, err := targetDecls(file, edit.Target)
135140
if err != nil {
136141
return err
137142
}
143+
insertIdx := -1
144+
if !edit.Before.IsZero() {
145+
idx := indexOf(decls, edit.Before)
146+
if idx < 0 {
147+
return errors.New("before not found in target")
148+
}
149+
insertIdx = idx
150+
}
138151
for j, ins := range edit.Insertions {
139152
if ins.IsZero() {
140153
return fmt.Errorf("insertion[%d] is zero", j)
141154
}
142155
if err := validateInsertion(container, ins); err != nil {
143156
return fmt.Errorf("insertion[%d]: %w", j, err)
144157
}
145-
seq.Append(decls, ins)
158+
if insertIdx >= 0 {
159+
decls.Insert(insertIdx+j, ins)
160+
} else {
161+
seq.Append(decls, ins)
162+
}
146163
}
147164
return nil
148165
}

experimental/ast/edit/edit_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package edit_test
1616

1717
import (
18+
"errors"
1819
"fmt"
1920
"strings"
2021
"testing"
@@ -139,6 +140,12 @@ type editSpec struct {
139140
Tag string `yaml:"tag"`
140141
Option string `yaml:"option"`
141142
Value string `yaml:"value"`
143+
// Before names the existing decl in Target before which the
144+
// insertion should land. Honored by `add_option`. The value is
145+
// the simple name of an existing decl in the target body (or
146+
// dotted path for `move_decl`). When empty, the insertion
147+
// appends.
148+
Before string `yaml:"before"`
142149
}
143150

144151
// pendingDecls maps a dotted path to a [ast.DeclAny] that has been
@@ -184,10 +191,19 @@ func buildEdit(file *ast.File, pending pendingDecls, spec editSpec) (edit.Edit,
184191
return edit.Edit{}, err
185192
}
186193
opt := createOptionDecl(stream, nodes, spec.Option, spec.Value)
194+
var before ast.DeclAny
195+
if spec.Before != "" {
196+
anchor, err := resolveAnchor(file, target, spec.Before)
197+
if err != nil {
198+
return edit.Edit{}, fmt.Errorf("before %q: %w", spec.Before, err)
199+
}
200+
before = anchor
201+
}
187202
return edit.Edit{
188203
Kind: edit.KindAdd,
189204
Target: target,
190205
Insertions: []ast.DeclAny{opt.AsAny()},
206+
Before: before,
191207
}, nil
192208

193209
case "add_message":
@@ -271,6 +287,11 @@ func buildAdd(file *ast.File, pending pendingDecls, targetPath, name string, ins
271287
// method without an existing `{}` body, one is created and attached
272288
// so the resulting target has a body.
273289
func ensureOptionTarget(file *ast.File, pending pendingDecls, targetPath string) (ast.DeclAny, error) {
290+
if targetPath == "" {
291+
// File-level target: edit.Edit treats Target.IsZero() as the
292+
// file's top-level decl list.
293+
return ast.DeclAny{}, nil
294+
}
274295
if d, ok := pending.resolve(file, targetPath); ok {
275296
return d, nil
276297
}
@@ -357,6 +378,33 @@ func findDeclByPath(file *ast.File, targetPath string) (ast.DeclAny, bool) {
357378
return ast.DeclAny{}, false
358379
}
359380

381+
// resolveAnchor returns the decl named `name` in the target container.
382+
// For a file-level target (zero), searches file.Decls(); otherwise
383+
// searches the target def's body decls. Used to resolve `before:` in
384+
// add_option specs.
385+
func resolveAnchor(file *ast.File, target ast.DeclAny, name string) (ast.DeclAny, error) {
386+
var decls seq.Indexer[ast.DeclAny]
387+
if target.IsZero() {
388+
decls = file.Decls()
389+
} else {
390+
def := target.AsDef()
391+
if def.IsZero() || def.Body().IsZero() {
392+
return ast.DeclAny{}, errors.New("target has no body")
393+
}
394+
decls = def.Body().Decls()
395+
}
396+
for d := range seq.Values(decls) {
397+
dd := d.AsDef()
398+
if dd.IsZero() {
399+
continue
400+
}
401+
if defName(dd) == name {
402+
return d, nil
403+
}
404+
}
405+
return ast.DeclAny{}, fmt.Errorf("decl %q not found in target", name)
406+
}
407+
360408
// findTopLevelDeclByName returns the file-level decl with the given
361409
// name.
362410
func findTopLevelDeclByName(file *ast.File, name string) (ast.DeclAny, bool) {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright 2020-2026 Buf Technologies, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# Regression test for trivia/blankBefore handling when synthetic
16+
# decls are inserted ahead of existing source decls.
17+
#
18+
# Mirrors the downstream `bufformat` deprecation flow: a synthetic
19+
# `option deprecated = true;` is inserted at index 0 of OuterMessage's
20+
# body (before the existing fields/nested types) and at the file level
21+
# (before the first message). The printer must translate AST
22+
# iteration indices to source decl indices when consulting
23+
# trivia.blankBefore — otherwise the inserted decl shifts the index,
24+
# and the source-level blank-line layout drifts off by one around the
25+
# surrounding source decls.
26+
27+
source: |
28+
syntax = "proto3";
29+
30+
package test;
31+
32+
// Outer message.
33+
message OuterMessage {
34+
string name = 1;
35+
36+
// Nested message.
37+
message NestedMessage {
38+
string value = 1;
39+
}
40+
41+
// Nested enum.
42+
enum NestedEnum {
43+
NESTED_ENUM_UNSPECIFIED = 0;
44+
NESTED_ENUM_VALUE = 1;
45+
}
46+
}
47+
48+
// Top-level enum.
49+
enum TopLevelEnum {
50+
TOP_LEVEL_ENUM_UNSPECIFIED = 0;
51+
TOP_LEVEL_ENUM_VALUE = 1;
52+
}
53+
54+
edits:
55+
# File-scope: insert option before the first source decl after package.
56+
- kind: add_option
57+
target: ""
58+
before: OuterMessage
59+
option: deprecated
60+
value: "true"
61+
# Body-scope: insert option before the first field of OuterMessage.
62+
- kind: add_option
63+
target: OuterMessage
64+
before: name
65+
option: deprecated
66+
value: "true"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
syntax = "proto3";
2+
3+
package test;
4+
5+
option deprecated = true;
6+
7+
// Outer message.
8+
message OuterMessage {
9+
option deprecated = true;
10+
string name = 1;
11+
12+
// Nested message.
13+
message NestedMessage {
14+
string value = 1;
15+
}
16+
17+
// Nested enum.
18+
enum NestedEnum {
19+
NESTED_ENUM_UNSPECIFIED = 0;
20+
NESTED_ENUM_VALUE = 1;
21+
}
22+
}
23+
24+
// Top-level enum.
25+
enum TopLevelEnum {
26+
TOP_LEVEL_ENUM_UNSPECIFIED = 0;
27+
TOP_LEVEL_ENUM_VALUE = 1;
28+
}

experimental/ast/printer/printer.go

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,17 @@ func (p *printer) printFile(file *ast.File) {
178178
// before sorting. After sortFileDeclsForFormat, the iteration
179179
// order changes but trivia.hasBlankBefore is keyed by source
180180
// position, so we need to translate sorted index -> source
181-
// index when consulting it.
181+
// index when consulting it. Synthetic decls (no source span)
182+
// map to -1 so they do not consume a slot in blankBefore.
182183
srcIdxByDecl := make(map[ast.DeclAny]int, len(sourceOrder))
183-
for i, d := range sourceOrder {
184-
srcIdxByDecl[d] = i
184+
natCounter := 0
185+
for _, d := range sourceOrder {
186+
if d.Span().IsZero() {
187+
srcIdxByDecl[d] = -1
188+
continue
189+
}
190+
srcIdxByDecl[d] = natCounter
191+
natCounter++
185192
}
186193
sorted := append([]ast.DeclAny(nil), sourceOrder...)
187194
sortFileDeclsForFormat(sorted)
@@ -190,7 +197,7 @@ func (p *printer) printFile(file *ast.File) {
190197
if idx, ok := srcIdxByDecl[d]; ok {
191198
p.declSourceIdx[i] = idx
192199
} else {
193-
p.declSourceIdx[i] = i
200+
p.declSourceIdx[i] = -1
194201
}
195202
}
196203
defer func() { p.declSourceIdx = nil }()
@@ -346,18 +353,55 @@ func (p *printer) appendPending(tokens []token.Token) {
346353
//
347354
// Attached trivia (leading on first token, trailing on last) is handled by
348355
// [printer.printDecl] via [printer.printTokenAs].
356+
//
357+
// AST decls may include synthetic decls (those without a source span,
358+
// e.g. inserted via the edit package). Synthetic decls do not consume
359+
// entries in trivia.slots or trivia.blankBefore, so iteration indices
360+
// are translated through scopeSourceIdx before consulting trivia.
349361
func (p *printer) printScopeDecls(
350362
trivia detachedTrivia,
351363
decls seq.Indexer[ast.DeclAny],
352364
scope scopeKind,
353365
) {
366+
sourceIdx := p.scopeSourceIdx(decls, scope)
367+
lastSrc := -1
354368
for i := range decls.Len() {
355-
p.emitTriviaSlot(trivia, i)
356-
gap := p.declGap(decls, trivia, i, scope)
369+
src := sourceIdx[i]
370+
if src >= 0 {
371+
for s := lastSrc + 1; s <= src; s++ {
372+
p.emitTriviaSlot(trivia, s)
373+
}
374+
if src > lastSrc {
375+
lastSrc = src
376+
}
377+
}
378+
gap := p.declGap(decls, trivia, i, src, scope)
357379
p.printDecl(decls.At(i), gap)
358380
}
359381

360-
p.emitRemainingTrivia(trivia, decls.Len())
382+
p.emitRemainingTrivia(trivia, lastSrc+1)
383+
}
384+
385+
// scopeSourceIdx returns a slice mapping each AST decl iteration index
386+
// to its source decl index, or -1 if the decl is synthetic (has no
387+
// source span). For the file scope under CanonicalizeFileOrder, the
388+
// pre-computed mapping from printFile is reused.
389+
func (p *printer) scopeSourceIdx(decls seq.Indexer[ast.DeclAny], scope scopeKind) []int {
390+
n := decls.Len()
391+
if scope == scopeFile && p.declSourceIdx != nil && len(p.declSourceIdx) == n {
392+
return p.declSourceIdx
393+
}
394+
out := make([]int, n)
395+
counter := 0
396+
for i := range n {
397+
if decls.At(i).Span().IsZero() {
398+
out[i] = -1
399+
continue
400+
}
401+
out[i] = counter
402+
counter++
403+
}
404+
return out
361405
}
362406

363407
// declGap computes the gap before declaration i in a scope.
@@ -366,10 +410,15 @@ func (p *printer) printScopeDecls(
366410
// comments (copyright headers at file level, or comments between '{'
367411
// and the first member at body level). For subsequent declarations, it
368412
// determines whether a blank line or regular newline separates them.
413+
//
414+
// srcIdx is the source decl index for decls.At(i), or -1 if the decl
415+
// is synthetic (no source span). Synthetic decls never consult
416+
// trivia.hasBlankBefore because they have no source position to
417+
// compare against.
369418
func (p *printer) declGap(
370419
decls seq.Indexer[ast.DeclAny],
371420
trivia detachedTrivia,
372-
i int,
421+
i, srcIdx int,
373422
scope scopeKind,
374423
) gapStyle {
375424
if i == 0 {
@@ -393,9 +442,7 @@ func (p *printer) declGap(
393442
// Within sorted sections (imports, file-level options),
394443
// canonicalization reorders entries, so hasBlankBefore (keyed
395444
// by source position) doesn't line up with the sorted output;
396-
// suppress source blanks there. For body decls, translate the
397-
// sorted index back to the source index before consulting
398-
// hasBlankBefore.
445+
// suppress source blanks there.
399446
if scope == scopeFile {
400447
prev, curr := rankDecl(decls.At(i-1)), rankDecl(decls.At(i))
401448
if prev != curr && (curr == rankImport || curr == rankOption) {
@@ -407,18 +454,14 @@ func (p *printer) declGap(
407454
if curr == rankImport || curr == rankOption {
408455
return gapNewline
409456
}
410-
srcIdx := i
411-
if p.declSourceIdx != nil && i < len(p.declSourceIdx) {
412-
srcIdx = p.declSourceIdx[i]
413-
}
414-
if trivia.hasBlankBefore(srcIdx) {
457+
if srcIdx >= 0 && trivia.hasBlankBefore(srcIdx) {
415458
return gapBlankline
416459
}
417460
return gapNewline
418461
}
419462

420463
// Body level: preserve blank lines from the original source.
421-
if trivia.hasBlankBefore(i) {
464+
if srcIdx >= 0 && trivia.hasBlankBefore(srcIdx) {
422465
return gapBlankline
423466
}
424467

0 commit comments

Comments
 (0)