feat(plugins): add initial implementation of the Navidrome Plugin Development Kit code generator - Pahse 1

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-29 21:24:07 -05:00
parent 7b36fcbaa1
commit ebba3a2c46
18 changed files with 3829 additions and 0 deletions

View File

@ -0,0 +1,165 @@
# ndpgen
Navidrome Plugin Development Kit (PDK) code generator. It reads Go interface definitions with special annotations and generates client wrappers for WASM plugins.
This tool is the unified code generator that replaces `hostgen` and will eventually handle both host function wrappers and capability wrappers.
## Usage
```bash
ndpgen -input <dir> -output <dir> [-package <name>] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust]
```
### Flags
| Flag | Description | Default |
|--------------|----------------------------------------------------------------|----------------------|
| `-input` | Directory containing Go source files with annotated interfaces | Required |
| `-output` | Directory where generated files will be written | Same as input |
| `-package` | Package name for generated files | Inferred from output |
| `-v` | Verbose output | `false` |
| `-dry-run` | Parse and validate without writing files | `false` |
| `-host-only` | Generate only host function wrappers (capability support TBD) | `true` |
| `-go` | Generate Go client wrappers | `true`* |
| `-python` | Generate Python client wrappers | `false` |
| `-rust` | Generate Rust client wrappers | `false` |
\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages.
### Example
```bash
go run ./plugins/cmd/ndpgen \
-input ./plugins/host \
-output ./plugins/pdk/go/host
```
## Annotations
### `//nd:hostservice`
Marks an interface as a host service that will have wrappers generated.
```go
//nd:hostservice name=<ServiceName> permission=<permission>
type MyService interface { ... }
```
| Parameter | Description | Required |
|--------------|-----------------------------------------------------------------|----------|
| `name` | Service name used in generated type names and function prefixes | Yes |
| `permission` | Permission required by plugins to use this service | Yes |
### `//nd:hostfunc`
Marks a method within a host service interface for export to plugins.
```go
//nd:hostfunc [name=<export_name>]
MethodName(ctx context.Context, ...) (result Type, err error)
```
| Parameter | Description | Required |
|-----------|-------------------------------------------------------------------------|----------|
| `name` | Custom export name (default: `<servicename>_<methodname>` in lowercase) | No |
## Input Format
Host service interfaces must follow these conventions:
1. **First parameter must be `context.Context`** - Required for all methods
2. **Last return value should be `error`** - For proper error handling
3. **Annotations must be on consecutive lines** - No blank comment lines between doc and annotation
### Example Interface
```go
package host
import "context"
// SubsonicAPIService provides access to Navidrome's Subsonic API.
// This documentation becomes part of the generated code.
//nd:hostservice name=SubsonicAPI permission=subsonicapi
type SubsonicAPIService interface {
// Call executes a Subsonic API request and returns the response.
//nd:hostfunc
Call(ctx context.Context, uri string) (response string, err error)
}
```
## Generated Output
### Go Client Library (Go/TinyGo WASM)
Generated files are named `nd_host_<servicename>.go` (lowercase) and placed directly in the output directory. The output directory becomes a complete Go module (`github.com/navidrome/navidrome/plugins/pdk/go/host`) with package name `ndpdk`, intended for import by Navidrome plugins built with TinyGo.
The generator creates:
- `nd_host_<servicename>.go` - Client wrapper code (WASM build)
- `nd_host_<servicename>_stub.go` - Stub code for non-WASM platforms
- `doc.go` - Package documentation listing all available services
- `go.mod` - Go module file with required dependencies
Each service file includes:
- `// Code generated by ndpgen. DO NOT EDIT.` header
- Required imports (`encoding/json`, `errors`, `github.com/extism/go-pdk`)
- `//go:wasmimport` declarations for each host function
- Response struct types and any struct definitions from the service
- Wrapper functions that handle memory allocation and JSON parsing
### Python Client Library
When using `-python`, Python client files are generated in a `python/` subdirectory.
### Rust Client Library
When using `-rust`, Rust client files are generated in a `rust/` subdirectory.
## Supported Types
ndpgen supports these Go types in method signatures:
| Type | JSON Representation |
|-------------------------------|------------------------------------------|
| `string`, `int`, `bool`, etc. | Native JSON types |
| `[]T` (slices) | JSON arrays |
| `map[K]V` (maps) | JSON objects |
| `*T` (pointers) | Nullable fields |
| `interface{}` / `any` | Converts to `any` |
| Custom structs | JSON objects (must be JSON-serializable) |
### Multiple Return Values
Methods can return multiple values (plus error):
```go
//nd:hostfunc
Search(ctx context.Context, query string) (results []string, total int, hasMore bool, err error)
```
Generates:
```go
type ServiceSearchResponse struct {
Results []string `json:"results,omitempty"`
Total int `json:"total,omitempty"`
HasMore bool `json:"hasMore,omitempty"`
Error string `json:"error,omitempty"`
}
```
## Migration from hostgen
The `ndpgen` tool replaces `hostgen` for plugin development. Key differences:
1. **Output structure**: Files go directly in the output directory (not a `go/` subdirectory)
2. **Package name**: Generated code uses `ndpdk` instead of `ndhost`
3. **Module path**: Uses `github.com/navidrome/navidrome/plugins/pdk/go/host` instead of `github.com/navidrome/navidrome/plugins/host/go`
4. **Focus**: `ndpgen` generates only client-side code (plugin SDK), not host-side code
## Running Tests
```bash
go test ./plugins/cmd/ndpgen/...
```

View File

@ -0,0 +1,512 @@
package main
import (
"fmt"
"go/format"
"os"
"os/exec"
"path/filepath"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// normalizeGeneratedCode normalizes differences between hostgen and ndpgen output
// so we can reuse hostgen's testdata for ndpgen verification.
func normalizeGeneratedCode(code string) string {
// Replace tool name references
code = strings.ReplaceAll(code, "Code generated by hostgen.", "Code generated by ndpgen.")
// Replace package names
code = strings.ReplaceAll(code, "package ndhost", "package ndpdk")
return code
}
var _ = Describe("ndpgen CLI", Ordered, func() {
var (
testDir string
outputDir string
ndpgenBin string
)
BeforeAll(func() {
// Set testdata directory (reuse hostgen's testdata)
testdataDir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "hostgen", "testdata")
// Build the ndpgen binary
ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test")
cmd := exec.Command("go", "build", "-o", ndpgenBin, ".")
cmd.Dir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "ndpgen")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Failed to build ndpgen: %s", output)
DeferCleanup(func() {
os.Remove(ndpgenBin)
})
})
BeforeEach(func() {
var err error
testDir, err = os.MkdirTemp("", "ndpgen-test-input-*")
Expect(err).ToNot(HaveOccurred())
outputDir, err = os.MkdirTemp("", "ndpgen-test-output-*")
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
os.RemoveAll(testDir)
os.RemoveAll(outputDir)
})
Describe("CLI flags and behavior", func() {
BeforeEach(func() {
serviceCode := `package testpkg
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
DoAction(ctx context.Context, input string) (output string, err error)
}
`
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
})
It("supports verbose mode", func() {
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
outputStr := string(output)
Expect(outputStr).To(ContainSubstring("Input directory:"))
Expect(outputStr).To(ContainSubstring("Output directory:"))
Expect(outputStr).To(ContainSubstring("Found 1 host service(s)"))
Expect(outputStr).To(ContainSubstring("Generated"))
})
It("supports dry-run mode", func() {
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-dry-run")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
Expect(string(output)).To(ContainSubstring("func TestDoAction("))
Expect(filepath.Join(outputDir, "nd_host_test.go")).ToNot(BeAnExistingFile())
})
It("infers package name from output directory", func() {
customOutput, err := os.MkdirTemp("", "mypkg")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(customOutput)
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", customOutput)
_, err = cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred())
content, err := os.ReadFile(filepath.Join(customOutput, "nd_host_test.go"))
Expect(err).ToNot(HaveOccurred())
Expect(string(content)).To(ContainSubstring("package mypkg"))
})
It("returns error for invalid input directory", func() {
cmd := exec.Command(ndpgenBin, "-input", "/nonexistent/path")
output, err := cmd.CombinedOutput()
Expect(err).To(HaveOccurred())
Expect(string(output)).To(ContainSubstring("parsing source files"))
})
It("handles no annotated services gracefully", func() {
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte("package testpkg\n"), 0600)).To(Succeed())
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-v")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
Expect(string(output)).To(ContainSubstring("No host services found"))
})
It("generates separate files for multiple services", func() {
// Remove service.go created by BeforeEach
Expect(os.Remove(filepath.Join(testDir, "service.go"))).To(Succeed())
service1 := `package testpkg
import "context"
//nd:hostservice name=ServiceA permission=a
type ServiceA interface {
//nd:hostfunc
MethodA(ctx context.Context) error
}
`
service2 := `package testpkg
import "context"
//nd:hostservice name=ServiceB permission=b
type ServiceB interface {
//nd:hostfunc
MethodB(ctx context.Context) error
}
`
Expect(os.WriteFile(filepath.Join(testDir, "a.go"), []byte(service1), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(testDir, "b.go"), []byte(service2), 0600)).To(Succeed())
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
Expect(string(output)).To(ContainSubstring("Found 2 host service(s)"))
Expect(filepath.Join(outputDir, "nd_host_servicea.go")).To(BeAnExistingFile())
Expect(filepath.Join(outputDir, "nd_host_serviceb.go")).To(BeAnExistingFile())
})
It("generates Go client code by default", func() {
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
// Client code in output directory
Expect(filepath.Join(outputDir, "nd_host_test.go")).To(BeAnExistingFile())
// Stub file also generated
Expect(filepath.Join(outputDir, "nd_host_test_stub.go")).To(BeAnExistingFile())
// doc.go and go.mod also generated
Expect(filepath.Join(outputDir, "doc.go")).To(BeAnExistingFile())
Expect(filepath.Join(outputDir, "go.mod")).To(BeAnExistingFile())
})
})
Describe("code generation", func() {
DescribeTable("generates correct client output",
func(serviceFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) {
serviceCode := readTestdata(serviceFile)
goClientExpected := readTestdata(goClientExpectedFile)
pyClientExpected := readTestdata(pyClientExpectedFile)
rsClientExpected := readTestdata(rsClientExpectedFile)
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
// Generate all client code (Go, Python, Rust)
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python", "-rust")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
// Verify Go client code
entries, err := os.ReadDir(outputDir)
Expect(err).ToNot(HaveOccurred())
var goClientFiles []string
for _, e := range entries {
if e.Name() != "python" && e.Name() != "rust" && !e.IsDir() &&
!strings.HasSuffix(e.Name(), "_stub.go") &&
e.Name() != "doc.go" && e.Name() != "go.mod" {
goClientFiles = append(goClientFiles, e.Name())
}
}
Expect(goClientFiles).To(HaveLen(1), "Expected exactly one Go client file, got: %v", goClientFiles)
goClientActual, err := os.ReadFile(filepath.Join(outputDir, goClientFiles[0]))
Expect(err).ToNot(HaveOccurred())
formattedGoClientActual, err := format.Source(goClientActual)
Expect(err).ToNot(HaveOccurred(), "Generated Go client code is not valid Go:\n%s", goClientActual)
// Normalize expected code to match ndpgen output format
normalizedExpected := normalizeGeneratedCode(goClientExpected)
formattedGoClientExpected, err := format.Source([]byte(normalizedExpected))
Expect(err).ToNot(HaveOccurred(), "Expected Go client code is not valid Go")
Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch")
// Verify Python client code
pythonDir := filepath.Join(outputDir, "python")
pyClientEntries, err := os.ReadDir(pythonDir)
Expect(err).ToNot(HaveOccurred())
Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file")
pyClientActual, err := os.ReadFile(filepath.Join(pythonDir, pyClientEntries[0].Name()))
Expect(err).ToNot(HaveOccurred())
Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch")
// Verify Rust client code
rustDir := filepath.Join(outputDir, "rust")
rsClientEntries, err := os.ReadDir(rustDir)
Expect(err).ToNot(HaveOccurred())
Expect(rsClientEntries).To(HaveLen(2), "Expected Rust client file and lib.rs")
// Find the client file (not lib.rs)
var rsClientName string
for _, entry := range rsClientEntries {
if entry.Name() != "lib.rs" {
rsClientName = entry.Name()
break
}
}
Expect(rsClientName).ToNot(BeEmpty(), "Expected to find Rust client file")
rsClientActual, err := os.ReadFile(filepath.Join(rustDir, rsClientName))
Expect(err).ToNot(HaveOccurred())
Expect(string(rsClientActual)).To(Equal(rsClientExpected), "Rust client code mismatch")
},
Entry("simple string params",
"echo_service.go", "echo_client_expected.go", "echo_client_expected.py", "echo_client_expected.rs"),
Entry("multiple simple params (int32)",
"math_service.go", "math_client_expected.go", "math_client_expected.py", "math_client_expected.rs"),
Entry("struct param with request type",
"store_service.go", "store_client_expected.go", "store_client_expected.py", "store_client_expected.rs"),
Entry("mixed simple and complex params",
"list_service.go", "list_client_expected.go", "list_client_expected.py", "list_client_expected.rs"),
Entry("method without error",
"counter_service.go", "counter_client_expected.go", "counter_client_expected.py", "counter_client_expected.rs"),
Entry("no params, error only",
"ping_service.go", "ping_client_expected.go", "ping_client_expected.py", "ping_client_expected.rs"),
Entry("map and interface types",
"meta_service.go", "meta_client_expected.go", "meta_client_expected.py", "meta_client_expected.rs"),
Entry("pointer types",
"users_service.go", "users_client_expected.go", "users_client_expected.py", "users_client_expected.rs"),
Entry("multiple returns",
"search_service.go", "search_client_expected.go", "search_client_expected.py", "search_client_expected.rs"),
Entry("bytes",
"codec_service.go", "codec_client_expected.go", "codec_client_expected.py", "codec_client_expected.rs"),
)
It("generates compilable client code for comprehensive service", func() {
serviceCode := readTestdata("comprehensive_service.go")
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
// Generate client code
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
// Read generated client code
entries, err := os.ReadDir(outputDir)
Expect(err).ToNot(HaveOccurred())
// Find the client file
var clientFileName string
for _, entry := range entries {
name := entry.Name()
if name != "doc.go" && name != "go.mod" && !strings.HasSuffix(name, "_stub.go") && strings.HasSuffix(name, ".go") {
clientFileName = name
break
}
}
Expect(clientFileName).ToNot(BeEmpty(), "Expected to find Go client file")
content, err := os.ReadFile(filepath.Join(outputDir, clientFileName))
Expect(err).ToNot(HaveOccurred())
// Verify key expected content
contentStr := string(content)
// Should have wasmimport declarations for all methods
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_simpleparams"))
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_structparam"))
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noerror"))
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparams"))
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparamsnoreturns"))
// Should have response types for methods with complex returns
Expect(contentStr).To(ContainSubstring("type ComprehensiveSimpleParamsResponse struct"))
Expect(contentStr).To(ContainSubstring("type ComprehensiveMultipleReturnsResponse struct"))
// Should have wrapper functions
Expect(contentStr).To(ContainSubstring("func ComprehensiveSimpleParams("))
Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParams()"))
Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParamsNoReturns()"))
// Create a plugin directory with proper import structure
pluginDir := filepath.Join(outputDir, "plugin")
Expect(os.MkdirAll(pluginDir, 0750)).To(Succeed())
// Create go.mod for the plugin that imports the generated library
goMod := fmt.Sprintf(`module testplugin
go 1.24
require github.com/navidrome/navidrome/plugins/pdk/go/host v0.0.0
replace github.com/navidrome/navidrome/plugins/pdk/go/host => %s
`, outputDir)
Expect(os.WriteFile(filepath.Join(pluginDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
// Add a simple main function that imports and uses the ndpdk package
mainGo := `package main
import ndpdk "github.com/navidrome/navidrome/plugins/pdk/go/host"
func main() {}
// Use some functions to ensure import is not unused
var _ = ndpdk.ComprehensiveNoParams
`
Expect(os.WriteFile(filepath.Join(pluginDir, "main.go"), []byte(mainGo), 0600)).To(Succeed())
// Tidy dependencies for the generated go library
goTidyLibCmd := exec.Command("go", "mod", "tidy")
goTidyLibCmd.Dir = outputDir
goTidyLibOutput, err := goTidyLibCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "go mod tidy (library) failed: %s", goTidyLibOutput)
// Tidy dependencies for the plugin
goTidyCmd := exec.Command("go", "mod", "tidy")
goTidyCmd.Dir = pluginDir
goTidyOutput, err := goTidyCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "go mod tidy (plugin) failed: %s", goTidyOutput)
// Build as WASM plugin - this validates the client code compiles correctly
buildCmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", "plugin.wasm", ".")
buildCmd.Dir = pluginDir
buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
buildOutput, err := buildCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "WASM build failed: %s", buildOutput)
// Verify .wasm file was created
Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile())
})
It("generates Python client code with -python flag", func() {
serviceCode := `package testpkg
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
DoAction(ctx context.Context, input string) (output string, err error)
}
`
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
// Verify Python client code exists
pythonDir := filepath.Join(outputDir, "python")
Expect(pythonDir).To(BeADirectory())
pythonFile := filepath.Join(pythonDir, "nd_host_test.py")
Expect(pythonFile).To(BeAnExistingFile())
content, err := os.ReadFile(pythonFile)
Expect(err).ToNot(HaveOccurred())
contentStr := string(content)
Expect(contentStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):"))
Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`))
Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:"))
})
It("generates both Go and Python client code with -go -python flags", func() {
serviceCode := `package testpkg
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
DoAction(ctx context.Context, input string) (output string, err error)
}
`
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
// Verify both Go and Python client code exist
Expect(filepath.Join(outputDir, "nd_host_test.go")).To(BeAnExistingFile())
pythonDir := filepath.Join(outputDir, "python")
Expect(pythonDir).To(BeADirectory())
Expect(filepath.Join(pythonDir, "nd_host_test.py")).To(BeAnExistingFile())
})
It("generates Python code with dataclass for multi-value returns", func() {
serviceCode := `package testpkg
import "context"
//nd:hostservice name=Cache permission=cache
type CacheService interface {
//nd:hostfunc
GetString(ctx context.Context, key string) (value string, exists bool, err error)
}
`
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
content, err := os.ReadFile(filepath.Join(outputDir, "python", "nd_host_cache.py"))
Expect(err).ToNot(HaveOccurred())
contentStr := string(content)
Expect(contentStr).To(ContainSubstring("@dataclass"))
Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:"))
Expect(contentStr).To(ContainSubstring("value: str"))
Expect(contentStr).To(ContainSubstring("exists: bool"))
Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
})
It("generates Python code for methods with no parameters", func() {
serviceCode := `package testpkg
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
Ping(ctx context.Context) (status string, err error)
}
`
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
content, err := os.ReadFile(filepath.Join(outputDir, "python", "nd_host_test.py"))
Expect(err).ToNot(HaveOccurred())
contentStr := string(content)
Expect(contentStr).To(ContainSubstring("def test_ping() -> str:"))
Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`))
})
})
})
var testdataDir string
func readTestdata(filename string) string {
content, err := os.ReadFile(filepath.Join(testdataDir, filename))
Expect(err).ToNot(HaveOccurred(), "Failed to read testdata file: %s", filename)
return string(content)
}
func mustGetWd(t FullGinkgoTInterface) string {
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find project root")
}
dir = parent
}
}

View File

@ -0,0 +1,305 @@
package internal
import (
"bytes"
"embed"
"fmt"
"strings"
"text/template"
)
//go:embed templates/*.tmpl
var templatesFS embed.FS
// hostFuncMap returns the template functions for host code generation.
func hostFuncMap(svc Service) template.FuncMap {
return template.FuncMap{
"lower": strings.ToLower,
"title": strings.Title,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
"requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
}
}
// clientFuncMap returns the template functions for client code generation.
func clientFuncMap(svc Service) template.FuncMap {
return template.FuncMap{
"lower": strings.ToLower,
"title": strings.Title,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
"requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
"formatDoc": formatDoc,
}
}
// pythonFuncMap returns the template functions for Python client code generation.
func pythonFuncMap(svc Service) template.FuncMap {
return template.FuncMap{
"lower": strings.ToLower,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
"pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) },
"pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) },
"pythonDefault": pythonDefaultValue,
}
}
// GenerateHost generates the host function wrapper code for a service.
func GenerateHost(svc Service, pkgName string) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl")
if err != nil {
return nil, fmt.Errorf("reading host template: %w", err)
}
tmpl, err := template.New("host").Funcs(hostFuncMap(svc)).Parse(string(tmplContent))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
data := templateData{
Package: pkgName,
Service: svc,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}
// GenerateClientGo generates client wrapper code for plugins to call host functions.
func GenerateClientGo(svc Service, pkgName string) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/client.go.tmpl")
if err != nil {
return nil, fmt.Errorf("reading client template: %w", err)
}
tmpl, err := template.New("client").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
data := templateData{
Package: pkgName,
Service: svc,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}
// GenerateClientGoStub generates stub code for non-WASM platforms.
// These stubs provide type definitions and function signatures for IDE support,
// but panic at runtime since host functions are only available in WASM plugins.
func GenerateClientGoStub(svc Service, pkgName string) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/client_stub.go.tmpl")
if err != nil {
return nil, fmt.Errorf("reading client stub template: %w", err)
}
tmpl, err := template.New("client_stub").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
data := templateData{
Package: pkgName,
Service: svc,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}
type templateData struct {
Package string
Service Service
}
// formatDoc formats a documentation string for Go comments.
// It prefixes each line with "// " and trims trailing whitespace.
func formatDoc(doc string) string {
if doc == "" {
return ""
}
lines := strings.Split(strings.TrimSpace(doc), "\n")
var result []string
for _, line := range lines {
result = append(result, "// "+strings.TrimRight(line, " \t"))
}
return strings.Join(result, "\n")
}
// GenerateClientPython generates Python client wrapper code for plugins.
func GenerateClientPython(svc Service) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/client.py.tmpl")
if err != nil {
return nil, fmt.Errorf("reading Python client template: %w", err)
}
tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
data := templateData{
Service: svc,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}
// pythonDefaultValue returns a Python default value for response.get() calls.
func pythonDefaultValue(p Param) string {
switch p.Type {
case "string":
return `, ""`
case "int", "int32", "int64":
return ", 0"
case "float32", "float64":
return ", 0.0"
case "bool":
return ", False"
case "[]byte":
return ", b\"\""
default:
return ", None"
}
}
// rustFuncMap returns the template functions for Rust client code generation.
func rustFuncMap(svc Service) template.FuncMap {
knownStructs := svc.KnownStructs()
return template.FuncMap{
"lower": strings.ToLower,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
"requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
"rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) },
"rustDocComment": RustDocComment,
"rustType": func(p Param) string { return p.RustTypeWithStructs(knownStructs) },
"rustParamType": func(p Param) string { return p.RustParamTypeWithStructs(knownStructs) },
"fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) },
}
}
// GenerateClientRust generates Rust client wrapper code for plugins.
func GenerateClientRust(svc Service) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/client.rs.tmpl")
if err != nil {
return nil, fmt.Errorf("reading Rust client template: %w", err)
}
tmpl, err := template.New("client_rs").Funcs(rustFuncMap(svc)).Parse(string(tmplContent))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
data := templateData{
Service: svc,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}
// firstLine returns the first line of a multi-line string, with the first word removed.
func firstLine(s string) string {
line := s
if idx := strings.Index(s, "\n"); idx >= 0 {
line = s[:idx]
}
// Remove the first word (service name like "ArtworkService")
if idx := strings.Index(line, " "); idx >= 0 {
line = line[idx+1:]
}
return line
}
// GenerateRustLib generates the lib.rs file that exposes all service modules.
func GenerateRustLib(services []Service) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/lib.rs.tmpl")
if err != nil {
return nil, fmt.Errorf("reading Rust lib template: %w", err)
}
tmpl, err := template.New("lib_rs").Funcs(template.FuncMap{
"lower": strings.ToLower,
"firstLine": firstLine,
}).Parse(string(tmplContent))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
data := struct {
Services []Service
}{
Services: services,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}
// GenerateGoDoc generates the doc.go file that provides package documentation.
func GenerateGoDoc(services []Service, pkgName string) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/doc.go.tmpl")
if err != nil {
return nil, fmt.Errorf("reading Go doc template: %w", err)
}
tmpl, err := template.New("doc_go").Funcs(template.FuncMap{
"firstLine": firstLine,
}).Parse(string(tmplContent))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
data := struct {
Package string
Services []Service
}{
Package: pkgName,
Services: services,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}
// GenerateGoMod generates the go.mod file for the Go client library.
func GenerateGoMod() ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/go.mod.tmpl")
if err != nil {
return nil, fmt.Errorf("reading go.mod template: %w", err)
}
return tmplContent, nil
}

View File

@ -0,0 +1,679 @@
package internal
import (
"go/format"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Generator", func() {
Describe("GenerateHost", func() {
It("should generate valid Go code for a simple service with strings", func() {
// All methods use JSON request/response types
svc := Service{
Name: "SubsonicAPI",
Permission: "subsonicapi",
Interface: "SubsonicAPIService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
// Verify the code is valid Go
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for generated header
Expect(codeStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
// Check for package declaration
Expect(codeStr).To(ContainSubstring("package host"))
// All methods now use request type for JSON protocol
Expect(codeStr).To(ContainSubstring("type SubsonicAPICallRequest struct"))
Expect(codeStr).To(ContainSubstring(`Uri string `))
// Response type with error handling
Expect(codeStr).To(ContainSubstring("type SubsonicAPICallResponse struct"))
Expect(codeStr).To(ContainSubstring(`Response string `))
Expect(codeStr).To(ContainSubstring(`Error string `))
// Check for registration function
Expect(codeStr).To(ContainSubstring("func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService)"))
// Check for host function name
Expect(codeStr).To(ContainSubstring(`"subsonicapi_call"`))
// Check for JSON unmarshal (all methods use JSON now)
Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
})
It("should generate code for methods without parameters", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "NoParams",
HasError: true,
Returns: []Param{NewParam("result", "string")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Methods without params don't need a request type - no params to serialize
Expect(codeStr).NotTo(ContainSubstring("type TestNoParamsRequest struct"))
// But still uses PTR input/output for consistency
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
})
It("should generate code for methods without return values", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "NoReturn",
HasError: true,
Params: []Param{NewParam("input", "string")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
})
It("should generate code for multiple methods", func() {
svc := Service{
Name: "Scheduler",
Permission: "scheduler",
Interface: "SchedulerService",
Methods: []Method{
{
Name: "ScheduleRecurring",
HasError: true,
Params: []Param{NewParam("cronExpression", "string")},
Returns: []Param{NewParam("scheduleID", "string")},
},
{
Name: "ScheduleOneTime",
HasError: true,
Params: []Param{NewParam("delaySeconds", "int32")},
Returns: []Param{NewParam("scheduleID", "string")},
},
{
Name: "CancelSchedule",
HasError: true,
Params: []Param{NewParam("scheduleID", "string")},
Returns: []Param{NewParam("canceled", "bool")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("scheduler_schedulerecurring"))
Expect(codeStr).To(ContainSubstring("scheduler_scheduleonetime"))
Expect(codeStr).To(ContainSubstring("scheduler_cancelschedule"))
})
It("should handle multiple simple parameters with JSON", func() {
// All params use JSON - single PTR input
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "MultiParam",
HasError: true,
Params: []Param{
NewParam("name", "string"),
NewParam("count", "int32"),
NewParam("enabled", "bool"),
},
Returns: []Param{NewParam("result", "string")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// All methods use request type with JSON protocol
Expect(codeStr).To(ContainSubstring("type TestMultiParamRequest struct"))
// Check for JSON unmarshal (all methods use JSON now)
Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
// Check that input/output ValueType both use PTR (JSON)
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
})
It("should use single PTR for mixed simple and complex params", func() {
// When any param needs JSON, all are bundled into one request struct
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "MixedParam",
HasError: true,
Params: []Param{
NewParam("id", "string"), // simple (PTR for string)
NewParam("tags", "[]string"), // complex - needs JSON
},
Returns: []Param{NewParam("count", "int32")}, // simple
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Request type IS needed because of complex param
Expect(codeStr).To(ContainSubstring("type TestMixedParamRequest struct"))
// When using request type, only ONE PTR for input (the JSON request)
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
})
It("should generate proper JSON tags for complex types", func() {
// Complex types (structs, slices, maps) need JSON serialization
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Method",
HasError: true,
Params: []Param{NewParam("inputValue", "[]string")}, // slice needs JSON
Returns: []Param{NewParam("outputValue", "map[string]string")}, // map needs JSON
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Complex params need request type with JSON tags
Expect(codeStr).To(ContainSubstring(`json:"inputValue"`))
// Complex returns need response type with JSON tags
Expect(codeStr).To(ContainSubstring(`json:"outputValue,omitempty"`))
})
It("should include required imports", func() {
// Service with complex types needs JSON import
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Method",
HasError: true,
Params: []Param{NewParam("data", "MyStruct")}, // struct needs JSON
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring(`"context"`))
Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
})
It("should always include json import for JSON protocol", func() {
// All services use JSON protocol, so json import is always needed
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Method",
Params: []Param{NewParam("count", "int32")},
Returns: []Param{NewParam("result", "int64")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring(`"context"`))
Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
})
})
Describe("toJSONName", func() {
It("should convert to camelCase matching Rust serde behavior", func() {
Expect(toJSONName("InputValue")).To(Equal("inputValue"))
Expect(toJSONName("URI")).To(Equal("uri"))
Expect(toJSONName("id")).To(Equal("id"))
Expect(toJSONName("ID")).To(Equal("id"))
Expect(toJSONName("ConnectionID")).To(Equal("connectionId"))
Expect(toJSONName("NewConnectionID")).To(Equal("newConnectionId"))
Expect(toJSONName("XMLHTTPRequest")).To(Equal("xmlhttpRequest"))
Expect(toJSONName("APIKey")).To(Equal("apiKey"))
})
It("should handle empty string", func() {
Expect(toJSONName("")).To(Equal(""))
})
})
Describe("NewParam", func() {
It("should create param with auto-generated JSON name", func() {
p := NewParam("MyParam", "string")
Expect(p.Name).To(Equal("MyParam"))
Expect(p.Type).To(Equal("string"))
Expect(p.JSONName).To(Equal("myParam"))
})
})
Describe("Python type and name helpers", func() {
Describe("ToPythonType", func() {
It("should map Go types to Python types", func() {
Expect(ToPythonType("string")).To(Equal("str"))
Expect(ToPythonType("int")).To(Equal("int"))
Expect(ToPythonType("int32")).To(Equal("int"))
Expect(ToPythonType("int64")).To(Equal("int"))
Expect(ToPythonType("float32")).To(Equal("float"))
Expect(ToPythonType("float64")).To(Equal("float"))
Expect(ToPythonType("bool")).To(Equal("bool"))
Expect(ToPythonType("[]byte")).To(Equal("bytes"))
Expect(ToPythonType("unknown")).To(Equal("Any"))
})
})
Describe("ToSnakeCase", func() {
It("should convert PascalCase to snake_case", func() {
Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
Expect(ToSnakeCase("simple")).To(Equal("simple"))
})
It("should handle acronyms correctly", func() {
Expect(ToSnakeCase("ID")).To(Equal("id"))
Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id"))
Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id"))
Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser"))
Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response"))
})
})
Describe("Method.PythonFunctionName", func() {
It("should generate snake_case function name with service prefix", func() {
m := Method{Name: "GetString"}
Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string"))
})
})
Describe("Param.PythonType", func() {
It("should return Python type for parameter", func() {
p := NewParam("value", "string")
Expect(p.PythonType()).To(Equal("str"))
})
})
Describe("Param.PythonName", func() {
It("should return snake_case name for parameter", func() {
p := NewParam("ttlSeconds", "int64")
Expect(p.PythonName()).To(Equal("ttl_seconds"))
})
})
})
Describe("GenerateClientPython", func() {
It("should generate valid Python code for a simple service", func() {
svc := Service{
Name: "SubsonicAPI",
Permission: "subsonicapi",
Interface: "SubsonicAPIService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("responseJSON", "string")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for generated header
Expect(codeStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
// Check for imports
Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass"))
Expect(codeStr).To(ContainSubstring("import extism"))
Expect(codeStr).To(ContainSubstring("import json"))
// Check for exception class
Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):"))
// Check for raw import function
Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`))
Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:"))
// Check for wrapper function with type hints
Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:"))
// Check for error handling
Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response["))
})
It("should generate dataclass for multi-value returns", func() {
svc := Service{
Name: "Cache",
Permission: "cache",
Interface: "CacheService",
Methods: []Method{
{
Name: "GetString",
HasError: true,
Params: []Param{NewParam("key", "string")},
Returns: []Param{
NewParam("value", "string"),
NewParam("exists", "bool"),
},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for dataclass
Expect(codeStr).To(ContainSubstring("@dataclass"))
Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:"))
Expect(codeStr).To(ContainSubstring("value: str"))
Expect(codeStr).To(ContainSubstring("exists: bool"))
// Check that function returns dataclass
Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
Expect(codeStr).To(ContainSubstring("return CacheGetStringResult("))
})
It("should handle methods with no parameters", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "NoParams",
HasError: true,
Returns: []Param{NewParam("result", "string")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Function with no params
Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:"))
// Empty request
Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`))
})
It("should handle methods with no return values", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "NoReturn",
HasError: true,
Params: []Param{NewParam("input", "string")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Function returns None
Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:"))
})
It("should generate correct Python defaults for different types", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "AllTypes",
HasError: true,
Returns: []Param{
NewParam("strVal", "string"),
NewParam("intVal", "int64"),
NewParam("floatVal", "float64"),
NewParam("boolVal", "bool"),
},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check defaults in response.get() calls
Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`))
Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`))
Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
})
})
Describe("GenerateGoDoc", func() {
It("should generate valid doc.go content for multiple services", func() {
services := []Service{
{
Name: "Cache",
Permission: "cache",
Interface: "CacheService",
Doc: "CacheService provides temporary key-value storage with TTL.",
},
{
Name: "Scheduler",
Permission: "scheduler",
Interface: "SchedulerService",
Doc: "SchedulerService manages scheduled tasks.",
},
}
code, err := GenerateGoDoc(services, "ndpdk")
Expect(err).NotTo(HaveOccurred())
// Verify it's valid Go code
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for generated header
Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
// Check for package declaration
Expect(codeStr).To(ContainSubstring("package ndpdk"))
// Check for package documentation
Expect(codeStr).To(ContainSubstring("Package ndpdk provides Navidrome Plugin Development Kit wrappers"))
// Check that services are listed
Expect(codeStr).To(ContainSubstring("Cache:"))
Expect(codeStr).To(ContainSubstring("Scheduler:"))
})
})
Describe("GenerateGoMod", func() {
It("should generate valid go.mod content", func() {
code, err := GenerateGoMod()
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for module declaration (using new PDK path)
Expect(codeStr).To(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go/host"))
// Check for Go version
Expect(codeStr).To(ContainSubstring("go 1.24"))
// Check for extism-go-pdk dependency
Expect(codeStr).To(ContainSubstring("github.com/extism/go-pdk"))
})
})
Describe("GenerateClientGoStub", func() {
It("should generate valid stub code with panic functions", func() {
svc := Service{
Name: "Cache",
Permission: "cache",
Interface: "CacheService",
Doc: "CacheService provides caching capabilities.",
Methods: []Method{
{
Name: "Get",
Doc: "Get retrieves a value from the cache.",
Params: []Param{
{Name: "key", Type: "string"},
},
Returns: []Param{
{Name: "value", Type: "string"},
{Name: "exists", Type: "bool"},
},
},
},
}
code, err := GenerateClientGoStub(svc, "ndpdk")
Expect(err).NotTo(HaveOccurred())
// Verify it's valid Go code
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for build tag (non-WASM)
Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
// Check for package declaration
Expect(codeStr).To(ContainSubstring("package ndpdk"))
// Check for stub comment
Expect(codeStr).To(ContainSubstring("stub implementations for non-WASM builds"))
// Check for panic in function body
Expect(codeStr).To(ContainSubstring(`panic("ndpdk: CacheGet is only available in WASM plugins")`))
// Check that types are defined (needed for IDE support)
Expect(codeStr).To(ContainSubstring("type CacheGetResponse struct"))
})
})
Describe("Integration", func() {
It("should generate compilable code from parsed source", func() {
// This is an integration test that verifies the full pipeline
src := `package host
import "context"
// TestService is a test service.
//nd:hostservice name=Test permission=test
type TestService interface {
// DoSomething does something.
//nd:hostfunc
DoSomething(ctx context.Context, input string) (output string, err error)
}
`
// Create temporary directory
tmpDir := GinkgoT().TempDir()
path := tmpDir + "/test.go"
err := writeFile(path, src)
Expect(err).NotTo(HaveOccurred())
// Parse
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
// Generate
code, err := GenerateHost(services[0], "host")
Expect(err).NotTo(HaveOccurred())
// Format (validates syntax)
formatted, err := format.Source(code)
Expect(err).NotTo(HaveOccurred())
// Verify key elements
codeStr := string(formatted)
Expect(codeStr).To(ContainSubstring("RegisterTestHostFunctions"))
Expect(codeStr).To(ContainSubstring(`"test_dosomething"`))
})
})
})
func writeFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0600)
}

View File

@ -0,0 +1,13 @@
package internal
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestInternal(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "NDPGen Internal Suite")
}

View File

@ -0,0 +1,474 @@
package internal
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"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+(.*))?`)
// key=value pairs
keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`)
)
// ParseDirectory parses all Go source files in a directory and extracts host services.
func ParseDirectory(dir string) ([]Service, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("reading directory: %w", err)
}
var services []Service
fset := token.NewFileSet()
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
continue
}
// Skip generated files and test files
if strings.HasSuffix(entry.Name(), "_gen.go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
path := filepath.Join(dir, entry.Name())
parsed, err := parseFile(fset, path)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err)
}
services = append(services, parsed...)
}
return services, nil
}
// parseFile parses a single Go source file and extracts host services.
func parseFile(fset *token.FileSet, path string) ([]Service, error) {
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
// First pass: collect all struct definitions in the file
allStructs := parseStructs(f)
structMap := make(map[string]StructDef)
for _, s := range allStructs {
structMap[s.Name] = s
}
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)
}
}
// Attach referenced structs to the service
for typeName := range referencedTypes {
if s, exists := structMap[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
}
// 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
}
// 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
}
// 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"))
}

View File

@ -0,0 +1,292 @@
package internal
import (
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Parser", func() {
var tmpDir string
BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "hostgen-test-*")
Expect(err).NotTo(HaveOccurred())
})
AfterEach(func() {
os.RemoveAll(tmpDir)
})
Describe("ParseDirectory", func() {
It("should parse a simple host service interface", func() {
src := `package host
import "context"
// SubsonicAPIService provides access to Navidrome's Subsonic API.
//nd:hostservice name=SubsonicAPI permission=subsonicapi
type SubsonicAPIService interface {
// Call executes a Subsonic API request.
//nd:hostfunc
Call(ctx context.Context, uri string) (response string, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
svc := services[0]
Expect(svc.Name).To(Equal("SubsonicAPI"))
Expect(svc.Permission).To(Equal("subsonicapi"))
Expect(svc.Interface).To(Equal("SubsonicAPIService"))
Expect(svc.Methods).To(HaveLen(1))
m := svc.Methods[0]
Expect(m.Name).To(Equal("Call"))
Expect(m.HasError).To(BeTrue())
Expect(m.Params).To(HaveLen(1))
Expect(m.Params[0].Name).To(Equal("uri"))
Expect(m.Params[0].Type).To(Equal("string"))
Expect(m.Returns).To(HaveLen(1))
Expect(m.Returns[0].Name).To(Equal("response"))
Expect(m.Returns[0].Type).To(Equal("string"))
})
It("should parse multiple methods", func() {
src := `package host
import "context"
// SchedulerService provides scheduling capabilities.
//nd:hostservice name=Scheduler permission=scheduler
type SchedulerService interface {
//nd:hostfunc
ScheduleRecurring(ctx context.Context, cronExpression string) (scheduleID string, err error)
//nd:hostfunc
ScheduleOneTime(ctx context.Context, delaySeconds int32) (scheduleID string, err error)
//nd:hostfunc
CancelSchedule(ctx context.Context, scheduleID string) (canceled bool, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "scheduler.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
svc := services[0]
Expect(svc.Name).To(Equal("Scheduler"))
Expect(svc.Methods).To(HaveLen(3))
Expect(svc.Methods[0].Name).To(Equal("ScheduleRecurring"))
Expect(svc.Methods[0].Params[0].Type).To(Equal("string"))
Expect(svc.Methods[1].Name).To(Equal("ScheduleOneTime"))
Expect(svc.Methods[1].Params[0].Type).To(Equal("int32"))
Expect(svc.Methods[2].Name).To(Equal("CancelSchedule"))
Expect(svc.Methods[2].Returns[0].Type).To(Equal("bool"))
})
It("should skip methods without hostfunc annotation", func() {
src := `package host
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
Exported(ctx context.Context) error
// This method is not exported
NotExported(ctx context.Context) error
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
Expect(services[0].Methods).To(HaveLen(1))
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
})
It("should handle custom export name", func() {
src := `package host
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc name=custom_export_name
MyMethod(ctx context.Context) error
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services[0].Methods[0].ExportName).To(Equal("custom_export_name"))
Expect(services[0].Methods[0].FunctionName("test")).To(Equal("custom_export_name"))
})
It("should skip generated files", func() {
regularSrc := `package host
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
Method(ctx context.Context) error
}
`
genSrc := `// Code generated. DO NOT EDIT.
package host
//nd:hostservice name=Generated permission=gen
type GeneratedService interface {
//nd:hostfunc
Method() error
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(regularSrc), 0600)
Expect(err).NotTo(HaveOccurred())
err = os.WriteFile(filepath.Join(tmpDir, "test_gen.go"), []byte(genSrc), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
Expect(services[0].Name).To(Equal("Test"))
})
It("should skip interfaces without hostservice annotation", func() {
src := `package host
import "context"
// Regular interface without annotation
type RegularInterface interface {
Method(ctx context.Context) error
}
//nd:hostservice name=Annotated permission=annotated
type AnnotatedService interface {
//nd:hostfunc
Method(ctx context.Context) error
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
Expect(services[0].Name).To(Equal("Annotated"))
})
It("should return empty slice for directory with no host services", func() {
src := `package host
type RegularInterface interface {
Method() error
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(BeEmpty())
})
})
Describe("parseKeyValuePairs", func() {
It("should parse key=value pairs", func() {
result := parseKeyValuePairs("name=Test permission=test")
Expect(result).To(HaveKeyWithValue("name", "Test"))
Expect(result).To(HaveKeyWithValue("permission", "test"))
})
It("should return nil for empty input", func() {
result := parseKeyValuePairs("")
Expect(result).To(BeNil())
})
})
Describe("typeToString", func() {
It("should handle basic types", func() {
src := `package test
type T interface {
Method(s string, i int, b bool) ([]byte, error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
// Parse and verify type conversion works
// This is implicitly tested through ParseDirectory
})
It("should convert interface{} to any", func() {
src := `package test
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
GetMetadata(ctx context.Context) (data map[string]interface{}, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
Expect(services[0].Methods[0].Returns[0].Type).To(Equal("map[string]any"))
})
})
Describe("Method helpers", func() {
It("should generate correct function names", func() {
m := Method{Name: "Call"}
Expect(m.FunctionName("subsonicapi")).To(Equal("subsonicapi_call"))
m.ExportName = "custom_name"
Expect(m.FunctionName("subsonicapi")).To(Equal("custom_name"))
})
It("should generate correct type names", func() {
m := Method{Name: "Call"}
Expect(m.RequestTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallRequest"))
Expect(m.ResponseTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallResponse"))
})
})
Describe("Service helpers", func() {
It("should generate correct output file name", func() {
s := Service{Name: "SubsonicAPI"}
Expect(s.OutputFileName()).To(Equal("subsonicapi_gen.go"))
})
It("should generate correct export prefix", func() {
s := Service{Name: "SubsonicAPI"}
Expect(s.ExportPrefix()).To(Equal("subsonicapi"))
})
})
})

View File

@ -0,0 +1,108 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the {{.Service.Name}} host service.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package {{.Package}}
import (
"encoding/json"
"errors"
"github.com/extism/go-pdk"
)
{{- /* Generate struct definitions */ -}}
{{- range .Service.Structs}}
// {{.Name}} represents the {{.Name}} data structure.
{{- if .Doc}}
{{formatDoc .Doc}}
{{- end}}
type {{.Name}} struct {
{{- range .Fields}}
{{.Name}} {{.Type}} `json:"{{.JSONTag}}"`
{{- end}}
}
{{- end}}
{{- /* Generate wasmimport declarations for each method */ -}}
{{range .Service.Methods}}
// {{exportName .}} is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user {{exportName .}}
func {{exportName .}}(uint64) uint64
{{- end}}
{{- /* Generate request/response types for all methods */ -}}
{{range .Service.Methods}}
{{- if .HasParams}}
// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
type {{requestType .}} struct {
{{- range .Params}}
{{title .Name}} {{.Type}} `json:"{{.JSONName}}"`
{{- end}}
}
{{- end}}
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
type {{responseType .}} struct {
{{- range .Returns}}
{{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"`
{{- end}}
Error string `json:"error,omitempty"`
}
{{- end}}
{{- /* Generate wrapper functions */ -}}
{{range .Service.Methods}}
// {{$.Service.Name}}{{.Name}} calls the {{exportName .}} host function.
{{- if .Doc}}
{{formatDoc .Doc}}
{{- end}}
func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) (*{{responseType .}}, error) {
{{- if .HasParams}}
// Marshal request to JSON
req := {{requestType .}}{
{{- range .Params}}
{{title .Name}}: {{.Name}},
{{- end}}
}
reqBytes, err := json.Marshal(req)
if err != nil {
return nil, err
}
reqMem := pdk.AllocateBytes(reqBytes)
defer reqMem.Free()
{{- else}}
// No parameters - allocate empty JSON object
reqMem := pdk.AllocateBytes([]byte("{}"))
defer reqMem.Free()
{{- end}}
// Call the host function
responsePtr := {{exportName .}}(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response {{responseType .}}
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
// Convert Error field to Go error
if response.Error != "" {
return nil, errors.New(response.Error)
}
return &response, nil
}
{{- end}}

View File

@ -0,0 +1,95 @@
# Code generated by hostgen. DO NOT EDIT.
#
# This file contains client wrappers for the {{.Service.Name}} host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any
import extism
import json
class HostFunctionError(Exception):
"""Raised when a host function returns an error."""
pass
{{- /* Generate raw host function imports */ -}}
{{range .Service.Methods}}
@extism.import_fn("extism:host/user", "{{exportName .}}")
def _{{exportName .}}(offset: int) -> int:
"""Raw host function - do not call directly."""
...
{{- end}}
{{- /* Generate dataclasses for multi-value returns */ -}}
{{range .Service.Methods}}
{{- if .NeedsResultClass}}
@dataclass
class {{pythonResultType .}}:
"""Result type for {{pythonFunc .}}."""
{{- range .Returns}}
{{.PythonName}}: {{.PythonType}}
{{- end}}
{{- end}}
{{- end}}
{{- /* Generate wrapper functions */ -}}
{{range .Service.Methods}}
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
Args:
{{- range .Params}}
{{.PythonName}}: {{.PythonType}} parameter.
{{- end}}
{{- end}}
{{- if .HasReturns}}
Returns:
{{- if .NeedsResultClass}}
{{pythonResultType .}} containing{{range .Returns}} {{.PythonName}},{{end}}.
{{- else}}
{{(index .Returns 0).PythonType}}: The result value.
{{- end}}
{{- end}}
Raises:
HostFunctionError: If the host function returns an error.
"""
{{- if .HasParams}}
request = {
{{- range .Params}}
"{{.JSONName}}": {{.PythonName}},
{{- end}}
}
request_bytes = json.dumps(request).encode("utf-8")
{{- else}}
request_bytes = b"{}"
{{- end}}
request_mem = extism.memory.alloc(request_bytes)
response_offset = _{{exportName .}}(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])
{{if .NeedsResultClass}}
return {{pythonResultType .}}(
{{- range .Returns}}
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
{{- end}}
)
{{- else if .HasReturns}}
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
{{- end}}
{{- end}}

View File

@ -0,0 +1,103 @@
// Code generated by hostgen. DO NOT EDIT.
//
// This file contains client wrappers for the {{.Service.Name}} host service.
// It is intended for use in Navidrome plugins built with extism-pdk.
use extism_pdk::*;
use serde::{Deserialize, Serialize};
{{- /* Generate struct definitions */ -}}
{{- range .Service.Structs}}
{{if .Doc}}
{{rustDocComment .Doc}}
{{else}}
{{end}}#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct {{.Name}} {
{{- range .Fields}}
{{- if .NeedsDefault}}
#[serde(default)]
{{- end}}
pub {{.RustName}}: {{fieldRustType .}},
{{- end}}
}
{{- end}}
{{- /* Generate request/response types */ -}}
{{- range .Service.Methods}}
{{- if .HasParams}}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct {{requestType .}} {
{{- range .Params}}
{{.RustName}}: {{rustType .}},
{{- end}}
}
{{- end}}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct {{responseType .}} {
{{- range .Returns}}
#[serde(default)]
{{.RustName}}: {{rustType .}},
{{- end}}
#[serde(default)]
error: Option<String>,
}
{{- end}}
#[host_fn]
extern "ExtismHost" {
{{- range .Service.Methods}}
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
{{- end}}
}
{{- /* Generate wrapper functions */ -}}
{{range .Service.Methods}}
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
///
/// # Arguments
{{- range .Params}}
/// * `{{.RustName}}` - {{rustType .}} parameter.
{{- end}}
{{- end}}
{{- if .HasReturns}}
///
/// # Returns
{{- if eq (len .Returns) 1}}
/// The {{(index .Returns 0).RustName}} value.
{{- else}}
/// A tuple of ({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{$r.RustName}}{{end}}).
{{- end}}
{{- end}}
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<{{if eq (len .Returns) 0}}(){{else if eq (len .Returns) 1}}{{rustType (index .Returns 0)}}{{else}}({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{rustType $r}}{{end}}){{end}}, Error> {
let response = unsafe {
{{- if .HasParams}}
{{exportName .}}(Json({{requestType .}} {
{{- range .Params}}
{{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}},
{{- end}}
}))?
{{- else}}
{{exportName .}}(Json(serde_json::json!({})))?
{{- end}}
};
if let Some(err) = response.0.error {
return Err(Error::msg(err));
}
{{if eq (len .Returns) 0}}
Ok(())
{{- else if eq (len .Returns) 1}}
Ok(response.0.{{(index .Returns 0).RustName}})
{{- else}}
Ok(({{range $i, $r := .Returns}}{{if $i}}, {{end}}response.0.{{$r.RustName}}{{end}}))
{{- end}}
}
{{- end}}

View File

@ -0,0 +1,56 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains stub implementations for non-WASM builds.
// These stubs allow IDE support and compilation on non-WASM platforms.
// They panic at runtime since host functions are only available in WASM plugins.
//
//go:build !wasip1
package {{.Package}}
{{- /* Generate struct definitions (same as main file, needed for type references) */ -}}
{{- range .Service.Structs}}
// {{.Name}} represents the {{.Name}} data structure.
{{- if .Doc}}
{{formatDoc .Doc}}
{{- end}}
type {{.Name}} struct {
{{- range .Fields}}
{{.Name}} {{.Type}} `json:"{{.JSONTag}}"`
{{- end}}
}
{{- end}}
{{- /* Generate request/response types (same as main file) */ -}}
{{range .Service.Methods}}
{{- if .HasParams}}
// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
type {{requestType .}} struct {
{{- range .Params}}
{{title .Name}} {{.Type}} `json:"{{.JSONName}}"`
{{- end}}
}
{{- end}}
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
type {{responseType .}} struct {
{{- range .Returns}}
{{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"`
{{- end}}
Error string `json:"error,omitempty"`
}
{{- end}}
{{- /* Generate stub wrapper functions that panic */ -}}
{{range .Service.Methods}}
// {{$.Service.Name}}{{.Name}} is a stub that panics on non-WASM platforms.
{{- if .Doc}}
{{formatDoc .Doc}}
{{- end}}
func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) (*{{responseType .}}, error) {
panic("{{$.Package}}: {{$.Service.Name}}{{.Name}} is only available in WASM plugins")
}
{{- end}}

View File

@ -0,0 +1,49 @@
// Code generated by ndpgen. DO NOT EDIT.
/*
Package {{.Package}} provides Navidrome Plugin Development Kit wrappers for Go/TinyGo plugins.
This package is auto-generated by the ndpgen tool and should not be edited manually.
# Usage
Add this module as a dependency in your plugin's go.mod:
require github.com/navidrome/navidrome/plugins/pdk/go/host v0.0.0
Then import the package in your plugin code:
import {{.Package}} "github.com/navidrome/navidrome/plugins/pdk/go/host"
func myPluginFunction() error {
// Use the cache service
_, err := {{.Package}}.CacheSetString("my_key", "my_value", 3600)
if err != nil {
return err
}
// Schedule a recurring task
_, err = {{.Package}}.SchedulerScheduleRecurring("@every 5m", "payload", "task_id")
if err != nil {
return err
}
return nil
}
# Available Services
The following host services are available:
{{range .Services}}
- {{.Name}}: {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} service{{end}}
{{- end}}
# Building Plugins
Go plugins must be compiled to WebAssembly using TinyGo:
tinygo build -o plugin.wasm -target=wasip1 -buildmode=c-shared .
See the examples directory for complete plugin implementations.
*/
package {{.Package}}

View File

@ -0,0 +1,5 @@
module github.com/navidrome/navidrome/plugins/pdk/go/host
go 1.24
require github.com/extism/go-pdk v1.1.3

View File

@ -0,0 +1,119 @@
// Code generated by hostgen. DO NOT EDIT.
package {{.Package}}
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
{{- /* Generate request/response types for all methods */ -}}
{{range .Service.Methods}}
{{- if .HasParams}}
// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
type {{requestType .}} struct {
{{- range .Params}}
{{title .Name}} {{.Type}} `json:"{{.JSONName}}"`
{{- end}}
}
{{- end}}
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
type {{responseType .}} struct {
{{- range .Returns}}
{{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"`
{{- end}}
Error string `json:"error,omitempty"`
}
{{end}}
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
// The returned host functions should be added to the plugin's configuration.
func Register{{.Service.Name}}HostFunctions(service {{.Service.Interface}}) []extism.HostFunction {
return []extism.HostFunction{
{{- range .Service.Methods}}
new{{$.Service.Name}}{{.Name}}HostFunction(service),
{{- end}}
}
}
{{range .Service.Methods}}
func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"{{exportName .}}",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
{{- if .HasParams}}
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
{{$.Service.Name | lower}}WriteError(p, stack, err)
return
}
var req {{requestType .}}
if err := json.Unmarshal(reqBytes, &req); err != nil {
{{$.Service.Name | lower}}WriteError(p, stack, err)
return
}
{{- end}}
// Call the service method
{{- if .HasReturns}}
{{- if .HasError}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
if svcErr != nil {
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
return
}
{{- else}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
{{- end}}
{{- else if .HasError}}
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
return
}
{{- else}}
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
{{- end}}
// Write JSON response to plugin memory
resp := {{responseType .}}{
{{- range .Returns}}
{{title .Name}}: {{lower .Name}},
{{- end}}
}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
{{end}}
// {{.Service.Name | lower}}WriteResponse writes a JSON response to plugin memory.
func {{.Service.Name | lower}}WriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
{{.Service.Name | lower}}WriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// {{.Service.Name | lower}}WriteError writes an error response to plugin memory.
func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@ -0,0 +1,43 @@
// Code generated by hostgen. DO NOT EDIT.
//
//! Navidrome Host Function Wrappers for Rust Plugins
//!
//! This crate provides idiomatic Rust wrappers for all Navidrome host services.
//! It is auto-generated by the hostgen tool and should not be edited manually.
//!
//! # Usage
//!
//! Add this crate as a dependency in your plugin's Cargo.toml:
//!
//! ```toml
//! [dependencies]
//! nd-host = { path = "../../host/rust" }
//! ```
//!
//! Then import the services you need:
//!
//! ```ignore
//! use nd_host::{cache, scheduler};
//!
//! fn my_plugin_function() -> Result<(), extism_pdk::Error> {
//! // Use the cache service
//! cache::set_string("my_key", "my_value", 3600)?;
//!
//! // Schedule a recurring task
//! scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
//!
//! Ok(())
//! }
//! ```
//!
//! # Available Services
//!
{{- range .Services}}
//! - [`{{.Name | lower}}`] - {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} service{{end}}
{{- end}}
{{range .Services}}
#[path = "nd_host_{{.Name | lower}}.rs"]
pub mod {{.Name | lower}};
{{end}}
// Re-export commonly used types from extism-pdk for convenience
pub use extism_pdk::Error;

View File

@ -0,0 +1,384 @@
package internal
import (
"strings"
"unicode"
)
// Service represents a parsed host service interface.
type Service struct {
Name string // Service name from annotation (e.g., "SubsonicAPI")
Permission string // Manifest permission key (e.g., "subsonicapi")
Interface string // Go interface name (e.g., "SubsonicAPIService")
Methods []Method // Methods marked with //nd:hostfunc
Doc string // Documentation comment for the service
Structs []StructDef // Structs used by this service
}
// StructDef represents a Go struct type definition.
type StructDef struct {
Name string // Go struct name (e.g., "Library")
Fields []FieldDef // Struct fields
Doc string // Documentation comment
}
// FieldDef represents a field within a struct.
type FieldDef struct {
Name string // Go field name (e.g., "TotalSongs")
Type string // Go type (e.g., "int32", "*string", "[]User")
JSONTag string // JSON tag value (e.g., "totalSongs,omitempty")
OmitEmpty bool // Whether the field has omitempty tag
Doc string // Field documentation
}
// OutputFileName returns the generated file name for this service.
func (s Service) OutputFileName() string {
return strings.ToLower(s.Name) + "_gen.go"
}
// ExportPrefix returns the prefix for exported host function names.
func (s Service) ExportPrefix() string {
return strings.ToLower(s.Name)
}
// KnownStructs returns a map of struct names defined in this service.
func (s Service) KnownStructs() map[string]bool {
result := make(map[string]bool)
for _, st := range s.Structs {
result[st.Name] = true
}
return result
}
// Method represents a host function method within a service.
type Method struct {
Name string // Go method name (e.g., "Call")
ExportName string // Optional override for export name
Params []Param // Method parameters (excluding context.Context)
Returns []Param // Return values (excluding error)
HasError bool // Whether the method returns an error
Doc string // Documentation comment for the method
}
// FunctionName returns the Extism host function export name.
func (m Method) FunctionName(servicePrefix string) string {
if m.ExportName != "" {
return m.ExportName
}
return servicePrefix + "_" + strings.ToLower(m.Name)
}
// RequestTypeName returns the generated request type name.
func (m Method) RequestTypeName(serviceName string) string {
return serviceName + m.Name + "Request"
}
// ResponseTypeName returns the generated response type name.
func (m Method) ResponseTypeName(serviceName string) string {
return serviceName + m.Name + "Response"
}
// HasParams returns true if the method has input parameters.
func (m Method) HasParams() bool {
return len(m.Params) > 0
}
// HasReturns returns true if the method has return values (excluding error).
func (m Method) HasReturns() bool {
return len(m.Returns) > 0
}
// Param represents a method parameter or return value.
type Param struct {
Name string // Parameter name
Type string // Go type (e.g., "string", "int32", "[]byte")
JSONName string // JSON field name (camelCase)
}
// NewParam creates a Param with auto-generated JSON name.
func NewParam(name, typ string) Param {
return Param{
Name: name,
Type: typ,
JSONName: toJSONName(name),
}
}
// toJSONName converts a Go identifier to camelCase JSON field name.
// This matches Rust serde's rename_all = "camelCase" behavior.
// Examples: "ConnectionID" -> "connectionId", "NewConnectionID" -> "newConnectionId"
func toJSONName(name string) string {
if name == "" {
return ""
}
runes := []rune(name)
result := make([]rune, 0, len(runes))
for i, r := range runes {
if i == 0 {
// First character is always lowercase
result = append(result, unicode.ToLower(r))
} else if unicode.IsUpper(r) {
// Check if this is part of an acronym (consecutive uppercase)
// or a word boundary
prevIsUpper := unicode.IsUpper(runes[i-1])
nextIsLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
if prevIsUpper && !nextIsLower {
// Middle of an acronym - lowercase it
result = append(result, unicode.ToLower(r))
} else if prevIsUpper && nextIsLower {
// End of acronym followed by lowercase - this starts a new word
// Keep uppercase
result = append(result, r)
} else {
// Regular word boundary - keep uppercase
result = append(result, r)
}
} else {
result = append(result, r)
}
}
return string(result)
}
// ToPythonType converts a Go type to its Python equivalent.
func ToPythonType(goType string) string {
switch goType {
case "string":
return "str"
case "int", "int32", "int64":
return "int"
case "float32", "float64":
return "float"
case "bool":
return "bool"
case "[]byte":
return "bytes"
default:
return "Any"
}
}
// ToSnakeCase converts a PascalCase or camelCase string to snake_case.
// It handles consecutive uppercase letters correctly (e.g., "ScheduleID" -> "schedule_id").
func ToSnakeCase(s string) string {
var result strings.Builder
runes := []rune(s)
for i, r := range runes {
if i > 0 && r >= 'A' && r <= 'Z' {
// Add underscore before uppercase, but not if:
// - Previous char was uppercase AND next char is uppercase or end of string
// (this handles acronyms like "ID" in "NewScheduleID")
prevUpper := runes[i-1] >= 'A' && runes[i-1] <= 'Z'
nextUpper := i+1 < len(runes) && runes[i+1] >= 'A' && runes[i+1] <= 'Z'
atEnd := i+1 == len(runes)
// Only skip underscore if we're in the middle of an acronym
if !prevUpper || (!nextUpper && !atEnd) {
result.WriteByte('_')
}
}
result.WriteRune(r)
}
return strings.ToLower(result.String())
}
// PythonFunctionName returns the Python function name for a method.
func (m Method) PythonFunctionName(servicePrefix string) string {
return ToSnakeCase(servicePrefix + m.Name)
}
// PythonResultTypeName returns the Python dataclass name for multi-value returns.
func (m Method) PythonResultTypeName(serviceName string) string {
return serviceName + m.Name + "Result"
}
// NeedsResultClass returns true if the method needs a dataclass for returns.
func (m Method) NeedsResultClass() bool {
return len(m.Returns) > 1
}
// PythonType returns the Python type for this parameter.
func (p Param) PythonType() string {
return ToPythonType(p.Type)
}
// PythonName returns the snake_case Python name for this parameter.
func (p Param) PythonName() string {
return ToSnakeCase(p.Name)
}
// ToRustType converts a Go type to its Rust equivalent.
func ToRustType(goType string) string {
return ToRustTypeWithStructs(goType, nil)
}
// RustParamType returns the Rust type for a function parameter (uses &str for strings).
func RustParamType(goType string) string {
if goType == "string" {
return "&str"
}
return ToRustType(goType)
}
// RustDefaultValue returns the default value for a Rust type.
func RustDefaultValue(goType string) string {
switch goType {
case "string":
return `String::new()`
case "int", "int32":
return "0"
case "int64":
return "0"
case "float32", "float64":
return "0.0"
case "bool":
return "false"
default:
if strings.HasPrefix(goType, "[]") {
return "Vec::new()"
}
if strings.HasPrefix(goType, "map[") {
return "std::collections::HashMap::new()"
}
if strings.HasPrefix(goType, "*") {
return "None"
}
return "serde_json::Value::Null"
}
}
// RustFunctionName returns the Rust function name for a method (snake_case).
// Uses just the method name without service prefix since the module provides namespacing.
func (m Method) RustFunctionName(_ string) string {
return ToSnakeCase(m.Name)
}
// RustDocComment returns a properly formatted Rust doc comment.
// Each line of the input doc string is prefixed with "/// ".
func RustDocComment(doc string) string {
if doc == "" {
return ""
}
lines := strings.Split(doc, "\n")
var result []string
for _, line := range lines {
result = append(result, "/// "+line)
}
return strings.Join(result, "\n")
}
// RustType returns the Rust type for this parameter.
func (p Param) RustType() string {
return ToRustType(p.Type)
}
// RustTypeWithStructs returns the Rust type using known struct names.
func (p Param) RustTypeWithStructs(knownStructs map[string]bool) string {
return ToRustTypeWithStructs(p.Type, knownStructs)
}
// RustParamType returns the Rust type for this parameter when used as a function argument.
func (p Param) RustParamType() string {
return RustParamType(p.Type)
}
// RustParamTypeWithStructs returns the Rust param type using known struct names.
func (p Param) RustParamTypeWithStructs(knownStructs map[string]bool) string {
if p.Type == "string" {
return "&str"
}
return ToRustTypeWithStructs(p.Type, knownStructs)
}
// RustName returns the snake_case Rust name for this parameter.
func (p Param) RustName() string {
return ToSnakeCase(p.Name)
}
// NeedsToOwned returns true if the parameter needs .to_owned() when used.
func (p Param) NeedsToOwned() bool {
return p.Type == "string"
}
// RustType returns the Rust type for this field, using known struct names.
func (f FieldDef) RustType(knownStructs map[string]bool) string {
return ToRustTypeWithStructs(f.Type, knownStructs)
}
// RustName returns the snake_case Rust name for this field.
func (f FieldDef) RustName() string {
return ToSnakeCase(f.Name)
}
// NeedsDefault returns true if the field needs #[serde(default)] attribute.
// This is true for fields with omitempty tag.
func (f FieldDef) NeedsDefault() bool {
return f.OmitEmpty
}
// ToRustTypeWithStructs converts a Go type to its Rust equivalent,
// using known struct names instead of serde_json::Value.
func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string {
// Handle pointer types
if strings.HasPrefix(goType, "*") {
inner := ToRustTypeWithStructs(goType[1:], knownStructs)
return "Option<" + inner + ">"
}
// Handle slice types
if strings.HasPrefix(goType, "[]") {
if goType == "[]byte" {
return "Vec<u8>"
}
inner := ToRustTypeWithStructs(goType[2:], knownStructs)
return "Vec<" + inner + ">"
}
// Handle map types
if strings.HasPrefix(goType, "map[") {
// Extract key and value types from map[K]V
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:]
return "std::collections::HashMap<" + ToRustTypeWithStructs(keyType, knownStructs) + ", " + ToRustTypeWithStructs(valueType, knownStructs) + ">"
}
switch goType {
case "string":
return "String"
case "int", "int32":
return "i32"
case "int64":
return "i64"
case "float32":
return "f32"
case "float64":
return "f64"
case "bool":
return "bool"
case "interface{}", "any":
return "serde_json::Value"
default:
// Check if this is a known struct type
if knownStructs != nil && knownStructs[goType] {
return goType
}
// For unknown custom types, fall back to Value
return "serde_json::Value"
}
}

414
plugins/cmd/ndpgen/main.go Normal file
View File

@ -0,0 +1,414 @@
// ndpgen generates Navidrome Plugin Development Kit (PDK) code from annotated Go interfaces.
//
// This is the unified code generator that replaces hostgen and handles both host function
// wrappers and capability wrappers (when implemented).
//
// Usage:
//
// ndpgen -input=./plugins/host -output=./plugins/pdk/go/host
//
// Flags:
//
// -input Input directory containing Go source files with annotated interfaces
// -output Output directory for generated files (default: same as input)
// -package Output package name (default: inferred from output directory)
// -host-only Generate only host function wrappers (default: true, capability support TBD)
// -go Generate Go client wrappers (default: true when not using -python/-rust)
// -python Generate Python client wrappers (default: false)
// -rust Generate Rust client wrappers (default: false)
// -v Verbose output
// -dry-run Preview generated code without writing files
package main
import (
"flag"
"fmt"
"go/format"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/plugins/cmd/ndpgen/internal"
)
// config holds the parsed command-line configuration.
type config struct {
inputDir string
outputDir string
pkgName string
hostOnly bool
generateGoClient bool
generatePyClient bool
generateRsClient bool
verbose bool
dryRun bool
}
func main() {
cfg, err := parseConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
services, err := parseServices(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(services) == 0 {
return
}
if err := generateAllCode(cfg, services); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// parseConfig parses command-line flags and returns the configuration.
func parseConfig() (*config, error) {
var (
inputDir = flag.String("input", ".", "Input directory containing Go source files")
outputDir = flag.String("output", "", "Output directory for generated files (default: same as input)")
pkgName = flag.String("package", "", "Output package name (default: inferred from output directory)")
hostOnly = flag.Bool("host-only", true, "Generate only host function wrappers (capability support TBD)")
goClient = flag.Bool("go", false, "Generate Go client wrappers")
pyClient = flag.Bool("python", false, "Generate Python client wrappers")
rsClient = flag.Bool("rust", false, "Generate Rust client wrappers")
verbose = flag.Bool("v", false, "Verbose output")
dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files")
)
flag.Parse()
if *outputDir == "" {
*outputDir = *inputDir
}
absInput, err := filepath.Abs(*inputDir)
if err != nil {
return nil, fmt.Errorf("resolving input path: %w", err)
}
absOutput, err := filepath.Abs(*outputDir)
if err != nil {
return nil, fmt.Errorf("resolving output path: %w", err)
}
if *pkgName == "" {
*pkgName = filepath.Base(absOutput)
}
// Determine what to generate
// Default: generate Go clients if no language flag is specified
anyLangFlag := *goClient || *pyClient || *rsClient
return &config{
inputDir: absInput,
outputDir: absOutput,
pkgName: *pkgName,
hostOnly: *hostOnly,
generateGoClient: *goClient || !anyLangFlag,
generatePyClient: *pyClient,
generateRsClient: *rsClient,
verbose: *verbose,
dryRun: *dryRun,
}, nil
}
// parseServices parses source files and returns discovered services.
func parseServices(cfg *config) ([]internal.Service, error) {
if cfg.verbose {
fmt.Printf("Input directory: %s\n", cfg.inputDir)
fmt.Printf("Output directory: %s\n", cfg.outputDir)
fmt.Printf("Package name: %s\n", cfg.pkgName)
fmt.Printf("Host-only mode: %v\n", cfg.hostOnly)
fmt.Printf("Generate Go client code: %v\n", cfg.generateGoClient)
fmt.Printf("Generate Python client code: %v\n", cfg.generatePyClient)
fmt.Printf("Generate Rust client code: %v\n", cfg.generateRsClient)
}
services, err := internal.ParseDirectory(cfg.inputDir)
if err != nil {
return nil, fmt.Errorf("parsing source files: %w", err)
}
if len(services) == 0 {
if cfg.verbose {
fmt.Println("No host services found")
}
return nil, nil
}
if cfg.verbose {
fmt.Printf("Found %d host service(s)\n", len(services))
for _, svc := range services {
fmt.Printf(" - %s (%d methods)\n", svc.Name, len(svc.Methods))
}
}
return services, nil
}
// generateAllCode generates all requested code for the services.
func generateAllCode(cfg *config, services []internal.Service) error {
for _, svc := range services {
if cfg.generateGoClient {
if err := generateGoClientCode(svc, cfg.outputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil {
return fmt.Errorf("generating Go client code for %s: %w", svc.Name, err)
}
}
if cfg.generatePyClient {
if err := generatePythonClientCode(svc, cfg.outputDir, cfg.dryRun, cfg.verbose); err != nil {
return fmt.Errorf("generating Python client code for %s: %w", svc.Name, err)
}
}
if cfg.generateRsClient {
if err := generateRustClientCode(svc, cfg.outputDir, cfg.dryRun, cfg.verbose); err != nil {
return fmt.Errorf("generating Rust client code for %s: %w", svc.Name, err)
}
}
}
if cfg.generateRsClient && len(services) > 0 {
if err := generateRustLibFile(services, cfg.outputDir, cfg.dryRun, cfg.verbose); err != nil {
return fmt.Errorf("generating Rust lib.rs: %w", err)
}
}
if cfg.generateGoClient && len(services) > 0 {
if err := generateGoDocFile(services, cfg.outputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil {
return fmt.Errorf("generating Go doc.go: %w", err)
}
if err := generateGoModFile(cfg.outputDir, cfg.dryRun, cfg.verbose); err != nil {
return fmt.Errorf("generating Go go.mod: %w", err)
}
}
return nil
}
// generateGoClientCode generates Go client-side code for a service.
func generateGoClientCode(svc internal.Service, outputDir, pkgName string, dryRun, verbose bool) error {
code, err := internal.GenerateClientGo(svc, pkgName)
if err != nil {
return fmt.Errorf("generating code: %w", err)
}
formatted, err := format.Source(code)
if err != nil {
return fmt.Errorf("formatting code: %w\nRaw code:\n%s", err, code)
}
// Client code goes directly in the output directory
clientFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+".go")
if dryRun {
fmt.Printf("=== %s ===\n%s\n", clientFile, formatted)
} else {
// Create output directory if needed
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
if err := os.WriteFile(clientFile, formatted, 0600); err != nil {
return fmt.Errorf("writing file: %w", err)
}
if verbose {
fmt.Printf("Generated Go client code: %s\n", clientFile)
}
}
// Also generate stub file for non-WASM platforms
return generateGoClientStubCode(svc, outputDir, pkgName, dryRun, verbose)
}
// generateGoClientStubCode generates stub code for non-WASM platforms.
func generateGoClientStubCode(svc internal.Service, outputDir, pkgName string, dryRun, verbose bool) error {
code, err := internal.GenerateClientGoStub(svc, pkgName)
if err != nil {
return fmt.Errorf("generating stub code: %w", err)
}
formatted, err := format.Source(code)
if err != nil {
return fmt.Errorf("formatting stub code: %w\nRaw code:\n%s", err, code)
}
// Stub code goes directly in output directory with _stub suffix
stubFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+"_stub.go")
if dryRun {
fmt.Printf("=== %s ===\n%s\n", stubFile, formatted)
return nil
}
// Create output directory if needed
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
if err := os.WriteFile(stubFile, formatted, 0600); err != nil {
return fmt.Errorf("writing stub file: %w", err)
}
if verbose {
fmt.Printf("Generated Go client stub: %s\n", stubFile)
}
return nil
}
// generatePythonClientCode generates Python client-side code for a service.
func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
code, err := internal.GenerateClientPython(svc)
if err != nil {
return fmt.Errorf("generating code: %w", err)
}
// Python code goes in python/ subdirectory
clientDir := filepath.Join(outputDir, "python")
clientFile := filepath.Join(clientDir, "nd_host_"+strings.ToLower(svc.Name)+".py")
if dryRun {
fmt.Printf("=== %s ===\n%s\n", clientFile, code)
return nil
}
// Create python/ subdirectory if needed
if err := os.MkdirAll(clientDir, 0755); err != nil {
return fmt.Errorf("creating python client directory: %w", err)
}
if err := os.WriteFile(clientFile, code, 0600); err != nil {
return fmt.Errorf("writing file: %w", err)
}
if verbose {
fmt.Printf("Generated Python client code: %s\n", clientFile)
}
return nil
}
// generateRustClientCode generates Rust client-side code for a service.
func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
code, err := internal.GenerateClientRust(svc)
if err != nil {
return fmt.Errorf("generating code: %w", err)
}
// Rust code goes in rust/ subdirectory
clientDir := filepath.Join(outputDir, "rust")
clientFile := filepath.Join(clientDir, "nd_host_"+strings.ToLower(svc.Name)+".rs")
if dryRun {
fmt.Printf("=== %s ===\n%s\n", clientFile, code)
return nil
}
// Create rust/ subdirectory if needed
if err := os.MkdirAll(clientDir, 0755); err != nil {
return fmt.Errorf("creating rust client directory: %w", err)
}
if err := os.WriteFile(clientFile, code, 0600); err != nil {
return fmt.Errorf("writing file: %w", err)
}
if verbose {
fmt.Printf("Generated Rust client code: %s\n", clientFile)
}
return nil
}
// generateRustLibFile generates the lib.rs file that exposes all Rust modules.
func generateRustLibFile(services []internal.Service, outputDir string, dryRun, verbose bool) error {
code, err := internal.GenerateRustLib(services)
if err != nil {
return fmt.Errorf("generating lib.rs: %w", err)
}
clientDir := filepath.Join(outputDir, "rust")
libFile := filepath.Join(clientDir, "lib.rs")
if dryRun {
fmt.Printf("=== %s ===\n%s\n", libFile, code)
return nil
}
// Create rust/ subdirectory if needed
if err := os.MkdirAll(clientDir, 0755); err != nil {
return fmt.Errorf("creating rust client directory: %w", err)
}
if err := os.WriteFile(libFile, code, 0600); err != nil {
return fmt.Errorf("writing file: %w", err)
}
if verbose {
fmt.Printf("Generated Rust lib.rs: %s\n", libFile)
}
return nil
}
// generateGoDocFile generates the doc.go file for the Go library.
func generateGoDocFile(services []internal.Service, outputDir, pkgName string, dryRun, verbose bool) error {
code, err := internal.GenerateGoDoc(services, pkgName)
if err != nil {
return fmt.Errorf("generating doc.go: %w", err)
}
formatted, err := format.Source(code)
if err != nil {
return fmt.Errorf("formatting doc.go: %w\nRaw code:\n%s", err, code)
}
docFile := filepath.Join(outputDir, "doc.go")
if dryRun {
fmt.Printf("=== %s ===\n%s\n", docFile, formatted)
return nil
}
// Create output directory if needed
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
if err := os.WriteFile(docFile, formatted, 0600); err != nil {
return fmt.Errorf("writing file: %w", err)
}
if verbose {
fmt.Printf("Generated Go doc.go: %s\n", docFile)
}
return nil
}
// generateGoModFile generates the go.mod file for the Go library.
func generateGoModFile(outputDir string, dryRun, verbose bool) error {
code, err := internal.GenerateGoMod()
if err != nil {
return fmt.Errorf("generating go.mod: %w", err)
}
modFile := filepath.Join(outputDir, "go.mod")
if dryRun {
fmt.Printf("=== %s ===\n%s\n", modFile, code)
return nil
}
// Create output directory if needed
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
if err := os.WriteFile(modFile, code, 0600); err != nil {
return fmt.Errorf("writing file: %w", err)
}
if verbose {
fmt.Printf("Generated Go go.mod: %s\n", modFile)
}
return nil
}

View File

@ -0,0 +1,13 @@
package main
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestNdpgen(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "NDPGen CLI Suite")
}