feat(hostgen): add hostgen tool for generating Extism host function wrappers

- Implemented hostgen tool to generate wrappers from annotated Go interfaces.
- Added command-line flags for input/output directories and package name.
- Introduced parsing and code generation logic for host services.
- Created test data for various service interfaces and expected generated code.
- Added documentation for host services and annotations for code generation.
- Implemented SubsonicAPI service with corresponding generated code.
This commit is contained in:
Deluan
2025-12-23 14:34:09 -05:00
parent 76d7e684d0
commit 0ab34f1102
37 changed files with 3281 additions and 0 deletions

View File

@@ -88,6 +88,16 @@ jobs:
exit 1
fi
- name: Run go generate
run: go generate ./...
- name: Verify no changes from go generate
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
echo 'Generated code is out of date. Run "make gen" and commit the changes'
exit 1
fi
go:
name: Test Go code
runs-on: ubuntu-latest

View File

@@ -103,6 +103,10 @@ wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags=netgo ./...
.PHONY: wire
gen: check_go_env ##@Development Run go generate for code generation
go generate ./...
.PHONY: gen
snapshots: ##@Development Update (GoLang) Snapshot tests
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
.PHONY: snapshots

View File

@@ -342,6 +342,118 @@ err := manager.ReloadPlugin("my-plugin")
- **Config changes**: Plugin configuration (`PluginConfig.<name>`) is read at load time. Changes require a reload.
- **Failed reloads**: If loading fails after unloading, the plugin remains unloaded. Check logs for errors.
## Host Services (Internal Development)
This section is for Navidrome developers who want to add new host services that plugins can call.
### Overview
Host services allow plugins to call back into Navidrome for functionality like Subsonic API access, scheduling, and other internal services. The `hostgen` tool generates Extism host function wrappers from annotated Go interfaces, automating the boilerplate of memory management, JSON marshalling, and error handling.
### Adding a New Host Service
1. **Create an annotated interface** in `plugins/host/`:
```go
// MyService provides some functionality to plugins.
//nd:hostservice name=MyService permission=myservice
type MyService interface {
// DoSomething performs an action.
//nd:hostfunc
DoSomething(ctx context.Context, input string) (output string, err error)
}
```
2. **Run the generator**:
```bash
make gen
# Or directly:
go run ./plugins/cmd/hostgen -input=./plugins/host -output=./plugins/host
```
3. **Implement the interface** and wire it up in `plugins/manager.go`.
### Annotation Format
#### Service-level (`//nd:hostservice`)
Marks an interface as a host service:
- `name=<ServiceName>` - Service identifier used in generated code
- `permission=<key>` - Manifest permission key (e.g., "subsonicapi", "scheduler")
#### Method-level (`//nd:hostfunc`)
Marks a method for host function wrapper generation:
- `name=<CustomName>` - (Optional) Override the export name
### Method Signature Requirements
- First parameter must be `context.Context`
- Last return value must be `error`
- All parameter types must be JSON-serializable
- Supported types: primitives, structs, slices, maps
### Generated Code
The generator creates `<servicename>_gen.go` with:
- Request/response structs for each method
- `Register<Service>HostFunctions()` - Returns Extism host functions to register
- Helper functions for memory operations and error handling
Example generated function name: `subsonicapi_call` for `SubsonicAPIService.Call`
### Important: Annotation Placement
**The annotation line must immediately precede the type/method declaration without an empty comment line between them.**
**Correct** (annotation directly before type):
```go
// MyService provides functionality.
// More documentation here.
//nd:hostservice name=MyService permission=myservice
type MyService interface { ... }
```
**Incorrect** (empty comment line separates annotation):
```go
// MyService provides functionality.
//
//nd:hostservice name=MyService permission=myservice
type MyService interface { ... }
```
This is due to how Go's AST parser groups comments. An empty `//` line creates a new comment group, causing the annotation to be separated from the type's doc comment.
### Troubleshooting
#### "No host services found" when running generator
1. **Check annotation placement**: Ensure `//nd:hostservice` is on the line immediately before the `type` declaration (no blank `//` line between doc text and annotation).
2. **Check file naming**: The generator skips files ending in `_gen.go` or `_test.go`.
3. **Check interface syntax**: The type must be an interface, not a struct.
4. **Run with verbose flag**: Use `-v` to see what the generator is finding:
```bash
go run ./plugins/cmd/hostgen -input=./plugins/host -output=./plugins/host -v
```
#### Generated code doesn't compile
1. **Check method signatures**: First parameter must be `context.Context`, last return must be `error`.
2. **Check parameter types**: All types must be JSON-serializable. Avoid channels, functions, and unexported types.
3. **Review raw output**: Use `-dry-run` to see the generated code without writing files.
#### Methods not being generated
1. **Check `//nd:hostfunc` annotation**: It must be in the method's doc comment, immediately before the method signature.
2. **Check method visibility**: Only methods with names (not embedded interfaces) are processed.
## Security
Plugins run in a secure WebAssembly sandbox with these restrictions:

View File

@@ -0,0 +1,223 @@
# hostgen
A code generator for Navidrome's plugin host functions. It reads Go interface definitions with special annotations and generates Extism host function wrappers.
## Usage
```bash
hostgen -input <dir> -output <dir> -package <name> [-v] [-dry-run]
```
### Flags
| Flag | Description | Default |
|------------|----------------------------------------------------------------|----------|
| `-input` | Directory containing Go source files with annotated interfaces | Required |
| `-output` | Directory where generated files will be written | Required |
| `-package` | Package name for generated files | Required |
| `-v` | Verbose output | `false` |
| `-dry-run` | Parse and validate without writing files | `false` |
### Example
```bash
go run ./plugins/cmd/hostgen \
-input ./plugins/host \
-output ./plugins/host \
-package host
```
Or via `go generate` (recommended):
```go
//go:generate go run ../cmd/hostgen -input . -output . -package host
package 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
For each annotated interface, hostgen generates:
### Request/Response Types
```go
// SubsonicAPICallRequest is the request type for SubsonicAPI.Call.
type SubsonicAPICallRequest struct {
Uri string `json:"uri"`
}
// SubsonicAPICallResponse is the response type for SubsonicAPI.Call.
type SubsonicAPICallResponse struct {
Response string `json:"response,omitempty"`
Error string `json:"error,omitempty"`
}
```
### Registration Function
```go
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
return []extism.HostFunction{
newSubsonicAPICallHostFunction(service),
}
}
```
### Host Function Wrappers
Each method gets a wrapper that:
1. Reads JSON request from plugin memory
2. Unmarshals to the request type
3. Calls the service method
4. Marshals the response
5. Writes JSON response to plugin memory
## Supported Types
hostgen 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"`
}
```
## Output Files
Generated files are named `<servicename>_gen.go` (lowercase). Each file includes:
- `// Code generated by hostgen. DO NOT EDIT.` header
- Required imports (`context`, `encoding/json`, `extism`)
- Request/response struct types
- Registration function
- Host function wrappers
- Helper functions (`writeResponse`, `writeErrorResponse`)
## Troubleshooting
### Annotations Not Detected
Ensure annotations are on consecutive lines with no blank `//` lines:
```go
// ✅ Correct
// Documentation for the service.
//nd:hostservice name=Test permission=test
// ❌ Wrong - blank comment line breaks detection
// Documentation for the service.
//
//nd:hostservice name=Test permission=test
```
### Methods Not Exported
Methods without `//nd:hostfunc` annotation are skipped. Ensure the annotation is directly above the method:
```go
// ✅ Correct
// Method documentation.
//nd:hostfunc
MyMethod(ctx context.Context) error
// ❌ Wrong - annotation not directly above method
//nd:hostfunc
MyMethod(ctx context.Context) error
```
### Generated Files Skipped
Files ending in `_gen.go` are automatically skipped during parsing to avoid processing previously generated code.
## Development
Run tests:
```bash
go test -v ./plugins/cmd/hostgen/...
```
The test suite includes:
- CLI integration tests
- Complex type handling (structs, slices, maps, pointers)
- Multiple return value scenarios
- Error cases and edge conditions

View File

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

View File

@@ -0,0 +1,259 @@
package main
import (
"go/format"
"os"
"os/exec"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
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)
}
var _ = Describe("hostgen CLI", Ordered, func() {
var (
testDir string
outputDir string
hostgenBin string
)
BeforeAll(func() {
// Set testdata directory
testdataDir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "hostgen", "testdata")
// Build the hostgen binary
hostgenBin = filepath.Join(os.TempDir(), "hostgen-test")
cmd := exec.Command("go", "build", "-o", hostgenBin, ".")
cmd.Dir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "hostgen")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Failed to build hostgen: %s", output)
DeferCleanup(func() {
os.Remove(hostgenBin)
})
})
BeforeEach(func() {
var err error
testDir, err = os.MkdirTemp("", "hostgen-test-input-*")
Expect(err).ToNot(HaveOccurred())
outputDir, err = os.MkdirTemp("", "hostgen-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(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-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(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-dry-run")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
Expect(string(output)).To(ContainSubstring("RegisterTestHostFunctions"))
Expect(filepath.Join(outputDir, "test_gen.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(hostgenBin, "-input", testDir, "-output", customOutput)
_, err = cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred())
content, err := os.ReadFile(filepath.Join(customOutput, "test_gen.go"))
Expect(err).ToNot(HaveOccurred())
Expect(string(content)).To(ContainSubstring("package mypkg"))
})
It("returns error for invalid input directory", func() {
cmd := exec.Command(hostgenBin, "-input", "/nonexistent/path")
output, err := cmd.CombinedOutput()
Expect(err).To(HaveOccurred())
Expect(string(output)).To(ContainSubstring("Error 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(hostgenBin, "-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(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-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, "servicea_gen.go")).To(BeAnExistingFile())
Expect(filepath.Join(outputDir, "serviceb_gen.go")).To(BeAnExistingFile())
})
})
Describe("code generation", func() {
DescribeTable("generates correct output",
func(serviceFile, expectedFile string) {
serviceCode := readTestdata(serviceFile)
expectedCode := readTestdata(expectedFile)
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
entries, err := os.ReadDir(outputDir)
Expect(err).ToNot(HaveOccurred())
Expect(entries).To(HaveLen(1), "Expected exactly one generated file")
actual, err := os.ReadFile(filepath.Join(outputDir, entries[0].Name()))
Expect(err).ToNot(HaveOccurred())
// Format both for comparison
formattedActual, err := format.Source(actual)
Expect(err).ToNot(HaveOccurred(), "Generated code is not valid Go:\n%s", actual)
formattedExpected, err := format.Source([]byte(expectedCode))
Expect(err).ToNot(HaveOccurred(), "Expected code is not valid Go")
Expect(string(formattedActual)).To(Equal(string(formattedExpected)))
},
Entry("simple string params - no request type needed",
"echo_service.go", "echo_expected.go"),
Entry("multiple simple params",
"math_service.go", "math_expected.go"),
Entry("struct param with request type",
"store_service.go", "store_expected.go"),
Entry("mixed simple and complex params",
"list_service.go", "list_expected.go"),
Entry("method without error",
"counter_service.go", "counter_expected.go"),
Entry("no params, error only",
"ping_service.go", "ping_expected.go"),
Entry("map and interface types",
"meta_service.go", "meta_expected.go"),
Entry("pointer types",
"users_service.go", "users_expected.go"),
Entry("multiple returns",
"search_service.go", "search_expected.go"),
Entry("bytes",
"codec_service.go", "codec_expected.go"),
)
It("generates compilable code for comprehensive service", func() {
serviceCode := readTestdata("comprehensive_service.go")
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
// Create go.mod
goMod := "module testpkg\n\ngo 1.23\n\nrequire github.com/extism/go-sdk v1.7.1\n"
Expect(os.WriteFile(filepath.Join(testDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
// Generate
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", testDir, "-package", "testpkg")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
// Tidy dependencies
goGetCmd := exec.Command("go", "mod", "tidy")
goGetCmd.Dir = testDir
goGetOutput, err := goGetCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "go mod tidy failed: %s", goGetOutput)
// Build
buildCmd := exec.Command("go", "build", ".")
buildCmd.Dir = testDir
buildOutput, err := buildCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Build failed: %s", buildOutput)
})
})
})
func mustGetWd(t FullGinkgoTInterface) string {
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find project root")
}
dir = parent
}
}

View File

@@ -0,0 +1,359 @@
package internal
import (
"bytes"
"fmt"
"strings"
"text/template"
)
// GenerateService generates the host function wrapper code for a service.
func GenerateService(svc Service, pkgName string) ([]byte, error) {
tmpl, err := template.New("service").Funcs(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) },
"valueType": GoTypeToValueType,
"isSimple": IsSimpleType,
"isString": IsStringType,
"isBytes": IsBytesType,
"needsJSON": NeedsJSON,
"needsRequestType": func(m Method) bool { return m.NeedsRequestType() },
"needsRespType": func(m Method) bool { return m.NeedsResponseType() },
"hasErrFromRead": hasErrorFromRead,
"readParam": generateReadParam,
"writeReturn": generateWriteReturn,
"encodeReturn": generateEncodeReturn,
}).Parse(serviceTemplate)
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
data := templateData{
Package: pkgName,
Service: svc,
NeedsJSON: serviceNeedsJSON(svc),
NeedsWriteHelper: serviceNeedsWriteHelper(svc),
NeedsErrorHelper: serviceNeedsErrorHelper(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
NeedsJSON bool
NeedsWriteHelper bool
NeedsErrorHelper bool
}
// serviceNeedsJSON returns true if any method needs JSON encoding.
func serviceNeedsJSON(svc Service) bool {
for _, m := range svc.Methods {
for _, p := range m.Params {
if NeedsJSON(p.Type) {
return true
}
}
for _, r := range m.Returns {
if NeedsJSON(r.Type) {
return true
}
}
// Error responses are also JSON
if m.HasError && m.NeedsResponseType() {
return true
}
}
return false
}
// serviceNeedsWriteHelper returns true if any method needs the write helper.
func serviceNeedsWriteHelper(svc Service) bool {
for _, m := range svc.Methods {
if m.NeedsResponseType() {
return true
}
}
return false
}
// serviceNeedsErrorHelper returns true if any method needs error handling with JSON.
func serviceNeedsErrorHelper(svc Service) bool {
for _, m := range svc.Methods {
if m.HasError && m.NeedsResponseType() {
return true
}
}
return false
}
// hasErrorFromRead returns true if reading params declares an err variable.
// This happens when using needsRequestType (JSON) or when any param is a PTR type.
func hasErrorFromRead(m Method) bool {
if m.NeedsRequestType() {
return true
}
for _, p := range m.Params {
if IsStringType(p.Type) || IsBytesType(p.Type) {
return true
}
}
return false
}
// generateReadParam generates code to read a parameter from the stack.
func generateReadParam(p Param, stackIndex int) string {
switch {
case IsSimpleType(p.Type):
return generateReadSimple(p, stackIndex)
case IsStringType(p.Type):
return fmt.Sprintf(`%s, err := p.ReadString(stack[%d])
if err != nil {
return
}`, p.Name, stackIndex)
case IsBytesType(p.Type):
return fmt.Sprintf(`%s, err := p.ReadBytes(stack[%d])
if err != nil {
return
}`, p.Name, stackIndex)
default:
// Complex type - JSON
return fmt.Sprintf(`%sBytes, err := p.ReadBytes(stack[%d])
if err != nil {
return
}
var %s %s
if err := json.Unmarshal(%sBytes, &%s); err != nil {
return
}`, p.Name, stackIndex, p.Name, p.Type, p.Name, p.Name)
}
}
// generateReadSimple generates code to read a simple type from the stack.
func generateReadSimple(p Param, stackIndex int) string {
switch p.Type {
case "int32":
return fmt.Sprintf(`%s := extism.DecodeI32(stack[%d])`, p.Name, stackIndex)
case "uint32":
return fmt.Sprintf(`%s := extism.DecodeU32(stack[%d])`, p.Name, stackIndex)
case "int64":
return fmt.Sprintf(`%s := int64(stack[%d])`, p.Name, stackIndex)
case "uint64":
return fmt.Sprintf(`%s := stack[%d]`, p.Name, stackIndex)
case "float32":
return fmt.Sprintf(`%s := extism.DecodeF32(stack[%d])`, p.Name, stackIndex)
case "float64":
return fmt.Sprintf(`%s := extism.DecodeF64(stack[%d])`, p.Name, stackIndex)
case "bool":
return fmt.Sprintf(`%s := extism.DecodeI32(stack[%d]) != 0`, p.Name, stackIndex)
default:
return fmt.Sprintf(`// FIXME: unsupported type: %s`, p.Type)
}
}
// generateWriteReturn generates code to write a return value to the stack.
func generateWriteReturn(p Param, stackIndex int, varName string) string {
switch {
case IsSimpleType(p.Type):
return fmt.Sprintf(`stack[%d] = %s`, stackIndex, generateEncodeReturn(p, varName))
case IsStringType(p.Type):
return fmt.Sprintf(`if ptr, err := p.WriteString(%s); err == nil {
stack[%d] = ptr
}`, varName, stackIndex)
case IsBytesType(p.Type):
return fmt.Sprintf(`if ptr, err := p.WriteBytes(%s); err == nil {
stack[%d] = ptr
}`, varName, stackIndex)
default:
// Complex type - JSON
return fmt.Sprintf(`if bytes, err := json.Marshal(%s); err == nil {
if ptr, err := p.WriteBytes(bytes); err == nil {
stack[%d] = ptr
}
}`, varName, stackIndex)
}
}
// generateEncodeReturn generates the encoding expression for a simple return.
func generateEncodeReturn(p Param, varName string) string {
switch p.Type {
case "int32":
return fmt.Sprintf("extism.EncodeI32(%s)", varName)
case "uint32":
return fmt.Sprintf("extism.EncodeU32(%s)", varName)
case "int64":
return fmt.Sprintf("uint64(%s)", varName)
case "uint64":
return varName
case "float32":
return fmt.Sprintf("extism.EncodeF32(%s)", varName)
case "float64":
return fmt.Sprintf("extism.EncodeF64(%s)", varName)
case "bool":
return fmt.Sprintf("func() uint64 { if %s { return 1 }; return 0 }()", varName)
default:
return "0"
}
}
const serviceTemplate = `// Code generated by hostgen. DO NOT EDIT.
package {{.Package}}
import (
"context"
{{- if .NeedsJSON}}
"encoding/json"
{{- end}}
extism "github.com/extism/go-sdk"
)
{{- /* Generate request/response types only when needed */ -}}
{{range .Service.Methods}}
{{- if needsRequestType .}}
// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
type {{requestType .}} struct {
{{- range .Params}}
{{title .Name}} {{.Type}} ` + "`" + `json:"{{.JSONName}}"` + "`" + `
{{- end}}
}
{{- end}}
{{- if needsRespType .}}
// {{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}}
{{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}}
{{- if needsRequestType .}}
// 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
}
{{- else}}
// Read parameters from stack
{{- range $i, $p := .Params}}
{{readParam $p $i}}
{{- end}}
{{- end}}
{{- end}}
// Call the service method
{{- $m := .}}
{{- if .HasReturns}}
{{- if .HasError}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, err := service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
{{- else}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
{{- end}}
{{- else if .HasError}}
err {{if hasErrFromRead .}}={{else}}:={{end}} service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
{{- else}}
service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
{{- end}}
{{- if .HasError}}
if err != nil {
{{- if needsRespType .}}
{{$.Service.Name | lower}}WriteError(p, stack, err)
{{- end}}
return
}
{{- end}}
{{- if needsRespType .}}
// Write JSON response to plugin memory
resp := {{responseType .}}{
{{- range .Returns}}
{{title .Name}}: {{lower .Name}},
{{- end}}
}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- else if .HasReturns}}
// Write return values to stack
{{- range $i, $r := .Returns}}
{{writeReturn $r $i (lower $r.Name)}}
{{- end}}
{{- end}}
},
{{- if needsRequestType $m}}
[]extism.ValueType{extism.ValueTypePTR},
{{- else}}
[]extism.ValueType{ {{- range $i, $p := .Params}}{{if $i}}, {{end}}{{valueType $p.Type}}{{end}}{{if not .HasParams}}{{end}} },
{{- end}}
{{- if needsRespType .}}
[]extism.ValueType{extism.ValueTypePTR},
{{- else}}
[]extism.ValueType{ {{- range $i, $r := .Returns}}{{if $i}}, {{end}}{{valueType $r.Type}}{{end}}{{if not .HasReturns}}{{end}} },
{{- end}}
)
}
{{end}}
{{- if .NeedsWriteHelper}}
// {{.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
}
{{- end}}
{{- if .NeedsErrorHelper}}
// {{.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
}
{{- end}}
`

View File

@@ -0,0 +1,356 @@
package internal
import (
"go/format"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Generator", func() {
Describe("GenerateService", func() {
It("should generate valid Go code for a simple service with strings", func() {
// String params/returns don't need JSON - they use direct memory read/write
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 := GenerateService(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"))
// String params don't need request type - read directly from memory
Expect(codeStr).NotTo(ContainSubstring("type SubsonicAPICallRequest struct"))
// String return with error needs response type for 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 direct string read (not JSON unmarshal)
Expect(codeStr).To(ContainSubstring("p.ReadString(stack[0])"))
})
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 := GenerateService(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should not have request type for methods without params
Expect(codeStr).NotTo(ContainSubstring("type TestNoParamsRequest struct"))
})
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 := GenerateService(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 := GenerateService(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 without JSON", func() {
// All simple params (string, int32, bool) can be passed on stack directly
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 := GenerateService(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// No request type for simple params - they're read directly from stack
Expect(codeStr).NotTo(ContainSubstring("type TestMultiParamRequest struct"))
// Check for direct stack reads
Expect(codeStr).To(ContainSubstring("p.ReadString(stack[0])"))
Expect(codeStr).To(ContainSubstring("extism.DecodeI32(stack[1])"))
Expect(codeStr).To(ContainSubstring("extism.DecodeI32(stack[2])"))
// Check that input ValueType slice has correct entries (3 params: PTR for string, I32 for int32, I32 for bool)
Expect(codeStr).To(ContainSubstring("extism.ValueTypePTR, extism.ValueTypeI32, extism.ValueTypeI32"))
})
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 := GenerateService(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 := GenerateService(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 := GenerateService(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 not include json import when not needed", func() {
// Service with only simple types doesn't need JSON import
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Method",
Params: []Param{NewParam("count", "int32")},
Returns: []Param{NewParam("result", "int64")},
},
},
}
code, err := GenerateService(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring(`"context"`))
Expect(codeStr).NotTo(ContainSubstring(`"encoding/json"`))
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
})
})
Describe("toJSONName", func() {
It("should convert to camelCase", func() {
Expect(toJSONName("InputValue")).To(Equal("inputValue"))
Expect(toJSONName("URI")).To(Equal("uRI"))
Expect(toJSONName("id")).To(Equal("id"))
})
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("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 := GenerateService(services[0], "host")
Expect(err).NotTo(HaveOccurred())
// Format (validates syntax)
formatted, err := format.Source(code)
Expect(err).NotTo(HaveOccurred())
// Verify key elements
codeStr := string(formatted)
Expect(codeStr).To(ContainSubstring("RegisterTestHostFunctions"))
Expect(codeStr).To(ContainSubstring(`"test_dosomething"`))
})
})
})
func writeFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0600)
}

View File

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

View File

@@ -0,0 +1,294 @@
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
}
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
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)
}
if len(service.Methods) > 0 {
services = append(services, service)
}
}
}
return services, nil
}
// getDocComment extracts the doc comment for a type spec.
// Returns both the readable doc text and the raw comment text (which includes pragma-style comments).
func getDocComment(genDecl *ast.GenDecl, typeSpec *ast.TypeSpec) (docText, rawText string) {
var docGroup *ast.CommentGroup
// First check the TypeSpec's own doc (when multiple types in one block)
if typeSpec.Doc != nil {
docGroup = typeSpec.Doc
} else if genDecl.Doc != nil {
// Fall back to GenDecl doc (single type declaration)
docGroup = genDecl.Doc
}
if docGroup == nil {
return "", ""
}
return docGroup.Text(), commentGroupRaw(docGroup)
}
// commentGroupRaw returns all comment text including pragma-style comments (//nd:...).
// Go's ast.CommentGroup.Text() strips comments without a space after //, so we need this.
func commentGroupRaw(cg *ast.CommentGroup) string {
if cg == nil {
return ""
}
var lines []string
for _, c := range cg.List {
lines = append(lines, c.Text)
}
return strings.Join(lines, "\n")
}
// getMethodDocComment extracts the doc comment for a method.
func getMethodDocComment(field *ast.Field) (docText, rawText string) {
if field.Doc == nil {
return "", ""
}
return field.Doc.Text(), commentGroupRaw(field.Doc)
}
// parseHostServiceAnnotation extracts //nd:hostservice annotation parameters.
func parseHostServiceAnnotation(doc string) map[string]string {
for _, line := range strings.Split(doc, "\n") {
line = strings.TrimSpace(line)
match := hostServicePattern.FindStringSubmatch(line)
if match != nil {
return parseKeyValuePairs(match[1])
}
}
return nil
}
// parseHostFuncAnnotation extracts //nd:hostfunc annotation parameters.
func parseHostFuncAnnotation(doc string) map[string]string {
for _, line := range strings.Split(doc, "\n") {
line = strings.TrimSpace(line)
match := hostFuncPattern.FindStringSubmatch(line)
if match != nil {
params := parseKeyValuePairs(match[1])
if params == nil {
params = make(map[string]string)
}
return params
}
}
return nil
}
// parseKeyValuePairs extracts key=value pairs from annotation text.
func parseKeyValuePairs(text string) map[string]string {
matches := keyValuePattern.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
result := make(map[string]string)
for _, m := range matches {
result[m[1]] = m[2]
}
return result
}
// parseMethod parses a method signature into a Method struct.
func parseMethod(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Method, error) {
m := Method{
Name: name,
ExportName: annotation["name"],
Doc: doc,
}
// Parse parameters (skip context.Context)
if funcType.Params != nil {
for _, field := range funcType.Params.List {
typeName := typeToString(field.Type)
if typeName == "context.Context" {
continue // Skip context parameter
}
for _, name := range field.Names {
m.Params = append(m.Params, NewParam(name.Name, typeName))
}
}
}
// Parse return values
if funcType.Results != nil {
for _, field := range funcType.Results.List {
typeName := typeToString(field.Type)
if typeName == "error" {
m.HasError = true
continue // Track error but don't include in Returns
}
// Handle anonymous returns
if len(field.Names) == 0 {
// Generate a name based on position
m.Returns = append(m.Returns, NewParam("result", typeName))
} else {
for _, name := range field.Names {
m.Returns = append(m.Returns, NewParam(name.Name, typeName))
}
}
}
}
return m, nil
}
// typeToString converts an AST type expression to a string.
func typeToString(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.SelectorExpr:
return typeToString(t.X) + "." + t.Sel.Name
case *ast.StarExpr:
return "*" + typeToString(t.X)
case *ast.ArrayType:
if t.Len == nil {
return "[]" + typeToString(t.Elt)
}
return fmt.Sprintf("[%s]%s", typeToString(t.Len), typeToString(t.Elt))
case *ast.MapType:
return fmt.Sprintf("map[%s]%s", typeToString(t.Key), typeToString(t.Value))
case *ast.BasicLit:
return t.Value
case *ast.InterfaceType:
// Empty interface (interface{} or any)
if t.Methods == nil || len(t.Methods.List) == 0 {
return "any"
}
// Non-empty interfaces can't be easily represented
return "any"
default:
return fmt.Sprintf("%T", expr)
}
}
// cleanDoc removes annotation lines from documentation.
func cleanDoc(doc string) string {
var lines []string
for _, line := range strings.Split(doc, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//nd:") {
continue
}
lines = append(lines, line)
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}

View File

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

View File

@@ -0,0 +1,203 @@
package internal
import (
"strings"
)
// 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
}
// 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)
}
// 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),
}
}
// IsSimple returns true if the type can be passed directly on the WASM stack
// without JSON serialization (primitive numeric types).
func (p Param) IsSimple() bool {
return IsSimpleType(p.Type)
}
// IsPTR returns true if the type should be passed via memory pointer
// (strings, bytes, and complex types that need JSON serialization).
func (p Param) IsPTR() bool {
return !p.IsSimple()
}
// ValueType returns the Extism ValueType constant name for this parameter.
func (p Param) ValueType() string {
return GoTypeToValueType(p.Type)
}
// IsSimpleType returns true if a Go type can be passed directly on WASM stack.
func IsSimpleType(typ string) bool {
switch typ {
case "int32", "uint32", "int64", "uint64", "float32", "float64", "bool":
return true
default:
return false
}
}
// IsStringType returns true if the type is a string.
func IsStringType(typ string) bool {
return typ == "string"
}
// IsBytesType returns true if the type is []byte.
func IsBytesType(typ string) bool {
return typ == "[]byte"
}
// NeedsJSON returns true if the type requires JSON serialization.
func NeedsJSON(typ string) bool {
if IsSimpleType(typ) || IsStringType(typ) || IsBytesType(typ) {
return false
}
return true
}
// GoTypeToValueType returns the Extism ValueType constant for a Go type.
func GoTypeToValueType(typ string) string {
switch typ {
case "int32", "uint32":
return "extism.ValueTypeI32"
case "int64", "uint64":
return "extism.ValueTypeI64"
case "float32":
return "extism.ValueTypeF32"
case "float64":
return "extism.ValueTypeF64"
case "bool":
return "extism.ValueTypeI32" // bool as i32
default:
// strings, []byte, structs, maps, slices all use PTR (i64)
return "extism.ValueTypePTR"
}
}
// AllParamsSimple returns true if all params can be passed on the stack.
func (m Method) AllParamsSimple() bool {
for _, p := range m.Params {
if !p.IsSimple() {
return false
}
}
return true
}
// AllReturnsSimple returns true if all returns can be passed on the stack.
func (m Method) AllReturnsSimple() bool {
for _, r := range m.Returns {
if !r.IsSimple() {
return false
}
}
return true
}
// NeedsRequestType returns true if a request struct is needed.
// Only needed when we have complex params that require JSON.
func (m Method) NeedsRequestType() bool {
if !m.HasParams() {
return false
}
for _, p := range m.Params {
if NeedsJSON(p.Type) {
return true
}
}
return false
}
// NeedsResponseType returns true if a response struct is needed.
// Needed when we have complex returns that require JSON, or error handling.
func (m Method) NeedsResponseType() bool {
// If there's an error, we need a response type to serialize it
if m.HasError {
// But only if there are also returns, or if returns need JSON
if m.HasReturns() {
return true
}
}
for _, r := range m.Returns {
if NeedsJSON(r.Type) {
return true
}
}
return false
}
// toJSONName converts a Go identifier to camelCase JSON field name.
func toJSONName(name string) string {
if name == "" {
return ""
}
// Simple conversion: lowercase first letter
return strings.ToLower(name[:1]) + name[1:]
}

116
plugins/cmd/hostgen/main.go Normal file
View File

@@ -0,0 +1,116 @@
// hostgen generates Extism host function wrappers from annotated Go interfaces.
//
// Usage:
//
// hostgen -input=./plugins/host -output=./plugins/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)
// -v Verbose output
// -dry-run Preview generated code without writing files
package main
import (
"flag"
"fmt"
"go/format"
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/cmd/hostgen/internal"
)
func main() {
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)")
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
}
// Resolve absolute paths
absInput, err := filepath.Abs(*inputDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving input path: %v\n", err)
os.Exit(1)
}
absOutput, err := filepath.Abs(*outputDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving output path: %v\n", err)
os.Exit(1)
}
// Infer package name if not provided
if *pkgName == "" {
*pkgName = filepath.Base(absOutput)
}
if *verbose {
fmt.Printf("Input directory: %s\n", absInput)
fmt.Printf("Output directory: %s\n", absOutput)
fmt.Printf("Package name: %s\n", *pkgName)
}
// Parse source files
services, err := internal.ParseDirectory(absInput)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing source files: %v\n", err)
os.Exit(1)
}
if len(services) == 0 {
if *verbose {
fmt.Println("No host services found")
}
return
}
if *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))
}
}
// Generate code for each service
for _, svc := range services {
code, err := internal.GenerateService(svc, *pkgName)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating code for %s: %v\n", svc.Name, err)
os.Exit(1)
}
// Format the generated code
formatted, err := format.Source(code)
if err != nil {
fmt.Fprintf(os.Stderr, "Error formatting generated code for %s: %v\n", svc.Name, err)
fmt.Fprintf(os.Stderr, "Raw code:\n%s\n", code)
os.Exit(1)
}
outputFile := filepath.Join(absOutput, svc.OutputFileName())
if *dryRun {
fmt.Printf("=== %s ===\n%s\n", outputFile, formatted)
continue
}
if err := os.WriteFile(outputFile, formatted, 0600); err != nil {
fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", outputFile, err)
os.Exit(1)
}
if *verbose {
fmt.Printf("Generated %s\n", outputFile)
}
}
}

View File

@@ -0,0 +1,76 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// CodecEncodeResponse is the response type for Codec.Encode.
type CodecEncodeResponse struct {
Result []byte `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterCodecHostFunctions registers Codec service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterCodecHostFunctions(service CodecService) []extism.HostFunction {
return []extism.HostFunction{
newCodecEncodeHostFunction(service),
}
}
func newCodecEncodeHostFunction(service CodecService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"codec_encode",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
data, err := p.ReadBytes(stack[0])
if err != nil {
return
}
// Call the service method
result, err := service.Encode(ctx, data)
if err != nil {
codecWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := CodecEncodeResponse{
Result: result,
}
codecWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// codecWriteResponse writes a JSON response to plugin memory.
func codecWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
codecWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// codecWriteError writes an error response to plugin memory.
func codecWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@@ -0,0 +1,9 @@
package testpkg
import "context"
//nd:hostservice name=Codec permission=codec
type CodecService interface {
//nd:hostfunc
Encode(ctx context.Context, data []byte) ([]byte, error)
}

View File

@@ -0,0 +1,36 @@
package testpkg
import "context"
type User2 struct {
ID string
Name string
}
type Filter2 struct {
Active bool
}
//nd:hostservice name=Comprehensive permission=comprehensive
type ComprehensiveService interface {
//nd:hostfunc
SimpleParams(ctx context.Context, name string, count int32) (string, error)
//nd:hostfunc
StructParam(ctx context.Context, user User2) error
//nd:hostfunc
MixedParams(ctx context.Context, id string, filter Filter2) (int32, error)
//nd:hostfunc
NoError(ctx context.Context, name string) string
//nd:hostfunc
NoParams(ctx context.Context) error
//nd:hostfunc
NoParamsNoReturns(ctx context.Context)
//nd:hostfunc
PointerParams(ctx context.Context, id *string, user *User2) (*User2, error)
//nd:hostfunc
MapParams(ctx context.Context, data map[string]any) (interface{}, error)
//nd:hostfunc
MultipleReturns(ctx context.Context, query string) (results []User2, total int32, err error)
//nd:hostfunc
ByteSlice(ctx context.Context, data []byte) ([]byte, error)
}

View File

@@ -0,0 +1,37 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
extism "github.com/extism/go-sdk"
)
// RegisterCounterHostFunctions registers Counter service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterCounterHostFunctions(service CounterService) []extism.HostFunction {
return []extism.HostFunction{
newCounterCountHostFunction(service),
}
}
func newCounterCountHostFunction(service CounterService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"counter_count",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
name, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
value := service.Count(ctx, name)
// Write return values to stack
stack[0] = extism.EncodeI32(value)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypeI32},
)
}

View File

@@ -0,0 +1,9 @@
package testpkg
import "context"
//nd:hostservice name=Counter permission=counter
type CounterService interface {
//nd:hostfunc
Count(ctx context.Context, name string) (value int32)
}

View File

@@ -0,0 +1,76 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// EchoEchoResponse is the response type for Echo.Echo.
type EchoEchoResponse struct {
Reply string `json:"reply,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterEchoHostFunctions registers Echo service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterEchoHostFunctions(service EchoService) []extism.HostFunction {
return []extism.HostFunction{
newEchoEchoHostFunction(service),
}
}
func newEchoEchoHostFunction(service EchoService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"echo_echo",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
message, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
reply, err := service.Echo(ctx, message)
if err != nil {
echoWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := EchoEchoResponse{
Reply: reply,
}
echoWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// echoWriteResponse writes a JSON response to plugin memory.
func echoWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
echoWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// echoWriteError writes an error response to plugin memory.
func echoWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@@ -0,0 +1,9 @@
package testpkg
import "context"
//nd:hostservice name=Echo permission=echo
type EchoService interface {
//nd:hostfunc
Echo(ctx context.Context, message string) (reply string, err error)
}

View File

@@ -0,0 +1,88 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// ListItemsRequest is the request type for List.Items.
type ListItemsRequest struct {
Name string `json:"name"`
Filter Filter `json:"filter"`
}
// ListItemsResponse is the response type for List.Items.
type ListItemsResponse struct {
Count int32 `json:"count,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterListHostFunctions registers List service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterListHostFunctions(service ListService) []extism.HostFunction {
return []extism.HostFunction{
newListItemsHostFunction(service),
}
}
func newListItemsHostFunction(service ListService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"list_items",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
listWriteError(p, stack, err)
return
}
var req ListItemsRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
listWriteError(p, stack, err)
return
}
// Call the service method
count, err := service.Items(ctx, req.Name, req.Filter)
if err != nil {
listWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := ListItemsResponse{
Count: count,
}
listWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// listWriteResponse writes a JSON response to plugin memory.
func listWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
listWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// listWriteError writes an error response to plugin memory.
func listWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@@ -0,0 +1,13 @@
package testpkg
import "context"
type Filter struct {
Active bool
}
//nd:hostservice name=List permission=list
type ListService interface {
//nd:hostfunc
Items(ctx context.Context, name string, filter Filter) (count int32, err error)
}

View File

@@ -0,0 +1,74 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// MathAddResponse is the response type for Math.Add.
type MathAddResponse struct {
Result int32 `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterMathHostFunctions registers Math service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterMathHostFunctions(service MathService) []extism.HostFunction {
return []extism.HostFunction{
newMathAddHostFunction(service),
}
}
func newMathAddHostFunction(service MathService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"math_add",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
a := extism.DecodeI32(stack[0])
b := extism.DecodeI32(stack[1])
// Call the service method
result, err := service.Add(ctx, a, b)
if err != nil {
mathWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := MathAddResponse{
Result: result,
}
mathWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypeI32, extism.ValueTypeI32},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// mathWriteResponse writes a JSON response to plugin memory.
func mathWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
mathWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// mathWriteError writes an error response to plugin memory.
func mathWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@@ -0,0 +1,9 @@
package testpkg
import "context"
//nd:hostservice name=Math permission=math
type MathService interface {
//nd:hostfunc
Add(ctx context.Context, a int32, b int32) (result int32, err error)
}

View File

@@ -0,0 +1,109 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// MetaGetResponse is the response type for Meta.Get.
type MetaGetResponse struct {
Value any `json:"value,omitempty"`
Error string `json:"error,omitempty"`
}
// MetaSetRequest is the request type for Meta.Set.
type MetaSetRequest struct {
Data map[string]any `json:"data"`
}
// RegisterMetaHostFunctions registers Meta service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterMetaHostFunctions(service MetaService) []extism.HostFunction {
return []extism.HostFunction{
newMetaGetHostFunction(service),
newMetaSetHostFunction(service),
}
}
func newMetaGetHostFunction(service MetaService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"meta_get",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
key, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
value, err := service.Get(ctx, key)
if err != nil {
metaWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := MetaGetResponse{
Value: value,
}
metaWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
func newMetaSetHostFunction(service MetaService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"meta_set",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
metaWriteError(p, stack, err)
return
}
var req MetaSetRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
metaWriteError(p, stack, err)
return
}
// Call the service method
err = service.Set(ctx, req.Data)
if err != nil {
return
}
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{},
)
}
// metaWriteResponse writes a JSON response to plugin memory.
func metaWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
metaWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// metaWriteError writes an error response to plugin memory.
func metaWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@@ -0,0 +1,11 @@
package testpkg
import "context"
//nd:hostservice name=Meta permission=meta
type MetaService interface {
//nd:hostfunc
Get(ctx context.Context, key string) (value interface{}, err error)
//nd:hostfunc
Set(ctx context.Context, data map[string]any) error
}

View File

@@ -0,0 +1,33 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
extism "github.com/extism/go-sdk"
)
// RegisterPingHostFunctions registers Ping service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterPingHostFunctions(service PingService) []extism.HostFunction {
return []extism.HostFunction{
newPingPingHostFunction(service),
}
}
func newPingPingHostFunction(service PingService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"ping_ping",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Call the service method
err := service.Ping(ctx)
if err != nil {
return
}
},
[]extism.ValueType{},
[]extism.ValueType{},
)
}

View File

@@ -0,0 +1,9 @@
package testpkg
import "context"
//nd:hostservice name=Ping permission=ping
type PingService interface {
//nd:hostfunc
Ping(ctx context.Context) error
}

View File

@@ -0,0 +1,78 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// SearchFindResponse is the response type for Search.Find.
type SearchFindResponse struct {
Results []Result `json:"results,omitempty"`
Total int32 `json:"total,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterSearchHostFunctions registers Search service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterSearchHostFunctions(service SearchService) []extism.HostFunction {
return []extism.HostFunction{
newSearchFindHostFunction(service),
}
}
func newSearchFindHostFunction(service SearchService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"search_find",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
query, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
results, total, err := service.Find(ctx, query)
if err != nil {
searchWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := SearchFindResponse{
Results: results,
Total: total,
}
searchWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// searchWriteResponse writes a JSON response to plugin memory.
func searchWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
searchWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// searchWriteError writes an error response to plugin memory.
func searchWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@@ -0,0 +1,13 @@
package testpkg
import "context"
type Result struct {
ID string
}
//nd:hostservice name=Search permission=search
type SearchService interface {
//nd:hostfunc
Find(ctx context.Context, query string) (results []Result, total int32, err error)
}

View File

@@ -0,0 +1,87 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// StoreSaveRequest is the request type for Store.Save.
type StoreSaveRequest struct {
Item Item `json:"item"`
}
// StoreSaveResponse is the response type for Store.Save.
type StoreSaveResponse struct {
Id string `json:"id,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterStoreHostFunctions registers Store service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterStoreHostFunctions(service StoreService) []extism.HostFunction {
return []extism.HostFunction{
newStoreSaveHostFunction(service),
}
}
func newStoreSaveHostFunction(service StoreService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"store_save",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
storeWriteError(p, stack, err)
return
}
var req StoreSaveRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
storeWriteError(p, stack, err)
return
}
// Call the service method
id, err := service.Save(ctx, req.Item)
if err != nil {
storeWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := StoreSaveResponse{
Id: id,
}
storeWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// storeWriteResponse writes a JSON response to plugin memory.
func storeWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
storeWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// storeWriteError writes an error response to plugin memory.
func storeWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@@ -0,0 +1,14 @@
package testpkg
import "context"
type Item struct {
ID string
Name string
}
//nd:hostservice name=Store permission=store
type StoreService interface {
//nd:hostfunc
Save(ctx context.Context, item Item) (id string, err error)
}

View File

@@ -0,0 +1,88 @@
// Code generated by hostgen. DO NOT EDIT.
package testpkg
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// UsersGetRequest is the request type for Users.Get.
type UsersGetRequest struct {
Id *string `json:"id"`
Filter *User `json:"filter"`
}
// UsersGetResponse is the response type for Users.Get.
type UsersGetResponse struct {
Result *User `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterUsersHostFunctions registers Users service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterUsersHostFunctions(service UsersService) []extism.HostFunction {
return []extism.HostFunction{
newUsersGetHostFunction(service),
}
}
func newUsersGetHostFunction(service UsersService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"users_get",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
usersWriteError(p, stack, err)
return
}
var req UsersGetRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
usersWriteError(p, stack, err)
return
}
// Call the service method
result, err := service.Get(ctx, req.Id, req.Filter)
if err != nil {
usersWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := UsersGetResponse{
Result: result,
}
usersWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// usersWriteResponse writes a JSON response to plugin memory.
func usersWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
usersWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// usersWriteError writes an error response to plugin memory.
func usersWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@@ -0,0 +1,14 @@
package testpkg
import "context"
type User struct {
ID string
Name string
}
//nd:hostservice name=Users permission=users
type UsersService interface {
//nd:hostfunc
Get(ctx context.Context, id *string, filter *User) (*User, error)
}

41
plugins/host/doc.go Normal file
View File

@@ -0,0 +1,41 @@
// Package host provides host services that can be called by plugins via Extism host functions.
//
// Host services allow plugins to access Navidrome functionality like the Subsonic API,
// scheduler, and other internal services. Services are defined as Go interfaces with
// special annotations that enable automatic code generation of Extism host function wrappers.
//
// # Annotation Format
//
// Host services use Go doc comment annotations to mark interfaces and methods for code generation:
//
// // MyService provides some functionality.
// //nd:hostservice name=MyService permission=myservice
// type MyService interface {
// // DoSomething performs an action.
// //nd:hostfunc
// DoSomething(ctx context.Context, input string) (output string, err error)
// }
//
// Service-level annotations:
// - //nd:hostservice - Marks an interface as a host service
// - name=<ServiceName> - Service identifier used in generated code
// - permission=<key> - Manifest permission key (e.g., "subsonicapi", "scheduler")
//
// Method-level annotations:
// - //nd:hostfunc - Marks a method for host function wrapper generation
// - name=<CustomName> - Optional: override the export name
//
// # Generated Code
//
// The hostgen tool reads annotated interfaces and generates Extism host function wrappers
// that handle:
// - JSON serialization/deserialization of request/response types
// - Memory operations (ReadBytes, WriteBytes, Alloc)
// - Error handling and propagation
// - Service registration functions
//
// Generated files follow the pattern <servicename>_gen.go and include a header comment
// indicating they should not be edited manually.
//
//go:generate go run ../cmd/hostgen -input=. -output=.
package host

View File

@@ -0,0 +1,18 @@
package host
import "context"
// SubsonicAPIService provides access to Navidrome's Subsonic API from plugins.
//
// This service allows plugins to make Subsonic API requests on behalf of the plugin's user,
// enabling access to library data, user preferences, and other Subsonic-compatible operations.
//
//nd:hostservice name=SubsonicAPI permission=subsonicapi
type SubsonicAPIService interface {
// Call executes a Subsonic API request and returns the JSON response.
//
// The uri parameter should be the Subsonic API path without the server prefix,
// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
//nd:hostfunc
Call(ctx context.Context, uri string) (response []byte, err error)
}

View File

@@ -0,0 +1,76 @@
// Code generated by hostgen. DO NOT EDIT.
package host
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// SubsonicAPICallResponse is the response type for SubsonicAPI.Call.
type SubsonicAPICallResponse struct {
Response []byte `json:"response,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
return []extism.HostFunction{
newSubsonicAPICallHostFunction(service),
}
}
func newSubsonicAPICallHostFunction(service SubsonicAPIService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"subsonicapi_call",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
uri, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
response, err := service.Call(ctx, uri)
if err != nil {
subsonicapiWriteError(p, stack, err)
return
}
// Write JSON response to plugin memory
resp := SubsonicAPICallResponse{
Response: response,
}
subsonicapiWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// subsonicapiWriteResponse writes a JSON response to plugin memory.
func subsonicapiWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
subsonicapiWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// subsonicapiWriteError writes an error response to plugin memory.
func subsonicapiWriteError(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
}