From ebba3a2c46c874ce891ab8b0515fd7fb748616e9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 29 Dec 2025 21:24:07 -0500 Subject: [PATCH] feat(plugins): add initial implementation of the Navidrome Plugin Development Kit code generator - Pahse 1 Signed-off-by: Deluan --- plugins/cmd/ndpgen/README.md | 165 +++++ plugins/cmd/ndpgen/integration_test.go | 512 +++++++++++++ plugins/cmd/ndpgen/internal/generator.go | 305 ++++++++ plugins/cmd/ndpgen/internal/generator_test.go | 679 ++++++++++++++++++ .../ndpgen/internal/internal_suite_test.go | 13 + plugins/cmd/ndpgen/internal/parser.go | 474 ++++++++++++ plugins/cmd/ndpgen/internal/parser_test.go | 292 ++++++++ .../ndpgen/internal/templates/client.go.tmpl | 108 +++ .../ndpgen/internal/templates/client.py.tmpl | 95 +++ .../ndpgen/internal/templates/client.rs.tmpl | 103 +++ .../internal/templates/client_stub.go.tmpl | 56 ++ .../cmd/ndpgen/internal/templates/doc.go.tmpl | 49 ++ .../cmd/ndpgen/internal/templates/go.mod.tmpl | 5 + .../ndpgen/internal/templates/host.go.tmpl | 119 +++ .../cmd/ndpgen/internal/templates/lib.rs.tmpl | 43 ++ plugins/cmd/ndpgen/internal/types.go | 384 ++++++++++ plugins/cmd/ndpgen/main.go | 414 +++++++++++ plugins/cmd/ndpgen/ndpgen_suite_test.go | 13 + 18 files changed, 3829 insertions(+) create mode 100644 plugins/cmd/ndpgen/README.md create mode 100644 plugins/cmd/ndpgen/integration_test.go create mode 100644 plugins/cmd/ndpgen/internal/generator.go create mode 100644 plugins/cmd/ndpgen/internal/generator_test.go create mode 100644 plugins/cmd/ndpgen/internal/internal_suite_test.go create mode 100644 plugins/cmd/ndpgen/internal/parser.go create mode 100644 plugins/cmd/ndpgen/internal/parser_test.go create mode 100644 plugins/cmd/ndpgen/internal/templates/client.go.tmpl create mode 100644 plugins/cmd/ndpgen/internal/templates/client.py.tmpl create mode 100644 plugins/cmd/ndpgen/internal/templates/client.rs.tmpl create mode 100644 plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl create mode 100644 plugins/cmd/ndpgen/internal/templates/doc.go.tmpl create mode 100644 plugins/cmd/ndpgen/internal/templates/go.mod.tmpl create mode 100644 plugins/cmd/ndpgen/internal/templates/host.go.tmpl create mode 100644 plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl create mode 100644 plugins/cmd/ndpgen/internal/types.go create mode 100644 plugins/cmd/ndpgen/main.go create mode 100644 plugins/cmd/ndpgen/ndpgen_suite_test.go 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") +}