package internal
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"maps"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
)
var (
hostServicePattern = regexp.MustCompile(`//nd:hostservice\s+(.*)`)
hostFuncPattern = regexp.MustCompile(`//nd:hostfunc(?:\s+(.*))?`)
capabilityPattern = regexp.MustCompile(`//nd:capability\s+(.*)`)
exportPattern = regexp.MustCompile(`//nd:export\s+(.*)`)
keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`)
)
type parsedGoFile struct {
path string
file *ast.File
}
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
}
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
}
func ParseDirectory(dir string) ([]Service, error) {
return ParseDirectoryWithShared(dir, nil)
}
func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Service, error) {
fset := token.NewFileSet()
parsed, err := parseGoFiles(dir, fset)
if err != nil {
return nil, err
}
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
}
}
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
}
func ParseCapabilities(dir string) ([]Capability, error) {
return ParseCapabilitiesWithShared(dir, nil)
}
func ParseCapabilitiesWithShared(dir string, shared map[string]StructDef) ([]Capability, error) {
fset := token.NewFileSet()
parsed, err := parseGoFiles(dir, fset)
if err != nil {
return nil, err
}
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)...)
}
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
}
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
}
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
}
docText, rawDoc := getDocComment(genDecl, typeSpec)
capAnnotation := parseCapabilityAnnotation(rawDoc)
if capAnnotation == nil {
continue
}
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,
}
referencedTypes := make(map[string]bool)
for _, method := range interfaceType.Methods.List {
if len(method.Names) == 0 {
continue
}
funcType, ok := method.Type.(*ast.FuncType)
if !ok {
continue
}
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)
collectReferencedTypes(export.Input.Type, referencedTypes)
collectReferencedTypes(export.Output.Type, referencedTypes)
}
collectAllStructDependencies(referencedTypes, structMap)
sharedAliases, sharedTypes, err := resolveSharedAliases(referencedTypes, aliasMap, shared)
if err != nil {
return nil, err
}
capability.SharedAliases = sharedAliases
capability.SharedTypes = sharedTypes
sharedAliasNames := make(map[string]bool, len(capability.SharedAliases))
for _, sa := range capability.SharedAliases {
sharedAliasNames[sa.Name] = true
}
sortedTypeNames := slices.Sorted(maps.Keys(referencedTypes))
for _, typeName := range sortedTypeNames {
if s, exists := structMap[typeName]; exists {
capability.Structs = append(capability.Structs, s)
}
}
for _, typeName := range sortedTypeNames {
if sharedAliasNames[typeName] {
continue
}
if a, exists := aliasMap[typeName]; exists {
capability.TypeAliases = append(capability.TypeAliases, a)
}
}
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
}
}
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
}
func resolveSharedAliases(referenced map[string]bool, aliasMap map[string]TypeAlias, shared map[string]StructDef) ([]SharedAlias, []StructDef, error) {
aliasesByCanonical := map[string][]TypeAlias{}
for _, a := range aliasMap {
if a.IsSharedAlias() {
canonical := strings.TrimPrefix(a.Type, sharedTypesPrefix)
aliasesByCanonical[canonical] = append(aliasesByCanonical[canonical], a)
}
}
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=<dir> 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
}
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
}
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
}
func collectAllStructDependencies(referencedTypes map[string]bool, structMap map[string]StructDef) {
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)
}
}
}
foundNew := false
for t := range newTypes {
if !referencedTypes[t] {
referencedTypes[t] = true
foundNew = true
}
}
if !foundNew {
break
}
}
}
func parseExport(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Export, error) {
export := Export{
Name: name,
ExportName: annotation["name"],
Doc: doc,
}
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)
}
if funcType.Results != nil {
for _, field := range funcType.Results.List {
typeName := typeToString(field.Type)
if typeName == "error" {
continue
}
paramName := "output"
if len(field.Names) > 0 {
paramName = field.Names[0].Name
}
export.Output = NewParam(paramName, typeName)
break
}
}
return export, nil
}
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
}
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),
}
referencedTypes := make(map[string]bool)
for _, method := range interfaceType.Methods.List {
if len(method.Names) == 0 {
continue
}
funcType, ok := method.Type.(*ast.FuncType)
if !ok {
continue
}
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)
for _, p := range m.Params {
collectReferencedTypes(p.Type, referencedTypes)
}
for _, r := range m.Returns {
collectReferencedTypes(r.Type, referencedTypes)
}
}
sharedAliases, _, err := resolveSharedAliases(referencedTypes, pkgAliasMap, shared)
if err != nil {
return nil, err
}
service.SharedAliases = sharedAliases
collectAllStructDependencies(referencedTypes, pkgStructMap)
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
}
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),
}
for _, field := range structType.Fields.List {
if len(field.Names) == 0 {
continue
}
fieldDef := parseStructField(field)
s.Fields = append(s.Fields, fieldDef...)
}
structs = append(structs, s)
}
}
return structs
}
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
}
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
}
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
}
if valueSpec.Type != nil && group.Type == "" {
group.Type = typeToString(valueSpec.Type)
}
for i, name := range valueSpec.Names {
def := ConstDef{
Name: name.Name,
}
if i < len(valueSpec.Values) {
def.Value = exprToString(valueSpec.Values[i])
}
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
}
func exprToString(expr ast.Expr) string {
switch e := expr.(type) {
case *ast.BasicLit:
return e.Value
case *ast.Ident:
return e.Name
default:
return ""
}
}
func parseStructField(field *ast.Field) []FieldDef {
var fields []FieldDef
typeName := typeToString(field.Type)
jsonTag := ""
omitEmpty := false
if field.Tag != nil {
tag := field.Tag.Value
tag = strings.Trim(tag, "`")
jsonTag, omitEmpty = parseJSONTag(tag)
}
var doc string
if field.Doc != nil {
doc = cleanDoc(field.Doc.Text())
}
for _, name := range field.Names {
fieldJSONTag := jsonTag
if fieldJSONTag == "" {
fieldJSONTag = toJSONName(name.Name)
}
fields = append(fields, FieldDef{
Name: name.Name,
Type: typeName,
JSONTag: fieldJSONTag,
OmitEmpty: omitEmpty,
Doc: doc,
})
}
return fields
}
func parseJSONTag(tag string) (name string, omitEmpty bool) {
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
}
func collectReferencedTypes(goType string, refs map[string]bool) {
if strings.HasPrefix(goType, "*") {
collectReferencedTypes(goType[1:], refs)
return
}
if strings.HasPrefix(goType, "[]") {
if goType != "[]byte" {
collectReferencedTypes(goType[2:], refs)
}
return
}
if strings.HasPrefix(goType, "map[") {
rest := goType[4:]
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
}
if strings.HasPrefix(goType, sharedTypesPrefix) {
refs[goType] = true
return
}
if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' {
switch goType {
case "String", "Bool", "Int", "Int32", "Int64", "Float32", "Float64":
default:
refs[goType] = true
}
}
}
func getDocComment(genDecl *ast.GenDecl, typeSpec *ast.TypeSpec) (docText, rawText string) {
var docGroup *ast.CommentGroup
if typeSpec.Doc != nil {
docGroup = typeSpec.Doc
} else if genDecl.Doc != nil {
docGroup = genDecl.Doc
}
if docGroup == nil {
return "", ""
}
return docGroup.Text(), commentGroupRaw(docGroup)
}
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")
}
func getMethodDocComment(field *ast.Field) (docText, rawText string) {
if field.Doc == nil {
return "", ""
}
return field.Doc.Text(), commentGroupRaw(field.Doc)
}
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
}
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
}
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
}
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
}
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
}
func parseMethod(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Method, error) {
m := Method{
Name: name,
ExportName: annotation["name"],
Doc: doc,
}
if funcType.Params != nil {
for _, field := range funcType.Params.List {
typeName := typeToString(field.Type)
if typeName == "context.Context" {
continue
}
for _, name := range field.Names {
m.Params = append(m.Params, NewParam(name.Name, typeName))
}
}
}
if funcType.Results != nil {
for _, field := range funcType.Results.List {
typeName := typeToString(field.Type)
if typeName == "error" {
m.HasError = true
continue
}
if len(field.Names) == 0 {
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
}
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:
if t.Methods == nil || len(t.Methods.List) == 0 {
return "any"
}
return "any"
default:
return fmt.Sprintf("%T", expr)
}
}
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"))
}