Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

read comments from fields of anonymous structs #32

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
155 changes: 97 additions & 58 deletions comment_extractor.go
Original file line number Diff line number Diff line change
@@ -1,32 +1,73 @@
package jsonschema

import (
"fmt"
"io/fs"
gopath "path"
"path/filepath"
"strings"

"go/ast"
"go/doc"
"go/parser"
"go/token"
"io/fs"
gopath "path"
"path/filepath"
"strings"
)

// ExtractGoComments will read all the go files contained in the provided path,
// including sub-directories, in order to generate a dictionary of comments
// associated with Types and Fields. The results will be added to the `commentsMap`
// provided in the parameters and expected to be used for Schema "description" fields.
//
// The `go/parser` library is used to extract all the comments and unfortunately doesn't
// have a built-in way to determine the fully qualified name of a package. The `base` paremeter,
// the URL used to import that package, is thus required to be able to match reflected types.
//
// When parsing type comments, we use the `go/doc`'s Synopsis method to extract the first phrase
// only. Field comments, which tend to be much shorter, will include everything.
func ExtractGoComments(base, path string, commentMap map[string]string) error {
type breadcrumb []string

func (b breadcrumb) With(breadcrumb string) breadcrumb {
return append(b, breadcrumb)
}

func (b breadcrumb) Field(fieldName string) breadcrumb {
return b.With(fieldName)
}

func (b breadcrumb) SliceElem() breadcrumb {
return b.With("[]")
}

func (b breadcrumb) MapKey() breadcrumb {
return b.With("[key]")
}

func (b breadcrumb) MapElem() breadcrumb {
return b.With("[value]")
}

func (b breadcrumb) String() string {
return strings.Join(b, ".")
}

func handleType(expr ast.Expr, breadcrumb breadcrumb, comments map[string]string) {
switch t := expr.(type) {
case *ast.StructType:
for _, field := range t.Fields.List {
for _, name := range field.Names {
if !ast.IsExported(name.Name) {
continue
}

b := breadcrumb.Field(name.Name)
txt := field.Doc.Text()
if txt == "" {
txt = field.Comment.Text()
}
comments[b.String()] = strings.TrimSpace(txt)
handleType(field.Type, b, comments)
}
}
case *ast.ArrayType:
handleType(t.Elt, breadcrumb.SliceElem(), comments)
case *ast.MapType:
handleType(t.Key, breadcrumb.MapKey(), comments)
handleType(t.Value, breadcrumb.MapElem(), comments)
case *ast.StarExpr:
handleType(t.X, breadcrumb, comments)
}
}

func getPackages(base, path string) (map[string]*ast.Package, error) {
fset := token.NewFileSet()
dict := make(map[string][]*ast.Package)
dict := make(map[string]*ast.Package)
err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
Expand All @@ -36,58 +77,56 @@ func ExtractGoComments(base, path string, commentMap map[string]string) error {
if err != nil {
return err
}
for _, v := range d {
// paths may have multiple packages, like for tests
k := gopath.Join(base, path)
dict[k] = append(dict[k], v)
for pkgName, v := range d {
k := gopath.Join(base, gopath.Dir(path), pkgName)
dict[k] = v
}
}
return nil
})
return dict, err
}

// ExtractGoComments will read all the go files contained in the provided path,
// including sub-directories, in order to generate a dictionary of comments
// associated with Types and Fields. The results will be added to the `commentsMap`
// provided in the parameters and expected to be used for Schema "description" fields.
//
// The `go/parser` library is used to extract all the comments and unfortunately doesn't
// have a built-in way to determine the fully qualified name of a package. The `base` paremeter,
// the URL used to import that package, is thus required to be able to match reflected types.
//
// When parsing type comments, we use the `go/doc`'s Synopsis method to extract the first phrase
// only. Field comments, which tend to be much shorter, will include everything.
func ExtractGoComments(base, path string, commentMap map[string]string) error {
pkgs, err := getPackages(base, path)
if err != nil {
return err
}

for pkg, p := range dict {
for _, f := range p {
gtxt := ""
typ := ""
ast.Inspect(f, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.TypeSpec:
typ = x.Name.String()
if !ast.IsExported(typ) {
typ = ""
} else {
txt := x.Doc.Text()
if txt == "" && gtxt != "" {
txt = gtxt
gtxt = ""
}
txt = doc.Synopsis(txt)
commentMap[fmt.Sprintf("%s.%s", pkg, typ)] = strings.TrimSpace(txt)
}
case *ast.Field:
txt := x.Doc.Text()
if txt == "" {
txt = x.Comment.Text()
}
if typ != "" && txt != "" {
for _, n := range x.Names {
if ast.IsExported(n.String()) {
k := fmt.Sprintf("%s.%s.%s", pkg, typ, n)
commentMap[k] = strings.TrimSpace(txt)
for qualifiedName, pkg := range pkgs {
rootBreadcrumb := breadcrumb{qualifiedName}
for _, file := range pkg.Files {
for _, decl := range file.Decls {
if d, ok := decl.(*ast.GenDecl); ok {
for _, spec := range d.Specs {
if s, ok := spec.(*ast.TypeSpec); ok {
if !ast.IsExported(s.Name.Name) {
continue
}

breadcrumb := rootBreadcrumb.With(s.Name.Name)
txt := s.Doc.Text()
if txt == "" {
txt = d.Doc.Text()
}
commentMap[breadcrumb.String()] = strings.TrimSpace(doc.Synopsis(txt))
handleType(s.Type, breadcrumb, commentMap)
}
}
case *ast.GenDecl:
// remember for the next type
gtxt = x.Doc.Text()
}
return true
})
}
}
}

return nil
}
6 changes: 6 additions & 0 deletions examples/nested/nested.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ type (
// Multicellular is true if the plant is multicellular
Multicellular bool `json:"multicellular,omitempty" jsonschema:"title=Multicellular"` // This comment will be ignored
}

// Metadata is additional arbitrary metadata to embed in a struct.
Metadata[T any] struct {
// The value of the metadata
Data T `json:"metadata"`
}
)
25 changes: 22 additions & 3 deletions examples/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ type User struct {
// Unique sequential identifier.
ID int `json:"id" jsonschema:"required"`
// This comment will be ignored
Name string `json:"name" jsonschema:"required,minLength=1,maxLength=20,pattern=.*,description=this is a property,title=the name,example=joe,example=lucy,default=alex"`
Friends []int `json:"friends,omitempty" jsonschema_description:"list of IDs, omitted when empty"`
Tags map[string]any `json:"tags,omitempty"`
Name string `json:"name" jsonschema:"required,minLength=1,maxLength=20,pattern=.*,description=this is a property,title=the name,example=joe,example=lucy,default=alex"`
Friends []struct {
// The ID of the friend
FriendID int `json:"friend_id"`
// A note about this friend
FriendNote string `json:"friend_note,omitempty"`
} `json:"friends,omitempty" jsonschema_description:"list of friends, omitted when empty"`
Tags map[string]any `json:"tags,omitempty"`

// An array of pets the user cares for.
Pets nested.Pets `json:"pets"`
Expand All @@ -22,4 +27,18 @@ type User struct {

// Set of plants that the user likes
Plants []*nested.Plant `json:"plants" jsonschema:"title=Plants"`

// Additional data about this user
AdditionalData struct {
// This user's favorite color
FavoriteColor string `json:"favorite_color"`
} `json:"additional_data"`

// A mapping from friend IDs to notes
FriendToNote map[int]*struct {
// The note for this friend
Note string `json:"note"`
} `json:"friend_to_note,omitempty"`

nested.Metadata[string]
}
60 changes: 57 additions & 3 deletions fixtures/go_comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,24 @@
},
"friends": {
"items": {
"type": "integer"
"type": "object",
"properties": {
"friend_id": {
"type": "integer",
"description": "The ID of the friend"
},
"friend_note": {
"type": "string",
"description": "A note about this friend"
}
},
"additionalProperties": false,
"required": [
"friend_id"
]
},
"type": "array",
"description": "list of IDs, omitted when empty"
"description": "list of friends, omitted when empty"
},
"tags": {
"type": "object"
Expand All @@ -96,6 +110,44 @@
"type": "array",
"title": "Plants",
"description": "Set of plants that the user likes"
},
"additional_data": {
"type": "object",
"properties": {
"favorite_color": {
"type": "string",
"description": "This user's favorite color"
}
},
"additionalProperties": false,
"description": "Additional data about this user",
"required": [
"favorite_color"
]
},
"friend_to_note": {
"type": "object",
"patternProperties": {
"^[0-9]+$": {
"type": "object",
"properties": {
"note": {
"type": "string",
"description": "The note for this friend"
}
},
"additionalProperties": false,
"required": [
"note"
]
}
},
"additionalProperties": false,
"description": "A mapping from friend IDs to notes"
},
"metadata": {
"type": "string",
"description": "The value of the metadata"
}
},
"additionalProperties": false,
Expand All @@ -105,7 +157,9 @@
"name",
"pets",
"named_pets",
"plants"
"plants",
"additional_data",
"metadata"
],
"description": "User is used as a base to provide tests for comments."
}
Expand Down