Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ See the [simple example](./example/simple) for a basic usage of the library. A l
```go
// Step 1: Create a new Golang parser
golang, _ := guts.NewGolangParser()

// Optional: Preserve comments from the golang source code
// This feature is still experimental and may not work in all cases
golang.PreserveComments()

// Step 2: Configure the parser
_ = golang.IncludeGenerate("github.com/coder/guts/example/simple")
// Step 3: Convert the Golang to the typescript AST
Expand Down
2 changes: 1 addition & 1 deletion bindings/bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@ func (b *Bindings) CommentGojaObject(comments []SyntheticComment, object *goja.O
node,
b.vm.ToValue(c.Leading),
b.vm.ToValue(c.SingleLine),
b.vm.ToValue(" "+c.Text),
b.vm.ToValue(c.Text),
b.vm.ToValue(c.TrailingNewLine),
)
if err != nil {
Expand Down
19 changes: 12 additions & 7 deletions bindings/comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
// Commentable indicates if the AST node supports adding comments.
// Any number of comments are supported and can be attached to a typescript AST node.
type Commentable interface {
Comment(comment SyntheticComment)
AppendComment(comment SyntheticComment)
Comments() []SyntheticComment
}

Expand All @@ -27,15 +27,20 @@ type SupportComments struct {

// LeadingComment is a helper function for the most common type of comment.
func (s *SupportComments) LeadingComment(text string) {
s.Comment(SyntheticComment{
Leading: true,
SingleLine: true,
Text: text,
s.AppendComment(SyntheticComment{
Leading: true,
SingleLine: true,
// All go comments are `// ` prefixed, so add a space.
Text: " " + text,
TrailingNewLine: false,
})
}

func (s *SupportComments) Comment(comment SyntheticComment) {
func (s *SupportComments) AppendComments(comments []SyntheticComment) {
s.comments = append(s.comments, comments...)
}

func (s *SupportComments) AppendComment(comment SyntheticComment) {
s.comments = append(s.comments, comment)
}

Expand All @@ -59,7 +64,7 @@ func (s Source) SourceComment() (SyntheticComment, bool) {
return SyntheticComment{
Leading: true,
SingleLine: true,
Text: fmt.Sprintf("From %s", s.File),
Text: fmt.Sprintf(" From %s", s.File),
TrailingNewLine: false,
}, s.File != ""
}
1 change: 1 addition & 0 deletions bindings/declarations.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ type VariableStatement struct {
Modifiers []Modifier
Declarations *VariableDeclarationList
Source
SupportComments
}

func (*VariableStatement) isNode() {}
Expand Down
173 changes: 173 additions & 0 deletions comments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package guts

import (
"go/ast"
"go/token"
"go/types"
"strings"

"golang.org/x/tools/go/packages"

"github.com/coder/guts/bindings"
)

func (p *GoParser) CommentForObject(obj types.Object) []bindings.SyntheticComment {
for _, pkg := range p.Pkgs {
if obj.Pkg() != nil && pkg.PkgPath == obj.Pkg().Path() {
return CommentForObject(obj, pkg)
}
}
return []bindings.SyntheticComment{}
}

// CommentForObject returns the comment group associated with the object's declaration.
// For functions/methods it returns FuncDecl.Doc.
// For const/var/type it prefers Spec.Doc, else GenDecl.Doc.
// For struct/interface members it returns Field.Doc, else Field.Comment (trailing).
// Disclaimer: A lot of this code was AI generated. Feel free to improve it!
func CommentForObject(obj types.Object, pkg *packages.Package) []bindings.SyntheticComment {
if obj == nil || pkg == nil {
return nil
}
pos := obj.Pos()

for _, f := range pkg.Syntax {
if !covers(f, pos) {
continue
}

var found []bindings.SyntheticComment
ast.Inspect(f, func(n ast.Node) bool {
// The decl nodes "cover" the types they comment on. So we can check quickly if
// the node is relevant.
if n == nil || !covers(n, pos) {
return false
}

switch nd := n.(type) {
case *ast.FuncDecl:
// Match function/method name token exactly.
if nd.Name != nil && nd.Name.Pos() == pos {
found = syntheticComments(true, nd.Doc)
return false
}

case *ast.GenDecl:
// Walk specs to prefer per-spec docs over decl docs.
for _, sp := range nd.Specs {
if !covers(sp, pos) {
continue
}

// nd.Doc are the comments for the entire type/const/var block.
if nd.Doc != nil {
found = append(found, syntheticComments(true, nd.Doc)...)
}

switch spec := sp.(type) {
case *ast.ValueSpec:
// const/var
for _, name := range spec.Names {
if name.Pos() == pos {
found = append(found, syntheticComments(true, spec.Doc)...)
found = append(found, syntheticComments(false, spec.Comment)...)
return false
}
}

case *ast.TypeSpec:
// type declarations (struct/interface/alias)
if spec.Name != nil && spec.Name.Pos() == pos {
// comment on the type itself
found = append(found, syntheticComments(true, spec.Doc)...)
found = append(found, syntheticComments(false, spec.Comment)...)
return false
}

// dive into members for struct/interface fields
switch t := spec.Type.(type) {
case *ast.StructType:
if cg := commentForFieldList(t.Fields, pos); cg != nil {
found = cg
return false
}
case *ast.InterfaceType:
if cg := commentForFieldList(t.Methods, pos); cg != nil {
found = cg
return false
}
}
}
}

// If we saw the decl but not a more specific match, keep walking.
return true
}

// Keep drilling down until we either match or run out.
return true
})

return found
}

return nil
}

func commentForFieldList(fl *ast.FieldList, pos token.Pos) []bindings.SyntheticComment {
if fl == nil {
return nil
}
cmts := []bindings.SyntheticComment{}
for _, fld := range fl.List {
if !covers(fld, pos) {
continue
}
// Named field or interface method: match any of the Names.
if len(fld.Names) > 0 {
for _, nm := range fld.Names {
if nm.Pos() == pos {
cmts = append(cmts, syntheticComments(true, fld.Doc)...)
cmts = append(cmts, syntheticComments(false, fld.Comment)...)
return cmts
}
}
} else {
// Embedded field (anonymous): no Names; match on the Type span.
if covers(fld.Type, pos) {
cmts = append(cmts, syntheticComments(true, fld.Doc)...)
cmts = append(cmts, syntheticComments(false, fld.Comment)...)
return cmts
}
}
}
return nil
}

func covers(n ast.Node, p token.Pos) bool {
return n != nil && n.Pos() <= p && p <= n.End()
}

func syntheticComments(leading bool, grp *ast.CommentGroup) []bindings.SyntheticComment {
cmts := []bindings.SyntheticComment{}
if grp == nil {
return cmts
}
for _, c := range grp.List {
cmts = append(cmts, bindings.SyntheticComment{
Leading: leading,
SingleLine: !strings.Contains(c.Text, "\n"),
Text: normalizeCommentText(c.Text),
TrailingNewLine: true,
})
}
return cmts
}

func normalizeCommentText(text string) string {
// TODO: Is there a better way to get just the text of the comment?
text = strings.TrimPrefix(text, "//")
text = strings.TrimPrefix(text, "/*")
text = strings.TrimSuffix(text, "*/")
return text
}
66 changes: 50 additions & 16 deletions convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ type GoParser struct {
// This needs to be a producer function, as the AST is mutated directly,
// and we cannot have shared references.
// Eg: "time.Time" -> "string"
typeOverrides map[string]TypeOverride
config *packages.Config
fileSet *token.FileSet
typeOverrides map[string]TypeOverride
config *packages.Config
fileSet *token.FileSet
preserveComments bool
}

// NewGolangParser returns a new GoParser object.
Expand Down Expand Up @@ -80,6 +81,14 @@ func NewGolangParser() (*GoParser, error) {
}, nil
}

// PreserveComments will attempt to preserve any comments associated with
// the golang types. This feature is still a work in progress, and may not
// preserve all comments or match all expectations.
func (p *GoParser) PreserveComments() *GoParser {
p.preserveComments = true
return p
}

// IncludeCustomDeclaration is an advanced form of IncludeCustom.
func (p *GoParser) IncludeCustomDeclaration(mappings map[string]TypeOverride) {
for k, v := range mappings {
Expand Down Expand Up @@ -115,6 +124,7 @@ func (p *GoParser) IncludeCustom(mappings map[GolangType]GolangType) error {
return exp
}
}

return nil
}

Expand Down Expand Up @@ -174,9 +184,10 @@ func (p *GoParser) include(directory string, prefix string, reference bool) erro
// The returned typescript object can be mutated before serializing.
func (p *GoParser) ToTypescript() (*Typescript, error) {
typescript := &Typescript{
typescriptNodes: make(map[string]*typescriptNode),
parsed: p,
skip: p.Skips,
typescriptNodes: make(map[string]*typescriptNode),
parsed: p,
skip: p.Skips,
preserveComments: p.preserveComments,
}

// Parse all go types to the typescript AST
Expand Down Expand Up @@ -209,9 +220,10 @@ type Typescript struct {
// parsed go code. All names should be unique. If non-unique names exist, that
// means packages contain the same named types.
// TODO: the key "string" should be replaced with "Identifier"
typescriptNodes map[string]*typescriptNode
parsed *GoParser
skip map[string]struct{}
typescriptNodes map[string]*typescriptNode
parsed *GoParser
skip map[string]struct{}
preserveComments bool
// Do not allow calling serialize more than once.
// The call affects the state.
serialized bool
Expand Down Expand Up @@ -437,6 +449,11 @@ func (ts *Typescript) parse(obj types.Object) error {
if err != nil {
return xerrors.Errorf("generate %q: %w", objectIdentifier.Ref(), err)
}

if ts.preserveComments {
cmts := ts.parsed.CommentForObject(obj)
node.AppendComments(cmts)
}
return ts.setNode(objectIdentifier.Ref(), typescriptNode{
Node: node,
})
Expand Down Expand Up @@ -471,14 +488,21 @@ func (ts *Typescript) parse(obj types.Object) error {
return xerrors.Errorf("(map) generate %q: %w", objectIdentifier.Ref(), err)
}

aliasNode := &bindings.Alias{
Name: objectIdentifier,
Modifiers: []bindings.Modifier{},
Type: ty.Value,
Parameters: ty.TypeParameters,
Source: ts.location(obj),
}

if ts.preserveComments {
cmts := ts.parsed.CommentForObject(obj)
aliasNode.AppendComments(cmts)
}

return ts.setNode(objectIdentifier.Ref(), typescriptNode{
Node: &bindings.Alias{
Name: objectIdentifier,
Modifiers: []bindings.Modifier{},
Type: ty.Value,
Parameters: ty.TypeParameters,
Source: ts.location(obj),
},
Node: aliasNode,
})
case *types.Interface:
// Interfaces are used as generics. Non-generic interfaces are
Expand Down Expand Up @@ -559,6 +583,12 @@ func (ts *Typescript) parse(obj types.Object) error {
if err != nil {
return xerrors.Errorf("basic const %q: %w", objectIdentifier.Ref(), err)
}

if ts.preserveComments {
cmts := ts.parsed.CommentForObject(obj)
cnst.AppendComments(cmts)
}

return ts.setNode(objectIdentifier.Ref(), typescriptNode{
Node: cnst,
})
Expand Down Expand Up @@ -791,6 +821,10 @@ func (ts *Typescript) buildStruct(obj types.Object, st *types.Struct) (*bindings
}
}

if ts.preserveComments {
cmts := ts.parsed.CommentForObject(field)
tsField.AppendComments(cmts)
}
tsi.Fields = append(tsi.Fields, tsField)
}

Expand Down
3 changes: 3 additions & 0 deletions convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ func TestGeneration(t *testing.T) {
gen, err := guts.NewGolangParser()
require.NoError(t, err, "new convert")

// PreserveComments will attach golang comments to the typescript nodes.
gen.PreserveComments()

dir := filepath.Join(".", "testdata", f.Name())
err = gen.IncludeGenerate("./" + dir)
require.NoErrorf(t, err, "include %q", dir)
Expand Down
Loading