diff --git a/README.md b/README.md index 0ed6b87..46f1bc1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bindings/bindings.go b/bindings/bindings.go index 5768208..ede3a01 100644 --- a/bindings/bindings.go +++ b/bindings/bindings.go @@ -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 { diff --git a/bindings/comments.go b/bindings/comments.go index 8aa349f..599c13f 100644 --- a/bindings/comments.go +++ b/bindings/comments.go @@ -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 } @@ -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) } @@ -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 != "" } diff --git a/bindings/declarations.go b/bindings/declarations.go index 2fea9c8..80cad6c 100644 --- a/bindings/declarations.go +++ b/bindings/declarations.go @@ -105,6 +105,7 @@ type VariableStatement struct { Modifiers []Modifier Declarations *VariableDeclarationList Source + SupportComments } func (*VariableStatement) isNode() {} diff --git a/comments.go b/comments.go new file mode 100644 index 0000000..2be0819 --- /dev/null +++ b/comments.go @@ -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 +} diff --git a/convert.go b/convert.go index 0918d8e..7c000f0 100644 --- a/convert.go +++ b/convert.go @@ -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. @@ -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 { @@ -115,6 +124,7 @@ func (p *GoParser) IncludeCustom(mappings map[GolangType]GolangType) error { return exp } } + return nil } @@ -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 @@ -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 @@ -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, }) @@ -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 @@ -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, }) @@ -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) } diff --git a/convert_test.go b/convert_test.go index 57f56a2..c6f4d42 100644 --- a/convert_test.go +++ b/convert_test.go @@ -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) diff --git a/testdata/comments/comments.go b/testdata/comments/comments.go new file mode 100644 index 0000000..1660dd7 --- /dev/null +++ b/testdata/comments/comments.go @@ -0,0 +1,43 @@ +package comments + +// CommentedStructure is a struct with a comment. +// +// It actually has 2 comments?! +// TODO: Maybe add a third comment! +type CommentedStructure struct { + Inline string // Field comment + + // Leading comment + Leading string + + Trailing string + // Trailing comment + + // Leading comment + All string // Inline comment + // Trailing comment + + /* Another leading comment */ + Block string + + /* Multi + Line + Comment + */ + MultiLine string +} + +type InheritedCommentedStructure struct { + // CommentedStructure is a commented field, but in typescript it has no place + // since it is covered by an "extend" clause. Not sure where to put it. + CommentedStructure +} + +/* + BlockComments are not idiomatic in Go, but can be used +*/ +type BlockComment struct { +} + +// Constant is just a value +const Constant = "value" // An inline note diff --git a/testdata/comments/comments.ts b/testdata/comments/comments.ts new file mode 100644 index 0000000..0c7cf9c --- /dev/null +++ b/testdata/comments/comments.ts @@ -0,0 +1,38 @@ +// Code generated by 'guts'. DO NOT EDIT. + +// From comments/comments.go +/* + BlockComments are not idiomatic in Go, but can be used +*/ +export interface BlockComment { +} + +// From comments/comments.go +// CommentedStructure is a struct with a comment. +// +// It actually has 2 comments?! +// TODO: Maybe add a third comment! +export interface CommentedStructure { + readonly Inline: string; // Field comment + // Leading comment + readonly Leading: string; + readonly Trailing: string; + // Leading comment + readonly All: string; // Inline comment + // Another leading comment + readonly Block: string; + /* Multi + Line + Comment + */ + readonly MultiLine: string; +} + +// From comments/comments.go +// Constant is just a value +export const Constant = "value"; // An inline note + + +// From comments/comments.go +export interface InheritedCommentedStructure extends CommentedStructure { +} diff --git a/testdata/enums/enums.go b/testdata/enums/enums.go index 39cda22..c101673 100644 --- a/testdata/enums/enums.go +++ b/testdata/enums/enums.go @@ -3,7 +3,9 @@ package enums import "time" type ( - EnumString string + // EnumString is a string-based enum + EnumString string + // EnumSliceType is a slice of string-based enums EnumSliceType []EnumString EnumInt int @@ -12,6 +14,7 @@ type ( ) const ( + // EnumFoo is the "foo" value EnumFoo EnumString = "foo" EnumBar EnumString = "bar" EnumBaz EnumString = "baz" diff --git a/testdata/enums/enums.ts b/testdata/enums/enums.ts index 63a40e7..5d438c2 100644 --- a/testdata/enums/enums.ts +++ b/testdata/enums/enums.ts @@ -14,6 +14,7 @@ export enum EnumInt { } // From enums/enums.go +// EnumSliceType is a slice of string-based enums export type EnumSliceType = readonly EnumString[]; // From enums/enums.go diff --git a/testdata/generics/generics.ts b/testdata/generics/generics.ts index cdd7879..9c9dbc3 100644 --- a/testdata/generics/generics.ts +++ b/testdata/generics/generics.ts @@ -25,6 +25,7 @@ export interface Complex { readonly dynamic: Fields; readonly comparable: boolean | null; @@ -48,6 +49,7 @@ export interface FieldsDiffOrder; } diff --git a/testdata/inheritance/inheritance.ts b/testdata/inheritance/inheritance.ts index c8d623a..ccfd3f6 100644 --- a/testdata/inheritance/inheritance.ts +++ b/testdata/inheritance/inheritance.ts @@ -21,8 +21,10 @@ export interface FooBarPtr extends Bar, GenBar { } // From codersdk/inheritance.go +// FooBuzz has a json tag for the embedded +// See: https://go.dev/play/p/-p6QYmY8mtR export interface FooBuzz { - readonly foo: Buzz; + readonly foo: Buzz; // Json tag changes the inheritance readonly bazz: string; } diff --git a/testdata/nonids/nonids.ts b/testdata/nonids/nonids.ts index 62649c2..795759b 100644 --- a/testdata/nonids/nonids.ts +++ b/testdata/nonids/nonids.ts @@ -2,6 +2,7 @@ // From nonids/nonids.go export interface Foo { + // Hyphen is an odd case, but this field is not ignored readonly "-": string; readonly "hyphenated-string": string; readonly "1numbered": number; diff --git a/testdata/union/union.ts b/testdata/union/union.ts index 15f2eb1..326de9c 100644 --- a/testdata/union/union.ts +++ b/testdata/union/union.ts @@ -1,6 +1,7 @@ // Code generated by 'guts'. DO NOT EDIT. // From union/union.go +// Repeated constraints are redundant export interface Repeated { readonly Value: T; }