Skip to content

Repository files navigation

starbind

starbind generates Starlark bindings for your Go code. You annotate a Go service struct, run starbind generate, and get a Go file that exposes that service's methods to Starlark scripts -- with scalar and protobuf arguments converted for you, context.Context threaded through automatically, and a runtime that compiles a script once and runs it concurrently.

It exists so you can hand a scripting surface to your users (policy, glue, automation) without hand-writing wrapper code for every method, and without tying your service types to the scripting layer.

How it works

Go package  ->  generator.Analyze  ->  generator.Emit  ->  runtime
             (go/packages, go/types,   (deterministic       (compile once,
              annotations, snake_case)  go/format output)     run concurrently)

Analyze reads your package with go/packages/go/types, picks the types and functions to bind, and validates their signatures. Emit writes a formatted, deterministic .gen.go file. The runtime package compiles a script into a reusable program, instantiates it once, freezes its globals, and lets you call exported functions from many goroutines.

Install

go install git.tatikoma.dev/corpix/starbind/cmd/starbind@latest

Or drive it from go generate without installing:

//go:generate go run git.tatikoma.dev/corpix/starbind/cmd/starbind generate --go-out bindings.gen.go --go-package bindings ./service/calc

Nix

The flake exposes the starbind executable as packages.${system}.starbind and packages.${system}.default.

Run it directly:

nix run git+https://git.tatikoma.dev/corpix/starbind -- generate --help

Install it into your profile:

nix profile install git+https://git.tatikoma.dev/corpix/starbind

To add starbind to your own flake, declare it as an input and use its package in your development shell or build inputs:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    starbind.url = "git+https://git.tatikoma.dev/corpix/starbind";
    starbind.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { nixpkgs, starbind, ... }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs { inherit system; };
    in
    {
      devShells.${system}.default = pkgs.mkShell {
        packages = [
          starbind.packages.${system}.default
        ];
      };
    };
}

Quickstart

1. Annotate a dependency-backed type

Mark the type with // starbind:bind dependency methods to expose a value from StarbindDependencies and bind its methods. Give it (and its methods) a Starlark name with // starbind:name. A leading context.Context is detected automatically and is not visible from Starlark.

package service

import "context"

// starbind:bind dependency methods
// starbind:name calc
type Calculator struct{}

func NewCalculator() *Calculator { return &Calculator{} }

// starbind:name add
func (c *Calculator) Add(ctx context.Context, a int64, b int64) (int64, error) {
    return a + b, nil
}

func (c *Calculator) Greet(ctx context.Context, name string) string {
    return "hello " + name
}

// starbind:ignore
func (c *Calculator) Internal() {}

Exported methods are bound by default. // starbind:ignore drops one.

2. Generate

starbind generate \
  --go-out ./bindings/bindings.gen.go \
  --go-package bindings \
  ./service

This writes bindings/bindings.gen.go containing a StarbindCalculator wrapper, a StarbindDependencies struct, and the StarbindNewRegistry / StarbindNewRuntime helpers.

3. Wire it up and run a script

You pass your live instances in through StarbindDependencies. Everything else (wrappers, registry, runtime) is generated.

package main

import (
    "context"
    "fmt"

    "your/module/bindings"
    "your/module/service"
)

const script = `
load("calc.star", "calc")

def add(a, b):
    return calc.add(a, b)

def greet(name):
    return calc.greet(name)
`

func main() {
    rt, err := bindings.StarbindNewRuntime(bindings.StarbindDependencies{
        Calculator: service.NewCalculator(),
    })
    if err != nil {
        panic(err)
    }

    compiled, err := rt.Compile("policy.star", script)
    if err != nil {
        panic(err)
    }

    ctx := context.Background()
    mod, err := rt.Instantiate(ctx, compiled)
    if err != nil {
        panic(err)
    }

    var sum int64
    if err := mod.Invoke(ctx, "add", &sum, int64(20), int64(22)); err != nil {
        panic(err)
    }
    fmt.Println(sum) // 42
}

Invoke converts your Go arguments into Starlark, calls the named function, and decodes the result into the pointer you pass. Use Call instead if you want the raw starlark.Value back. A compiled+instantiated module is safe to call from many goroutines at once.

Protobuf

If your methods take or return protobuf messages, --protobuf auto recognizes them by default. Each message type gets a script-side constructor bound by its name, and the runtime copies messages across the Go/Starlark boundary by marshaling them:

// starbind:bind dependency methods
// starbind:name echo
type EchoService struct{}

func (s *EchoService) Echo(ctx context.Context, in *wrapperspb.StringValue) (*wrapperspb.StringValue, error) {
    return in, nil
}
resp = echo.echo(StringValue(value = "hi"))
print(resp.value)

Nested messages, repeated fields, and maps work with no extra effort -- they ride along in the copy. Copying keeps each execution isolated: scripts cannot mutate the Go message you passed in, freeze semantics stay predictable, and there are no data races on shared protobuf state.

Protobuf support is built on go.starlark.net/lib/proto. A type is treated as a protobuf message when it has a ProtoReflect() method. Generated stubs include protobuf fields by default; use --protobuf-stubs names for nominal classes only or --protobuf-stubs none to omit protobuf classes from stubs.

Modules and load()

Every binding is available through generated modules. StarbindNewModules groups symbols into loadable modules so scripts can pull in exactly what they need with load():

load("example/echo.star", "echo", "StringValue")

def run(value):
    return echo.echo(StringValue(value = value)).value

A dependency binding and the protobuf message constructors its methods reference land in the same module. The module path defaults to <module-root>/<starlark-name>.star and can be overridden per type or function with // starbind:module <path>. StarbindNewRuntime wires the module loader, so load() works out of the box and an unknown module path is a clear error. If you want flat predeclared globals instead, use StarbindNewPredeclaredRegistry(deps) when constructing a runtime directly.

Scripts can also load() other Starlark scripts. Pass runtime.WithModuleSource a resolver -- or use the built-in runtime.DirModuleSource("./policies") to load .star files from a directory:

rt, err := bindings.StarbindNewRuntime(deps, runtime.WithModuleSource(runtime.DirModuleSource("./policies")))

Loaded script modules are compiled, executed once, frozen, and cached; concurrent loads of the same path are deduplicated, load errors are cached, and import cycles are reported.

Composing multiple packages

A program is not limited to one binding package. Each generated package exposes its contribution as a runtime.Bindings (flat globals, loadable modules, and the runtime-global options it needs) through StarbindNewBindings(deps). Combine several with runtime.Compose (or runtime.ComposeWithOptions to also pass runtime options):

rt, err := runtime.ComposeWithOptions([]runtime.Bindings{
    domain.StarbindNewBindings(domain.StarbindDependencies{...}),
    shared.StarbindNewBindings(shared.StarbindDependencies{...}),
}, runtime.WithMaxExecutionSteps(1_000_000))

Compose merges the registries and modules with deterministic collision detection: a duplicate global name or a duplicate module path across packages is a compose-time error naming the conflict, never silent last-wins. The schema hash is always recomputed over the combined symbol set, so the compile cache stays correct -- no per-package baked constant leaks into a composed runtime.

StarbindNewRuntime is just Compose(StarbindNewBindings(deps)), so the single-package path is unchanged. Registry.Merge and Modules.Merge are also public if you need the merge without building a full runtime.

Generated artifacts

Generated outputs are selected by path. If no output flag is provided, starbind writes zz_starbind.gen.go; otherwise it writes exactly the requested artifacts:

  • --go-out -> generated Go wrappers, registries, modules, and runtime helpers.
  • --manifest-out -> a machine-readable description of every service, method, function, and module (names, parameter and return types, deprecations).
  • --typefacts-out -> a generic starlark-lsp type facts JSON file with type/member signatures and implementation locations.
  • --stubs-out -> builtins.py plus <module>.star files. Point starlark-lsp --builtin-paths at stubs/builtins.py for predeclared globals and --load-paths at stubs/ so load() statements resolve to generated API stubs.
  • --docs-out -> human-readable reference documentation.
starbind generate \
  --go-out bindings.gen.go \
  --manifest-out schema.gen.json \
  --typefacts-out typefacts.gen.json \
  --stubs-out stubs \
  --docs-out docs.gen.md \
  ./service

Type bindings

Type-level // starbind:bind takes explicit facets:

  • dependency: add a field to StarbindDependencies and bind that value under the Starlark name.
  • methods: expose selected methods as receiver-bound builtins.
  • constructor: bind a top-level constructor under the Go type name.
  • fields: expose selected struct fields as Starlark attributes.
  • convert: allow this Go type to flow through generated method/function signatures as a wrapped Starlark value.

Plain domain structs commonly use constructor fields convert. // starbind:constructor <name> does not select the constructor binding; it only chooses the Go factory used by a type that already has the constructor facet.

// starbind:bind constructor fields convert
type Selector struct {
    Name     string            `starbind:"name"`
    Labels   map[string]string `starbind:"labels"`
    Tags     []string          `starbind:"tags,readonly"`
    Endpoint *Endpoint         `starbind:"endpoint"`
    Secret   string            `starbind:"-"`
}

Field tags name the field and set its access: starbind:"name", starbind:"labels,readonly", starbind:"desc,writable", or starbind:"-" to skip it. Fields are writable by default; // starbind:readonly on the type makes all fields read-only unless a field tag uses writable. --struct-fields picks which Go struct fields are exposed: exported (all exported fields, the default), annotated (only tagged fields), or none. --methods does the same for methods (exported default, or annotated).

Scripts construct a value by calling its type name and read or assign fields:

sel = Selector(name = "web", labels = {"region": "de"})
sel.name = "web-1"
print(sel.labels["region"])
print(sel.endpoint.host)   # nested value

Values flow through service methods as arguments and results. Scalars, map, and slice fields are copied across the boundary (assign the whole field to change a map or slice); nested value fields are wrapped in turn. Assigning a read-only field, or any field of a frozen value, is an error. Use // starbind:constructor <name> to build the base instance from a Go constructor instead of the zero value.

CLI reference

Flag Values Default
package args Go package patterns .
--select annotated, all annotated
--type, --type-glob, --type-regexp type selectors, repeatable empty
--type-bind selected type binding facets: dependency, constructor, fields, methods, convert empty
--func, --func-glob, --func-regexp function selectors, repeatable empty
--inject Go type to inject from execution context, repeatable empty
--go-out generated Go file path zz_starbind.gen.go when no outputs are set
--go-package generated Go package name starbindbindings
--manifest-out generated manifest JSON file path empty
--typefacts-out generated Starlark LSP type facts JSON file path empty
--stubs-out generated stubs directory empty
--docs-out generated docs Markdown file path empty
--type-prefix generated type prefix Starbind
--type-suffix generated type suffix empty
--module-root prefix for generated load() module paths empty
--unsupported error, warn, skip mode-dependent
--protobuf auto, off auto
--protobuf-stubs fields, names, none fields
--struct-fields exported, annotated, none exported
--methods annotated, exported exported
--ref-types referenced, explicit, none referenced
--ref-type, --ref-type-glob referenced struct selectors, repeatable empty
--ref-type-exclude referenced struct exclude globs, repeatable empty

Generated wrapper types are named <type-prefix><GoTypeName><type-suffix>, so the defaults give you StarbindCalculator.

Selection modes

  • annotated (default): bind only types marked with // starbind:bind <facets> and functions marked with bare // starbind:bind. This is the mode you usually want.
  • all: bind every supported exported service type and package-level function. Members with unsupported signatures produce warnings by default.
  • Exact, glob, and regexp selectors bind only what they match. Type selectors require --type-bind to say which facets to generate. A missing or unsupported selector is an error.

--unsupported controls what happens when a member cannot be bound: error (the default outside all), warn (the default in all), or skip. Package-level functions can be bound too; they become top-level builtins in the generated registry (see example/mixed).

Annotations and supported signatures

Annotations honored today:

// starbind:bind <facets> mark a type for binding
// starbind:bind          mark a function or method for binding
// starbind:ignore        drop a type, method, or function
// starbind:name <name>   set the Starlark name
// starbind:module <path> set the load() module path (type or function only)
// starbind:inject <param> hide a parameter and read it from execution context
// starbind:optional <param>  make a trailing parameter optional (defaults to None)
// starbind:readonly      make all fields read-only unless a field is writable
// starbind:constructor <name>  use a Go factory for the constructor binding
// starbind:deprecated <text>  emit a // Deprecated: comment on the binding

Field tags on value structs: starbind:"name", starbind:"name,readonly", starbind:"name,writable", starbind:"-".

Names default to snake_case of the Go identifier (Calculator -> calculator, ReconcileLinkSet -> reconcile_link_set).

Generated methods and functions accept positional or keyword arguments, keyed by the snake_case parameter name (calc.add(a=1, b=2)).

Injected parameters are omitted from the Starlark signature and loaded from the call context. Use --inject import/path.Type to hide matching parameter types globally, or // starbind:inject name on a type, method, or function to hide a named parameter. Type-level annotations apply to methods where the parameter is present; method-level annotations add to type-level named injections for that method. Method/function-level named injections must match a parameter on that callable. Provide values at execution time with runtime.WithInjected[T](ctx, v).

Optional parameters may be omitted by the caller. Use // starbind:optional name on a method or function; like starbind:inject, name is the Go parameter name. An omitted optional argument is passed to Go as None, so only nullable types are allowed (pointer, map, slice, any, starlark.Value, a proto message, or a pointer to a value/text type) where None maps to a nil Go value. Optional parameters must be trailing (no required parameter may follow one) and cannot be a variadic or injected parameter.

Scalar conversions:

Go Starlark
bool bool
string string
signed integers int
unsigned integers int (range-checked)
float32, float64 float
[]byte bytes
scalar pointer scalar or None
named scalar underlying scalar

Results:

Go Starlark
error (nil) None
error (non-nil) evaluation error
T converted value
(T, nil) converted value
(nil, nil) None

Callback parameters may use anonymous function types or named function types:

type Mapper func(context.Context, string) (string, error)

func (s *Store) MapValues(ctx context.Context, values []string, mapper Mapper) ([]string, error)
func (s *Store) Weight(ctx context.Context, values []string, weight func(string) int64) (int64, error)

The script passes a normal Starlark function or lambda. Callback parameters and results use the same supported conversions as ordinary bindings. A leading context.Context in the callback type is supplied by Go and is not visible to Starlark.

Callback caveats

If the Go callback type returns error, Starlark call failures are returned through that error. If the Go callback type has no error result, generated code panics with runtime.CallbackError when the Starlark callable fails or its return value cannot be converted. Starbind does not recover this panic at the top level. That keeps no-error callbacks honest: there is no fake place to put an error in a Go function type that did not ask for one.

This is especially important for stored callbacks. If Go keeps a callback and calls it later, the panic reaches that later Go caller unless the host recovers it. Prefer an error-returning callback type for stored callbacks and for any callback where script failure is part of the normal control flow.

Not supported: channels, variadic methods, variadic callback types, callback values in fields/results/containers, nested callback signatures, arbitrary interfaces, unresolved generics, unsafe.Pointer, a misplaced context.Context. These are rejected at generate time according to --unsupported.

Concurrency

A Runtime, a CompiledModule, and a module's frozen globals are shared and safe to reuse. Everything per-execution is not shared: each call gets its own starlark.Thread, its own context.Context, and its own argument copies. The runtime never serializes calls into your services -- if a wrapped service is called concurrently, that service is responsible for its own thread safety, transactions, and locking.

Context flows in through the call: inside a generated method, runtime.CurrentContext(thread) returns the context you passed to Call / Invoke. runtime.WithContext lets a derived context stay visible across synchronous Starlark callbacks without spinning up a new thread.

Compile caches programs, so recompiling the same source against the same bindings is cheap. By default (InitOnce) top-level code runs once at Instantiate and its globals are frozen and shared; runtime.WithInitMode(runtime.InitPerCall) re-runs top-level state for every call instead, at higher cost.

A value codec is runtime-global (it installs a per-thread descriptor pool and value conversion). When you Compose several packages, all of them must agree on a compatible codec: composing packages that require different codecs is a compose-time error rather than a silent replacement. Packages that share one codec (for example the protobuf codec) compose without conflict.

Status and roadmap

starbind is being built in iterations. The spec (starbind.spec) describes the full intended design; this is what is actually implemented right now.

Working today:

  • service struct wrappers (starlark.Value + HasAttrs, receiver-bound method builtins)
  • value bindings: struct field access and assignment, field tags, readonly, nested value fields, map/slice fields, constructors, and --struct-fields/--methods
  • annotated, all, exact, glob, and regexp selection
  • package-level function binding
  • explicit output paths for Go, manifest, stubs, and docs
  • configurable type prefix/suffix and module root
  • implicit context.Context
  • scalar conversion and (T, error) results
  • typed callback parameters
  • protobuf copy conversion, including nested, repeated, and map fields
  • protobuf field-aware generated stubs
  • generated StarbindDependencies, StarbindNewRegistry, StarbindNewModules, and StarbindNewRuntime
  • load()-able modules driven by --module-root and // starbind:module
  • positional and keyword arguments
  • // starbind:deprecated comments
  • name-collision detection at generate time
  • program compilation cache; InitOnce (default) and InitPerCall
  • script-module loading via runtime.WithModuleSource with load caching, dedup, cycle detection, and cached errors
  • compile once, freeze globals, reuse concurrently; one thread per execution
  • context propagation across callbacks

This completes the spec's MVP (starbind.spec section 15) plus value bindings.

Not built yet (the spec describes these, but they error or no-op today):

  • live element mutation of value map/slice fields (assign the whole field instead)
  • multiple naming policies (snake_case is the only one)

If you point a flag or annotation at one of the unbuilt features, generation fails with a clear "not implemented" message rather than emitting silently wrong code.

Development

just build   # build the CLI
just test    # go test ./...
just lint    # go vet + golangci-lint

Regenerate the example bindings after changing the generator:

go generate ./example/...

Generated output is deterministic and gofumpt-stable, so regenerating without a real change produces no diff. The example/ bindings also emit the manifest, stub, and doc artifacts (schema.gen.json, stubs/, docs.gen.md) next to the generated Go. The example/ tree is the best reference for what generated code looks like: example/service + example/bindings (scalars), example/protoservice + example/protobindings (protobuf), example/valueservice

  • example/valuebindings (value bindings), and example/mixed (all-mode plus a bound function).

About

Golang->Starlark binding code generator (public mirror)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages