cmd/viewer: add field comments to generated view methods

Extract field comments from AST and include them in generated view
methods. Comments are preserved from the original struct fields to
provide documentation for the view accessors.

Fixes #16958

Signed-off-by: Maisem Ali <3953239+maisem@users.noreply.github.com>
This commit is contained in:
Maisem Ali
2025-08-27 00:06:28 -07:00
committed by Brad Fitzpatrick
parent 80f5a00e76
commit 882b05fff9
10 changed files with 1383 additions and 245 deletions

View File

@@ -247,47 +247,41 @@ func (v *MapView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
return nil
}
func (v MapView) Int() views.Map[string, int] { return views.MapOf(v.ж.Int) }
func (v MapView) Int() views.Map[string, int] { return views.MapOf(v.ж.Int) }
func (v MapView) SliceInt() views.MapSlice[string, int] { return views.MapSliceOf(v.ж.SliceInt) }
func (v MapView) StructPtrWithPtr() views.MapFn[string, *StructWithPtrs, StructWithPtrsView] {
return views.MapFnOf(v.ж.StructPtrWithPtr, func(t *StructWithPtrs) StructWithPtrsView {
return t.View()
})
}
func (v MapView) StructPtrWithoutPtr() views.MapFn[string, *StructWithoutPtrs, StructWithoutPtrsView] {
return views.MapFnOf(v.ж.StructPtrWithoutPtr, func(t *StructWithoutPtrs) StructWithoutPtrsView {
return t.View()
})
}
func (v MapView) StructWithoutPtr() views.Map[string, StructWithoutPtrs] {
return views.MapOf(v.ж.StructWithoutPtr)
}
func (v MapView) SlicesWithPtrs() views.MapFn[string, []*StructWithPtrs, views.SliceView[*StructWithPtrs, StructWithPtrsView]] {
return views.MapFnOf(v.ж.SlicesWithPtrs, func(t []*StructWithPtrs) views.SliceView[*StructWithPtrs, StructWithPtrsView] {
return views.SliceOfViews[*StructWithPtrs, StructWithPtrsView](t)
})
}
func (v MapView) SlicesWithoutPtrs() views.MapFn[string, []*StructWithoutPtrs, views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView]] {
return views.MapFnOf(v.ж.SlicesWithoutPtrs, func(t []*StructWithoutPtrs) views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView] {
return views.SliceOfViews[*StructWithoutPtrs, StructWithoutPtrsView](t)
})
}
func (v MapView) StructWithoutPtrKey() views.Map[StructWithoutPtrs, int] {
return views.MapOf(v.ж.StructWithoutPtrKey)
}
func (v MapView) StructWithPtr() views.MapFn[string, StructWithPtrs, StructWithPtrsView] {
return views.MapFnOf(v.ж.StructWithPtr, func(t StructWithPtrs) StructWithPtrsView {
return t.View()
})
}
// Unsupported views.
func (v MapView) SliceIntPtr() map[string][]*int { panic("unsupported") }
func (v MapView) PointerKey() map[*string]int { panic("unsupported") }
func (v MapView) StructWithPtrKey() map[StructWithPtrs]int { panic("unsupported") }
@@ -389,8 +383,10 @@ func (v StructWithSlicesView) Prefixes() views.Slice[netip.Prefix] {
return views.SliceOf(v.ж.Prefixes)
}
func (v StructWithSlicesView) Data() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Data) }
func (v StructWithSlicesView) Structs() StructWithPtrs { panic("unsupported") }
func (v StructWithSlicesView) Ints() *int { panic("unsupported") }
// Unsupported views.
func (v StructWithSlicesView) Structs() StructWithPtrs { panic("unsupported") }
func (v StructWithSlicesView) Ints() *int { panic("unsupported") }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithSlicesViewNeedsRegeneration = StructWithSlices(struct {
@@ -554,9 +550,10 @@ func (v GenericIntStructView[T]) Pointer() views.ValuePointer[T] {
return views.ValuePointerOf(v.ж.Pointer)
}
func (v GenericIntStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) }
func (v GenericIntStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) }
func (v GenericIntStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) }
func (v GenericIntStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) }
// Unsupported views.
func (v GenericIntStructView[T]) PtrSlice() *T { panic("unsupported") }
func (v GenericIntStructView[T]) PtrKeyMap() map[*T]string { panic("unsupported") }
func (v GenericIntStructView[T]) PtrValueMap() map[string]*T { panic("unsupported") }
@@ -648,9 +645,10 @@ func (v GenericNoPtrsStructView[T]) Pointer() views.ValuePointer[T] {
return views.ValuePointerOf(v.ж.Pointer)
}
func (v GenericNoPtrsStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) }
func (v GenericNoPtrsStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) }
func (v GenericNoPtrsStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) }
func (v GenericNoPtrsStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) }
// Unsupported views.
func (v GenericNoPtrsStructView[T]) PtrSlice() *T { panic("unsupported") }
func (v GenericNoPtrsStructView[T]) PtrKeyMap() map[*T]string { panic("unsupported") }
func (v GenericNoPtrsStructView[T]) PtrValueMap() map[string]*T { panic("unsupported") }
@@ -741,12 +739,13 @@ func (v GenericCloneableStructView[T, V]) Value() V { return v.ж.Value.View() }
func (v GenericCloneableStructView[T, V]) Slice() views.SliceView[T, V] {
return views.SliceOfViews[T, V](v.ж.Slice)
}
func (v GenericCloneableStructView[T, V]) Map() views.MapFn[string, T, V] {
return views.MapFnOf(v.ж.Map, func(t T) V {
return t.View()
})
}
// Unsupported views.
func (v GenericCloneableStructView[T, V]) Pointer() map[string]T { panic("unsupported") }
func (v GenericCloneableStructView[T, V]) PtrSlice() *T { panic("unsupported") }
func (v GenericCloneableStructView[T, V]) PtrKeyMap() map[*T]string { panic("unsupported") }
@@ -942,25 +941,21 @@ func (v StructWithTypeAliasFieldsView) SliceWithPtrs() views.SliceView[*StructWi
func (v StructWithTypeAliasFieldsView) SliceWithoutPtrs() views.SliceView[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView] {
return views.SliceOfViews[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView](v.ж.SliceWithoutPtrs)
}
func (v StructWithTypeAliasFieldsView) MapWithPtrs() views.MapFn[string, *StructWithPtrsAlias, StructWithPtrsAliasView] {
return views.MapFnOf(v.ж.MapWithPtrs, func(t *StructWithPtrsAlias) StructWithPtrsAliasView {
return t.View()
})
}
func (v StructWithTypeAliasFieldsView) MapWithoutPtrs() views.MapFn[string, *StructWithoutPtrsAlias, StructWithoutPtrsAliasView] {
return views.MapFnOf(v.ж.MapWithoutPtrs, func(t *StructWithoutPtrsAlias) StructWithoutPtrsAliasView {
return t.View()
})
}
func (v StructWithTypeAliasFieldsView) MapOfSlicesWithPtrs() views.MapFn[string, []*StructWithPtrsAlias, views.SliceView[*StructWithPtrsAlias, StructWithPtrsAliasView]] {
return views.MapFnOf(v.ж.MapOfSlicesWithPtrs, func(t []*StructWithPtrsAlias) views.SliceView[*StructWithPtrsAlias, StructWithPtrsAliasView] {
return views.SliceOfViews[*StructWithPtrsAlias, StructWithPtrsAliasView](t)
})
}
func (v StructWithTypeAliasFieldsView) MapOfSlicesWithoutPtrs() views.MapFn[string, []*StructWithoutPtrsAlias, views.SliceView[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView]] {
return views.MapFnOf(v.ж.MapOfSlicesWithoutPtrs, func(t []*StructWithoutPtrsAlias) views.SliceView[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView] {
return views.SliceOfViews[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView](t)

View File

@@ -9,6 +9,8 @@ import (
"bytes"
"flag"
"fmt"
"go/ast"
"go/token"
"go/types"
"html/template"
"log"
@@ -17,6 +19,7 @@ import (
"strings"
"tailscale.com/util/codegen"
"tailscale.com/util/mak"
"tailscale.com/util/must"
)
@@ -104,16 +107,13 @@ func (v *{{.ViewName}}{{.TypeParamNames}}) UnmarshalJSONFrom(dec *jsontext.Decod
{{define "valuePointerField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.ValuePointer[{{.FieldType}}] { return views.ValuePointerOf(v.ж.{{.FieldName}}) }
{{end}}
{{define "mapField"}}
func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.Map[{{.MapKeyType}},{{.MapValueType}}] { return views.MapOf(v.ж.{{.FieldName}})}
{{define "mapField"}}func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.Map[{{.MapKeyType}},{{.MapValueType}}] { return views.MapOf(v.ж.{{.FieldName}})}
{{end}}
{{define "mapFnField"}}
func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapFn[{{.MapKeyType}},{{.MapValueType}},{{.MapValueView}}] { return views.MapFnOf(v.ж.{{.FieldName}}, func (t {{.MapValueType}}) {{.MapValueView}} {
{{define "mapFnField"}}func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapFn[{{.MapKeyType}},{{.MapValueType}},{{.MapValueView}}] { return views.MapFnOf(v.ж.{{.FieldName}}, func (t {{.MapValueType}}) {{.MapValueView}} {
return {{.MapFn}}
})}
{{end}}
{{define "mapSliceField"}}
func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapSlice[{{.MapKeyType}},{{.MapValueType}}] { return views.MapSliceOf(v.ж.{{.FieldName}}) }
{{define "mapSliceField"}}func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapSlice[{{.MapKeyType}},{{.MapValueType}}] { return views.MapSliceOf(v.ж.{{.FieldName}}) }
{{end}}
{{define "unsupportedField"}}func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} {panic("unsupported")}
{{end}}
@@ -142,7 +142,81 @@ func requiresCloning(t types.Type) (shallow, deep bool, base types.Type) {
return p, p, t
}
func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *types.Package) {
type fieldNameKey struct {
typeName string
fieldName string
}
// getFieldComments extracts field comments from the AST for a given struct type.
func getFieldComments(syntax []*ast.File) map[fieldNameKey]string {
if len(syntax) == 0 {
return nil
}
var fieldComments map[fieldNameKey]string
// Search through all AST files in the package
for _, file := range syntax {
// Look for the type declaration
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
typeName := typeSpec.Name.Name
// Check if it's a struct type
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
// Extract field comments
for _, field := range structType.Fields.List {
if len(field.Names) == 0 {
// Anonymous field or no names
continue
}
// Get the field name
fieldName := field.Names[0].Name
key := fieldNameKey{typeName, fieldName}
// Get the comment
var comment string
if field.Doc != nil && field.Doc.Text() != "" {
// Format the comment for Go code generation
comment = strings.TrimSpace(field.Doc.Text())
// Convert multi-line comments to proper Go comment format
var sb strings.Builder
for line := range strings.Lines(comment) {
sb.WriteString("// ")
sb.WriteString(line)
}
if sb.Len() > 0 {
comment = sb.String()
}
} else if field.Comment != nil && field.Comment.Text() != "" {
// Handle inline comments
comment = "// " + strings.TrimSpace(field.Comment.Text())
}
if comment != "" {
mak.Set(&fieldComments, key, comment)
}
}
}
}
}
return fieldComments
}
func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, fieldComments map[fieldNameKey]string) {
t, ok := typ.Underlying().(*types.Struct)
if !ok || codegen.IsViewType(t) {
return
@@ -182,6 +256,15 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
log.Fatal(err)
}
}
writeTemplateWithComment := func(name, fieldName string) {
// Write the field comment if it exists
key := fieldNameKey{args.StructName, fieldName}
if comment, ok := fieldComments[key]; ok && comment != "" {
fmt.Fprintln(buf, comment)
}
writeTemplate(name)
}
writeTemplate("common")
for i := range t.NumFields() {
f := t.Field(i)
@@ -196,7 +279,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
}
if !codegen.ContainsPointers(fieldType) || codegen.IsViewType(fieldType) || codegen.HasNoClone(t.Tag(i)) {
args.FieldType = it.QualifiedName(fieldType)
writeTemplate("valueField")
writeTemplateWithComment("valueField", fname)
continue
}
switch underlying := fieldType.Underlying().(type) {
@@ -207,7 +290,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
case "byte":
args.FieldType = it.QualifiedName(fieldType)
it.Import("", "tailscale.com/types/views")
writeTemplate("byteSliceField")
writeTemplateWithComment("byteSliceField", fname)
default:
args.FieldType = it.QualifiedName(elem)
it.Import("", "tailscale.com/types/views")
@@ -217,35 +300,35 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
case *types.Pointer:
if _, isIface := base.Underlying().(*types.Interface); !isIface {
args.FieldViewName = appendNameSuffix(it.QualifiedName(base), "View")
writeTemplate("viewSliceField")
writeTemplateWithComment("viewSliceField", fname)
} else {
writeTemplate("unsupportedField")
writeTemplateWithComment("unsupportedField", fname)
}
continue
case *types.Interface:
if viewType := viewTypeForValueType(elem); viewType != nil {
args.FieldViewName = it.QualifiedName(viewType)
writeTemplate("viewSliceField")
writeTemplateWithComment("viewSliceField", fname)
continue
}
}
writeTemplate("unsupportedField")
writeTemplateWithComment("unsupportedField", fname)
continue
} else if shallow {
switch base.Underlying().(type) {
case *types.Basic, *types.Interface:
writeTemplate("unsupportedField")
writeTemplateWithComment("unsupportedField", fname)
default:
if _, isIface := base.Underlying().(*types.Interface); !isIface {
args.FieldViewName = appendNameSuffix(it.QualifiedName(base), "View")
writeTemplate("viewSliceField")
writeTemplateWithComment("viewSliceField", fname)
} else {
writeTemplate("unsupportedField")
writeTemplateWithComment("unsupportedField", fname)
}
}
continue
}
writeTemplate("sliceField")
writeTemplateWithComment("sliceField", fname)
}
continue
case *types.Struct:
@@ -254,26 +337,26 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
if codegen.ContainsPointers(strucT) {
if viewType := viewTypeForValueType(fieldType); viewType != nil {
args.FieldViewName = it.QualifiedName(viewType)
writeTemplate("viewField")
writeTemplateWithComment("viewField", fname)
continue
}
if viewType, makeViewFn := viewTypeForContainerType(fieldType); viewType != nil {
args.FieldViewName = it.QualifiedName(viewType)
args.MakeViewFnName = it.PackagePrefix(makeViewFn.Pkg()) + makeViewFn.Name()
writeTemplate("makeViewField")
writeTemplateWithComment("makeViewField", fname)
continue
}
writeTemplate("unsupportedField")
writeTemplateWithComment("unsupportedField", fname)
continue
}
writeTemplate("valueField")
writeTemplateWithComment("valueField", fname)
continue
case *types.Map:
m := underlying
args.FieldType = it.QualifiedName(fieldType)
shallow, deep, key := requiresCloning(m.Key())
if shallow || deep {
writeTemplate("unsupportedField")
writeTemplateWithComment("unsupportedField", fname)
continue
}
it.Import("", "tailscale.com/types/views")
@@ -358,7 +441,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
default:
template = "unsupportedField"
}
writeTemplate(template)
writeTemplateWithComment(template, fname)
continue
case *types.Pointer:
ptr := underlying
@@ -368,9 +451,9 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
if _, isIface := base.Underlying().(*types.Interface); !isIface {
args.FieldType = it.QualifiedName(base)
args.FieldViewName = appendNameSuffix(args.FieldType, "View")
writeTemplate("viewField")
writeTemplateWithComment("viewField", fname)
} else {
writeTemplate("unsupportedField")
writeTemplateWithComment("unsupportedField", fname)
}
continue
}
@@ -379,7 +462,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
if viewType := viewTypeForValueType(base); viewType != nil {
args.FieldType = it.QualifiedName(base)
args.FieldViewName = it.QualifiedName(viewType)
writeTemplate("viewField")
writeTemplateWithComment("viewField", fname)
continue
}
@@ -389,7 +472,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
baseTypeName := it.QualifiedName(base)
args.FieldType = baseTypeName
args.FieldViewName = appendNameSuffix(args.FieldType, "View")
writeTemplate("viewField")
writeTemplateWithComment("viewField", fname)
continue
}
@@ -397,18 +480,18 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, _ *
// and will not have a generated view type, use views.ValuePointer[T] as the field's view type.
// Its Get/GetOk methods return stack-allocated shallow copies of the field's value.
args.FieldType = it.QualifiedName(base)
writeTemplate("valuePointerField")
writeTemplateWithComment("valuePointerField", fname)
continue
case *types.Interface:
// If fieldType is an interface with a "View() {ViewType}" method, it can be used to clone the field.
// This includes scenarios where fieldType is a constrained type parameter.
if viewType := viewTypeForValueType(underlying); viewType != nil {
args.FieldViewName = it.QualifiedName(viewType)
writeTemplate("viewField")
writeTemplateWithComment("viewField", fname)
continue
}
}
writeTemplate("unsupportedField")
writeTemplateWithComment("unsupportedField", fname)
}
for i := range typ.NumMethods() {
f := typ.Method(i)
@@ -627,6 +710,7 @@ func main() {
log.Fatal(err)
}
it := codegen.NewImportTracker(pkg.Types)
fieldComments := getFieldComments(pkg.Syntax)
cloneOnlyType := map[string]bool{}
for _, t := range strings.Split(*flagCloneOnlyTypes, ",") {
@@ -654,7 +738,7 @@ func main() {
if !hasClone {
runCloner = true
}
genView(buf, it, typ, pkg.Types)
genView(buf, it, typ, fieldComments)
}
out := pkg.Name + "_view"
if *flagBuildTags == "test" {

View File

@@ -53,6 +53,7 @@ func TestViewerImports(t *testing.T) {
if err != nil {
t.Fatal(err)
}
var fieldComments map[fieldNameKey]string // don't need it for this test.
var output bytes.Buffer
tracker := codegen.NewImportTracker(pkg)
@@ -65,7 +66,7 @@ func TestViewerImports(t *testing.T) {
if !ok {
t.Fatalf("%q is not a named type", tt.typeNames[i])
}
genView(&output, tracker, namedType, pkg)
genView(&output, tracker, namedType, fieldComments)
}
for _, pkg := range tt.wantImports {