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")) }