package internal
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"maps"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
)
// Annotation patterns
var (
// //nd:hostservice name=ServiceName permission=key
hostServicePattern = regexp.MustCompile(`//nd:hostservice\s+(.*)`)
// //nd:hostfunc [name=CustomName]
hostFuncPattern = regexp.MustCompile(`//nd:hostfunc(?:\s+(.*))?`)
// //nd:capability name=PackageName [required=true]
capabilityPattern = regexp.MustCompile(`//nd:capability\s+(.*)`)
// //nd:export name=ExportName
exportPattern = regexp.MustCompile(`//nd:export\s+(.*)`)
// key=value pairs
keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`)
)
// parsedGoFile pairs a source path with its already-parsed AST.
type parsedGoFile struct {
path string
file *ast.File
}
// parseGoFiles returns the eligible Go source files in dir, each parsed once.
func parseGoFiles(dir string, fset *token.FileSet) ([]parsedGoFile, error) {
paths, err := goSourceFiles(dir)
if err != nil {
return nil, err
}
out := make([]parsedGoFile, 0, len(paths))
for _, p := range paths {
f, err := parser.ParseFile(fset, p, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", filepath.Base(p), err)
}
out = append(out, parsedGoFile{path: p, file: f})
}
return out, nil
}
// goSourceFiles returns the Go source file paths in dir, excluding generated,
// test, and doc files.
func goSourceFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("reading directory: %w", err)
}
var paths []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") {
continue
}
if strings.HasSuffix(name, "_gen.go") || strings.HasSuffix(name, "_test.go") || name == "doc.go" {
continue
}
paths = append(paths, filepath.Join(dir, name))
}
return paths, nil
}
// ParseDirectory parses all Go source files in a directory and extracts host services.
func ParseDirectory(dir string) ([]Service, error) {
return ParseDirectoryWithShared(dir, nil)
}
// ParseDirectoryWithShared parses all Go source files in a directory, resolving any
// type aliases that reference the shared `types` package against the provided registry.
func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Service, error) {
fset := token.NewFileSet()
parsed, err := parseGoFiles(dir, fset)
if err != nil {
return nil, err
}
// First pass: collect all struct definitions and type aliases from every file
// so that a struct or alias declared in one file is visible when resolving
// types in a sibling file.
pkgStructMap := make(map[string]StructDef)
pkgAliasMap := make(map[string]TypeAlias)
for _, pf := range parsed {
for _, s := range parseStructs(pf.file) {
pkgStructMap[s.Name] = s
}
for _, a := range parseTypeAliases(pf.file) {
pkgAliasMap[a.Name] = a
}
}
// Second pass: parse services using the package-level maps.
var services []Service
for _, pf := range parsed {
svcList, err := parseServiceFile(pf.file, pkgStructMap, pkgAliasMap, shared)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err)
}
services = append(services, svcList...)
}
return services, nil
}
// ParseCapabilities parses all Go source files in a directory and extracts capabilities.
func ParseCapabilities(dir string) ([]Capability, error) {
return ParseCapabilitiesWithShared(dir, nil)
}
// ParseCapabilitiesWithShared parses all Go source files in a directory, resolving any
// type aliases that reference the shared `types` package against the provided registry.
func ParseCapabilitiesWithShared(dir string, shared map[string]StructDef) ([]Capability, error) {
fset := token.NewFileSet()
parsed, err := parseGoFiles(dir, fset)
if err != nil {
return nil, err
}
// First pass: collect all structs, type aliases, and const groups.
pkgStructMap := make(map[string]StructDef)
pkgAliasMap := make(map[string]TypeAlias)
var allConstGroups []ConstGroup
for _, pf := range parsed {
for _, s := range parseStructs(pf.file) {
pkgStructMap[s.Name] = s
}
for _, a := range parseTypeAliases(pf.file) {
pkgAliasMap[a.Name] = a
}
allConstGroups = append(allConstGroups, parseConstGroups(pf.file)...)
}
// Second pass: parse capabilities using the package-level type maps.
var capabilities []Capability
for _, pf := range parsed {
capList, err := parseCapabilityFile(pf.path, pf.file, pkgStructMap, pkgAliasMap, allConstGroups, shared)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err)
}
capabilities = append(capabilities, capList...)
}
return capabilities, nil
}
// LoadSharedTypes parses every struct defined in dir (the shared `types` source
// package) and returns them keyed by name. dir == "" yields an empty map.
func LoadSharedTypes(dir string) (map[string]StructDef, error) {
result := map[string]StructDef{}
if dir == "" {
return result, nil
}
paths, err := goSourceFiles(dir)
if err != nil {
return nil, fmt.Errorf("reading shared types directory: %w", err)
}
fset := token.NewFileSet()
for _, path := range paths {
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err)
}
for _, s := range parseStructs(f) {
result[s.Name] = s
}
}
return result, nil
}
// parseCapabilityFile parses a single Go source file and extracts capabilities.
func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup, shared map[string]StructDef) ([]Capability, error) {
var capabilities []Capability
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
interfaceType, ok := typeSpec.Type.(*ast.InterfaceType)
if !ok {
continue
}
// Check for //nd:capability annotation in doc comment
docText, rawDoc := getDocComment(genDecl, typeSpec)
capAnnotation := parseCapabilityAnnotation(rawDoc)
if capAnnotation == nil {
continue
}
// Extract source file base name (e.g., "websocket_callback" from "websocket_callback.go")
baseName := filepath.Base(path)
sourceFile := strings.TrimSuffix(baseName, ".go")
capability := Capability{
Name: capAnnotation["name"],
Interface: typeSpec.Name.Name,
Required: capAnnotation["required"] == "true",
Doc: cleanDoc(docText),
SourceFile: sourceFile,
}
// Parse methods and collect referenced types
referencedTypes := make(map[string]bool)
for _, method := range interfaceType.Methods.List {
if len(method.Names) == 0 {
continue // Embedded interface
}
funcType, ok := method.Type.(*ast.FuncType)
if !ok {
continue
}
// Check for //nd:export annotation
methodDocText, methodRawDoc := getMethodDocComment(method)
exportAnnotation := parseExportAnnotation(methodRawDoc)
if exportAnnotation == nil {
continue
}
export, err := parseExport(method.Names[0].Name, funcType, exportAnnotation, cleanDoc(methodDocText))
if err != nil {
return nil, fmt.Errorf("parsing export %s.%s: %w", typeSpec.Name.Name, method.Names[0].Name, err)
}
capability.Methods = append(capability.Methods, export)
// Collect referenced types from input and output
collectReferencedTypes(export.Input.Type, referencedTypes)
collectReferencedTypes(export.Output.Type, referencedTypes)
}
// Recursively collect all struct dependencies
collectAllStructDependencies(referencedTypes, structMap)
// Resolve shared-type aliases against the registry
sharedAliases, sharedTypes, err := resolveSharedAliases(referencedTypes, aliasMap, shared)
if err != nil {
return nil, err
}
capability.SharedAliases = sharedAliases
capability.SharedTypes = sharedTypes
// Build a set of names already covered by SharedAliases so we don't
// emit them again in TypeAliases (which would cause a redeclaration).
sharedAliasNames := make(map[string]bool, len(capability.SharedAliases))
for _, sa := range capability.SharedAliases {
sharedAliasNames[sa.Name] = true
}
// Sort type names for stable output order
sortedTypeNames := slices.Sorted(maps.Keys(referencedTypes))
// Attach referenced structs to the capability
for _, typeName := range sortedTypeNames {
if s, exists := structMap[typeName]; exists {
capability.Structs = append(capability.Structs, s)
}
}
// Attach referenced type aliases (skip those already in SharedAliases)
for _, typeName := range sortedTypeNames {
if sharedAliasNames[typeName] {
continue
}
if a, exists := aliasMap[typeName]; exists {
capability.TypeAliases = append(capability.TypeAliases, a)
}
}
// Also attach type aliases prefixed with interface name (e.g., ScrobblerError for Scrobbler interface)
// This supports error types that are not directly referenced in method signatures
interfaceName := typeSpec.Name.Name
for _, typeName := range slices.Sorted(maps.Keys(aliasMap)) {
if sharedAliasNames[typeName] {
continue
}
a := aliasMap[typeName]
if strings.HasPrefix(typeName, interfaceName) && !referencedTypes[typeName] {
capability.TypeAliases = append(capability.TypeAliases, a)
referencedTypes[typeName] = true // Mark as referenced for const lookup
}
}
// Attach const groups that match referenced type aliases
for _, group := range allConstGroups {
if group.Type == "" {
continue
}
if referencedTypes[group.Type] {
capability.Consts = append(capability.Consts, group)
}
}
if len(capability.Methods) > 0 {
capabilities = append(capabilities, capability)
}
}
}
return capabilities, nil
}
// resolveSharedAliases determines which shared `types` package structs a host
// service or capability uses and returns the deprecated re-export aliases to emit
// for them.
//
// A shared type counts as used when a field references it by qualified name
// (e.g. types.Track) or via a declared alias used by bare name (e.g. a field of
// type TrackInfo where `type TrackInfo = types.Track`). The shared struct's own
// fields are followed transitively so nested shared types are picked up too. For
// every used canonical type, each declared `type X = types.Canonical` alias is
// emitted as a SharedAlias so the generated PDK keeps re-exporting it for
// backwards compatibility.
//
// It returns the deprecated re-export aliases to emit and the resolved shapes of
// every used shared type (alias or not, for schema inlining).
//
// Returns an error if a referenced shared type cannot be found in the shared registry.
func resolveSharedAliases(referenced map[string]bool, aliasMap map[string]TypeAlias, shared map[string]StructDef) ([]SharedAlias, []StructDef, error) {
// Index declared shared aliases by the canonical type they target, e.g.
// "Track" -> [TrackInfo]. A canonical type may have more than one alias.
aliasesByCanonical := map[string][]TypeAlias{}
for _, a := range aliasMap {
if a.IsSharedAlias() {
canonical := strings.TrimPrefix(a.Type, sharedTypesPrefix)
aliasesByCanonical[canonical] = append(aliasesByCanonical[canonical], a)
}
}
// Walk the referenced types, following nested shared references inside the
// shared structs, to find the set of canonical shared types used.
used := map[string]bool{}
var queue []string
for name := range referenced {
if c, ok := seedSharedCanonical(name, aliasMap); ok {
queue = append(queue, c)
}
}
fieldRefs := map[string]bool{}
for len(queue) > 0 {
canonical := queue[0]
queue = queue[1:]
if used[canonical] {
continue
}
def, ok := shared[canonical]
if !ok {
return nil, nil, fmt.Errorf(
"shared type %q could not be resolved: pass -shared=
pointing at the shared types package, and ensure %s is defined there",
canonical, sharedTypesPrefix+canonical,
)
}
used[canonical] = true
for _, f := range def.Fields {
clear(fieldRefs)
collectReferencedTypes(f.Type, fieldRefs)
for t := range fieldRefs {
if c, ok := nestedSharedCanonical(t, shared); ok {
queue = append(queue, c)
}
}
}
}
var out []SharedAlias
var usedDefs []StructDef
for canonical := range used {
usedDefs = append(usedDefs, shared[canonical])
for _, a := range aliasesByCanonical[canonical] {
out = append(out, SharedAlias{Name: a.Name, Target: a.Type, Doc: a.Doc, Def: shared[canonical]})
}
}
slices.SortFunc(out, func(a, b SharedAlias) int { return strings.Compare(a.Name, b.Name) })
slices.SortFunc(usedDefs, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) })
return out, usedDefs, nil
}
// seedSharedCanonical maps a type token referenced by a capability/service field
// to the canonical shared type it denotes. It recognizes qualified references
// (types.X -> X) and declared shared aliases used by bare name (X where
// `type X = types.Y` -> Y). A bare name that is not a declared shared alias is
// not treated as shared, so a local struct sharing a name with a shared type is
// never misclassified.
func seedSharedCanonical(name string, aliasMap map[string]TypeAlias) (string, bool) {
if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok {
return rest, true
}
if a, ok := aliasMap[name]; ok && a.IsSharedAlias() {
return strings.TrimPrefix(a.Type, sharedTypesPrefix), true
}
return "", false
}
// nestedSharedCanonical maps a type token found inside a shared struct's own
// fields to a canonical shared type. Within the shared package, types reference
// each other by bare name (e.g. Track.Artists is []ArtistRef), so any bare name
// present in the shared registry counts.
func nestedSharedCanonical(name string, shared map[string]StructDef) (string, bool) {
if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok {
return rest, true
}
if _, ok := shared[name]; ok {
return name, true
}
return "", false
}
// collectAllStructDependencies recursively collects all struct types referenced by other structs.
func collectAllStructDependencies(referencedTypes map[string]bool, structMap map[string]StructDef) {
// Keep iterating until no new types are added
for {
newTypes := make(map[string]bool)
for typeName := range referencedTypes {
if s, exists := structMap[typeName]; exists {
for _, field := range s.Fields {
collectReferencedTypes(field.Type, newTypes)
}
}
}
// Check if any new types were found
foundNew := false
for t := range newTypes {
if !referencedTypes[t] {
referencedTypes[t] = true
foundNew = true
}
}
if !foundNew {
break
}
}
}
// parseExport parses an export method signature into an Export struct.
func parseExport(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Export, error) {
export := Export{
Name: name,
ExportName: annotation["name"],
Doc: doc,
}
// Capability exports have exactly one input parameter (the struct type)
if funcType.Params != nil && len(funcType.Params.List) == 1 {
field := funcType.Params.List[0]
typeName := typeToString(field.Type)
paramName := "input"
if len(field.Names) > 0 {
paramName = field.Names[0].Name
}
export.Input = NewParam(paramName, typeName)
}
// Capability exports return (OutputType, error)
if funcType.Results != nil {
for _, field := range funcType.Results.List {
typeName := typeToString(field.Type)
if typeName == "error" {
continue // Skip error return
}
paramName := "output"
if len(field.Names) > 0 {
paramName = field.Names[0].Name
}
export.Output = NewParam(paramName, typeName)
break // Only take the first non-error return
}
}
return export, nil
}
// parseServiceFile parses a single Go source file and extracts host services.
// pkgStructMap and pkgAliasMap are the package-wide struct and alias maps built
// from all files in the package, so a host-service interface can reference types
// defined in a sibling file.
func parseServiceFile(f *ast.File, pkgStructMap map[string]StructDef, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) {
var services []Service
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
interfaceType, ok := typeSpec.Type.(*ast.InterfaceType)
if !ok {
continue
}
// Check for //nd:hostservice annotation in doc comment
docText, rawDoc := getDocComment(genDecl, typeSpec)
svcAnnotation := parseHostServiceAnnotation(rawDoc)
if svcAnnotation == nil {
continue
}
service := Service{
Name: svcAnnotation["name"],
Permission: svcAnnotation["permission"],
Interface: typeSpec.Name.Name,
Doc: cleanDoc(docText),
}
// Parse methods and collect referenced types
referencedTypes := make(map[string]bool)
for _, method := range interfaceType.Methods.List {
if len(method.Names) == 0 {
continue // Embedded interface
}
funcType, ok := method.Type.(*ast.FuncType)
if !ok {
continue
}
// Check for //nd:hostfunc annotation
methodDocText, methodRawDoc := getMethodDocComment(method)
methodAnnotation := parseHostFuncAnnotation(methodRawDoc)
if methodAnnotation == nil {
continue
}
m, err := parseMethod(method.Names[0].Name, funcType, methodAnnotation, cleanDoc(methodDocText))
if err != nil {
return nil, fmt.Errorf("parsing method %s.%s: %w", typeSpec.Name.Name, method.Names[0].Name, err)
}
service.Methods = append(service.Methods, m)
// Collect referenced types from params and returns
for _, p := range m.Params {
collectReferencedTypes(p.Type, referencedTypes)
}
for _, r := range m.Returns {
collectReferencedTypes(r.Type, referencedTypes)
}
}
// Resolve shared-type aliases against the registry. Host-service schemas
// are not generated (the -schemas pass is capability-only), so the resolved
// shared shapes are not needed here.
sharedAliases, _, err := resolveSharedAliases(referencedTypes, pkgAliasMap, shared)
if err != nil {
return nil, err
}
service.SharedAliases = sharedAliases
// Recursively collect all struct dependencies so types referenced only
// transitively (e.g. a field type of a referenced struct) are attached.
collectAllStructDependencies(referencedTypes, pkgStructMap)
// Attach referenced structs to the service (sorted for stable output)
for _, typeName := range slices.Sorted(maps.Keys(referencedTypes)) {
if s, exists := pkgStructMap[typeName]; exists {
service.Structs = append(service.Structs, s)
}
}
if len(service.Methods) > 0 {
services = append(services, service)
}
}
}
return services, nil
}
// parseStructs extracts all struct type definitions from a parsed Go file.
func parseStructs(f *ast.File) []StructDef {
var structs []StructDef
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
docText, _ := getDocComment(genDecl, typeSpec)
s := StructDef{
Name: typeSpec.Name.Name,
Doc: cleanDoc(docText),
}
// Parse struct fields
for _, field := range structType.Fields.List {
if len(field.Names) == 0 {
continue // Embedded field
}
fieldDef := parseStructField(field)
s.Fields = append(s.Fields, fieldDef...)
}
structs = append(structs, s)
}
}
return structs
}
// parseTypeAliases extracts all type alias definitions from a parsed Go file.
// Type aliases are non-struct type declarations like: type MyType string
func parseTypeAliases(f *ast.File) []TypeAlias {
var aliases []TypeAlias
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
// Skip struct and interface types
if _, isStruct := typeSpec.Type.(*ast.StructType); isStruct {
continue
}
if _, isInterface := typeSpec.Type.(*ast.InterfaceType); isInterface {
continue
}
docText, _ := getDocComment(genDecl, typeSpec)
aliases = append(aliases, TypeAlias{
Name: typeSpec.Name.Name,
Type: typeToString(typeSpec.Type),
Doc: cleanDoc(docText),
IsAlias: typeSpec.Assign.IsValid(),
})
}
}
return aliases
}
// parseConstGroups extracts const groups from a parsed Go file.
func parseConstGroups(f *ast.File) []ConstGroup {
var groups []ConstGroup
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.CONST {
continue
}
group := ConstGroup{}
for _, spec := range genDecl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
// Get type if specified
if valueSpec.Type != nil && group.Type == "" {
group.Type = typeToString(valueSpec.Type)
}
// Extract values
for i, name := range valueSpec.Names {
def := ConstDef{
Name: name.Name,
}
// Get value if present
if i < len(valueSpec.Values) {
def.Value = exprToString(valueSpec.Values[i])
}
// Get doc comment
if valueSpec.Doc != nil {
def.Doc = cleanDoc(valueSpec.Doc.Text())
} else if valueSpec.Comment != nil {
def.Doc = cleanDoc(valueSpec.Comment.Text())
}
group.Values = append(group.Values, def)
}
}
if len(group.Values) > 0 {
groups = append(groups, group)
}
}
return groups
}
// exprToString converts an AST expression to a Go source string.
func exprToString(expr ast.Expr) string {
switch e := expr.(type) {
case *ast.BasicLit:
return e.Value
case *ast.Ident:
return e.Name
default:
return ""
}
}
// parseStructField parses a struct field and returns FieldDef for each name.
func parseStructField(field *ast.Field) []FieldDef {
var fields []FieldDef
typeName := typeToString(field.Type)
// Parse struct tag for JSON field name and omitempty
jsonTag := ""
omitEmpty := false
if field.Tag != nil {
tag := field.Tag.Value
// Remove backticks
tag = strings.Trim(tag, "`")
// Parse json tag
jsonTag, omitEmpty = parseJSONTag(tag)
}
// Get doc comment
var doc string
if field.Doc != nil {
doc = cleanDoc(field.Doc.Text())
}
for _, name := range field.Names {
fieldJSONTag := jsonTag
if fieldJSONTag == "" {
// Default to field name with camelCase
fieldJSONTag = toJSONName(name.Name)
}
fields = append(fields, FieldDef{
Name: name.Name,
Type: typeName,
JSONTag: fieldJSONTag,
OmitEmpty: omitEmpty,
Doc: doc,
})
}
return fields
}
// parseJSONTag extracts the json field name and omitempty flag from a struct tag.
func parseJSONTag(tag string) (name string, omitEmpty bool) {
// Find json:"..." in the tag
for _, part := range strings.Split(tag, " ") {
if strings.HasPrefix(part, `json:"`) {
value := strings.TrimPrefix(part, `json:"`)
value = strings.TrimSuffix(value, `"`)
parts := strings.Split(value, ",")
if len(parts) > 0 && parts[0] != "-" {
name = parts[0]
}
for _, opt := range parts[1:] {
if opt == "omitempty" {
omitEmpty = true
}
}
return
}
}
return "", false
}
// collectReferencedTypes extracts custom type names from a Go type string.
// It handles pointers, slices, and maps, collecting base type names.
func collectReferencedTypes(goType string, refs map[string]bool) {
// Strip pointer
if strings.HasPrefix(goType, "*") {
collectReferencedTypes(goType[1:], refs)
return
}
// Strip slice
if strings.HasPrefix(goType, "[]") {
if goType != "[]byte" {
collectReferencedTypes(goType[2:], refs)
}
return
}
// Handle map
if strings.HasPrefix(goType, "map[") {
rest := goType[4:] // Remove "map["
depth := 1
keyEnd := 0
for i, r := range rest {
if r == '[' {
depth++
} else if r == ']' {
depth--
if depth == 0 {
keyEnd = i
break
}
}
}
keyType := rest[:keyEnd]
valueType := rest[keyEnd+1:]
collectReferencedTypes(keyType, refs)
collectReferencedTypes(valueType, refs)
return
}
// Qualified reference to the shared `types` package (e.g. types.Track).
// These start with a lowercase package selector, so they must be collected
// before the uppercase check below would skip them.
if strings.HasPrefix(goType, sharedTypesPrefix) {
refs[goType] = true
return
}
// Check if it's a custom type (starts with uppercase, not a builtin)
if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' {
switch goType {
case "String", "Bool", "Int", "Int32", "Int64", "Float32", "Float64":
// Not custom types (just capitalized for some reason)
default:
refs[goType] = true
}
}
}
// toJSONName is imported from types.go via the same package
// getDocComment extracts the doc comment for a type spec.
// Returns both the readable doc text and the raw comment text (which includes pragma-style comments).
func getDocComment(genDecl *ast.GenDecl, typeSpec *ast.TypeSpec) (docText, rawText string) {
var docGroup *ast.CommentGroup
// First check the TypeSpec's own doc (when multiple types in one block)
if typeSpec.Doc != nil {
docGroup = typeSpec.Doc
} else if genDecl.Doc != nil {
// Fall back to GenDecl doc (single type declaration)
docGroup = genDecl.Doc
}
if docGroup == nil {
return "", ""
}
return docGroup.Text(), commentGroupRaw(docGroup)
}
// commentGroupRaw returns all comment text including pragma-style comments (//nd:...).
// Go's ast.CommentGroup.Text() strips comments without a space after //, so we need this.
func commentGroupRaw(cg *ast.CommentGroup) string {
if cg == nil {
return ""
}
var lines []string
for _, c := range cg.List {
lines = append(lines, c.Text)
}
return strings.Join(lines, "\n")
}
// getMethodDocComment extracts the doc comment for a method.
func getMethodDocComment(field *ast.Field) (docText, rawText string) {
if field.Doc == nil {
return "", ""
}
return field.Doc.Text(), commentGroupRaw(field.Doc)
}
// parseHostServiceAnnotation extracts //nd:hostservice annotation parameters.
func parseHostServiceAnnotation(doc string) map[string]string {
for _, line := range strings.Split(doc, "\n") {
line = strings.TrimSpace(line)
match := hostServicePattern.FindStringSubmatch(line)
if match != nil {
return parseKeyValuePairs(match[1])
}
}
return nil
}
// parseHostFuncAnnotation extracts //nd:hostfunc annotation parameters.
func parseHostFuncAnnotation(doc string) map[string]string {
for _, line := range strings.Split(doc, "\n") {
line = strings.TrimSpace(line)
match := hostFuncPattern.FindStringSubmatch(line)
if match != nil {
params := parseKeyValuePairs(match[1])
if params == nil {
params = make(map[string]string)
}
return params
}
}
return nil
}
// parseCapabilityAnnotation extracts //nd:capability annotation parameters.
func parseCapabilityAnnotation(doc string) map[string]string {
for _, line := range strings.Split(doc, "\n") {
line = strings.TrimSpace(line)
match := capabilityPattern.FindStringSubmatch(line)
if match != nil {
return parseKeyValuePairs(match[1])
}
}
return nil
}
// parseExportAnnotation extracts //nd:export annotation parameters.
func parseExportAnnotation(doc string) map[string]string {
for _, line := range strings.Split(doc, "\n") {
line = strings.TrimSpace(line)
match := exportPattern.FindStringSubmatch(line)
if match != nil {
return parseKeyValuePairs(match[1])
}
}
return nil
}
// parseKeyValuePairs extracts key=value pairs from annotation text.
func parseKeyValuePairs(text string) map[string]string {
matches := keyValuePattern.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
result := make(map[string]string)
for _, m := range matches {
result[m[1]] = m[2]
}
return result
}
// parseMethod parses a method signature into a Method struct.
func parseMethod(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Method, error) {
m := Method{
Name: name,
ExportName: annotation["name"],
Doc: doc,
}
// Parse parameters (skip context.Context)
if funcType.Params != nil {
for _, field := range funcType.Params.List {
typeName := typeToString(field.Type)
if typeName == "context.Context" {
continue // Skip context parameter
}
for _, name := range field.Names {
m.Params = append(m.Params, NewParam(name.Name, typeName))
}
}
}
// Parse return values
if funcType.Results != nil {
for _, field := range funcType.Results.List {
typeName := typeToString(field.Type)
if typeName == "error" {
m.HasError = true
continue // Track error but don't include in Returns
}
// Handle anonymous returns
if len(field.Names) == 0 {
// Generate a name based on position
m.Returns = append(m.Returns, NewParam("result", typeName))
} else {
for _, name := range field.Names {
m.Returns = append(m.Returns, NewParam(name.Name, typeName))
}
}
}
}
return m, nil
}
// typeToString converts an AST type expression to a string.
func typeToString(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.SelectorExpr:
return typeToString(t.X) + "." + t.Sel.Name
case *ast.StarExpr:
return "*" + typeToString(t.X)
case *ast.ArrayType:
if t.Len == nil {
return "[]" + typeToString(t.Elt)
}
return fmt.Sprintf("[%s]%s", typeToString(t.Len), typeToString(t.Elt))
case *ast.MapType:
return fmt.Sprintf("map[%s]%s", typeToString(t.Key), typeToString(t.Value))
case *ast.BasicLit:
return t.Value
case *ast.InterfaceType:
// Empty interface (interface{} or any)
if t.Methods == nil || len(t.Methods.List) == 0 {
return "any"
}
// Non-empty interfaces can't be easily represented
return "any"
default:
return fmt.Sprintf("%T", expr)
}
}
// cleanDoc removes annotation lines from documentation.
func cleanDoc(doc string) string {
var lines []string
for _, line := range strings.Split(doc, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//nd:") {
continue
}
lines = append(lines, line)
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}