From 8ea30a6ab9af4ce7d783154c280b3f34d02296cc Mon Sep 17 00:00:00 2001 From: Stavros Kois Date: Mon, 29 Sep 2025 18:54:39 +0300 Subject: [PATCH] feat: add comment transfer from Go structs to TypeScript interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PreserveComments mutation to transfer documentation comments from Go source code to generated TypeScript interfaces and fields. Features: - Extract type-level comments from struct declarations - Extract field-level comments from struct fields (both doc and inline) - Handle JSON tag field name mapping correctly - Preserve original comment formatting and content - Optional via PreserveComments mutation (backwards compatible) Implements comment transfer as requested in GitHub issue #50. Example: Go: // User holds info type User struct { Name string `json:"name"` // The name } TS: // User holds info interface User { // The name name: string; } 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- config/mutations.go | 21 ++++++ convert.go | 131 +++++++++++++++++++++++++++++++++- convert_test.go | 2 + testdata/comments/comments.go | 15 ++++ testdata/comments/comments.ts | 22 ++++++ testdata/comments/mutations | 1 + 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 testdata/comments/comments.go create mode 100644 testdata/comments/comments.ts create mode 100644 testdata/comments/mutations diff --git a/config/mutations.go b/config/mutations.go index 626dd00..e267d1a 100644 --- a/config/mutations.go +++ b/config/mutations.go @@ -342,6 +342,27 @@ func (v *notNullMaps) Visit(node bindings.Node) walk.Visitor { return v } +// PreserveComments applies the parsed comments from Go source code to TypeScript interfaces and fields. +// This mutation transfers documentation comments from Go structs to their TypeScript equivalents. +func PreserveComments(ts *guts.Typescript) { + ts.ForEach(func(key string, node bindings.Node) { + switch node := node.(type) { + case *bindings.Interface: + // Apply type-level comments if they were extracted + if typeComments := ts.GetTypeComments(key); len(typeComments) > 0 { + node.Comments = append(node.Comments, typeComments...) + } + + // Apply field-level comments + for _, field := range node.Fields { + if fieldComments := ts.GetFieldComments(key, field.Name); len(fieldComments) > 0 { + field.FieldComments = append(fieldComments, field.FieldComments...) + } + } + } + }) +} + func isGoEnum(n bindings.Node) (*bindings.Alias, *bindings.UnionType, bool) { al, ok := n.(*bindings.Alias) if !ok { diff --git a/convert.go b/convert.go index 6214f76..5613e87 100644 --- a/convert.go +++ b/convert.go @@ -3,6 +3,7 @@ package guts import ( "context" "fmt" + "go/ast" "go/constant" "go/token" "go/types" @@ -174,9 +175,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, + extractedComments: make(map[string][]string), } // Parse all go types to the typescript AST @@ -212,6 +214,9 @@ type Typescript struct { typescriptNodes map[string]*typescriptNode parsed *GoParser skip map[string]struct{} + // extractedComments stores comments extracted from Go source code + // Key format: "TypeName" for type comments, "TypeName.FieldName" for field comments + extractedComments map[string][]string // Do not allow calling serialize more than once. // The call affects the state. serialized bool @@ -341,6 +346,30 @@ func (ts *Typescript) updateNode(key string, update func(n *typescriptNode)) { update(v) } +// GetTypeComments returns the extracted comments for a given type +func (ts *Typescript) GetTypeComments(typeName string) []string { + return ts.extractedComments[typeName] +} + +// GetFieldComments returns the extracted comments for a given field +func (ts *Typescript) GetFieldComments(typeName, fieldName string) []string { + return ts.extractedComments[typeName+"."+fieldName] +} + +// setTypeComments stores the extracted comments for a type +func (ts *Typescript) setTypeComments(typeName string, comments []string) { + if len(comments) > 0 { + ts.extractedComments[typeName] = comments + } +} + +// setFieldComments stores the extracted comments for a field +func (ts *Typescript) setFieldComments(typeName, fieldName string, comments []string) { + if len(comments) > 0 { + ts.extractedComments[typeName+"."+fieldName] = comments + } +} + type MutationFunc func(typescript *Typescript) func (ts *Typescript) ApplyMutations(muts ...MutationFunc) { @@ -630,6 +659,91 @@ func (ts *Typescript) constantValue(obj *types.Const) (*bindings.LiteralType, er return &constValue, nil } +// extractTypeComments extracts documentation comments for a given type declaration +func (ts *Typescript) extractTypeComments(obj types.Object) []string { + structType := ts.findStructType(obj) + if structType == nil { + return nil + } + return ts.extractCommentsFromGroup(structType.genDecl.Doc) +} + +// extractFieldComments extracts documentation comments for struct fields +func (ts *Typescript) extractFieldComments(obj types.Object, fieldName string) []string { + structType := ts.findStructType(obj) + if structType == nil { + return nil + } + + for _, field := range structType.structAST.Fields.List { + for _, name := range field.Names { + if name.Name == fieldName { + var comments []string + // Extract doc comment (above the field) and line comment (at end of line) + comments = append(comments, ts.extractCommentsFromGroup(field.Doc)...) + comments = append(comments, ts.extractCommentsFromGroup(field.Comment)...) + return comments + } + } + } + return nil +} + +// structTypeInfo holds the AST information for a struct type +type structTypeInfo struct { + genDecl *ast.GenDecl + structAST *ast.StructType +} + +// findStructType finds the AST information for a given type object +func (ts *Typescript) findStructType(obj types.Object) *structTypeInfo { + pkg, ok := ts.parsed.Pkgs[obj.Pkg().Path()] + if !ok { + return nil + } + + for _, file := range pkg.Syntax { + for _, decl := range file.Decls { + if genDecl, ok := decl.(*ast.GenDecl); ok && genDecl.Tok == token.TYPE { + for _, spec := range genDecl.Specs { + if typeSpec, ok := spec.(*ast.TypeSpec); ok && typeSpec.Name.Name == obj.Name() { + if structAST, ok := typeSpec.Type.(*ast.StructType); ok { + return &structTypeInfo{ + genDecl: genDecl, + structAST: structAST, + } + } + // Found the type but it's not a struct + return nil + } + } + } + } + } + return nil +} + +// extractCommentsFromGroup extracts and cleans comments from a comment group +func (ts *Typescript) extractCommentsFromGroup(commentGroup *ast.CommentGroup) []string { + if commentGroup == nil { + return nil + } + + var comments []string + for _, comment := range commentGroup.List { + text := comment.Text + // Remove comment prefixes and suffixes + text = strings.TrimPrefix(text, "//") + text = strings.TrimPrefix(text, "/*") + text = strings.TrimSuffix(text, "*/") + text = strings.TrimSpace(text) + if text != "" { + comments = append(comments, text) + } + } + return comments +} + // buildStruct just prints the typescript def for a type. // Generic type parameters are inferred from the type and inferred. func (ts *Typescript) buildStruct(obj types.Object, st *types.Struct) (*bindings.Interface, error) { @@ -642,6 +756,11 @@ func (ts *Typescript) buildStruct(obj types.Object, st *types.Struct) (*bindings Source: ts.location(obj), } + // Extract and store type-level comments for later use by mutations + typeName := ts.parsed.Identifier(obj).Ref() + typeComments := ts.extractTypeComments(obj) + ts.setTypeComments(typeName, typeComments) + // Handle named embedded structs in the codersdk package via extension. // This is inheritance. // TODO: Maybe this could be done inline in the main for loop? @@ -758,6 +877,12 @@ func (ts *Typescript) buildStruct(obj types.Object, st *types.Struct) (*bindings } tsField.Type = tsType.Value tsi.Parameters = append(tsi.Parameters, tsType.TypeParameters...) + + // Extract and store field comments for later use by mutations + fieldComments := ts.extractFieldComments(obj, field.Name()) + ts.setFieldComments(typeName, tsField.Name, fieldComments) + + // Keep only raised comments from type analysis in the field initially tsField.FieldComments = tsType.RaisedComments // Some tag support diff --git a/convert_test.go b/convert_test.go index c3fc5c5..381abb6 100644 --- a/convert_test.go +++ b/convert_test.go @@ -123,6 +123,8 @@ func TestGeneration(t *testing.T) { mutations = append(mutations, config.NullUnionSlices) case "TrimEnumPrefix": mutations = append(mutations, config.TrimEnumPrefix) + case "PreserveComments": + mutations = append(mutations, config.PreserveComments) default: t.Fatal("unknown mutation, add it to the list:", m) } diff --git a/testdata/comments/comments.go b/testdata/comments/comments.go new file mode 100644 index 0000000..a4759ea --- /dev/null +++ b/testdata/comments/comments.go @@ -0,0 +1,15 @@ +package codersdk + +// User holds information for a user +type User struct { + Name string `json:"name"` // The name of the user + Age int `json:"age"` // The age of the user +} + +// Product represents a product in the system +// This is a multi-line comment +type Product struct { + ID int `json:"id"` // Product identifier + Title string `json:"title"` // Product title + Price int `json:"price,omitempty"` // Price in cents +} \ No newline at end of file diff --git a/testdata/comments/comments.ts b/testdata/comments/comments.ts new file mode 100644 index 0000000..297699f --- /dev/null +++ b/testdata/comments/comments.ts @@ -0,0 +1,22 @@ +// Code generated by 'guts'. DO NOT EDIT. + +// From codersdk/comments.go +// Product represents a product in the system +// This is a multi-line comment +export interface Product { + // Product identifier + readonly id: number; + // Product title + readonly title: string; + // Price in cents + readonly price?: number; +} + +// From codersdk/comments.go +// User holds information for a user +export interface User { + // The name of the user + readonly name: string; + // The age of the user + readonly age: number; +} \ No newline at end of file diff --git a/testdata/comments/mutations b/testdata/comments/mutations new file mode 100644 index 0000000..aa1281a --- /dev/null +++ b/testdata/comments/mutations @@ -0,0 +1 @@ +PreserveComments,ExportTypes,ReadOnly \ No newline at end of file