mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(plugins): add initial implementation of the Navidrome Plugin Development Kit code generator - Pahse 1
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
4f9d0a5016
commit
4e0cd7571b
165
plugins/cmd/ndpgen/README.md
Normal file
165
plugins/cmd/ndpgen/README.md
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# ndpgen
|
||||||
|
|
||||||
|
Navidrome Plugin Development Kit (PDK) code generator. It reads Go interface definitions with special annotations and generates client wrappers for WASM plugins.
|
||||||
|
|
||||||
|
This tool is the unified code generator that replaces `hostgen` and will eventually handle both host function wrappers and capability wrappers.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ndpgen -input <dir> -output <dir> [-package <name>] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
| Flag | Description | Default |
|
||||||
|
|--------------|----------------------------------------------------------------|----------------------|
|
||||||
|
| `-input` | Directory containing Go source files with annotated interfaces | Required |
|
||||||
|
| `-output` | Directory where generated files will be written | Same as input |
|
||||||
|
| `-package` | Package name for generated files | Inferred from output |
|
||||||
|
| `-v` | Verbose output | `false` |
|
||||||
|
| `-dry-run` | Parse and validate without writing files | `false` |
|
||||||
|
| `-host-only` | Generate only host function wrappers (capability support TBD) | `true` |
|
||||||
|
| `-go` | Generate Go client wrappers | `true`* |
|
||||||
|
| `-python` | Generate Python client wrappers | `false` |
|
||||||
|
| `-rust` | Generate Rust client wrappers | `false` |
|
||||||
|
|
||||||
|
\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./plugins/cmd/ndpgen \
|
||||||
|
-input ./plugins/host \
|
||||||
|
-output ./plugins/pdk/go/host
|
||||||
|
```
|
||||||
|
|
||||||
|
## Annotations
|
||||||
|
|
||||||
|
### `//nd:hostservice`
|
||||||
|
|
||||||
|
Marks an interface as a host service that will have wrappers generated.
|
||||||
|
|
||||||
|
```go
|
||||||
|
//nd:hostservice name=<ServiceName> permission=<permission>
|
||||||
|
type MyService interface { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description | Required |
|
||||||
|
|--------------|-----------------------------------------------------------------|----------|
|
||||||
|
| `name` | Service name used in generated type names and function prefixes | Yes |
|
||||||
|
| `permission` | Permission required by plugins to use this service | Yes |
|
||||||
|
|
||||||
|
### `//nd:hostfunc`
|
||||||
|
|
||||||
|
Marks a method within a host service interface for export to plugins.
|
||||||
|
|
||||||
|
```go
|
||||||
|
//nd:hostfunc [name=<export_name>]
|
||||||
|
MethodName(ctx context.Context, ...) (result Type, err error)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description | Required |
|
||||||
|
|-----------|-------------------------------------------------------------------------|----------|
|
||||||
|
| `name` | Custom export name (default: `<servicename>_<methodname>` in lowercase) | No |
|
||||||
|
|
||||||
|
## Input Format
|
||||||
|
|
||||||
|
Host service interfaces must follow these conventions:
|
||||||
|
|
||||||
|
1. **First parameter must be `context.Context`** - Required for all methods
|
||||||
|
2. **Last return value should be `error`** - For proper error handling
|
||||||
|
3. **Annotations must be on consecutive lines** - No blank comment lines between doc and annotation
|
||||||
|
|
||||||
|
### Example Interface
|
||||||
|
|
||||||
|
```go
|
||||||
|
package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// SubsonicAPIService provides access to Navidrome's Subsonic API.
|
||||||
|
// This documentation becomes part of the generated code.
|
||||||
|
//nd:hostservice name=SubsonicAPI permission=subsonicapi
|
||||||
|
type SubsonicAPIService interface {
|
||||||
|
// Call executes a Subsonic API request and returns the response.
|
||||||
|
//nd:hostfunc
|
||||||
|
Call(ctx context.Context, uri string) (response string, err error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generated Output
|
||||||
|
|
||||||
|
### Go Client Library (Go/TinyGo WASM)
|
||||||
|
|
||||||
|
Generated files are named `nd_host_<servicename>.go` (lowercase) and placed directly in the output directory. The output directory becomes a complete Go module (`github.com/navidrome/navidrome/plugins/pdk/go/host`) with package name `ndpdk`, intended for import by Navidrome plugins built with TinyGo.
|
||||||
|
|
||||||
|
The generator creates:
|
||||||
|
- `nd_host_<servicename>.go` - Client wrapper code (WASM build)
|
||||||
|
- `nd_host_<servicename>_stub.go` - Stub code for non-WASM platforms
|
||||||
|
- `doc.go` - Package documentation listing all available services
|
||||||
|
- `go.mod` - Go module file with required dependencies
|
||||||
|
|
||||||
|
Each service file includes:
|
||||||
|
|
||||||
|
- `// Code generated by ndpgen. DO NOT EDIT.` header
|
||||||
|
- Required imports (`encoding/json`, `errors`, `github.com/extism/go-pdk`)
|
||||||
|
- `//go:wasmimport` declarations for each host function
|
||||||
|
- Response struct types and any struct definitions from the service
|
||||||
|
- Wrapper functions that handle memory allocation and JSON parsing
|
||||||
|
|
||||||
|
### Python Client Library
|
||||||
|
|
||||||
|
When using `-python`, Python client files are generated in a `python/` subdirectory.
|
||||||
|
|
||||||
|
### Rust Client Library
|
||||||
|
|
||||||
|
When using `-rust`, Rust client files are generated in a `rust/` subdirectory.
|
||||||
|
|
||||||
|
## Supported Types
|
||||||
|
|
||||||
|
ndpgen supports these Go types in method signatures:
|
||||||
|
|
||||||
|
| Type | JSON Representation |
|
||||||
|
|-------------------------------|------------------------------------------|
|
||||||
|
| `string`, `int`, `bool`, etc. | Native JSON types |
|
||||||
|
| `[]T` (slices) | JSON arrays |
|
||||||
|
| `map[K]V` (maps) | JSON objects |
|
||||||
|
| `*T` (pointers) | Nullable fields |
|
||||||
|
| `interface{}` / `any` | Converts to `any` |
|
||||||
|
| Custom structs | JSON objects (must be JSON-serializable) |
|
||||||
|
|
||||||
|
### Multiple Return Values
|
||||||
|
|
||||||
|
Methods can return multiple values (plus error):
|
||||||
|
|
||||||
|
```go
|
||||||
|
//nd:hostfunc
|
||||||
|
Search(ctx context.Context, query string) (results []string, total int, hasMore bool, err error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Generates:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ServiceSearchResponse struct {
|
||||||
|
Results []string `json:"results,omitempty"`
|
||||||
|
Total int `json:"total,omitempty"`
|
||||||
|
HasMore bool `json:"hasMore,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration from hostgen
|
||||||
|
|
||||||
|
The `ndpgen` tool replaces `hostgen` for plugin development. Key differences:
|
||||||
|
|
||||||
|
1. **Output structure**: Files go directly in the output directory (not a `go/` subdirectory)
|
||||||
|
2. **Package name**: Generated code uses `ndpdk` instead of `ndhost`
|
||||||
|
3. **Module path**: Uses `github.com/navidrome/navidrome/plugins/pdk/go/host` instead of `github.com/navidrome/navidrome/plugins/host/go`
|
||||||
|
4. **Focus**: `ndpgen` generates only client-side code (plugin SDK), not host-side code
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./plugins/cmd/ndpgen/...
|
||||||
|
```
|
||||||
512
plugins/cmd/ndpgen/integration_test.go
Normal file
512
plugins/cmd/ndpgen/integration_test.go
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/format"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeGeneratedCode normalizes differences between hostgen and ndpgen output
|
||||||
|
// so we can reuse hostgen's testdata for ndpgen verification.
|
||||||
|
func normalizeGeneratedCode(code string) string {
|
||||||
|
// Replace tool name references
|
||||||
|
code = strings.ReplaceAll(code, "Code generated by hostgen.", "Code generated by ndpgen.")
|
||||||
|
// Replace package names
|
||||||
|
code = strings.ReplaceAll(code, "package ndhost", "package ndpdk")
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("ndpgen CLI", Ordered, func() {
|
||||||
|
var (
|
||||||
|
testDir string
|
||||||
|
outputDir string
|
||||||
|
ndpgenBin string
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeAll(func() {
|
||||||
|
// Set testdata directory (reuse hostgen's testdata)
|
||||||
|
testdataDir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "hostgen", "testdata")
|
||||||
|
|
||||||
|
// Build the ndpgen binary
|
||||||
|
ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test")
|
||||||
|
cmd := exec.Command("go", "build", "-o", ndpgenBin, ".")
|
||||||
|
cmd.Dir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "ndpgen")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Failed to build ndpgen: %s", output)
|
||||||
|
DeferCleanup(func() {
|
||||||
|
os.Remove(ndpgenBin)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
var err error
|
||||||
|
testDir, err = os.MkdirTemp("", "ndpgen-test-input-*")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
outputDir, err = os.MkdirTemp("", "ndpgen-test-output-*")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
os.RemoveAll(testDir)
|
||||||
|
os.RemoveAll(outputDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("CLI flags and behavior", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
serviceCode := `package testpkg
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
DoAction(ctx context.Context, input string) (output string, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("supports verbose mode", func() {
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
|
||||||
|
outputStr := string(output)
|
||||||
|
Expect(outputStr).To(ContainSubstring("Input directory:"))
|
||||||
|
Expect(outputStr).To(ContainSubstring("Output directory:"))
|
||||||
|
Expect(outputStr).To(ContainSubstring("Found 1 host service(s)"))
|
||||||
|
Expect(outputStr).To(ContainSubstring("Generated"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("supports dry-run mode", func() {
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-dry-run")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
|
||||||
|
Expect(string(output)).To(ContainSubstring("func TestDoAction("))
|
||||||
|
Expect(filepath.Join(outputDir, "nd_host_test.go")).ToNot(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("infers package name from output directory", func() {
|
||||||
|
customOutput, err := os.MkdirTemp("", "mypkg")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer os.RemoveAll(customOutput)
|
||||||
|
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", customOutput)
|
||||||
|
_, err = cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(customOutput, "nd_host_test.go"))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(content)).To(ContainSubstring("package mypkg"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for invalid input directory", func() {
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", "/nonexistent/path")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(string(output)).To(ContainSubstring("parsing source files"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles no annotated services gracefully", func() {
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte("package testpkg\n"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-v")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
Expect(string(output)).To(ContainSubstring("No host services found"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("generates separate files for multiple services", func() {
|
||||||
|
// Remove service.go created by BeforeEach
|
||||||
|
Expect(os.Remove(filepath.Join(testDir, "service.go"))).To(Succeed())
|
||||||
|
|
||||||
|
service1 := `package testpkg
|
||||||
|
import "context"
|
||||||
|
//nd:hostservice name=ServiceA permission=a
|
||||||
|
type ServiceA interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
MethodA(ctx context.Context) error
|
||||||
|
}
|
||||||
|
`
|
||||||
|
service2 := `package testpkg
|
||||||
|
import "context"
|
||||||
|
//nd:hostservice name=ServiceB permission=b
|
||||||
|
type ServiceB interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
MethodB(ctx context.Context) error
|
||||||
|
}
|
||||||
|
`
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "a.go"), []byte(service1), 0600)).To(Succeed())
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "b.go"), []byte(service2), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
Expect(string(output)).To(ContainSubstring("Found 2 host service(s)"))
|
||||||
|
|
||||||
|
Expect(filepath.Join(outputDir, "nd_host_servicea.go")).To(BeAnExistingFile())
|
||||||
|
Expect(filepath.Join(outputDir, "nd_host_serviceb.go")).To(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("generates Go client code by default", func() {
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
|
||||||
|
// Client code in output directory
|
||||||
|
Expect(filepath.Join(outputDir, "nd_host_test.go")).To(BeAnExistingFile())
|
||||||
|
// Stub file also generated
|
||||||
|
Expect(filepath.Join(outputDir, "nd_host_test_stub.go")).To(BeAnExistingFile())
|
||||||
|
// doc.go and go.mod also generated
|
||||||
|
Expect(filepath.Join(outputDir, "doc.go")).To(BeAnExistingFile())
|
||||||
|
Expect(filepath.Join(outputDir, "go.mod")).To(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("code generation", func() {
|
||||||
|
DescribeTable("generates correct client output",
|
||||||
|
func(serviceFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) {
|
||||||
|
serviceCode := readTestdata(serviceFile)
|
||||||
|
goClientExpected := readTestdata(goClientExpectedFile)
|
||||||
|
pyClientExpected := readTestdata(pyClientExpectedFile)
|
||||||
|
rsClientExpected := readTestdata(rsClientExpectedFile)
|
||||||
|
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Generate all client code (Go, Python, Rust)
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python", "-rust")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
|
||||||
|
// Verify Go client code
|
||||||
|
entries, err := os.ReadDir(outputDir)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
var goClientFiles []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() != "python" && e.Name() != "rust" && !e.IsDir() &&
|
||||||
|
!strings.HasSuffix(e.Name(), "_stub.go") &&
|
||||||
|
e.Name() != "doc.go" && e.Name() != "go.mod" {
|
||||||
|
goClientFiles = append(goClientFiles, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expect(goClientFiles).To(HaveLen(1), "Expected exactly one Go client file, got: %v", goClientFiles)
|
||||||
|
|
||||||
|
goClientActual, err := os.ReadFile(filepath.Join(outputDir, goClientFiles[0]))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
formattedGoClientActual, err := format.Source(goClientActual)
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Generated Go client code is not valid Go:\n%s", goClientActual)
|
||||||
|
|
||||||
|
// Normalize expected code to match ndpgen output format
|
||||||
|
normalizedExpected := normalizeGeneratedCode(goClientExpected)
|
||||||
|
formattedGoClientExpected, err := format.Source([]byte(normalizedExpected))
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Expected Go client code is not valid Go")
|
||||||
|
|
||||||
|
Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch")
|
||||||
|
|
||||||
|
// Verify Python client code
|
||||||
|
pythonDir := filepath.Join(outputDir, "python")
|
||||||
|
pyClientEntries, err := os.ReadDir(pythonDir)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file")
|
||||||
|
|
||||||
|
pyClientActual, err := os.ReadFile(filepath.Join(pythonDir, pyClientEntries[0].Name()))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch")
|
||||||
|
|
||||||
|
// Verify Rust client code
|
||||||
|
rustDir := filepath.Join(outputDir, "rust")
|
||||||
|
rsClientEntries, err := os.ReadDir(rustDir)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rsClientEntries).To(HaveLen(2), "Expected Rust client file and lib.rs")
|
||||||
|
|
||||||
|
// Find the client file (not lib.rs)
|
||||||
|
var rsClientName string
|
||||||
|
for _, entry := range rsClientEntries {
|
||||||
|
if entry.Name() != "lib.rs" {
|
||||||
|
rsClientName = entry.Name()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expect(rsClientName).ToNot(BeEmpty(), "Expected to find Rust client file")
|
||||||
|
|
||||||
|
rsClientActual, err := os.ReadFile(filepath.Join(rustDir, rsClientName))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(string(rsClientActual)).To(Equal(rsClientExpected), "Rust client code mismatch")
|
||||||
|
},
|
||||||
|
|
||||||
|
Entry("simple string params",
|
||||||
|
"echo_service.go", "echo_client_expected.go", "echo_client_expected.py", "echo_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("multiple simple params (int32)",
|
||||||
|
"math_service.go", "math_client_expected.go", "math_client_expected.py", "math_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("struct param with request type",
|
||||||
|
"store_service.go", "store_client_expected.go", "store_client_expected.py", "store_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("mixed simple and complex params",
|
||||||
|
"list_service.go", "list_client_expected.go", "list_client_expected.py", "list_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("method without error",
|
||||||
|
"counter_service.go", "counter_client_expected.go", "counter_client_expected.py", "counter_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("no params, error only",
|
||||||
|
"ping_service.go", "ping_client_expected.go", "ping_client_expected.py", "ping_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("map and interface types",
|
||||||
|
"meta_service.go", "meta_client_expected.go", "meta_client_expected.py", "meta_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("pointer types",
|
||||||
|
"users_service.go", "users_client_expected.go", "users_client_expected.py", "users_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("multiple returns",
|
||||||
|
"search_service.go", "search_client_expected.go", "search_client_expected.py", "search_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("bytes",
|
||||||
|
"codec_service.go", "codec_client_expected.go", "codec_client_expected.py", "codec_client_expected.rs"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("generates compilable client code for comprehensive service", func() {
|
||||||
|
serviceCode := readTestdata("comprehensive_service.go")
|
||||||
|
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Generate client code
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
|
||||||
|
|
||||||
|
// Read generated client code
|
||||||
|
entries, err := os.ReadDir(outputDir)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Find the client file
|
||||||
|
var clientFileName string
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if name != "doc.go" && name != "go.mod" && !strings.HasSuffix(name, "_stub.go") && strings.HasSuffix(name, ".go") {
|
||||||
|
clientFileName = name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expect(clientFileName).ToNot(BeEmpty(), "Expected to find Go client file")
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(outputDir, clientFileName))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Verify key expected content
|
||||||
|
contentStr := string(content)
|
||||||
|
// Should have wasmimport declarations for all methods
|
||||||
|
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_simpleparams"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_structparam"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noerror"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparams"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparamsnoreturns"))
|
||||||
|
|
||||||
|
// Should have response types for methods with complex returns
|
||||||
|
Expect(contentStr).To(ContainSubstring("type ComprehensiveSimpleParamsResponse struct"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("type ComprehensiveMultipleReturnsResponse struct"))
|
||||||
|
|
||||||
|
// Should have wrapper functions
|
||||||
|
Expect(contentStr).To(ContainSubstring("func ComprehensiveSimpleParams("))
|
||||||
|
Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParams()"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParamsNoReturns()"))
|
||||||
|
|
||||||
|
// Create a plugin directory with proper import structure
|
||||||
|
pluginDir := filepath.Join(outputDir, "plugin")
|
||||||
|
Expect(os.MkdirAll(pluginDir, 0750)).To(Succeed())
|
||||||
|
|
||||||
|
// Create go.mod for the plugin that imports the generated library
|
||||||
|
goMod := fmt.Sprintf(`module testplugin
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/navidrome/navidrome/plugins/pdk/go/host v0.0.0
|
||||||
|
|
||||||
|
replace github.com/navidrome/navidrome/plugins/pdk/go/host => %s
|
||||||
|
`, outputDir)
|
||||||
|
Expect(os.WriteFile(filepath.Join(pluginDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Add a simple main function that imports and uses the ndpdk package
|
||||||
|
mainGo := `package main
|
||||||
|
|
||||||
|
import ndpdk "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
|
||||||
|
// Use some functions to ensure import is not unused
|
||||||
|
var _ = ndpdk.ComprehensiveNoParams
|
||||||
|
`
|
||||||
|
Expect(os.WriteFile(filepath.Join(pluginDir, "main.go"), []byte(mainGo), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Tidy dependencies for the generated go library
|
||||||
|
goTidyLibCmd := exec.Command("go", "mod", "tidy")
|
||||||
|
goTidyLibCmd.Dir = outputDir
|
||||||
|
goTidyLibOutput, err := goTidyLibCmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "go mod tidy (library) failed: %s", goTidyLibOutput)
|
||||||
|
|
||||||
|
// Tidy dependencies for the plugin
|
||||||
|
goTidyCmd := exec.Command("go", "mod", "tidy")
|
||||||
|
goTidyCmd.Dir = pluginDir
|
||||||
|
goTidyOutput, err := goTidyCmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "go mod tidy (plugin) failed: %s", goTidyOutput)
|
||||||
|
|
||||||
|
// Build as WASM plugin - this validates the client code compiles correctly
|
||||||
|
buildCmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", "plugin.wasm", ".")
|
||||||
|
buildCmd.Dir = pluginDir
|
||||||
|
buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
|
||||||
|
buildOutput, err := buildCmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "WASM build failed: %s", buildOutput)
|
||||||
|
|
||||||
|
// Verify .wasm file was created
|
||||||
|
Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("generates Python client code with -python flag", func() {
|
||||||
|
serviceCode := `package testpkg
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
DoAction(ctx context.Context, input string) (output string, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
|
||||||
|
// Verify Python client code exists
|
||||||
|
pythonDir := filepath.Join(outputDir, "python")
|
||||||
|
Expect(pythonDir).To(BeADirectory())
|
||||||
|
|
||||||
|
pythonFile := filepath.Join(pythonDir, "nd_host_test.py")
|
||||||
|
Expect(pythonFile).To(BeAnExistingFile())
|
||||||
|
|
||||||
|
content, err := os.ReadFile(pythonFile)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
contentStr := string(content)
|
||||||
|
Expect(contentStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
|
||||||
|
Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):"))
|
||||||
|
Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`))
|
||||||
|
Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("generates both Go and Python client code with -go -python flags", func() {
|
||||||
|
serviceCode := `package testpkg
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
DoAction(ctx context.Context, input string) (output string, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
|
||||||
|
// Verify both Go and Python client code exist
|
||||||
|
Expect(filepath.Join(outputDir, "nd_host_test.go")).To(BeAnExistingFile())
|
||||||
|
|
||||||
|
pythonDir := filepath.Join(outputDir, "python")
|
||||||
|
Expect(pythonDir).To(BeADirectory())
|
||||||
|
Expect(filepath.Join(pythonDir, "nd_host_test.py")).To(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("generates Python code with dataclass for multi-value returns", func() {
|
||||||
|
serviceCode := `package testpkg
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Cache permission=cache
|
||||||
|
type CacheService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
GetString(ctx context.Context, key string) (value string, exists bool, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(outputDir, "python", "nd_host_cache.py"))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
contentStr := string(content)
|
||||||
|
Expect(contentStr).To(ContainSubstring("@dataclass"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("value: str"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("exists: bool"))
|
||||||
|
Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("generates Python code for methods with no parameters", func() {
|
||||||
|
serviceCode := `package testpkg
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
Ping(ctx context.Context) (status string, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(outputDir, "python", "nd_host_test.py"))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
contentStr := string(content)
|
||||||
|
Expect(contentStr).To(ContainSubstring("def test_ping() -> str:"))
|
||||||
|
Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var testdataDir string
|
||||||
|
|
||||||
|
func readTestdata(filename string) string {
|
||||||
|
content, err := os.ReadFile(filepath.Join(testdataDir, filename))
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "Failed to read testdata file: %s", filename)
|
||||||
|
return string(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustGetWd(t FullGinkgoTInterface) string {
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
t.Fatal("could not find project root")
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
305
plugins/cmd/ndpgen/internal/generator.go
Normal file
305
plugins/cmd/ndpgen/internal/generator.go
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.tmpl
|
||||||
|
var templatesFS embed.FS
|
||||||
|
|
||||||
|
// hostFuncMap returns the template functions for host code generation.
|
||||||
|
func hostFuncMap(svc Service) template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
"lower": strings.ToLower,
|
||||||
|
"title": strings.Title,
|
||||||
|
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
|
||||||
|
"requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
|
||||||
|
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientFuncMap returns the template functions for client code generation.
|
||||||
|
func clientFuncMap(svc Service) template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
"lower": strings.ToLower,
|
||||||
|
"title": strings.Title,
|
||||||
|
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
|
||||||
|
"requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
|
||||||
|
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
|
||||||
|
"formatDoc": formatDoc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pythonFuncMap returns the template functions for Python client code generation.
|
||||||
|
func pythonFuncMap(svc Service) template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
"lower": strings.ToLower,
|
||||||
|
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
|
||||||
|
"pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) },
|
||||||
|
"pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) },
|
||||||
|
"pythonDefault": pythonDefaultValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateHost generates the host function wrapper code for a service.
|
||||||
|
func GenerateHost(svc Service, pkgName string) ([]byte, error) {
|
||||||
|
tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading host template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("host").Funcs(hostFuncMap(svc)).Parse(string(tmplContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{
|
||||||
|
Package: pkgName,
|
||||||
|
Service: svc,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateClientGo generates client wrapper code for plugins to call host functions.
|
||||||
|
func GenerateClientGo(svc Service, pkgName string) ([]byte, error) {
|
||||||
|
tmplContent, err := templatesFS.ReadFile("templates/client.go.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading client template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("client").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{
|
||||||
|
Package: pkgName,
|
||||||
|
Service: svc,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateClientGoStub generates stub code for non-WASM platforms.
|
||||||
|
// These stubs provide type definitions and function signatures for IDE support,
|
||||||
|
// but panic at runtime since host functions are only available in WASM plugins.
|
||||||
|
func GenerateClientGoStub(svc Service, pkgName string) ([]byte, error) {
|
||||||
|
tmplContent, err := templatesFS.ReadFile("templates/client_stub.go.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading client stub template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("client_stub").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{
|
||||||
|
Package: pkgName,
|
||||||
|
Service: svc,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateData struct {
|
||||||
|
Package string
|
||||||
|
Service Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDoc formats a documentation string for Go comments.
|
||||||
|
// It prefixes each line with "// " and trims trailing whitespace.
|
||||||
|
func formatDoc(doc string) string {
|
||||||
|
if doc == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(doc), "\n")
|
||||||
|
var result []string
|
||||||
|
for _, line := range lines {
|
||||||
|
result = append(result, "// "+strings.TrimRight(line, " \t"))
|
||||||
|
}
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateClientPython generates Python client wrapper code for plugins.
|
||||||
|
func GenerateClientPython(svc Service) ([]byte, error) {
|
||||||
|
tmplContent, err := templatesFS.ReadFile("templates/client.py.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading Python client template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{
|
||||||
|
Service: svc,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pythonDefaultValue returns a Python default value for response.get() calls.
|
||||||
|
func pythonDefaultValue(p Param) string {
|
||||||
|
switch p.Type {
|
||||||
|
case "string":
|
||||||
|
return `, ""`
|
||||||
|
case "int", "int32", "int64":
|
||||||
|
return ", 0"
|
||||||
|
case "float32", "float64":
|
||||||
|
return ", 0.0"
|
||||||
|
case "bool":
|
||||||
|
return ", False"
|
||||||
|
case "[]byte":
|
||||||
|
return ", b\"\""
|
||||||
|
default:
|
||||||
|
return ", None"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rustFuncMap returns the template functions for Rust client code generation.
|
||||||
|
func rustFuncMap(svc Service) template.FuncMap {
|
||||||
|
knownStructs := svc.KnownStructs()
|
||||||
|
return template.FuncMap{
|
||||||
|
"lower": strings.ToLower,
|
||||||
|
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
|
||||||
|
"requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
|
||||||
|
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
|
||||||
|
"rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) },
|
||||||
|
"rustDocComment": RustDocComment,
|
||||||
|
"rustType": func(p Param) string { return p.RustTypeWithStructs(knownStructs) },
|
||||||
|
"rustParamType": func(p Param) string { return p.RustParamTypeWithStructs(knownStructs) },
|
||||||
|
"fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateClientRust generates Rust client wrapper code for plugins.
|
||||||
|
func GenerateClientRust(svc Service) ([]byte, error) {
|
||||||
|
tmplContent, err := templatesFS.ReadFile("templates/client.rs.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading Rust client template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("client_rs").Funcs(rustFuncMap(svc)).Parse(string(tmplContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{
|
||||||
|
Service: svc,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstLine returns the first line of a multi-line string, with the first word removed.
|
||||||
|
func firstLine(s string) string {
|
||||||
|
line := s
|
||||||
|
if idx := strings.Index(s, "\n"); idx >= 0 {
|
||||||
|
line = s[:idx]
|
||||||
|
}
|
||||||
|
// Remove the first word (service name like "ArtworkService")
|
||||||
|
if idx := strings.Index(line, " "); idx >= 0 {
|
||||||
|
line = line[idx+1:]
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRustLib generates the lib.rs file that exposes all service modules.
|
||||||
|
func GenerateRustLib(services []Service) ([]byte, error) {
|
||||||
|
tmplContent, err := templatesFS.ReadFile("templates/lib.rs.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading Rust lib template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("lib_rs").Funcs(template.FuncMap{
|
||||||
|
"lower": strings.ToLower,
|
||||||
|
"firstLine": firstLine,
|
||||||
|
}).Parse(string(tmplContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Services []Service
|
||||||
|
}{
|
||||||
|
Services: services,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateGoDoc generates the doc.go file that provides package documentation.
|
||||||
|
func GenerateGoDoc(services []Service, pkgName string) ([]byte, error) {
|
||||||
|
tmplContent, err := templatesFS.ReadFile("templates/doc.go.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading Go doc template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("doc_go").Funcs(template.FuncMap{
|
||||||
|
"firstLine": firstLine,
|
||||||
|
}).Parse(string(tmplContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Package string
|
||||||
|
Services []Service
|
||||||
|
}{
|
||||||
|
Package: pkgName,
|
||||||
|
Services: services,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateGoMod generates the go.mod file for the Go client library.
|
||||||
|
func GenerateGoMod() ([]byte, error) {
|
||||||
|
tmplContent, err := templatesFS.ReadFile("templates/go.mod.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading go.mod template: %w", err)
|
||||||
|
}
|
||||||
|
return tmplContent, nil
|
||||||
|
}
|
||||||
679
plugins/cmd/ndpgen/internal/generator_test.go
Normal file
679
plugins/cmd/ndpgen/internal/generator_test.go
Normal file
@ -0,0 +1,679 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/format"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Generator", func() {
|
||||||
|
Describe("GenerateHost", func() {
|
||||||
|
It("should generate valid Go code for a simple service with strings", func() {
|
||||||
|
// All methods use JSON request/response types
|
||||||
|
svc := Service{
|
||||||
|
Name: "SubsonicAPI",
|
||||||
|
Permission: "subsonicapi",
|
||||||
|
Interface: "SubsonicAPIService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "Call",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("uri", "string")},
|
||||||
|
Returns: []Param{NewParam("response", "string")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Verify the code is valid Go
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Check for generated header
|
||||||
|
Expect(codeStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
|
||||||
|
|
||||||
|
// Check for package declaration
|
||||||
|
Expect(codeStr).To(ContainSubstring("package host"))
|
||||||
|
|
||||||
|
// All methods now use request type for JSON protocol
|
||||||
|
Expect(codeStr).To(ContainSubstring("type SubsonicAPICallRequest struct"))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`Uri string `))
|
||||||
|
|
||||||
|
// Response type with error handling
|
||||||
|
Expect(codeStr).To(ContainSubstring("type SubsonicAPICallResponse struct"))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`Response string `))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`Error string `))
|
||||||
|
|
||||||
|
// Check for registration function
|
||||||
|
Expect(codeStr).To(ContainSubstring("func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService)"))
|
||||||
|
|
||||||
|
// Check for host function name
|
||||||
|
Expect(codeStr).To(ContainSubstring(`"subsonicapi_call"`))
|
||||||
|
|
||||||
|
// Check for JSON unmarshal (all methods use JSON now)
|
||||||
|
Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should generate code for methods without parameters", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "NoParams",
|
||||||
|
HasError: true,
|
||||||
|
Returns: []Param{NewParam("result", "string")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
// Methods without params don't need a request type - no params to serialize
|
||||||
|
Expect(codeStr).NotTo(ContainSubstring("type TestNoParamsRequest struct"))
|
||||||
|
// But still uses PTR input/output for consistency
|
||||||
|
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should generate code for methods without return values", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "NoReturn",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("input", "string")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should generate code for multiple methods", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Scheduler",
|
||||||
|
Permission: "scheduler",
|
||||||
|
Interface: "SchedulerService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "ScheduleRecurring",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("cronExpression", "string")},
|
||||||
|
Returns: []Param{NewParam("scheduleID", "string")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "ScheduleOneTime",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("delaySeconds", "int32")},
|
||||||
|
Returns: []Param{NewParam("scheduleID", "string")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "CancelSchedule",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("scheduleID", "string")},
|
||||||
|
Returns: []Param{NewParam("canceled", "bool")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
Expect(codeStr).To(ContainSubstring("scheduler_schedulerecurring"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("scheduler_scheduleonetime"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("scheduler_cancelschedule"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle multiple simple parameters with JSON", func() {
|
||||||
|
// All params use JSON - single PTR input
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "MultiParam",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{
|
||||||
|
NewParam("name", "string"),
|
||||||
|
NewParam("count", "int32"),
|
||||||
|
NewParam("enabled", "bool"),
|
||||||
|
},
|
||||||
|
Returns: []Param{NewParam("result", "string")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
// All methods use request type with JSON protocol
|
||||||
|
Expect(codeStr).To(ContainSubstring("type TestMultiParamRequest struct"))
|
||||||
|
// Check for JSON unmarshal (all methods use JSON now)
|
||||||
|
Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
|
||||||
|
// Check that input/output ValueType both use PTR (JSON)
|
||||||
|
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should use single PTR for mixed simple and complex params", func() {
|
||||||
|
// When any param needs JSON, all are bundled into one request struct
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "MixedParam",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{
|
||||||
|
NewParam("id", "string"), // simple (PTR for string)
|
||||||
|
NewParam("tags", "[]string"), // complex - needs JSON
|
||||||
|
},
|
||||||
|
Returns: []Param{NewParam("count", "int32")}, // simple
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
// Request type IS needed because of complex param
|
||||||
|
Expect(codeStr).To(ContainSubstring("type TestMixedParamRequest struct"))
|
||||||
|
// When using request type, only ONE PTR for input (the JSON request)
|
||||||
|
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should generate proper JSON tags for complex types", func() {
|
||||||
|
// Complex types (structs, slices, maps) need JSON serialization
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "Method",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("inputValue", "[]string")}, // slice needs JSON
|
||||||
|
Returns: []Param{NewParam("outputValue", "map[string]string")}, // map needs JSON
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
// Complex params need request type with JSON tags
|
||||||
|
Expect(codeStr).To(ContainSubstring(`json:"inputValue"`))
|
||||||
|
// Complex returns need response type with JSON tags
|
||||||
|
Expect(codeStr).To(ContainSubstring(`json:"outputValue,omitempty"`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should include required imports", func() {
|
||||||
|
// Service with complex types needs JSON import
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "Method",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("data", "MyStruct")}, // struct needs JSON
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
Expect(codeStr).To(ContainSubstring(`"context"`))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should always include json import for JSON protocol", func() {
|
||||||
|
// All services use JSON protocol, so json import is always needed
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "Method",
|
||||||
|
Params: []Param{NewParam("count", "int32")},
|
||||||
|
Returns: []Param{NewParam("result", "int64")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
Expect(codeStr).To(ContainSubstring(`"context"`))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("toJSONName", func() {
|
||||||
|
It("should convert to camelCase matching Rust serde behavior", func() {
|
||||||
|
Expect(toJSONName("InputValue")).To(Equal("inputValue"))
|
||||||
|
Expect(toJSONName("URI")).To(Equal("uri"))
|
||||||
|
Expect(toJSONName("id")).To(Equal("id"))
|
||||||
|
Expect(toJSONName("ID")).To(Equal("id"))
|
||||||
|
Expect(toJSONName("ConnectionID")).To(Equal("connectionId"))
|
||||||
|
Expect(toJSONName("NewConnectionID")).To(Equal("newConnectionId"))
|
||||||
|
Expect(toJSONName("XMLHTTPRequest")).To(Equal("xmlhttpRequest"))
|
||||||
|
Expect(toJSONName("APIKey")).To(Equal("apiKey"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle empty string", func() {
|
||||||
|
Expect(toJSONName("")).To(Equal(""))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("NewParam", func() {
|
||||||
|
It("should create param with auto-generated JSON name", func() {
|
||||||
|
p := NewParam("MyParam", "string")
|
||||||
|
Expect(p.Name).To(Equal("MyParam"))
|
||||||
|
Expect(p.Type).To(Equal("string"))
|
||||||
|
Expect(p.JSONName).To(Equal("myParam"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Python type and name helpers", func() {
|
||||||
|
Describe("ToPythonType", func() {
|
||||||
|
It("should map Go types to Python types", func() {
|
||||||
|
Expect(ToPythonType("string")).To(Equal("str"))
|
||||||
|
Expect(ToPythonType("int")).To(Equal("int"))
|
||||||
|
Expect(ToPythonType("int32")).To(Equal("int"))
|
||||||
|
Expect(ToPythonType("int64")).To(Equal("int"))
|
||||||
|
Expect(ToPythonType("float32")).To(Equal("float"))
|
||||||
|
Expect(ToPythonType("float64")).To(Equal("float"))
|
||||||
|
Expect(ToPythonType("bool")).To(Equal("bool"))
|
||||||
|
Expect(ToPythonType("[]byte")).To(Equal("bytes"))
|
||||||
|
Expect(ToPythonType("unknown")).To(Equal("Any"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ToSnakeCase", func() {
|
||||||
|
It("should convert PascalCase to snake_case", func() {
|
||||||
|
Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
|
||||||
|
Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
|
||||||
|
Expect(ToSnakeCase("simple")).To(Equal("simple"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle acronyms correctly", func() {
|
||||||
|
Expect(ToSnakeCase("ID")).To(Equal("id"))
|
||||||
|
Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id"))
|
||||||
|
Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id"))
|
||||||
|
Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser"))
|
||||||
|
Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Method.PythonFunctionName", func() {
|
||||||
|
It("should generate snake_case function name with service prefix", func() {
|
||||||
|
m := Method{Name: "GetString"}
|
||||||
|
Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Param.PythonType", func() {
|
||||||
|
It("should return Python type for parameter", func() {
|
||||||
|
p := NewParam("value", "string")
|
||||||
|
Expect(p.PythonType()).To(Equal("str"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Param.PythonName", func() {
|
||||||
|
It("should return snake_case name for parameter", func() {
|
||||||
|
p := NewParam("ttlSeconds", "int64")
|
||||||
|
Expect(p.PythonName()).To(Equal("ttl_seconds"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GenerateClientPython", func() {
|
||||||
|
It("should generate valid Python code for a simple service", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "SubsonicAPI",
|
||||||
|
Permission: "subsonicapi",
|
||||||
|
Interface: "SubsonicAPIService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "Call",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("uri", "string")},
|
||||||
|
Returns: []Param{NewParam("responseJSON", "string")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateClientPython(svc)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Check for generated header
|
||||||
|
Expect(codeStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
|
||||||
|
|
||||||
|
// Check for imports
|
||||||
|
Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("import extism"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("import json"))
|
||||||
|
|
||||||
|
// Check for exception class
|
||||||
|
Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):"))
|
||||||
|
|
||||||
|
// Check for raw import function
|
||||||
|
Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`))
|
||||||
|
Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:"))
|
||||||
|
|
||||||
|
// Check for wrapper function with type hints
|
||||||
|
Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:"))
|
||||||
|
|
||||||
|
// Check for error handling
|
||||||
|
Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response["))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should generate dataclass for multi-value returns", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Cache",
|
||||||
|
Permission: "cache",
|
||||||
|
Interface: "CacheService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "GetString",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("key", "string")},
|
||||||
|
Returns: []Param{
|
||||||
|
NewParam("value", "string"),
|
||||||
|
NewParam("exists", "bool"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateClientPython(svc)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Check for dataclass
|
||||||
|
Expect(codeStr).To(ContainSubstring("@dataclass"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("value: str"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("exists: bool"))
|
||||||
|
|
||||||
|
// Check that function returns dataclass
|
||||||
|
Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("return CacheGetStringResult("))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle methods with no parameters", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "NoParams",
|
||||||
|
HasError: true,
|
||||||
|
Returns: []Param{NewParam("result", "string")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateClientPython(svc)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Function with no params
|
||||||
|
Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:"))
|
||||||
|
// Empty request
|
||||||
|
Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle methods with no return values", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "NoReturn",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("input", "string")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateClientPython(svc)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Function returns None
|
||||||
|
Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should generate correct Python defaults for different types", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Test",
|
||||||
|
Permission: "test",
|
||||||
|
Interface: "TestService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "AllTypes",
|
||||||
|
HasError: true,
|
||||||
|
Returns: []Param{
|
||||||
|
NewParam("strVal", "string"),
|
||||||
|
NewParam("intVal", "int64"),
|
||||||
|
NewParam("floatVal", "float64"),
|
||||||
|
NewParam("boolVal", "bool"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateClientPython(svc)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Check defaults in response.get() calls
|
||||||
|
Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GenerateGoDoc", func() {
|
||||||
|
It("should generate valid doc.go content for multiple services", func() {
|
||||||
|
services := []Service{
|
||||||
|
{
|
||||||
|
Name: "Cache",
|
||||||
|
Permission: "cache",
|
||||||
|
Interface: "CacheService",
|
||||||
|
Doc: "CacheService provides temporary key-value storage with TTL.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Scheduler",
|
||||||
|
Permission: "scheduler",
|
||||||
|
Interface: "SchedulerService",
|
||||||
|
Doc: "SchedulerService manages scheduled tasks.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateGoDoc(services, "ndpdk")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Verify it's valid Go code
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Check for generated header
|
||||||
|
Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
|
||||||
|
|
||||||
|
// Check for package declaration
|
||||||
|
Expect(codeStr).To(ContainSubstring("package ndpdk"))
|
||||||
|
|
||||||
|
// Check for package documentation
|
||||||
|
Expect(codeStr).To(ContainSubstring("Package ndpdk provides Navidrome Plugin Development Kit wrappers"))
|
||||||
|
|
||||||
|
// Check that services are listed
|
||||||
|
Expect(codeStr).To(ContainSubstring("Cache:"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("Scheduler:"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GenerateGoMod", func() {
|
||||||
|
It("should generate valid go.mod content", func() {
|
||||||
|
code, err := GenerateGoMod()
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Check for module declaration (using new PDK path)
|
||||||
|
Expect(codeStr).To(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go/host"))
|
||||||
|
|
||||||
|
// Check for Go version
|
||||||
|
Expect(codeStr).To(ContainSubstring("go 1.24"))
|
||||||
|
|
||||||
|
// Check for extism-go-pdk dependency
|
||||||
|
Expect(codeStr).To(ContainSubstring("github.com/extism/go-pdk"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GenerateClientGoStub", func() {
|
||||||
|
It("should generate valid stub code with panic functions", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Cache",
|
||||||
|
Permission: "cache",
|
||||||
|
Interface: "CacheService",
|
||||||
|
Doc: "CacheService provides caching capabilities.",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "Get",
|
||||||
|
Doc: "Get retrieves a value from the cache.",
|
||||||
|
Params: []Param{
|
||||||
|
{Name: "key", Type: "string"},
|
||||||
|
},
|
||||||
|
Returns: []Param{
|
||||||
|
{Name: "value", Type: "string"},
|
||||||
|
{Name: "exists", Type: "bool"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateClientGoStub(svc, "ndpdk")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Verify it's valid Go code
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Check for build tag (non-WASM)
|
||||||
|
Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
|
||||||
|
|
||||||
|
// Check for package declaration
|
||||||
|
Expect(codeStr).To(ContainSubstring("package ndpdk"))
|
||||||
|
|
||||||
|
// Check for stub comment
|
||||||
|
Expect(codeStr).To(ContainSubstring("stub implementations for non-WASM builds"))
|
||||||
|
|
||||||
|
// Check for panic in function body
|
||||||
|
Expect(codeStr).To(ContainSubstring(`panic("ndpdk: CacheGet is only available in WASM plugins")`))
|
||||||
|
|
||||||
|
// Check that types are defined (needed for IDE support)
|
||||||
|
Expect(codeStr).To(ContainSubstring("type CacheGetResponse struct"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Integration", func() {
|
||||||
|
It("should generate compilable code from parsed source", func() {
|
||||||
|
// This is an integration test that verifies the full pipeline
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// TestService is a test service.
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
// DoSomething does something.
|
||||||
|
//nd:hostfunc
|
||||||
|
DoSomething(ctx context.Context, input string) (output string, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
// Create temporary directory
|
||||||
|
tmpDir := GinkgoT().TempDir()
|
||||||
|
path := tmpDir + "/test.go"
|
||||||
|
err := writeFile(path, src)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(HaveLen(1))
|
||||||
|
|
||||||
|
// Generate
|
||||||
|
code, err := GenerateHost(services[0], "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Format (validates syntax)
|
||||||
|
formatted, err := format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Verify key elements
|
||||||
|
codeStr := string(formatted)
|
||||||
|
Expect(codeStr).To(ContainSubstring("RegisterTestHostFunctions"))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`"test_dosomething"`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
func writeFile(path, content string) error {
|
||||||
|
return os.WriteFile(path, []byte(content), 0600)
|
||||||
|
}
|
||||||
13
plugins/cmd/ndpgen/internal/internal_suite_test.go
Normal file
13
plugins/cmd/ndpgen/internal/internal_suite_test.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInternal(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "NDPGen Internal Suite")
|
||||||
|
}
|
||||||
474
plugins/cmd/ndpgen/internal/parser.go
Normal file
474
plugins/cmd/ndpgen/internal/parser.go
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Annotation patterns
|
||||||
|
var (
|
||||||
|
// //nd:hostservice name=ServiceName permission=key
|
||||||
|
hostServicePattern = regexp.MustCompile(`//nd:hostservice\s+(.*)`)
|
||||||
|
// //nd:hostfunc [name=CustomName]
|
||||||
|
hostFuncPattern = regexp.MustCompile(`//nd:hostfunc(?:\s+(.*))?`)
|
||||||
|
// key=value pairs
|
||||||
|
keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseDirectory parses all Go source files in a directory and extracts host services.
|
||||||
|
func ParseDirectory(dir string) ([]Service, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var services []Service
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip generated files and test files
|
||||||
|
if strings.HasSuffix(entry.Name(), "_gen.go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, entry.Name())
|
||||||
|
parsed, err := parseFile(fset, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
services = append(services, parsed...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFile parses a single Go source file and extracts host services.
|
||||||
|
func parseFile(fset *token.FileSet, path string) ([]Service, error) {
|
||||||
|
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: collect all struct definitions in the file
|
||||||
|
allStructs := parseStructs(f)
|
||||||
|
structMap := make(map[string]StructDef)
|
||||||
|
for _, s := range allStructs {
|
||||||
|
structMap[s.Name] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
var services []Service
|
||||||
|
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
genDecl, ok := decl.(*ast.GenDecl)
|
||||||
|
if !ok || genDecl.Tok != token.TYPE {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, spec := range genDecl.Specs {
|
||||||
|
typeSpec, ok := spec.(*ast.TypeSpec)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaceType, ok := typeSpec.Type.(*ast.InterfaceType)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for //nd:hostservice annotation in doc comment
|
||||||
|
docText, rawDoc := getDocComment(genDecl, typeSpec)
|
||||||
|
svcAnnotation := parseHostServiceAnnotation(rawDoc)
|
||||||
|
if svcAnnotation == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
service := Service{
|
||||||
|
Name: svcAnnotation["name"],
|
||||||
|
Permission: svcAnnotation["permission"],
|
||||||
|
Interface: typeSpec.Name.Name,
|
||||||
|
Doc: cleanDoc(docText),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse methods and collect referenced types
|
||||||
|
referencedTypes := make(map[string]bool)
|
||||||
|
for _, method := range interfaceType.Methods.List {
|
||||||
|
if len(method.Names) == 0 {
|
||||||
|
continue // Embedded interface
|
||||||
|
}
|
||||||
|
|
||||||
|
funcType, ok := method.Type.(*ast.FuncType)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for //nd:hostfunc annotation
|
||||||
|
methodDocText, methodRawDoc := getMethodDocComment(method)
|
||||||
|
methodAnnotation := parseHostFuncAnnotation(methodRawDoc)
|
||||||
|
if methodAnnotation == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := parseMethod(method.Names[0].Name, funcType, methodAnnotation, cleanDoc(methodDocText))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing method %s.%s: %w", typeSpec.Name.Name, method.Names[0].Name, err)
|
||||||
|
}
|
||||||
|
service.Methods = append(service.Methods, m)
|
||||||
|
|
||||||
|
// Collect referenced types from params and returns
|
||||||
|
for _, p := range m.Params {
|
||||||
|
collectReferencedTypes(p.Type, referencedTypes)
|
||||||
|
}
|
||||||
|
for _, r := range m.Returns {
|
||||||
|
collectReferencedTypes(r.Type, referencedTypes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach referenced structs to the service
|
||||||
|
for typeName := range referencedTypes {
|
||||||
|
if s, exists := structMap[typeName]; exists {
|
||||||
|
service.Structs = append(service.Structs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(service.Methods) > 0 {
|
||||||
|
services = append(services, service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStructs extracts all struct type definitions from a parsed Go file.
|
||||||
|
func parseStructs(f *ast.File) []StructDef {
|
||||||
|
var structs []StructDef
|
||||||
|
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
genDecl, ok := decl.(*ast.GenDecl)
|
||||||
|
if !ok || genDecl.Tok != token.TYPE {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, spec := range genDecl.Specs {
|
||||||
|
typeSpec, ok := spec.(*ast.TypeSpec)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
structType, ok := typeSpec.Type.(*ast.StructType)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
docText, _ := getDocComment(genDecl, typeSpec)
|
||||||
|
s := StructDef{
|
||||||
|
Name: typeSpec.Name.Name,
|
||||||
|
Doc: cleanDoc(docText),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse struct fields
|
||||||
|
for _, field := range structType.Fields.List {
|
||||||
|
if len(field.Names) == 0 {
|
||||||
|
continue // Embedded field
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldDef := parseStructField(field)
|
||||||
|
s.Fields = append(s.Fields, fieldDef...)
|
||||||
|
}
|
||||||
|
|
||||||
|
structs = append(structs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return structs
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStructField parses a struct field and returns FieldDef for each name.
|
||||||
|
func parseStructField(field *ast.Field) []FieldDef {
|
||||||
|
var fields []FieldDef
|
||||||
|
typeName := typeToString(field.Type)
|
||||||
|
|
||||||
|
// Parse struct tag for JSON field name and omitempty
|
||||||
|
jsonTag := ""
|
||||||
|
omitEmpty := false
|
||||||
|
if field.Tag != nil {
|
||||||
|
tag := field.Tag.Value
|
||||||
|
// Remove backticks
|
||||||
|
tag = strings.Trim(tag, "`")
|
||||||
|
// Parse json tag
|
||||||
|
jsonTag, omitEmpty = parseJSONTag(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get doc comment
|
||||||
|
var doc string
|
||||||
|
if field.Doc != nil {
|
||||||
|
doc = cleanDoc(field.Doc.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range field.Names {
|
||||||
|
fieldJSONTag := jsonTag
|
||||||
|
if fieldJSONTag == "" {
|
||||||
|
// Default to field name with camelCase
|
||||||
|
fieldJSONTag = toJSONName(name.Name)
|
||||||
|
}
|
||||||
|
fields = append(fields, FieldDef{
|
||||||
|
Name: name.Name,
|
||||||
|
Type: typeName,
|
||||||
|
JSONTag: fieldJSONTag,
|
||||||
|
OmitEmpty: omitEmpty,
|
||||||
|
Doc: doc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJSONTag extracts the json field name and omitempty flag from a struct tag.
|
||||||
|
func parseJSONTag(tag string) (name string, omitEmpty bool) {
|
||||||
|
// Find json:"..." in the tag
|
||||||
|
for _, part := range strings.Split(tag, " ") {
|
||||||
|
if strings.HasPrefix(part, `json:"`) {
|
||||||
|
value := strings.TrimPrefix(part, `json:"`)
|
||||||
|
value = strings.TrimSuffix(value, `"`)
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
if len(parts) > 0 && parts[0] != "-" {
|
||||||
|
name = parts[0]
|
||||||
|
}
|
||||||
|
for _, opt := range parts[1:] {
|
||||||
|
if opt == "omitempty" {
|
||||||
|
omitEmpty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectReferencedTypes extracts custom type names from a Go type string.
|
||||||
|
// It handles pointers, slices, and maps, collecting base type names.
|
||||||
|
func collectReferencedTypes(goType string, refs map[string]bool) {
|
||||||
|
// Strip pointer
|
||||||
|
if strings.HasPrefix(goType, "*") {
|
||||||
|
collectReferencedTypes(goType[1:], refs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Strip slice
|
||||||
|
if strings.HasPrefix(goType, "[]") {
|
||||||
|
if goType != "[]byte" {
|
||||||
|
collectReferencedTypes(goType[2:], refs)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Handle map
|
||||||
|
if strings.HasPrefix(goType, "map[") {
|
||||||
|
rest := goType[4:] // Remove "map["
|
||||||
|
depth := 1
|
||||||
|
keyEnd := 0
|
||||||
|
for i, r := range rest {
|
||||||
|
if r == '[' {
|
||||||
|
depth++
|
||||||
|
} else if r == ']' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
keyEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyType := rest[:keyEnd]
|
||||||
|
valueType := rest[keyEnd+1:]
|
||||||
|
collectReferencedTypes(keyType, refs)
|
||||||
|
collectReferencedTypes(valueType, refs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a custom type (starts with uppercase, not a builtin)
|
||||||
|
if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' {
|
||||||
|
switch goType {
|
||||||
|
case "String", "Bool", "Int", "Int32", "Int64", "Float32", "Float64":
|
||||||
|
// Not custom types (just capitalized for some reason)
|
||||||
|
default:
|
||||||
|
refs[goType] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toJSONName is imported from types.go via the same package
|
||||||
|
|
||||||
|
// getDocComment extracts the doc comment for a type spec.
|
||||||
|
// Returns both the readable doc text and the raw comment text (which includes pragma-style comments).
|
||||||
|
func getDocComment(genDecl *ast.GenDecl, typeSpec *ast.TypeSpec) (docText, rawText string) {
|
||||||
|
var docGroup *ast.CommentGroup
|
||||||
|
// First check the TypeSpec's own doc (when multiple types in one block)
|
||||||
|
if typeSpec.Doc != nil {
|
||||||
|
docGroup = typeSpec.Doc
|
||||||
|
} else if genDecl.Doc != nil {
|
||||||
|
// Fall back to GenDecl doc (single type declaration)
|
||||||
|
docGroup = genDecl.Doc
|
||||||
|
}
|
||||||
|
if docGroup == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return docGroup.Text(), commentGroupRaw(docGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commentGroupRaw returns all comment text including pragma-style comments (//nd:...).
|
||||||
|
// Go's ast.CommentGroup.Text() strips comments without a space after //, so we need this.
|
||||||
|
func commentGroupRaw(cg *ast.CommentGroup) string {
|
||||||
|
if cg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, c := range cg.List {
|
||||||
|
lines = append(lines, c.Text)
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMethodDocComment extracts the doc comment for a method.
|
||||||
|
func getMethodDocComment(field *ast.Field) (docText, rawText string) {
|
||||||
|
if field.Doc == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return field.Doc.Text(), commentGroupRaw(field.Doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHostServiceAnnotation extracts //nd:hostservice annotation parameters.
|
||||||
|
func parseHostServiceAnnotation(doc string) map[string]string {
|
||||||
|
for _, line := range strings.Split(doc, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
match := hostServicePattern.FindStringSubmatch(line)
|
||||||
|
if match != nil {
|
||||||
|
return parseKeyValuePairs(match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHostFuncAnnotation extracts //nd:hostfunc annotation parameters.
|
||||||
|
func parseHostFuncAnnotation(doc string) map[string]string {
|
||||||
|
for _, line := range strings.Split(doc, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
match := hostFuncPattern.FindStringSubmatch(line)
|
||||||
|
if match != nil {
|
||||||
|
params := parseKeyValuePairs(match[1])
|
||||||
|
if params == nil {
|
||||||
|
params = make(map[string]string)
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKeyValuePairs extracts key=value pairs from annotation text.
|
||||||
|
func parseKeyValuePairs(text string) map[string]string {
|
||||||
|
matches := keyValuePattern.FindAllStringSubmatch(text, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string]string)
|
||||||
|
for _, m := range matches {
|
||||||
|
result[m[1]] = m[2]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMethod parses a method signature into a Method struct.
|
||||||
|
func parseMethod(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Method, error) {
|
||||||
|
m := Method{
|
||||||
|
Name: name,
|
||||||
|
ExportName: annotation["name"],
|
||||||
|
Doc: doc,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse parameters (skip context.Context)
|
||||||
|
if funcType.Params != nil {
|
||||||
|
for _, field := range funcType.Params.List {
|
||||||
|
typeName := typeToString(field.Type)
|
||||||
|
if typeName == "context.Context" {
|
||||||
|
continue // Skip context parameter
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range field.Names {
|
||||||
|
m.Params = append(m.Params, NewParam(name.Name, typeName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse return values
|
||||||
|
if funcType.Results != nil {
|
||||||
|
for _, field := range funcType.Results.List {
|
||||||
|
typeName := typeToString(field.Type)
|
||||||
|
if typeName == "error" {
|
||||||
|
m.HasError = true
|
||||||
|
continue // Track error but don't include in Returns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle anonymous returns
|
||||||
|
if len(field.Names) == 0 {
|
||||||
|
// Generate a name based on position
|
||||||
|
m.Returns = append(m.Returns, NewParam("result", typeName))
|
||||||
|
} else {
|
||||||
|
for _, name := range field.Names {
|
||||||
|
m.Returns = append(m.Returns, NewParam(name.Name, typeName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeToString converts an AST type expression to a string.
|
||||||
|
func typeToString(expr ast.Expr) string {
|
||||||
|
switch t := expr.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
return t.Name
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
return typeToString(t.X) + "." + t.Sel.Name
|
||||||
|
case *ast.StarExpr:
|
||||||
|
return "*" + typeToString(t.X)
|
||||||
|
case *ast.ArrayType:
|
||||||
|
if t.Len == nil {
|
||||||
|
return "[]" + typeToString(t.Elt)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[%s]%s", typeToString(t.Len), typeToString(t.Elt))
|
||||||
|
case *ast.MapType:
|
||||||
|
return fmt.Sprintf("map[%s]%s", typeToString(t.Key), typeToString(t.Value))
|
||||||
|
case *ast.BasicLit:
|
||||||
|
return t.Value
|
||||||
|
case *ast.InterfaceType:
|
||||||
|
// Empty interface (interface{} or any)
|
||||||
|
if t.Methods == nil || len(t.Methods.List) == 0 {
|
||||||
|
return "any"
|
||||||
|
}
|
||||||
|
// Non-empty interfaces can't be easily represented
|
||||||
|
return "any"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%T", expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanDoc removes annotation lines from documentation.
|
||||||
|
func cleanDoc(doc string) string {
|
||||||
|
var lines []string
|
||||||
|
for _, line := range strings.Split(doc, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "//nd:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
292
plugins/cmd/ndpgen/internal/parser_test.go
Normal file
292
plugins/cmd/ndpgen/internal/parser_test.go
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Parser", func() {
|
||||||
|
var tmpDir string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
var err error
|
||||||
|
tmpDir, err = os.MkdirTemp("", "hostgen-test-*")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ParseDirectory", func() {
|
||||||
|
It("should parse a simple host service interface", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// SubsonicAPIService provides access to Navidrome's Subsonic API.
|
||||||
|
//nd:hostservice name=SubsonicAPI permission=subsonicapi
|
||||||
|
type SubsonicAPIService interface {
|
||||||
|
// Call executes a Subsonic API request.
|
||||||
|
//nd:hostfunc
|
||||||
|
Call(ctx context.Context, uri string) (response string, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(HaveLen(1))
|
||||||
|
|
||||||
|
svc := services[0]
|
||||||
|
Expect(svc.Name).To(Equal("SubsonicAPI"))
|
||||||
|
Expect(svc.Permission).To(Equal("subsonicapi"))
|
||||||
|
Expect(svc.Interface).To(Equal("SubsonicAPIService"))
|
||||||
|
Expect(svc.Methods).To(HaveLen(1))
|
||||||
|
|
||||||
|
m := svc.Methods[0]
|
||||||
|
Expect(m.Name).To(Equal("Call"))
|
||||||
|
Expect(m.HasError).To(BeTrue())
|
||||||
|
Expect(m.Params).To(HaveLen(1))
|
||||||
|
Expect(m.Params[0].Name).To(Equal("uri"))
|
||||||
|
Expect(m.Params[0].Type).To(Equal("string"))
|
||||||
|
Expect(m.Returns).To(HaveLen(1))
|
||||||
|
Expect(m.Returns[0].Name).To(Equal("response"))
|
||||||
|
Expect(m.Returns[0].Type).To(Equal("string"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should parse multiple methods", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// SchedulerService provides scheduling capabilities.
|
||||||
|
//nd:hostservice name=Scheduler permission=scheduler
|
||||||
|
type SchedulerService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
ScheduleRecurring(ctx context.Context, cronExpression string) (scheduleID string, err error)
|
||||||
|
|
||||||
|
//nd:hostfunc
|
||||||
|
ScheduleOneTime(ctx context.Context, delaySeconds int32) (scheduleID string, err error)
|
||||||
|
|
||||||
|
//nd:hostfunc
|
||||||
|
CancelSchedule(ctx context.Context, scheduleID string) (canceled bool, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "scheduler.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(HaveLen(1))
|
||||||
|
|
||||||
|
svc := services[0]
|
||||||
|
Expect(svc.Name).To(Equal("Scheduler"))
|
||||||
|
Expect(svc.Methods).To(HaveLen(3))
|
||||||
|
|
||||||
|
Expect(svc.Methods[0].Name).To(Equal("ScheduleRecurring"))
|
||||||
|
Expect(svc.Methods[0].Params[0].Type).To(Equal("string"))
|
||||||
|
|
||||||
|
Expect(svc.Methods[1].Name).To(Equal("ScheduleOneTime"))
|
||||||
|
Expect(svc.Methods[1].Params[0].Type).To(Equal("int32"))
|
||||||
|
|
||||||
|
Expect(svc.Methods[2].Name).To(Equal("CancelSchedule"))
|
||||||
|
Expect(svc.Methods[2].Returns[0].Type).To(Equal("bool"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should skip methods without hostfunc annotation", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
Exported(ctx context.Context) error
|
||||||
|
|
||||||
|
// This method is not exported
|
||||||
|
NotExported(ctx context.Context) error
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(HaveLen(1))
|
||||||
|
Expect(services[0].Methods).To(HaveLen(1))
|
||||||
|
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle custom export name", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc name=custom_export_name
|
||||||
|
MyMethod(ctx context.Context) error
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services[0].Methods[0].ExportName).To(Equal("custom_export_name"))
|
||||||
|
Expect(services[0].Methods[0].FunctionName("test")).To(Equal("custom_export_name"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should skip generated files", func() {
|
||||||
|
regularSrc := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
Method(ctx context.Context) error
|
||||||
|
}
|
||||||
|
`
|
||||||
|
genSrc := `// Code generated. DO NOT EDIT.
|
||||||
|
package host
|
||||||
|
|
||||||
|
//nd:hostservice name=Generated permission=gen
|
||||||
|
type GeneratedService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
Method() error
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(regularSrc), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
err = os.WriteFile(filepath.Join(tmpDir, "test_gen.go"), []byte(genSrc), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(HaveLen(1))
|
||||||
|
Expect(services[0].Name).To(Equal("Test"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should skip interfaces without hostservice annotation", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Regular interface without annotation
|
||||||
|
type RegularInterface interface {
|
||||||
|
Method(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
//nd:hostservice name=Annotated permission=annotated
|
||||||
|
type AnnotatedService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
Method(ctx context.Context) error
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(HaveLen(1))
|
||||||
|
Expect(services[0].Name).To(Equal("Annotated"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should return empty slice for directory with no host services", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
type RegularInterface interface {
|
||||||
|
Method() error
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("parseKeyValuePairs", func() {
|
||||||
|
It("should parse key=value pairs", func() {
|
||||||
|
result := parseKeyValuePairs("name=Test permission=test")
|
||||||
|
Expect(result).To(HaveKeyWithValue("name", "Test"))
|
||||||
|
Expect(result).To(HaveKeyWithValue("permission", "test"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should return nil for empty input", func() {
|
||||||
|
result := parseKeyValuePairs("")
|
||||||
|
Expect(result).To(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("typeToString", func() {
|
||||||
|
It("should handle basic types", func() {
|
||||||
|
src := `package test
|
||||||
|
type T interface {
|
||||||
|
Method(s string, i int, b bool) ([]byte, error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Parse and verify type conversion works
|
||||||
|
// This is implicitly tested through ParseDirectory
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should convert interface{} to any", func() {
|
||||||
|
src := `package test
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
GetMetadata(ctx context.Context) (data map[string]interface{}, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(HaveLen(1))
|
||||||
|
Expect(services[0].Methods[0].Returns[0].Type).To(Equal("map[string]any"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Method helpers", func() {
|
||||||
|
It("should generate correct function names", func() {
|
||||||
|
m := Method{Name: "Call"}
|
||||||
|
Expect(m.FunctionName("subsonicapi")).To(Equal("subsonicapi_call"))
|
||||||
|
|
||||||
|
m.ExportName = "custom_name"
|
||||||
|
Expect(m.FunctionName("subsonicapi")).To(Equal("custom_name"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should generate correct type names", func() {
|
||||||
|
m := Method{Name: "Call"}
|
||||||
|
Expect(m.RequestTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallRequest"))
|
||||||
|
Expect(m.ResponseTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallResponse"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Service helpers", func() {
|
||||||
|
It("should generate correct output file name", func() {
|
||||||
|
s := Service{Name: "SubsonicAPI"}
|
||||||
|
Expect(s.OutputFileName()).To(Equal("subsonicapi_gen.go"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should generate correct export prefix", func() {
|
||||||
|
s := Service{Name: "SubsonicAPI"}
|
||||||
|
Expect(s.ExportPrefix()).To(Equal("subsonicapi"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
108
plugins/cmd/ndpgen/internal/templates/client.go.tmpl
Normal file
108
plugins/cmd/ndpgen/internal/templates/client.go.tmpl
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file contains client wrappers for the {{.Service.Name}} host service.
|
||||||
|
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||||
|
//
|
||||||
|
//go:build wasip1
|
||||||
|
|
||||||
|
package {{.Package}}
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/extism/go-pdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
{{- /* Generate struct definitions */ -}}
|
||||||
|
{{- range .Service.Structs}}
|
||||||
|
|
||||||
|
// {{.Name}} represents the {{.Name}} data structure.
|
||||||
|
{{- if .Doc}}
|
||||||
|
{{formatDoc .Doc}}
|
||||||
|
{{- end}}
|
||||||
|
type {{.Name}} struct {
|
||||||
|
{{- range .Fields}}
|
||||||
|
{{.Name}} {{.Type}} `json:"{{.JSONTag}}"`
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{- /* Generate wasmimport declarations for each method */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
|
||||||
|
// {{exportName .}} is the host function provided by Navidrome.
|
||||||
|
//
|
||||||
|
//go:wasmimport extism:host/user {{exportName .}}
|
||||||
|
func {{exportName .}}(uint64) uint64
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{- /* Generate request/response types for all methods */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
{{- if .HasParams}}
|
||||||
|
|
||||||
|
// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
|
||||||
|
type {{requestType .}} struct {
|
||||||
|
{{- range .Params}}
|
||||||
|
{{title .Name}} {{.Type}} `json:"{{.JSONName}}"`
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||||
|
type {{responseType .}} struct {
|
||||||
|
{{- range .Returns}}
|
||||||
|
{{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"`
|
||||||
|
{{- end}}
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{- /* Generate wrapper functions */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
|
||||||
|
// {{$.Service.Name}}{{.Name}} calls the {{exportName .}} host function.
|
||||||
|
{{- if .Doc}}
|
||||||
|
{{formatDoc .Doc}}
|
||||||
|
{{- end}}
|
||||||
|
func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) (*{{responseType .}}, error) {
|
||||||
|
{{- if .HasParams}}
|
||||||
|
// Marshal request to JSON
|
||||||
|
req := {{requestType .}}{
|
||||||
|
{{- range .Params}}
|
||||||
|
{{title .Name}}: {{.Name}},
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
reqBytes, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reqMem := pdk.AllocateBytes(reqBytes)
|
||||||
|
defer reqMem.Free()
|
||||||
|
{{- else}}
|
||||||
|
// No parameters - allocate empty JSON object
|
||||||
|
reqMem := pdk.AllocateBytes([]byte("{}"))
|
||||||
|
defer reqMem.Free()
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
// Call the host function
|
||||||
|
responsePtr := {{exportName .}}(reqMem.Offset())
|
||||||
|
|
||||||
|
// Read the response from memory
|
||||||
|
responseMem := pdk.FindMemory(responsePtr)
|
||||||
|
responseBytes := responseMem.ReadBytes()
|
||||||
|
|
||||||
|
// Parse the response
|
||||||
|
var response {{responseType .}}
|
||||||
|
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Error field to Go error
|
||||||
|
if response.Error != "" {
|
||||||
|
return nil, errors.New(response.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
95
plugins/cmd/ndpgen/internal/templates/client.py.tmpl
Normal file
95
plugins/cmd/ndpgen/internal/templates/client.py.tmpl
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# Code generated by hostgen. DO NOT EDIT.
|
||||||
|
#
|
||||||
|
# This file contains client wrappers for the {{.Service.Name}} host service.
|
||||||
|
# It is intended for use in Navidrome plugins built with extism-py.
|
||||||
|
#
|
||||||
|
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||||
|
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||||
|
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import extism
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class HostFunctionError(Exception):
|
||||||
|
"""Raised when a host function returns an error."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
{{- /* Generate raw host function imports */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
|
||||||
|
|
||||||
|
@extism.import_fn("extism:host/user", "{{exportName .}}")
|
||||||
|
def _{{exportName .}}(offset: int) -> int:
|
||||||
|
"""Raw host function - do not call directly."""
|
||||||
|
...
|
||||||
|
{{- end}}
|
||||||
|
{{- /* Generate dataclasses for multi-value returns */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
{{- if .NeedsResultClass}}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class {{pythonResultType .}}:
|
||||||
|
"""Result type for {{pythonFunc .}}."""
|
||||||
|
{{- range .Returns}}
|
||||||
|
{{.PythonName}}: {{.PythonType}}
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
{{- /* Generate wrapper functions */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
|
||||||
|
|
||||||
|
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
||||||
|
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
|
||||||
|
{{- if .HasParams}}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
{{- range .Params}}
|
||||||
|
{{.PythonName}}: {{.PythonType}} parameter.
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .HasReturns}}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{{- if .NeedsResultClass}}
|
||||||
|
{{pythonResultType .}} containing{{range .Returns}} {{.PythonName}},{{end}}.
|
||||||
|
{{- else}}
|
||||||
|
{{(index .Returns 0).PythonType}}: The result value.
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HostFunctionError: If the host function returns an error.
|
||||||
|
"""
|
||||||
|
{{- if .HasParams}}
|
||||||
|
request = {
|
||||||
|
{{- range .Params}}
|
||||||
|
"{{.JSONName}}": {{.PythonName}},
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
request_bytes = json.dumps(request).encode("utf-8")
|
||||||
|
{{- else}}
|
||||||
|
request_bytes = b"{}"
|
||||||
|
{{- end}}
|
||||||
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
|
response_offset = _{{exportName .}}(request_mem.offset)
|
||||||
|
response_mem = extism.memory.find(response_offset)
|
||||||
|
response = json.loads(extism.memory.string(response_mem))
|
||||||
|
|
||||||
|
if response.get("error"):
|
||||||
|
raise HostFunctionError(response["error"])
|
||||||
|
{{if .NeedsResultClass}}
|
||||||
|
return {{pythonResultType .}}(
|
||||||
|
{{- range .Returns}}
|
||||||
|
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
|
||||||
|
{{- end}}
|
||||||
|
)
|
||||||
|
{{- else if .HasReturns}}
|
||||||
|
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
103
plugins/cmd/ndpgen/internal/templates/client.rs.tmpl
Normal file
103
plugins/cmd/ndpgen/internal/templates/client.rs.tmpl
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
// Code generated by hostgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file contains client wrappers for the {{.Service.Name}} host service.
|
||||||
|
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||||
|
|
||||||
|
use extism_pdk::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
{{- /* Generate struct definitions */ -}}
|
||||||
|
{{- range .Service.Structs}}
|
||||||
|
{{if .Doc}}
|
||||||
|
{{rustDocComment .Doc}}
|
||||||
|
{{else}}
|
||||||
|
{{end}}#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct {{.Name}} {
|
||||||
|
{{- range .Fields}}
|
||||||
|
{{- if .NeedsDefault}}
|
||||||
|
#[serde(default)]
|
||||||
|
{{- end}}
|
||||||
|
pub {{.RustName}}: {{fieldRustType .}},
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
{{- /* Generate request/response types */ -}}
|
||||||
|
{{- range .Service.Methods}}
|
||||||
|
{{- if .HasParams}}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct {{requestType .}} {
|
||||||
|
{{- range .Params}}
|
||||||
|
{{.RustName}}: {{rustType .}},
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct {{responseType .}} {
|
||||||
|
{{- range .Returns}}
|
||||||
|
#[serde(default)]
|
||||||
|
{{.RustName}}: {{rustType .}},
|
||||||
|
{{- end}}
|
||||||
|
#[serde(default)]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
#[host_fn]
|
||||||
|
extern "ExtismHost" {
|
||||||
|
{{- range .Service.Methods}}
|
||||||
|
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
|
||||||
|
{{- /* Generate wrapper functions */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
|
||||||
|
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||||
|
{{- if .HasParams}}
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
{{- range .Params}}
|
||||||
|
/// * `{{.RustName}}` - {{rustType .}} parameter.
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .HasReturns}}
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
{{- if eq (len .Returns) 1}}
|
||||||
|
/// The {{(index .Returns 0).RustName}} value.
|
||||||
|
{{- else}}
|
||||||
|
/// A tuple of ({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{$r.RustName}}{{end}}).
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the host function call fails.
|
||||||
|
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<{{if eq (len .Returns) 0}}(){{else if eq (len .Returns) 1}}{{rustType (index .Returns 0)}}{{else}}({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{rustType $r}}{{end}}){{end}}, Error> {
|
||||||
|
let response = unsafe {
|
||||||
|
{{- if .HasParams}}
|
||||||
|
{{exportName .}}(Json({{requestType .}} {
|
||||||
|
{{- range .Params}}
|
||||||
|
{{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}},
|
||||||
|
{{- end}}
|
||||||
|
}))?
|
||||||
|
{{- else}}
|
||||||
|
{{exportName .}}(Json(serde_json::json!({})))?
|
||||||
|
{{- end}}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(err) = response.0.error {
|
||||||
|
return Err(Error::msg(err));
|
||||||
|
}
|
||||||
|
{{if eq (len .Returns) 0}}
|
||||||
|
Ok(())
|
||||||
|
{{- else if eq (len .Returns) 1}}
|
||||||
|
Ok(response.0.{{(index .Returns 0).RustName}})
|
||||||
|
{{- else}}
|
||||||
|
Ok(({{range $i, $r := .Returns}}{{if $i}}, {{end}}response.0.{{$r.RustName}}{{end}}))
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
56
plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl
Normal file
56
plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file contains stub implementations for non-WASM builds.
|
||||||
|
// These stubs allow IDE support and compilation on non-WASM platforms.
|
||||||
|
// They panic at runtime since host functions are only available in WASM plugins.
|
||||||
|
//
|
||||||
|
//go:build !wasip1
|
||||||
|
|
||||||
|
package {{.Package}}
|
||||||
|
|
||||||
|
{{- /* Generate struct definitions (same as main file, needed for type references) */ -}}
|
||||||
|
{{- range .Service.Structs}}
|
||||||
|
|
||||||
|
// {{.Name}} represents the {{.Name}} data structure.
|
||||||
|
{{- if .Doc}}
|
||||||
|
{{formatDoc .Doc}}
|
||||||
|
{{- end}}
|
||||||
|
type {{.Name}} struct {
|
||||||
|
{{- range .Fields}}
|
||||||
|
{{.Name}} {{.Type}} `json:"{{.JSONTag}}"`
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{- /* Generate request/response types (same as main file) */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
{{- if .HasParams}}
|
||||||
|
|
||||||
|
// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
|
||||||
|
type {{requestType .}} struct {
|
||||||
|
{{- range .Params}}
|
||||||
|
{{title .Name}} {{.Type}} `json:"{{.JSONName}}"`
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||||
|
type {{responseType .}} struct {
|
||||||
|
{{- range .Returns}}
|
||||||
|
{{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"`
|
||||||
|
{{- end}}
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{- /* Generate stub wrapper functions that panic */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
|
||||||
|
// {{$.Service.Name}}{{.Name}} is a stub that panics on non-WASM platforms.
|
||||||
|
{{- if .Doc}}
|
||||||
|
{{formatDoc .Doc}}
|
||||||
|
{{- end}}
|
||||||
|
func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) (*{{responseType .}}, error) {
|
||||||
|
panic("{{$.Package}}: {{$.Service.Name}}{{.Name}} is only available in WASM plugins")
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
49
plugins/cmd/ndpgen/internal/templates/doc.go.tmpl
Normal file
49
plugins/cmd/ndpgen/internal/templates/doc.go.tmpl
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
|
||||||
|
/*
|
||||||
|
Package {{.Package}} provides Navidrome Plugin Development Kit wrappers for Go/TinyGo plugins.
|
||||||
|
|
||||||
|
This package is auto-generated by the ndpgen tool and should not be edited manually.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
Add this module as a dependency in your plugin's go.mod:
|
||||||
|
|
||||||
|
require github.com/navidrome/navidrome/plugins/pdk/go/host v0.0.0
|
||||||
|
|
||||||
|
Then import the package in your plugin code:
|
||||||
|
|
||||||
|
import {{.Package}} "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||||
|
|
||||||
|
func myPluginFunction() error {
|
||||||
|
// Use the cache service
|
||||||
|
_, err := {{.Package}}.CacheSetString("my_key", "my_value", 3600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a recurring task
|
||||||
|
_, err = {{.Package}}.SchedulerScheduleRecurring("@every 5m", "payload", "task_id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
# Available Services
|
||||||
|
|
||||||
|
The following host services are available:
|
||||||
|
{{range .Services}}
|
||||||
|
- {{.Name}}: {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} service{{end}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
# Building Plugins
|
||||||
|
|
||||||
|
Go plugins must be compiled to WebAssembly using TinyGo:
|
||||||
|
|
||||||
|
tinygo build -o plugin.wasm -target=wasip1 -buildmode=c-shared .
|
||||||
|
|
||||||
|
See the examples directory for complete plugin implementations.
|
||||||
|
*/
|
||||||
|
package {{.Package}}
|
||||||
5
plugins/cmd/ndpgen/internal/templates/go.mod.tmpl
Normal file
5
plugins/cmd/ndpgen/internal/templates/go.mod.tmpl
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module github.com/navidrome/navidrome/plugins/pdk/go/host
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/extism/go-pdk v1.1.3
|
||||||
119
plugins/cmd/ndpgen/internal/templates/host.go.tmpl
Normal file
119
plugins/cmd/ndpgen/internal/templates/host.go.tmpl
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// Code generated by hostgen. DO NOT EDIT.
|
||||||
|
|
||||||
|
package {{.Package}}
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
extism "github.com/extism/go-sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
{{- /* Generate request/response types for all methods */ -}}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
{{- if .HasParams}}
|
||||||
|
|
||||||
|
// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
|
||||||
|
type {{requestType .}} struct {
|
||||||
|
{{- range .Params}}
|
||||||
|
{{title .Name}} {{.Type}} `json:"{{.JSONName}}"`
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||||
|
type {{responseType .}} struct {
|
||||||
|
{{- range .Returns}}
|
||||||
|
{{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"`
|
||||||
|
{{- end}}
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
|
||||||
|
// The returned host functions should be added to the plugin's configuration.
|
||||||
|
func Register{{.Service.Name}}HostFunctions(service {{.Service.Interface}}) []extism.HostFunction {
|
||||||
|
return []extism.HostFunction{
|
||||||
|
{{- range .Service.Methods}}
|
||||||
|
new{{$.Service.Name}}{{.Name}}HostFunction(service),
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{range .Service.Methods}}
|
||||||
|
|
||||||
|
func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) extism.HostFunction {
|
||||||
|
return extism.NewHostFunctionWithStack(
|
||||||
|
"{{exportName .}}",
|
||||||
|
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||||
|
{{- if .HasParams}}
|
||||||
|
// Read JSON request from plugin memory
|
||||||
|
reqBytes, err := p.ReadBytes(stack[0])
|
||||||
|
if err != nil {
|
||||||
|
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req {{requestType .}}
|
||||||
|
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||||
|
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
// Call the service method
|
||||||
|
{{- if .HasReturns}}
|
||||||
|
{{- if .HasError}}
|
||||||
|
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||||
|
if svcErr != nil {
|
||||||
|
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
{{- else}}
|
||||||
|
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||||
|
{{- end}}
|
||||||
|
{{- else if .HasError}}
|
||||||
|
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
|
||||||
|
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
{{- else}}
|
||||||
|
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
// Write JSON response to plugin memory
|
||||||
|
resp := {{responseType .}}{
|
||||||
|
{{- range .Returns}}
|
||||||
|
{{title .Name}}: {{lower .Name}},
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
|
||||||
|
},
|
||||||
|
[]extism.ValueType{extism.ValueTypePTR},
|
||||||
|
[]extism.ValueType{extism.ValueTypePTR},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
// {{.Service.Name | lower}}WriteResponse writes a JSON response to plugin memory.
|
||||||
|
func {{.Service.Name | lower}}WriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||||
|
respBytes, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
{{.Service.Name | lower}}WriteError(p, stack, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respPtr, err := p.WriteBytes(respBytes)
|
||||||
|
if err != nil {
|
||||||
|
stack[0] = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stack[0] = respPtr
|
||||||
|
}
|
||||||
|
|
||||||
|
// {{.Service.Name | lower}}WriteError writes an error response to plugin memory.
|
||||||
|
func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||||
|
errResp := struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}{Error: err.Error()}
|
||||||
|
respBytes, _ := json.Marshal(errResp)
|
||||||
|
respPtr, _ := p.WriteBytes(respBytes)
|
||||||
|
stack[0] = respPtr
|
||||||
|
}
|
||||||
43
plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl
Normal file
43
plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// Code generated by hostgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
//! Navidrome Host Function Wrappers for Rust Plugins
|
||||||
|
//!
|
||||||
|
//! This crate provides idiomatic Rust wrappers for all Navidrome host services.
|
||||||
|
//! It is auto-generated by the hostgen tool and should not be edited manually.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! Add this crate as a dependency in your plugin's Cargo.toml:
|
||||||
|
//!
|
||||||
|
//! ```toml
|
||||||
|
//! [dependencies]
|
||||||
|
//! nd-host = { path = "../../host/rust" }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Then import the services you need:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use nd_host::{cache, scheduler};
|
||||||
|
//!
|
||||||
|
//! fn my_plugin_function() -> Result<(), extism_pdk::Error> {
|
||||||
|
//! // Use the cache service
|
||||||
|
//! cache::set_string("my_key", "my_value", 3600)?;
|
||||||
|
//!
|
||||||
|
//! // Schedule a recurring task
|
||||||
|
//! scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
|
||||||
|
//!
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Available Services
|
||||||
|
//!
|
||||||
|
{{- range .Services}}
|
||||||
|
//! - [`{{.Name | lower}}`] - {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} service{{end}}
|
||||||
|
{{- end}}
|
||||||
|
{{range .Services}}
|
||||||
|
#[path = "nd_host_{{.Name | lower}}.rs"]
|
||||||
|
pub mod {{.Name | lower}};
|
||||||
|
{{end}}
|
||||||
|
// Re-export commonly used types from extism-pdk for convenience
|
||||||
|
pub use extism_pdk::Error;
|
||||||
384
plugins/cmd/ndpgen/internal/types.go
Normal file
384
plugins/cmd/ndpgen/internal/types.go
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents a parsed host service interface.
|
||||||
|
type Service struct {
|
||||||
|
Name string // Service name from annotation (e.g., "SubsonicAPI")
|
||||||
|
Permission string // Manifest permission key (e.g., "subsonicapi")
|
||||||
|
Interface string // Go interface name (e.g., "SubsonicAPIService")
|
||||||
|
Methods []Method // Methods marked with //nd:hostfunc
|
||||||
|
Doc string // Documentation comment for the service
|
||||||
|
Structs []StructDef // Structs used by this service
|
||||||
|
}
|
||||||
|
|
||||||
|
// StructDef represents a Go struct type definition.
|
||||||
|
type StructDef struct {
|
||||||
|
Name string // Go struct name (e.g., "Library")
|
||||||
|
Fields []FieldDef // Struct fields
|
||||||
|
Doc string // Documentation comment
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldDef represents a field within a struct.
|
||||||
|
type FieldDef struct {
|
||||||
|
Name string // Go field name (e.g., "TotalSongs")
|
||||||
|
Type string // Go type (e.g., "int32", "*string", "[]User")
|
||||||
|
JSONTag string // JSON tag value (e.g., "totalSongs,omitempty")
|
||||||
|
OmitEmpty bool // Whether the field has omitempty tag
|
||||||
|
Doc string // Field documentation
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputFileName returns the generated file name for this service.
|
||||||
|
func (s Service) OutputFileName() string {
|
||||||
|
return strings.ToLower(s.Name) + "_gen.go"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportPrefix returns the prefix for exported host function names.
|
||||||
|
func (s Service) ExportPrefix() string {
|
||||||
|
return strings.ToLower(s.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KnownStructs returns a map of struct names defined in this service.
|
||||||
|
func (s Service) KnownStructs() map[string]bool {
|
||||||
|
result := make(map[string]bool)
|
||||||
|
for _, st := range s.Structs {
|
||||||
|
result[st.Name] = true
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method represents a host function method within a service.
|
||||||
|
type Method struct {
|
||||||
|
Name string // Go method name (e.g., "Call")
|
||||||
|
ExportName string // Optional override for export name
|
||||||
|
Params []Param // Method parameters (excluding context.Context)
|
||||||
|
Returns []Param // Return values (excluding error)
|
||||||
|
HasError bool // Whether the method returns an error
|
||||||
|
Doc string // Documentation comment for the method
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionName returns the Extism host function export name.
|
||||||
|
func (m Method) FunctionName(servicePrefix string) string {
|
||||||
|
if m.ExportName != "" {
|
||||||
|
return m.ExportName
|
||||||
|
}
|
||||||
|
return servicePrefix + "_" + strings.ToLower(m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestTypeName returns the generated request type name.
|
||||||
|
func (m Method) RequestTypeName(serviceName string) string {
|
||||||
|
return serviceName + m.Name + "Request"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseTypeName returns the generated response type name.
|
||||||
|
func (m Method) ResponseTypeName(serviceName string) string {
|
||||||
|
return serviceName + m.Name + "Response"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasParams returns true if the method has input parameters.
|
||||||
|
func (m Method) HasParams() bool {
|
||||||
|
return len(m.Params) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasReturns returns true if the method has return values (excluding error).
|
||||||
|
func (m Method) HasReturns() bool {
|
||||||
|
return len(m.Returns) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Param represents a method parameter or return value.
|
||||||
|
type Param struct {
|
||||||
|
Name string // Parameter name
|
||||||
|
Type string // Go type (e.g., "string", "int32", "[]byte")
|
||||||
|
JSONName string // JSON field name (camelCase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewParam creates a Param with auto-generated JSON name.
|
||||||
|
func NewParam(name, typ string) Param {
|
||||||
|
return Param{
|
||||||
|
Name: name,
|
||||||
|
Type: typ,
|
||||||
|
JSONName: toJSONName(name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toJSONName converts a Go identifier to camelCase JSON field name.
|
||||||
|
// This matches Rust serde's rename_all = "camelCase" behavior.
|
||||||
|
// Examples: "ConnectionID" -> "connectionId", "NewConnectionID" -> "newConnectionId"
|
||||||
|
func toJSONName(name string) string {
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(name)
|
||||||
|
result := make([]rune, 0, len(runes))
|
||||||
|
|
||||||
|
for i, r := range runes {
|
||||||
|
if i == 0 {
|
||||||
|
// First character is always lowercase
|
||||||
|
result = append(result, unicode.ToLower(r))
|
||||||
|
} else if unicode.IsUpper(r) {
|
||||||
|
// Check if this is part of an acronym (consecutive uppercase)
|
||||||
|
// or a word boundary
|
||||||
|
prevIsUpper := unicode.IsUpper(runes[i-1])
|
||||||
|
nextIsLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
|
||||||
|
|
||||||
|
if prevIsUpper && !nextIsLower {
|
||||||
|
// Middle of an acronym - lowercase it
|
||||||
|
result = append(result, unicode.ToLower(r))
|
||||||
|
} else if prevIsUpper && nextIsLower {
|
||||||
|
// End of acronym followed by lowercase - this starts a new word
|
||||||
|
// Keep uppercase
|
||||||
|
result = append(result, r)
|
||||||
|
} else {
|
||||||
|
// Regular word boundary - keep uppercase
|
||||||
|
result = append(result, r)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = append(result, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToPythonType converts a Go type to its Python equivalent.
|
||||||
|
func ToPythonType(goType string) string {
|
||||||
|
switch goType {
|
||||||
|
case "string":
|
||||||
|
return "str"
|
||||||
|
case "int", "int32", "int64":
|
||||||
|
return "int"
|
||||||
|
case "float32", "float64":
|
||||||
|
return "float"
|
||||||
|
case "bool":
|
||||||
|
return "bool"
|
||||||
|
case "[]byte":
|
||||||
|
return "bytes"
|
||||||
|
default:
|
||||||
|
return "Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSnakeCase converts a PascalCase or camelCase string to snake_case.
|
||||||
|
// It handles consecutive uppercase letters correctly (e.g., "ScheduleID" -> "schedule_id").
|
||||||
|
func ToSnakeCase(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
runes := []rune(s)
|
||||||
|
for i, r := range runes {
|
||||||
|
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||||
|
// Add underscore before uppercase, but not if:
|
||||||
|
// - Previous char was uppercase AND next char is uppercase or end of string
|
||||||
|
// (this handles acronyms like "ID" in "NewScheduleID")
|
||||||
|
prevUpper := runes[i-1] >= 'A' && runes[i-1] <= 'Z'
|
||||||
|
nextUpper := i+1 < len(runes) && runes[i+1] >= 'A' && runes[i+1] <= 'Z'
|
||||||
|
atEnd := i+1 == len(runes)
|
||||||
|
|
||||||
|
// Only skip underscore if we're in the middle of an acronym
|
||||||
|
if !prevUpper || (!nextUpper && !atEnd) {
|
||||||
|
result.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
return strings.ToLower(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// PythonFunctionName returns the Python function name for a method.
|
||||||
|
func (m Method) PythonFunctionName(servicePrefix string) string {
|
||||||
|
return ToSnakeCase(servicePrefix + m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PythonResultTypeName returns the Python dataclass name for multi-value returns.
|
||||||
|
func (m Method) PythonResultTypeName(serviceName string) string {
|
||||||
|
return serviceName + m.Name + "Result"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsResultClass returns true if the method needs a dataclass for returns.
|
||||||
|
func (m Method) NeedsResultClass() bool {
|
||||||
|
return len(m.Returns) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// PythonType returns the Python type for this parameter.
|
||||||
|
func (p Param) PythonType() string {
|
||||||
|
return ToPythonType(p.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PythonName returns the snake_case Python name for this parameter.
|
||||||
|
func (p Param) PythonName() string {
|
||||||
|
return ToSnakeCase(p.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToRustType converts a Go type to its Rust equivalent.
|
||||||
|
func ToRustType(goType string) string {
|
||||||
|
return ToRustTypeWithStructs(goType, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustParamType returns the Rust type for a function parameter (uses &str for strings).
|
||||||
|
func RustParamType(goType string) string {
|
||||||
|
if goType == "string" {
|
||||||
|
return "&str"
|
||||||
|
}
|
||||||
|
return ToRustType(goType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustDefaultValue returns the default value for a Rust type.
|
||||||
|
func RustDefaultValue(goType string) string {
|
||||||
|
switch goType {
|
||||||
|
case "string":
|
||||||
|
return `String::new()`
|
||||||
|
case "int", "int32":
|
||||||
|
return "0"
|
||||||
|
case "int64":
|
||||||
|
return "0"
|
||||||
|
case "float32", "float64":
|
||||||
|
return "0.0"
|
||||||
|
case "bool":
|
||||||
|
return "false"
|
||||||
|
default:
|
||||||
|
if strings.HasPrefix(goType, "[]") {
|
||||||
|
return "Vec::new()"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(goType, "map[") {
|
||||||
|
return "std::collections::HashMap::new()"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(goType, "*") {
|
||||||
|
return "None"
|
||||||
|
}
|
||||||
|
return "serde_json::Value::Null"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustFunctionName returns the Rust function name for a method (snake_case).
|
||||||
|
// Uses just the method name without service prefix since the module provides namespacing.
|
||||||
|
func (m Method) RustFunctionName(_ string) string {
|
||||||
|
return ToSnakeCase(m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustDocComment returns a properly formatted Rust doc comment.
|
||||||
|
// Each line of the input doc string is prefixed with "/// ".
|
||||||
|
func RustDocComment(doc string) string {
|
||||||
|
if doc == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := strings.Split(doc, "\n")
|
||||||
|
var result []string
|
||||||
|
for _, line := range lines {
|
||||||
|
result = append(result, "/// "+line)
|
||||||
|
}
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustType returns the Rust type for this parameter.
|
||||||
|
func (p Param) RustType() string {
|
||||||
|
return ToRustType(p.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustTypeWithStructs returns the Rust type using known struct names.
|
||||||
|
func (p Param) RustTypeWithStructs(knownStructs map[string]bool) string {
|
||||||
|
return ToRustTypeWithStructs(p.Type, knownStructs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustParamType returns the Rust type for this parameter when used as a function argument.
|
||||||
|
func (p Param) RustParamType() string {
|
||||||
|
return RustParamType(p.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustParamTypeWithStructs returns the Rust param type using known struct names.
|
||||||
|
func (p Param) RustParamTypeWithStructs(knownStructs map[string]bool) string {
|
||||||
|
if p.Type == "string" {
|
||||||
|
return "&str"
|
||||||
|
}
|
||||||
|
return ToRustTypeWithStructs(p.Type, knownStructs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustName returns the snake_case Rust name for this parameter.
|
||||||
|
func (p Param) RustName() string {
|
||||||
|
return ToSnakeCase(p.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsToOwned returns true if the parameter needs .to_owned() when used.
|
||||||
|
func (p Param) NeedsToOwned() bool {
|
||||||
|
return p.Type == "string"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustType returns the Rust type for this field, using known struct names.
|
||||||
|
func (f FieldDef) RustType(knownStructs map[string]bool) string {
|
||||||
|
return ToRustTypeWithStructs(f.Type, knownStructs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RustName returns the snake_case Rust name for this field.
|
||||||
|
func (f FieldDef) RustName() string {
|
||||||
|
return ToSnakeCase(f.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsDefault returns true if the field needs #[serde(default)] attribute.
|
||||||
|
// This is true for fields with omitempty tag.
|
||||||
|
func (f FieldDef) NeedsDefault() bool {
|
||||||
|
return f.OmitEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToRustTypeWithStructs converts a Go type to its Rust equivalent,
|
||||||
|
// using known struct names instead of serde_json::Value.
|
||||||
|
func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string {
|
||||||
|
// Handle pointer types
|
||||||
|
if strings.HasPrefix(goType, "*") {
|
||||||
|
inner := ToRustTypeWithStructs(goType[1:], knownStructs)
|
||||||
|
return "Option<" + inner + ">"
|
||||||
|
}
|
||||||
|
// Handle slice types
|
||||||
|
if strings.HasPrefix(goType, "[]") {
|
||||||
|
if goType == "[]byte" {
|
||||||
|
return "Vec<u8>"
|
||||||
|
}
|
||||||
|
inner := ToRustTypeWithStructs(goType[2:], knownStructs)
|
||||||
|
return "Vec<" + inner + ">"
|
||||||
|
}
|
||||||
|
// Handle map types
|
||||||
|
if strings.HasPrefix(goType, "map[") {
|
||||||
|
// Extract key and value types from map[K]V
|
||||||
|
rest := goType[4:] // Remove "map["
|
||||||
|
depth := 1
|
||||||
|
keyEnd := 0
|
||||||
|
for i, r := range rest {
|
||||||
|
if r == '[' {
|
||||||
|
depth++
|
||||||
|
} else if r == ']' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
keyEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyType := rest[:keyEnd]
|
||||||
|
valueType := rest[keyEnd+1:]
|
||||||
|
return "std::collections::HashMap<" + ToRustTypeWithStructs(keyType, knownStructs) + ", " + ToRustTypeWithStructs(valueType, knownStructs) + ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch goType {
|
||||||
|
case "string":
|
||||||
|
return "String"
|
||||||
|
case "int", "int32":
|
||||||
|
return "i32"
|
||||||
|
case "int64":
|
||||||
|
return "i64"
|
||||||
|
case "float32":
|
||||||
|
return "f32"
|
||||||
|
case "float64":
|
||||||
|
return "f64"
|
||||||
|
case "bool":
|
||||||
|
return "bool"
|
||||||
|
case "interface{}", "any":
|
||||||
|
return "serde_json::Value"
|
||||||
|
default:
|
||||||
|
// Check if this is a known struct type
|
||||||
|
if knownStructs != nil && knownStructs[goType] {
|
||||||
|
return goType
|
||||||
|
}
|
||||||
|
// For unknown custom types, fall back to Value
|
||||||
|
return "serde_json::Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
414
plugins/cmd/ndpgen/main.go
Normal file
414
plugins/cmd/ndpgen/main.go
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
// ndpgen generates Navidrome Plugin Development Kit (PDK) code from annotated Go interfaces.
|
||||||
|
//
|
||||||
|
// This is the unified code generator that replaces hostgen and handles both host function
|
||||||
|
// wrappers and capability wrappers (when implemented).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// ndpgen -input=./plugins/host -output=./plugins/pdk/go/host
|
||||||
|
//
|
||||||
|
// Flags:
|
||||||
|
//
|
||||||
|
// -input Input directory containing Go source files with annotated interfaces
|
||||||
|
// -output Output directory for generated files (default: same as input)
|
||||||
|
// -package Output package name (default: inferred from output directory)
|
||||||
|
// -host-only Generate only host function wrappers (default: true, capability support TBD)
|
||||||
|
// -go Generate Go client wrappers (default: true when not using -python/-rust)
|
||||||
|
// -python Generate Python client wrappers (default: false)
|
||||||
|
// -rust Generate Rust client wrappers (default: false)
|
||||||
|
// -v Verbose output
|
||||||
|
// -dry-run Preview generated code without writing files
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"go/format"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/plugins/cmd/ndpgen/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// config holds the parsed command-line configuration.
|
||||||
|
type config struct {
|
||||||
|
inputDir string
|
||||||
|
outputDir string
|
||||||
|
pkgName string
|
||||||
|
hostOnly bool
|
||||||
|
generateGoClient bool
|
||||||
|
generatePyClient bool
|
||||||
|
generateRsClient bool
|
||||||
|
verbose bool
|
||||||
|
dryRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := parseConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
services, err := parseServices(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(services) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := generateAllCode(cfg, services); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConfig parses command-line flags and returns the configuration.
|
||||||
|
func parseConfig() (*config, error) {
|
||||||
|
var (
|
||||||
|
inputDir = flag.String("input", ".", "Input directory containing Go source files")
|
||||||
|
outputDir = flag.String("output", "", "Output directory for generated files (default: same as input)")
|
||||||
|
pkgName = flag.String("package", "", "Output package name (default: inferred from output directory)")
|
||||||
|
hostOnly = flag.Bool("host-only", true, "Generate only host function wrappers (capability support TBD)")
|
||||||
|
goClient = flag.Bool("go", false, "Generate Go client wrappers")
|
||||||
|
pyClient = flag.Bool("python", false, "Generate Python client wrappers")
|
||||||
|
rsClient = flag.Bool("rust", false, "Generate Rust client wrappers")
|
||||||
|
verbose = flag.Bool("v", false, "Verbose output")
|
||||||
|
dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *outputDir == "" {
|
||||||
|
*outputDir = *inputDir
|
||||||
|
}
|
||||||
|
|
||||||
|
absInput, err := filepath.Abs(*inputDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolving input path: %w", err)
|
||||||
|
}
|
||||||
|
absOutput, err := filepath.Abs(*outputDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolving output path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *pkgName == "" {
|
||||||
|
*pkgName = filepath.Base(absOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine what to generate
|
||||||
|
// Default: generate Go clients if no language flag is specified
|
||||||
|
anyLangFlag := *goClient || *pyClient || *rsClient
|
||||||
|
|
||||||
|
return &config{
|
||||||
|
inputDir: absInput,
|
||||||
|
outputDir: absOutput,
|
||||||
|
pkgName: *pkgName,
|
||||||
|
hostOnly: *hostOnly,
|
||||||
|
generateGoClient: *goClient || !anyLangFlag,
|
||||||
|
generatePyClient: *pyClient,
|
||||||
|
generateRsClient: *rsClient,
|
||||||
|
verbose: *verbose,
|
||||||
|
dryRun: *dryRun,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseServices parses source files and returns discovered services.
|
||||||
|
func parseServices(cfg *config) ([]internal.Service, error) {
|
||||||
|
if cfg.verbose {
|
||||||
|
fmt.Printf("Input directory: %s\n", cfg.inputDir)
|
||||||
|
fmt.Printf("Output directory: %s\n", cfg.outputDir)
|
||||||
|
fmt.Printf("Package name: %s\n", cfg.pkgName)
|
||||||
|
fmt.Printf("Host-only mode: %v\n", cfg.hostOnly)
|
||||||
|
fmt.Printf("Generate Go client code: %v\n", cfg.generateGoClient)
|
||||||
|
fmt.Printf("Generate Python client code: %v\n", cfg.generatePyClient)
|
||||||
|
fmt.Printf("Generate Rust client code: %v\n", cfg.generateRsClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
services, err := internal.ParseDirectory(cfg.inputDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing source files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(services) == 0 {
|
||||||
|
if cfg.verbose {
|
||||||
|
fmt.Println("No host services found")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.verbose {
|
||||||
|
fmt.Printf("Found %d host service(s)\n", len(services))
|
||||||
|
for _, svc := range services {
|
||||||
|
fmt.Printf(" - %s (%d methods)\n", svc.Name, len(svc.Methods))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateAllCode generates all requested code for the services.
|
||||||
|
func generateAllCode(cfg *config, services []internal.Service) error {
|
||||||
|
for _, svc := range services {
|
||||||
|
if cfg.generateGoClient {
|
||||||
|
if err := generateGoClientCode(svc, cfg.outputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil {
|
||||||
|
return fmt.Errorf("generating Go client code for %s: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.generatePyClient {
|
||||||
|
if err := generatePythonClientCode(svc, cfg.outputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||||
|
return fmt.Errorf("generating Python client code for %s: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.generateRsClient {
|
||||||
|
if err := generateRustClientCode(svc, cfg.outputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||||
|
return fmt.Errorf("generating Rust client code for %s: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.generateRsClient && len(services) > 0 {
|
||||||
|
if err := generateRustLibFile(services, cfg.outputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||||
|
return fmt.Errorf("generating Rust lib.rs: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.generateGoClient && len(services) > 0 {
|
||||||
|
if err := generateGoDocFile(services, cfg.outputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil {
|
||||||
|
return fmt.Errorf("generating Go doc.go: %w", err)
|
||||||
|
}
|
||||||
|
if err := generateGoModFile(cfg.outputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||||
|
return fmt.Errorf("generating Go go.mod: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateGoClientCode generates Go client-side code for a service.
|
||||||
|
func generateGoClientCode(svc internal.Service, outputDir, pkgName string, dryRun, verbose bool) error {
|
||||||
|
code, err := internal.GenerateClientGo(svc, pkgName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generating code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted, err := format.Source(code)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("formatting code: %w\nRaw code:\n%s", err, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client code goes directly in the output directory
|
||||||
|
clientFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+".go")
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", clientFile, formatted)
|
||||||
|
} else {
|
||||||
|
// Create output directory if needed
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(clientFile, formatted, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Generated Go client code: %s\n", clientFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also generate stub file for non-WASM platforms
|
||||||
|
return generateGoClientStubCode(svc, outputDir, pkgName, dryRun, verbose)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateGoClientStubCode generates stub code for non-WASM platforms.
|
||||||
|
func generateGoClientStubCode(svc internal.Service, outputDir, pkgName string, dryRun, verbose bool) error {
|
||||||
|
code, err := internal.GenerateClientGoStub(svc, pkgName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generating stub code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted, err := format.Source(code)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("formatting stub code: %w\nRaw code:\n%s", err, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub code goes directly in output directory with _stub suffix
|
||||||
|
stubFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+"_stub.go")
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", stubFile, formatted)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output directory if needed
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(stubFile, formatted, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing stub file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Generated Go client stub: %s\n", stubFile)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePythonClientCode generates Python client-side code for a service.
|
||||||
|
func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
|
||||||
|
code, err := internal.GenerateClientPython(svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generating code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python code goes in python/ subdirectory
|
||||||
|
clientDir := filepath.Join(outputDir, "python")
|
||||||
|
clientFile := filepath.Join(clientDir, "nd_host_"+strings.ToLower(svc.Name)+".py")
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", clientFile, code)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create python/ subdirectory if needed
|
||||||
|
if err := os.MkdirAll(clientDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating python client directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(clientFile, code, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Generated Python client code: %s\n", clientFile)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRustClientCode generates Rust client-side code for a service.
|
||||||
|
func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
|
||||||
|
code, err := internal.GenerateClientRust(svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generating code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rust code goes in rust/ subdirectory
|
||||||
|
clientDir := filepath.Join(outputDir, "rust")
|
||||||
|
clientFile := filepath.Join(clientDir, "nd_host_"+strings.ToLower(svc.Name)+".rs")
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", clientFile, code)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create rust/ subdirectory if needed
|
||||||
|
if err := os.MkdirAll(clientDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating rust client directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(clientFile, code, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Generated Rust client code: %s\n", clientFile)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRustLibFile generates the lib.rs file that exposes all Rust modules.
|
||||||
|
func generateRustLibFile(services []internal.Service, outputDir string, dryRun, verbose bool) error {
|
||||||
|
code, err := internal.GenerateRustLib(services)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generating lib.rs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientDir := filepath.Join(outputDir, "rust")
|
||||||
|
libFile := filepath.Join(clientDir, "lib.rs")
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", libFile, code)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create rust/ subdirectory if needed
|
||||||
|
if err := os.MkdirAll(clientDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating rust client directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(libFile, code, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Generated Rust lib.rs: %s\n", libFile)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateGoDocFile generates the doc.go file for the Go library.
|
||||||
|
func generateGoDocFile(services []internal.Service, outputDir, pkgName string, dryRun, verbose bool) error {
|
||||||
|
code, err := internal.GenerateGoDoc(services, pkgName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generating doc.go: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted, err := format.Source(code)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("formatting doc.go: %w\nRaw code:\n%s", err, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
docFile := filepath.Join(outputDir, "doc.go")
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", docFile, formatted)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output directory if needed
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(docFile, formatted, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Generated Go doc.go: %s\n", docFile)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateGoModFile generates the go.mod file for the Go library.
|
||||||
|
func generateGoModFile(outputDir string, dryRun, verbose bool) error {
|
||||||
|
code, err := internal.GenerateGoMod()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generating go.mod: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modFile := filepath.Join(outputDir, "go.mod")
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", modFile, code)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output directory if needed
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(modFile, code, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Generated Go go.mod: %s\n", modFile)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
13
plugins/cmd/ndpgen/ndpgen_suite_test.go
Normal file
13
plugins/cmd/ndpgen/ndpgen_suite_test.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNdpgen(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "NDPGen CLI Suite")
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user