Skip to content

Commit 3f5ec56

Browse files
authored
feat: preserve comments from golang -> typescript (#59)
First implementation of preserving comments. Only covers a subset of cases.
1 parent 1bb8a39 commit 3f5ec56

File tree

15 files changed

+338
-26
lines changed

15 files changed

+338
-26
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ See the [simple example](./example/simple) for a basic usage of the library. A l
3737
```go
3838
// Step 1: Create a new Golang parser
3939
golang, _ := guts.NewGolangParser()
40+
41+
// Optional: Preserve comments from the golang source code
42+
// This feature is still experimental and may not work in all cases
43+
golang.PreserveComments()
44+
4045
// Step 2: Configure the parser
4146
_ = golang.IncludeGenerate("github.com/coder/guts/example/simple")
4247
// Step 3: Convert the Golang to the typescript AST

bindings/bindings.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,7 @@ func (b *Bindings) CommentGojaObject(comments []SyntheticComment, object *goja.O
739739
node,
740740
b.vm.ToValue(c.Leading),
741741
b.vm.ToValue(c.SingleLine),
742-
b.vm.ToValue(" "+c.Text),
742+
b.vm.ToValue(c.Text),
743743
b.vm.ToValue(c.TrailingNewLine),
744744
)
745745
if err != nil {

bindings/comments.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
// Commentable indicates if the AST node supports adding comments.
99
// Any number of comments are supported and can be attached to a typescript AST node.
1010
type Commentable interface {
11-
Comment(comment SyntheticComment)
11+
AppendComment(comment SyntheticComment)
1212
Comments() []SyntheticComment
1313
}
1414

@@ -27,15 +27,20 @@ type SupportComments struct {
2727

2828
// LeadingComment is a helper function for the most common type of comment.
2929
func (s *SupportComments) LeadingComment(text string) {
30-
s.Comment(SyntheticComment{
31-
Leading: true,
32-
SingleLine: true,
33-
Text: text,
30+
s.AppendComment(SyntheticComment{
31+
Leading: true,
32+
SingleLine: true,
33+
// All go comments are `// ` prefixed, so add a space.
34+
Text: " " + text,
3435
TrailingNewLine: false,
3536
})
3637
}
3738

38-
func (s *SupportComments) Comment(comment SyntheticComment) {
39+
func (s *SupportComments) AppendComments(comments []SyntheticComment) {
40+
s.comments = append(s.comments, comments...)
41+
}
42+
43+
func (s *SupportComments) AppendComment(comment SyntheticComment) {
3944
s.comments = append(s.comments, comment)
4045
}
4146

@@ -59,7 +64,7 @@ func (s Source) SourceComment() (SyntheticComment, bool) {
5964
return SyntheticComment{
6065
Leading: true,
6166
SingleLine: true,
62-
Text: fmt.Sprintf("From %s", s.File),
67+
Text: fmt.Sprintf(" From %s", s.File),
6368
TrailingNewLine: false,
6469
}, s.File != ""
6570
}

bindings/declarations.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ type VariableStatement struct {
105105
Modifiers []Modifier
106106
Declarations *VariableDeclarationList
107107
Source
108+
SupportComments
108109
}
109110

110111
func (*VariableStatement) isNode() {}

comments.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package guts
2+
3+
import (
4+
"go/ast"
5+
"go/token"
6+
"go/types"
7+
"strings"
8+
9+
"golang.org/x/tools/go/packages"
10+
11+
"github.com/coder/guts/bindings"
12+
)
13+
14+
func (p *GoParser) CommentForObject(obj types.Object) []bindings.SyntheticComment {
15+
for _, pkg := range p.Pkgs {
16+
if obj.Pkg() != nil && pkg.PkgPath == obj.Pkg().Path() {
17+
return CommentForObject(obj, pkg)
18+
}
19+
}
20+
return []bindings.SyntheticComment{}
21+
}
22+
23+
// CommentForObject returns the comment group associated with the object's declaration.
24+
// For functions/methods it returns FuncDecl.Doc.
25+
// For const/var/type it prefers Spec.Doc, else GenDecl.Doc.
26+
// For struct/interface members it returns Field.Doc, else Field.Comment (trailing).
27+
// Disclaimer: A lot of this code was AI generated. Feel free to improve it!
28+
func CommentForObject(obj types.Object, pkg *packages.Package) []bindings.SyntheticComment {
29+
if obj == nil || pkg == nil {
30+
return nil
31+
}
32+
pos := obj.Pos()
33+
34+
for _, f := range pkg.Syntax {
35+
if !covers(f, pos) {
36+
continue
37+
}
38+
39+
var found []bindings.SyntheticComment
40+
ast.Inspect(f, func(n ast.Node) bool {
41+
// The decl nodes "cover" the types they comment on. So we can check quickly if
42+
// the node is relevant.
43+
if n == nil || !covers(n, pos) {
44+
return false
45+
}
46+
47+
switch nd := n.(type) {
48+
case *ast.FuncDecl:
49+
// Match function/method name token exactly.
50+
if nd.Name != nil && nd.Name.Pos() == pos {
51+
found = syntheticComments(true, nd.Doc)
52+
return false
53+
}
54+
55+
case *ast.GenDecl:
56+
// Walk specs to prefer per-spec docs over decl docs.
57+
for _, sp := range nd.Specs {
58+
if !covers(sp, pos) {
59+
continue
60+
}
61+
62+
// nd.Doc are the comments for the entire type/const/var block.
63+
if nd.Doc != nil {
64+
found = append(found, syntheticComments(true, nd.Doc)...)
65+
}
66+
67+
switch spec := sp.(type) {
68+
case *ast.ValueSpec:
69+
// const/var
70+
for _, name := range spec.Names {
71+
if name.Pos() == pos {
72+
found = append(found, syntheticComments(true, spec.Doc)...)
73+
found = append(found, syntheticComments(false, spec.Comment)...)
74+
return false
75+
}
76+
}
77+
78+
case *ast.TypeSpec:
79+
// type declarations (struct/interface/alias)
80+
if spec.Name != nil && spec.Name.Pos() == pos {
81+
// comment on the type itself
82+
found = append(found, syntheticComments(true, spec.Doc)...)
83+
found = append(found, syntheticComments(false, spec.Comment)...)
84+
return false
85+
}
86+
87+
// dive into members for struct/interface fields
88+
switch t := spec.Type.(type) {
89+
case *ast.StructType:
90+
if cg := commentForFieldList(t.Fields, pos); cg != nil {
91+
found = cg
92+
return false
93+
}
94+
case *ast.InterfaceType:
95+
if cg := commentForFieldList(t.Methods, pos); cg != nil {
96+
found = cg
97+
return false
98+
}
99+
}
100+
}
101+
}
102+
103+
// If we saw the decl but not a more specific match, keep walking.
104+
return true
105+
}
106+
107+
// Keep drilling down until we either match or run out.
108+
return true
109+
})
110+
111+
return found
112+
}
113+
114+
return nil
115+
}
116+
117+
func commentForFieldList(fl *ast.FieldList, pos token.Pos) []bindings.SyntheticComment {
118+
if fl == nil {
119+
return nil
120+
}
121+
cmts := []bindings.SyntheticComment{}
122+
for _, fld := range fl.List {
123+
if !covers(fld, pos) {
124+
continue
125+
}
126+
// Named field or interface method: match any of the Names.
127+
if len(fld.Names) > 0 {
128+
for _, nm := range fld.Names {
129+
if nm.Pos() == pos {
130+
cmts = append(cmts, syntheticComments(true, fld.Doc)...)
131+
cmts = append(cmts, syntheticComments(false, fld.Comment)...)
132+
return cmts
133+
}
134+
}
135+
} else {
136+
// Embedded field (anonymous): no Names; match on the Type span.
137+
if covers(fld.Type, pos) {
138+
cmts = append(cmts, syntheticComments(true, fld.Doc)...)
139+
cmts = append(cmts, syntheticComments(false, fld.Comment)...)
140+
return cmts
141+
}
142+
}
143+
}
144+
return nil
145+
}
146+
147+
func covers(n ast.Node, p token.Pos) bool {
148+
return n != nil && n.Pos() <= p && p <= n.End()
149+
}
150+
151+
func syntheticComments(leading bool, grp *ast.CommentGroup) []bindings.SyntheticComment {
152+
cmts := []bindings.SyntheticComment{}
153+
if grp == nil {
154+
return cmts
155+
}
156+
for _, c := range grp.List {
157+
cmts = append(cmts, bindings.SyntheticComment{
158+
Leading: leading,
159+
SingleLine: !strings.Contains(c.Text, "\n"),
160+
Text: normalizeCommentText(c.Text),
161+
TrailingNewLine: true,
162+
})
163+
}
164+
return cmts
165+
}
166+
167+
func normalizeCommentText(text string) string {
168+
// TODO: Is there a better way to get just the text of the comment?
169+
text = strings.TrimPrefix(text, "//")
170+
text = strings.TrimPrefix(text, "/*")
171+
text = strings.TrimSuffix(text, "*/")
172+
return text
173+
}

convert.go

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ type GoParser struct {
3939
// This needs to be a producer function, as the AST is mutated directly,
4040
// and we cannot have shared references.
4141
// Eg: "time.Time" -> "string"
42-
typeOverrides map[string]TypeOverride
43-
config *packages.Config
44-
fileSet *token.FileSet
42+
typeOverrides map[string]TypeOverride
43+
config *packages.Config
44+
fileSet *token.FileSet
45+
preserveComments bool
4546
}
4647

4748
// NewGolangParser returns a new GoParser object.
@@ -80,6 +81,14 @@ func NewGolangParser() (*GoParser, error) {
8081
}, nil
8182
}
8283

84+
// PreserveComments will attempt to preserve any comments associated with
85+
// the golang types. This feature is still a work in progress, and may not
86+
// preserve all comments or match all expectations.
87+
func (p *GoParser) PreserveComments() *GoParser {
88+
p.preserveComments = true
89+
return p
90+
}
91+
8392
// IncludeCustomDeclaration is an advanced form of IncludeCustom.
8493
func (p *GoParser) IncludeCustomDeclaration(mappings map[string]TypeOverride) {
8594
for k, v := range mappings {
@@ -115,6 +124,7 @@ func (p *GoParser) IncludeCustom(mappings map[GolangType]GolangType) error {
115124
return exp
116125
}
117126
}
127+
118128
return nil
119129
}
120130

@@ -174,9 +184,10 @@ func (p *GoParser) include(directory string, prefix string, reference bool) erro
174184
// The returned typescript object can be mutated before serializing.
175185
func (p *GoParser) ToTypescript() (*Typescript, error) {
176186
typescript := &Typescript{
177-
typescriptNodes: make(map[string]*typescriptNode),
178-
parsed: p,
179-
skip: p.Skips,
187+
typescriptNodes: make(map[string]*typescriptNode),
188+
parsed: p,
189+
skip: p.Skips,
190+
preserveComments: p.preserveComments,
180191
}
181192

182193
// Parse all go types to the typescript AST
@@ -209,9 +220,10 @@ type Typescript struct {
209220
// parsed go code. All names should be unique. If non-unique names exist, that
210221
// means packages contain the same named types.
211222
// TODO: the key "string" should be replaced with "Identifier"
212-
typescriptNodes map[string]*typescriptNode
213-
parsed *GoParser
214-
skip map[string]struct{}
223+
typescriptNodes map[string]*typescriptNode
224+
parsed *GoParser
225+
skip map[string]struct{}
226+
preserveComments bool
215227
// Do not allow calling serialize more than once.
216228
// The call affects the state.
217229
serialized bool
@@ -437,6 +449,11 @@ func (ts *Typescript) parse(obj types.Object) error {
437449
if err != nil {
438450
return xerrors.Errorf("generate %q: %w", objectIdentifier.Ref(), err)
439451
}
452+
453+
if ts.preserveComments {
454+
cmts := ts.parsed.CommentForObject(obj)
455+
node.AppendComments(cmts)
456+
}
440457
return ts.setNode(objectIdentifier.Ref(), typescriptNode{
441458
Node: node,
442459
})
@@ -471,14 +488,21 @@ func (ts *Typescript) parse(obj types.Object) error {
471488
return xerrors.Errorf("(map) generate %q: %w", objectIdentifier.Ref(), err)
472489
}
473490

491+
aliasNode := &bindings.Alias{
492+
Name: objectIdentifier,
493+
Modifiers: []bindings.Modifier{},
494+
Type: ty.Value,
495+
Parameters: ty.TypeParameters,
496+
Source: ts.location(obj),
497+
}
498+
499+
if ts.preserveComments {
500+
cmts := ts.parsed.CommentForObject(obj)
501+
aliasNode.AppendComments(cmts)
502+
}
503+
474504
return ts.setNode(objectIdentifier.Ref(), typescriptNode{
475-
Node: &bindings.Alias{
476-
Name: objectIdentifier,
477-
Modifiers: []bindings.Modifier{},
478-
Type: ty.Value,
479-
Parameters: ty.TypeParameters,
480-
Source: ts.location(obj),
481-
},
505+
Node: aliasNode,
482506
})
483507
case *types.Interface:
484508
// Interfaces are used as generics. Non-generic interfaces are
@@ -559,6 +583,12 @@ func (ts *Typescript) parse(obj types.Object) error {
559583
if err != nil {
560584
return xerrors.Errorf("basic const %q: %w", objectIdentifier.Ref(), err)
561585
}
586+
587+
if ts.preserveComments {
588+
cmts := ts.parsed.CommentForObject(obj)
589+
cnst.AppendComments(cmts)
590+
}
591+
562592
return ts.setNode(objectIdentifier.Ref(), typescriptNode{
563593
Node: cnst,
564594
})
@@ -791,6 +821,10 @@ func (ts *Typescript) buildStruct(obj types.Object, st *types.Struct) (*bindings
791821
}
792822
}
793823

824+
if ts.preserveComments {
825+
cmts := ts.parsed.CommentForObject(field)
826+
tsField.AppendComments(cmts)
827+
}
794828
tsi.Fields = append(tsi.Fields, tsField)
795829
}
796830

convert_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ func TestGeneration(t *testing.T) {
7676
gen, err := guts.NewGolangParser()
7777
require.NoError(t, err, "new convert")
7878

79+
// PreserveComments will attach golang comments to the typescript nodes.
80+
gen.PreserveComments()
81+
7982
dir := filepath.Join(".", "testdata", f.Name())
8083
err = gen.IncludeGenerate("./" + dir)
8184
require.NoErrorf(t, err, "include %q", dir)

0 commit comments

Comments
 (0)