Skip to content

Commit 4546fbd

Browse files
committed
internal/mcp: unify json tag parsing
Do a thorough job of parsing json struct tags. Move the code to a place where both mcp and jsonschema can use it. Change-Id: I6c86308834706b12a1e22cd96ae120590f49d3f4 Reviewed-on: https://go-review.googlesource.com/c/tools/+/678757 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Robert Findley <[email protected]>
1 parent 82473ce commit 4546fbd

File tree

7 files changed

+101
-97
lines changed

7 files changed

+101
-97
lines changed

internal/mcp/internal/util/util.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ package util
77
import (
88
"cmp"
99
"iter"
10+
"reflect"
1011
"slices"
12+
"strings"
1113
)
1214

1315
// Helpers below are copied from gopls' moremaps package.
@@ -34,3 +36,40 @@ func KeySlice[M ~map[K]V, K comparable, V any](m M) []K {
3436
}
3537
return r
3638
}
39+
40+
type JSONInfo struct {
41+
Omit bool // unexported or first tag element is "-"
42+
Name string // Go field name or first tag element. Empty if Omit is true.
43+
Settings map[string]bool // "omitempty", "omitzero", etc.
44+
}
45+
46+
// FieldJSONInfo reports information about how encoding/json
47+
// handles the given struct field.
48+
// If the field is unexported, JSONInfo.Omit is true and no other JSONInfo field
49+
// is populated.
50+
// If the field is exported and has no tag, then Name is the field's name and all
51+
// other fields are false.
52+
// Otherwise, the information is obtained from the tag.
53+
func FieldJSONInfo(f reflect.StructField) JSONInfo {
54+
if !f.IsExported() {
55+
return JSONInfo{Omit: true}
56+
}
57+
info := JSONInfo{Name: f.Name}
58+
if tag, ok := f.Tag.Lookup("json"); ok {
59+
name, rest, found := strings.Cut(tag, ",")
60+
// "-" means omit, but "-," means the name is "-"
61+
if name == "-" && !found {
62+
return JSONInfo{Omit: true}
63+
}
64+
if name != "" {
65+
info.Name = name
66+
}
67+
if len(rest) > 0 {
68+
info.Settings = map[string]bool{}
69+
for _, s := range strings.Split(rest, ",") {
70+
info.Settings[s] = true
71+
}
72+
}
73+
}
74+
return info
75+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package util
6+
7+
import (
8+
"reflect"
9+
"testing"
10+
)
11+
12+
func TestJSONInfo(t *testing.T) {
13+
type S struct {
14+
A int
15+
B int `json:","`
16+
C int `json:"-"`
17+
D int `json:"-,"`
18+
E int `json:"echo"`
19+
F int `json:"foxtrot,omitempty"`
20+
g int `json:"golf"`
21+
}
22+
want := []JSONInfo{
23+
{Name: "A"},
24+
{Name: "B"},
25+
{Omit: true},
26+
{Name: "-"},
27+
{Name: "echo"},
28+
{Name: "foxtrot", Settings: map[string]bool{"omitempty": true}},
29+
{Omit: true},
30+
}
31+
tt := reflect.TypeFor[S]()
32+
for i := range tt.NumField() {
33+
got := FieldJSONInfo(tt.Field(i))
34+
if !reflect.DeepEqual(got, want[i]) {
35+
t.Errorf("got %+v, want %+v", got, want[i])
36+
}
37+
}
38+
}

internal/mcp/jsonschema/infer.go

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ package jsonschema
99
import (
1010
"fmt"
1111
"reflect"
12-
"slices"
13-
"strings"
12+
13+
"golang.org/x/tools/internal/mcp/internal/util"
1414
)
1515

1616
// For constructs a JSON schema object for the given type argument.
@@ -108,19 +108,19 @@ func typeSchema(t reflect.Type) (*Schema, error) {
108108

109109
for i := range t.NumField() {
110110
field := t.Field(i)
111-
name, required, include := parseField(field)
112-
if !include {
111+
info := util.FieldJSONInfo(field)
112+
if info.Omit {
113113
continue
114114
}
115115
if s.Properties == nil {
116116
s.Properties = make(map[string]*Schema)
117117
}
118-
s.Properties[name], err = typeSchema(field.Type)
118+
s.Properties[info.Name], err = typeSchema(field.Type)
119119
if err != nil {
120120
return nil, err
121121
}
122-
if required {
123-
s.Required = append(s.Required, name)
122+
if !info.Settings["omitempty"] && !info.Settings["omitzero"] {
123+
s.Required = append(s.Required, info.Name)
124124
}
125125
}
126126

@@ -133,23 +133,3 @@ func typeSchema(t reflect.Type) (*Schema, error) {
133133
}
134134
return s, nil
135135
}
136-
137-
func parseField(f reflect.StructField) (name string, required, include bool) {
138-
if !f.IsExported() {
139-
return "", false, false
140-
}
141-
name = f.Name
142-
required = true
143-
if tag, ok := f.Tag.Lookup("json"); ok {
144-
props := strings.Split(tag, ",")
145-
if props[0] != "" {
146-
if props[0] == "-" {
147-
return "", false, false
148-
}
149-
name = props[0]
150-
}
151-
// TODO: support 'omitzero' as well.
152-
required = !slices.Contains(props[1:], "omitempty")
153-
}
154-
return name, required, true
155-
}

internal/mcp/jsonschema/schema.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
"reflect"
1818
"regexp"
1919
"slices"
20+
21+
"golang.org/x/tools/internal/mcp/internal/util"
2022
)
2123

2224
// A Schema is a JSON schema object.
@@ -426,8 +428,9 @@ var (
426428

427429
func init() {
428430
for _, sf := range reflect.VisibleFields(reflect.TypeFor[Schema]()) {
429-
if name, ok := jsonName(sf); ok {
430-
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, name})
431+
info := util.FieldJSONInfo(sf)
432+
if !info.Omit {
433+
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, info.Name})
431434
}
432435
}
433436
slices.SortFunc(schemaFieldInfos, func(i1, i2 structFieldInfo) int {

internal/mcp/jsonschema/validate.go

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"strings"
1717
"sync"
1818
"unicode/utf8"
19+
20+
"golang.org/x/tools/internal/mcp/internal/util"
1921
)
2022

2123
// The value of the "$schema" keyword for the version that we can validate.
@@ -688,7 +690,8 @@ func properties(v reflect.Value) iter.Seq2[string, reflect.Value] {
688690
for name, sf := range structPropertiesOf(v.Type()) {
689691
val := v.FieldByIndex(sf.Index)
690692
if val.IsZero() {
691-
if tag, ok := sf.Tag.Lookup("json"); ok && (strings.Contains(tag, "omitempty") || strings.Contains(tag, "omitzero")) {
693+
info := util.FieldJSONInfo(sf)
694+
if info.Settings["omitempty"] || info.Settings["omitzero"] {
692695
continue
693696
}
694697
}
@@ -740,30 +743,11 @@ func structPropertiesOf(t reflect.Type) propertyMap {
740743
}
741744
props := map[string]reflect.StructField{}
742745
for _, sf := range reflect.VisibleFields(t) {
743-
if name, ok := jsonName(sf); ok {
744-
props[name] = sf
746+
info := util.FieldJSONInfo(sf)
747+
if !info.Omit {
748+
props[info.Name] = sf
745749
}
746750
}
747751
structProperties.Store(t, props)
748752
return props
749753
}
750-
751-
// jsonName returns the name for f as would be used by [json.Marshal].
752-
// That is the name in the json struct tag, or the field name if there is no tag.
753-
// If f is not exported or the tag is "-", jsonName returns "", false.
754-
func jsonName(f reflect.StructField) (string, bool) {
755-
if !f.IsExported() {
756-
return "", false
757-
}
758-
if tag, ok := f.Tag.Lookup("json"); ok {
759-
name, _, found := strings.Cut(tag, ",")
760-
// "-" means omit, but "-," means the name is "-"
761-
if name == "-" && !found {
762-
return "", false
763-
}
764-
if name != "" {
765-
return name, true
766-
}
767-
}
768-
return f.Name, true
769-
}

internal/mcp/jsonschema/validate_test.go

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -259,26 +259,6 @@ func TestStructInstance(t *testing.T) {
259259
}
260260
}
261261

262-
func TestJSONName(t *testing.T) {
263-
type S struct {
264-
A int
265-
B int `json:","`
266-
C int `json:"-"`
267-
D int `json:"-,"`
268-
E int `json:"echo"`
269-
F int `json:"foxtrot,omitempty"`
270-
g int `json:"golf"`
271-
}
272-
want := []string{"A", "B", "", "-", "echo", "foxtrot", ""}
273-
tt := reflect.TypeFor[S]()
274-
for i := range tt.NumField() {
275-
got, _ := jsonName(tt.Field(i))
276-
if got != want[i] {
277-
t.Errorf("got %q, want %q", got, want[i])
278-
}
279-
}
280-
}
281-
282262
func mustMarshal(x any) json.RawMessage {
283263
data, err := json.Marshal(x)
284264
if err != nil {

internal/mcp/util.go

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import (
99
"encoding/json"
1010
"fmt"
1111
"reflect"
12-
"strings"
1312
"sync"
13+
14+
"golang.org/x/tools/internal/mcp/internal/util"
1415
)
1516

1617
func assert(cond bool, msg string) {
@@ -135,32 +136,11 @@ func jsonNames(t reflect.Type) map[string]bool {
135136
}
136137
m := map[string]bool{}
137138
for i := range t.NumField() {
138-
if n, ok := jsonName(t.Field(i)); ok {
139-
m[n] = true
139+
info := util.FieldJSONInfo(t.Field(i))
140+
if !info.Omit {
141+
m[info.Name] = true
140142
}
141143
}
142144
jsonNamesMap.Store(t, m)
143145
return m
144146
}
145-
146-
// jsonName returns the name for f as would be used by [json.Marshal].
147-
// That is the name in the json struct tag, or the field name if there is no tag.
148-
// If f is not exported or the tag is "-", jsonName returns "", false.
149-
//
150-
// Copied from jsonschema/validate.go.
151-
func jsonName(f reflect.StructField) (string, bool) {
152-
if !f.IsExported() {
153-
return "", false
154-
}
155-
if tag, ok := f.Tag.Lookup("json"); ok {
156-
name, _, found := strings.Cut(tag, ",")
157-
// "-" means omit, but "-," means the name is "-"
158-
if name == "-" && !found {
159-
return "", false
160-
}
161-
if name != "" {
162-
return name, true
163-
}
164-
}
165-
return f.Name, true
166-
}

0 commit comments

Comments
 (0)