Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 1cc6959

Browse files
committedMar 19, 2025
Extract gomodifytags to a library
This PR moves the gomodifytags tool into its package to be imported by other applications (i.e.: gopls). A new entrypoint into gomodifytags is provided via Modification.Apply(), which uses the specified start and end positions to apply struct tag modifications to the file without returning the new content. Also updates staticcheck version related: golang/vscode-go#2002
1 parent 0af24e1 commit 1cc6959

16 files changed

+1261
-745
lines changed
 

‎.github/workflows/go.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
- name: Staticcheck
4242
uses: dominikh/staticcheck-action@v1.3.0
4343
with:
44-
version: "2023.1.7"
44+
version: "2025.1"
4545
install-go: false
4646

4747
- name: Build

‎README.md

+6
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,12 @@ type Server struct {
386386
}
387387
```
388388

389+
To remove multiple options from the same tag, you can repeat the tag name. For example:
390+
391+
```
392+
$ gomodifytags -file demo.go -struct Server -remove-options json=first_option,json=other_option
393+
```
394+
389395
Lastly, to remove all options without explicitly defining the keys and names,
390396
we can use the `-clear-options` flag. The following example will remove all
391397
options for the given struct:

‎main.go

+184-480
Large diffs are not rendered by default.

‎main_test.go

+467-264
Large diffs are not rendered by default.

‎modifytags/modifytags.go

+372
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
package modifytags
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"go/ast"
8+
"go/token"
9+
"sort"
10+
"strconv"
11+
"strings"
12+
"unicode"
13+
14+
"github.com/fatih/camelcase"
15+
"github.com/fatih/structtag"
16+
)
17+
18+
// A Transform determines how Go field names will be translated into
19+
// names used in struct tags. For example, the [SnakeCase] transform
20+
// converts the field MyField into the json tag json:"my_field".
21+
type Transform int
22+
23+
const (
24+
NoTransform Transform = iota
25+
SnakeCase // MyField -> my_field
26+
CamelCase // MyField -> myField
27+
LispCase // MyField -> my-field
28+
PascalCase // MyField -> MyField
29+
TitleCase // MyField -> My Field
30+
Keep // keep the existing field name
31+
)
32+
33+
// A Modification defines how struct tags should be modified for a given input struct.
34+
type Modification struct {
35+
Add []string // tags to add
36+
AddOptions map[string][]string // options to add, per tag
37+
38+
Remove []string // tags to remove
39+
RemoveOptions map[string][]string // options to remove, per tag
40+
41+
Overwrite bool // if set, replace existing tags when adding
42+
SkipUnexportedFields bool // if set, do not modify tags on unexported struct fields
43+
44+
Transform Transform // transform rule for adding tags
45+
Sort bool // if set, sort tags in ascending order by key name
46+
ValueFormat string // format for the tag's value, after transformation; for example "column:{field}"
47+
Clear bool // if set, clear all tags. tags are cleared before any new tags are added
48+
ClearOptions bool // if set, clear all tag options; options are cleared before any new options are added
49+
}
50+
51+
// Apply applies the struct tag modifications of the receiver to all
52+
// struct fields contained within the given node between start and end position, modifying its input.
53+
func (mod *Modification) Apply(fset *token.FileSet, node ast.Node, start, end token.Pos) error {
54+
err := mod.validate()
55+
if err != nil {
56+
return err
57+
}
58+
59+
err = mod.rewrite(fset, node, start, end)
60+
if _, ok := err.(*RewriteErrors); ok {
61+
return err
62+
}
63+
return nil
64+
}
65+
66+
// rewrite rewrites the node for structs between the start and end positions
67+
func (mod *Modification) rewrite(fset *token.FileSet, node ast.Node, start, end token.Pos) error {
68+
var errs []error
69+
70+
rewriteFunc := func(n ast.Node) bool {
71+
x, ok := n.(*ast.StructType)
72+
if !ok {
73+
return true
74+
}
75+
76+
for _, f := range x.Fields.List {
77+
if !(start <= f.End() && f.Pos() <= end) {
78+
continue // not in range
79+
}
80+
81+
fieldName := ""
82+
if len(f.Names) != 0 {
83+
for _, field := range f.Names {
84+
if !mod.SkipUnexportedFields || isPublicName(field.Name) {
85+
fieldName = field.Name
86+
break
87+
}
88+
}
89+
}
90+
91+
// anonymous field
92+
if f.Names == nil {
93+
ident, ok := f.Type.(*ast.Ident)
94+
if !ok {
95+
continue
96+
}
97+
98+
if !mod.SkipUnexportedFields {
99+
fieldName = ident.Name
100+
}
101+
}
102+
103+
// nothing to process, continue with next field
104+
if fieldName == "" {
105+
continue
106+
}
107+
108+
if f.Tag == nil {
109+
f.Tag = &ast.BasicLit{}
110+
}
111+
112+
res, err := mod.processField(fieldName, f.Tag.Value)
113+
if err != nil {
114+
errs = append(errs, fmt.Errorf("%s:%d:%d:%s",
115+
fset.Position(f.Pos()).Filename,
116+
fset.Position(f.Pos()).Line,
117+
fset.Position(f.Pos()).Column,
118+
err))
119+
continue
120+
}
121+
122+
f.Tag.Value = res
123+
}
124+
125+
return true
126+
}
127+
128+
ast.Inspect(node, rewriteFunc)
129+
130+
if len(errs) > 0 {
131+
return &RewriteErrors{Errs: errs}
132+
}
133+
return nil
134+
}
135+
136+
// processField returns the new struct tag value for the given field
137+
func (mod *Modification) processField(fieldName, tagVal string) (string, error) {
138+
var tag string
139+
if tagVal != "" {
140+
var err error
141+
tag, err = strconv.Unquote(tagVal)
142+
if err != nil {
143+
return "", err
144+
}
145+
}
146+
147+
tags, err := structtag.Parse(tag)
148+
if err != nil {
149+
return "", err
150+
}
151+
152+
tags = mod.removeTags(tags)
153+
tags, err = mod.removeTagOptions(tags)
154+
if err != nil {
155+
return "", err
156+
}
157+
158+
tags = mod.clearTags(tags)
159+
tags = mod.clearOptions(tags)
160+
161+
tags, err = mod.addTags(fieldName, tags)
162+
if err != nil {
163+
return "", err
164+
}
165+
166+
tags, err = mod.addTagOptions(tags)
167+
if err != nil {
168+
return "", err
169+
}
170+
171+
if mod.Sort {
172+
sort.Sort(tags)
173+
}
174+
175+
res := tags.String()
176+
if res != "" {
177+
res = quote(tags.String())
178+
}
179+
180+
return res, nil
181+
}
182+
183+
func (mod *Modification) removeTags(tags *structtag.Tags) *structtag.Tags {
184+
if len(mod.Remove) == 0 {
185+
return tags
186+
}
187+
188+
tags.Delete(mod.Remove...)
189+
return tags
190+
}
191+
192+
func (mod *Modification) clearTags(tags *structtag.Tags) *structtag.Tags {
193+
if !mod.Clear {
194+
return tags
195+
}
196+
197+
tags.Delete(tags.Keys()...)
198+
return tags
199+
}
200+
201+
func (mod *Modification) clearOptions(tags *structtag.Tags) *structtag.Tags {
202+
if !mod.ClearOptions {
203+
return tags
204+
}
205+
206+
for _, t := range tags.Tags() {
207+
t.Options = nil
208+
}
209+
210+
return tags
211+
}
212+
213+
func (mod *Modification) removeTagOptions(tags *structtag.Tags) (*structtag.Tags, error) {
214+
if len(mod.RemoveOptions) == 0 {
215+
return tags, nil
216+
}
217+
218+
for key, val := range mod.RemoveOptions {
219+
for _, option := range val {
220+
tags.DeleteOptions(key, option)
221+
}
222+
}
223+
224+
return tags, nil
225+
}
226+
227+
func (mod *Modification) addTagOptions(tags *structtag.Tags) (*structtag.Tags, error) {
228+
if len(mod.AddOptions) == 0 {
229+
return tags, nil
230+
}
231+
232+
for key, val := range mod.AddOptions {
233+
tags.AddOptions(key, val...)
234+
}
235+
236+
return tags, nil
237+
}
238+
239+
func (mod *Modification) addTags(fieldName string, tags *structtag.Tags) (*structtag.Tags, error) {
240+
if len(mod.Add) == 0 {
241+
return tags, nil
242+
}
243+
244+
split := camelcase.Split(fieldName)
245+
name := ""
246+
247+
unknown := false
248+
switch mod.Transform {
249+
case SnakeCase:
250+
var lowerSplit []string
251+
for _, s := range split {
252+
s = strings.Trim(s, "_")
253+
if s == "" {
254+
continue
255+
}
256+
lowerSplit = append(lowerSplit, strings.ToLower(s))
257+
}
258+
259+
name = strings.Join(lowerSplit, "_")
260+
case LispCase:
261+
var lowerSplit []string
262+
for _, s := range split {
263+
lowerSplit = append(lowerSplit, strings.ToLower(s))
264+
}
265+
266+
name = strings.Join(lowerSplit, "-")
267+
case CamelCase:
268+
var titled []string
269+
for _, s := range split {
270+
titled = append(titled, strings.Title(s))
271+
}
272+
273+
titled[0] = strings.ToLower(titled[0])
274+
275+
name = strings.Join(titled, "")
276+
case PascalCase:
277+
var titled []string
278+
for _, s := range split {
279+
titled = append(titled, strings.Title(s))
280+
}
281+
282+
name = strings.Join(titled, "")
283+
case TitleCase:
284+
var titled []string
285+
for _, s := range split {
286+
titled = append(titled, strings.Title(s))
287+
}
288+
289+
name = strings.Join(titled, " ")
290+
case Keep:
291+
name = fieldName
292+
default:
293+
unknown = true
294+
}
295+
296+
if mod.ValueFormat != "" {
297+
prevName := name
298+
name = strings.ReplaceAll(mod.ValueFormat, "{field}", name)
299+
if name == mod.ValueFormat {
300+
// support old style for backward compatibility
301+
name = strings.ReplaceAll(mod.ValueFormat, "$field", prevName)
302+
}
303+
}
304+
305+
for _, key := range mod.Add {
306+
split = strings.SplitN(key, ":", 2)
307+
if len(split) >= 2 {
308+
key = split[0]
309+
name = strings.Join(split[1:], "")
310+
} else if unknown {
311+
// the user didn't pass any value but want to use an unknown
312+
// transform. We don't return above in the default as the user
313+
// might pass a value
314+
return nil, fmt.Errorf("unknown transform option %q", mod.Transform)
315+
}
316+
317+
tag, err := tags.Get(key)
318+
if err != nil {
319+
// tag doesn't exist, create a new one
320+
tag = &structtag.Tag{
321+
Key: key,
322+
Name: name,
323+
}
324+
} else if mod.Overwrite {
325+
tag.Name = name
326+
}
327+
328+
if err := tags.Set(tag); err != nil {
329+
return nil, err
330+
}
331+
}
332+
333+
return tags, nil
334+
}
335+
336+
func isPublicName(name string) bool {
337+
for _, c := range name {
338+
return unicode.IsUpper(c)
339+
}
340+
return false
341+
}
342+
343+
// validate determines whether the Modification is valid or not.
344+
func (mod *Modification) validate() error {
345+
if len(mod.Add) == 0 &&
346+
len(mod.AddOptions) == 0 &&
347+
!mod.Clear &&
348+
!mod.ClearOptions &&
349+
len(mod.RemoveOptions) == 0 &&
350+
len(mod.Remove) == 0 {
351+
return errors.New("one of " +
352+
"[-add-tags, -add-options, -remove-tags, -remove-options, -clear-tags, -clear-options]" +
353+
" should be defined")
354+
}
355+
return nil
356+
}
357+
358+
func quote(tag string) string {
359+
return "`" + tag + "`"
360+
}
361+
362+
type RewriteErrors struct {
363+
Errs []error
364+
}
365+
366+
func (r *RewriteErrors) Error() string {
367+
var buf bytes.Buffer
368+
for _, e := range r.Errs {
369+
buf.WriteString(fmt.Sprintf("%s\n", e.Error()))
370+
}
371+
return buf.String()
372+
}

‎modifytags/modifytags_test.go

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package modifytags
2+
3+
import (
4+
"bytes"
5+
"flag"
6+
"fmt"
7+
"go/format"
8+
"go/parser"
9+
"go/token"
10+
"io/ioutil"
11+
"os"
12+
"path/filepath"
13+
"testing"
14+
)
15+
16+
var update = flag.Bool("update", false, "update golden (.out) files")
17+
18+
// This is the directory where our test fixtures are.
19+
const fixtureDir = "./test-fixtures"
20+
21+
func TestApply(t *testing.T) {
22+
var tests = []struct {
23+
file string
24+
m *Modification
25+
start token.Pos
26+
end token.Pos
27+
}{
28+
{
29+
file: "all_structs",
30+
m: &Modification{
31+
Add: []string{"json"},
32+
Transform: SnakeCase,
33+
},
34+
start: token.Pos(1),
35+
end: token.NoPos, // will be set to end of file
36+
},
37+
{
38+
file: "clear_all_tags",
39+
m: &Modification{
40+
Clear: true,
41+
},
42+
start: token.Pos(1),
43+
end: token.NoPos, // will be set to end of file
44+
},
45+
{
46+
file: "remove_some_tags",
47+
m: &Modification{
48+
Remove: []string{"json"},
49+
},
50+
start: token.Pos(1),
51+
end: token.NoPos, // will be set to end of file
52+
},
53+
{
54+
file: "add_tags_pos_between_line",
55+
m: &Modification{
56+
Add: []string{"json"},
57+
AddOptions: map[string][]string{
58+
"json": {"omitempty"},
59+
},
60+
Transform: SnakeCase,
61+
},
62+
start: token.Pos(50), // middle of second struct field
63+
end: token.Pos(200), // middle of fourth struct field
64+
},
65+
}
66+
67+
for _, ts := range tests {
68+
ts := ts
69+
70+
t.Run(ts.file, func(t *testing.T) {
71+
filePath := filepath.Join(fixtureDir, fmt.Sprintf("%s.input", ts.file))
72+
file, err := os.Open(filePath)
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
defer file.Close()
77+
78+
fset := token.NewFileSet()
79+
80+
node, err := parser.ParseFile(fset, filepath.Join(fixtureDir, fmt.Sprintf("%s.input", ts.file)), nil, parser.ParseComments)
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
85+
if ts.start == token.NoPos {
86+
ts.start = node.End()
87+
}
88+
if ts.end == token.NoPos {
89+
ts.end = node.End()
90+
}
91+
92+
err = ts.m.Apply(fset, node, ts.start, ts.end)
93+
if err != nil {
94+
t.Fatal(err)
95+
}
96+
97+
var got bytes.Buffer
98+
err = format.Node(&got, fset, node)
99+
if err != nil {
100+
t.Fatal(err)
101+
}
102+
103+
// update golden file if necessary
104+
golden := filepath.Join(fixtureDir, fmt.Sprintf("%s.golden", ts.file))
105+
106+
if *update {
107+
err := ioutil.WriteFile(golden, got.Bytes(), 0644)
108+
if err != nil {
109+
t.Error(err)
110+
}
111+
return
112+
}
113+
114+
// get golden file
115+
want, err := ioutil.ReadFile(golden)
116+
if err != nil {
117+
t.Fatal(err)
118+
}
119+
120+
from, err := ioutil.ReadFile(filePath)
121+
if err != nil {
122+
t.Fatal(err)
123+
}
124+
125+
// compare
126+
if !bytes.Equal(got.Bytes(), want) {
127+
t.Errorf("case %s\ngot:\n====\n\n%s\nwant:\n=====\n\n%s\nfrom:\n=====\n\n%s\n",
128+
ts.file, got.Bytes(), want, from)
129+
}
130+
131+
})
132+
}
133+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package foo
2+
3+
type foo struct {
4+
bar string
5+
t bool `json:"t,omitempty"`
6+
qux string `json:"qux,omitempty"`
7+
yoo string `json:"yoo,omitempty"`
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package foo
2+
3+
type foo struct {
4+
bar string
5+
t bool
6+
qux string
7+
yoo string
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package main
2+
3+
type foo struct {
4+
bar string `json:"bar"`
5+
MyExample bool `json:"my_example"`
6+
MyAnother []string `json:"my_another"`
7+
}
8+
9+
const exampleVar = "foo"
10+
11+
type bar struct {
12+
// loose comment
13+
14+
// home
15+
ankara string `json:"ankara"`
16+
yeap bool `json:"yeap"` // just a boolean
17+
18+
// great cities
19+
cities []string `json:"cities"`
20+
21+
// seconed loose comment
22+
}
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package main
2+
3+
type foo struct {
4+
bar string
5+
MyExample bool
6+
MyAnother []string
7+
}
8+
9+
const exampleVar = "foo"
10+
11+
type bar struct {
12+
// loose comment
13+
14+
// home
15+
ankara string
16+
yeap bool // just a boolean
17+
18+
// great cities
19+
cities []string
20+
21+
// seconed loose comment
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package foo
2+
3+
type foo struct {
4+
bar string
5+
t bool
6+
t bool
7+
qux string
8+
yoo string
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package foo
2+
3+
type foo struct {
4+
bar string `json:"bar,omitempty" hcl:"bar,omitnested"`
5+
t bool `hcl:"t"`
6+
t bool `hcl:"t,omitempty"`
7+
qux string `json:"qux,omitempty" hcl:"qux,squash,keys"`
8+
yoo string `json:"yoo"`
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package main
2+
3+
type foo struct {
4+
bar string `hcl:"bar,omitnested"`
5+
t bool `hcl:"t"`
6+
t bool `hcl:"t,omitempty"`
7+
qux string `hcl:"qux,squash,keys"`
8+
yoo string
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package main
2+
3+
type foo struct {
4+
bar string `json:"bar,omitempty" hcl:"bar,omitnested"`
5+
t bool `hcl:"t"`
6+
t bool `hcl:"t,omitempty"`
7+
qux string `json:"qux,omitempty" hcl:"qux,squash,keys"`
8+
yoo string `json:"yoo"`
9+
}

‎test-fixtures/empty_file.golden

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package foo

‎test-fixtures/empty_file.input

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package foo

0 commit comments

Comments
 (0)
Please sign in to comment.