diff --git a/plugins/cmd/ndpgen/README.md b/plugins/cmd/ndpgen/README.md
new file mode 100644
index 000000000..2279e05a3
--- /dev/null
+++ b/plugins/cmd/ndpgen/README.md
@@ -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
-output [-package ] [-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= 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=]
+MethodName(ctx context.Context, ...) (result Type, err error)
+```
+
+| Parameter | Description | Required |
+|-----------|-------------------------------------------------------------------------|----------|
+| `name` | Custom export name (default: `_` 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_.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_.go` - Client wrapper code (WASM build)
+- `nd_host__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/...
+```
diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go
new file mode 100644
index 000000000..275d57fd0
--- /dev/null
+++ b/plugins/cmd/ndpgen/integration_test.go
@@ -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
+ }
+}
diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go
new file mode 100644
index 000000000..4f1c6e44b
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/generator.go
@@ -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
+}
diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go
new file mode 100644
index 000000000..68dcedee9
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/generator_test.go
@@ -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)
+}
diff --git a/plugins/cmd/ndpgen/internal/internal_suite_test.go b/plugins/cmd/ndpgen/internal/internal_suite_test.go
new file mode 100644
index 000000000..5c7d27088
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/internal_suite_test.go
@@ -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")
+}
diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go
new file mode 100644
index 000000000..48ffdeb0e
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/parser.go
@@ -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"))
+}
diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go
new file mode 100644
index 000000000..98c2cfb58
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/parser_test.go
@@ -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"))
+ })
+ })
+})
diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl
new file mode 100644
index 000000000..9d060dcf1
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl
@@ -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}}
diff --git a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl
new file mode 100644
index 000000000..4ad6ae01e
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl
@@ -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}}
diff --git a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl
new file mode 100644
index 000000000..3bd86779d
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl
@@ -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,
+}
+{{- 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}}
diff --git a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl
new file mode 100644
index 000000000..ef723c634
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl
@@ -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}}
diff --git a/plugins/cmd/ndpgen/internal/templates/doc.go.tmpl b/plugins/cmd/ndpgen/internal/templates/doc.go.tmpl
new file mode 100644
index 000000000..e48dc3363
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/templates/doc.go.tmpl
@@ -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}}
diff --git a/plugins/cmd/ndpgen/internal/templates/go.mod.tmpl b/plugins/cmd/ndpgen/internal/templates/go.mod.tmpl
new file mode 100644
index 000000000..db230df3a
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/templates/go.mod.tmpl
@@ -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
diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl
new file mode 100644
index 000000000..4a961bd56
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl
@@ -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
+}
diff --git a/plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl
new file mode 100644
index 000000000..7edbb0f62
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl
@@ -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;
diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go
new file mode 100644
index 000000000..af5ccfb6b
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/types.go
@@ -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"
+ }
+ 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"
+ }
+}
diff --git a/plugins/cmd/ndpgen/main.go b/plugins/cmd/ndpgen/main.go
new file mode 100644
index 000000000..f86c04ef4
--- /dev/null
+++ b/plugins/cmd/ndpgen/main.go
@@ -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
+}
diff --git a/plugins/cmd/ndpgen/ndpgen_suite_test.go b/plugins/cmd/ndpgen/ndpgen_suite_test.go
new file mode 100644
index 000000000..543fe8876
--- /dev/null
+++ b/plugins/cmd/ndpgen/ndpgen_suite_test.go
@@ -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")
+}