ollie/fsedsl/decl.go

225 lines
7.7 KiB
Go

// Package fsedsl provides an embedded domain-specific language for declaring
// synthetic filesystems. It is designed for building 9P namespaces where the
// entire filesystem structure is declared statically but populated dynamically.
//
// The core type is FsNodeDecl[Ctx], a recursive structure that describes
// directories, files, and template nodes (Each). Handlers receive a context
// of type Ctx, allowing domain-specific data to flow through the tree.
//
// Example usage:
//
// type MyCtx struct {
// User *User
// Data any
// }
//
// spec := DirNode[MyCtx]("/",
// FileNode[MyCtx]("version", 0444,
// Doc("API version"),
// Read(func(ctx MyCtx) ([]byte, error) {
// return []byte("1.0\n"), nil
// }),
// ),
// Each[MyCtx]("{user}", userBindings,
// FileNode[MyCtx]("name", 0444,
// Read(func(ctx MyCtx) ([]byte, error) {
// return []byte(ctx.User.Name + "\n"), nil
// }),
// ),
// ),
// )
//
// tree := BuildTree(spec, MyCtx{})
package fsedsl
import (
"context"
"os"
)
// FsNodeDecl describes one node in a filesystem namespace.
// The entire filesystem is a single recursive tree of these declarations.
// Ctx is the context type passed to all handler functions.
type FsNodeDecl[Ctx any] struct {
// Identity.
Name string
Aliases []string // alternate names that resolve to this node (e.g. immutable ID)
UID string // "" = inherit from parent
GID string // "" = inherit from parent
Mode os.FileMode // 0 = inherit parent default
Desc string // human-readable description
// Tstat — metadata override (non-nil bypasses Name/UID/GID/Mode/size defaulting).
Stat func() os.FileInfo
// Twalk — children (implies directory; at most one set).
Children []FsNodeDecl[Ctx] // static, known at compile time
Bindings func(Ctx) ([]Binding[Ctx], error) // Each bindings
BindTargets []BindTarget // declares what Bindings provides
// Tread (non-blocking) + Twrite.
Read func(ctx Ctx) ([]byte, error)
Write func(ctx Ctx, data []byte) error
// Tread — blocking variants (at most one set).
BlockOnce func(ctx Ctx, cctx context.Context, base string) ([]byte, string, error)
Stream func(ctx Ctx, cctx context.Context, base string) ([]byte, string, error)
// Request — rdwr: write triggers result, read returns it once.
Request func(ctx Ctx, data []byte) ([]byte, error)
// Tcreate + Tremove + Trename (directory ops).
Create func(ctx Ctx, name string) error
Remove func(ctx Ctx) error
Rename func(newName string) error
}
// NodeOption configures a FsNodeDecl at construction time.
type NodeOption[Ctx any] func(*FsNodeDecl[Ctx])
// Binding provides identity and data for one iteration of Each.
// The structure is cloned for each binding, and handlers receive
// the bound data via the Ctx fields specified by BindTargets.
type Binding[Ctx any] struct {
Name string // directory/file name (e.g. session name, agent name)
Aliases []string // alternate lookup names (e.g. immutable ID)
UID string // owner (empty = inherit)
GID string // group (empty = inherit)
// Applier updates the context with bound data.
// Called when traversing into this binding's node.
Applier func(ctx Ctx) Ctx
// Lifecycle callbacks.
Remove func()
Rename func(string) error
}
// BindTarget declares what data an Each block binds to handlers.
// This is advisory — the actual binding is done by Binding.Applier.
type BindTarget int
const (
BindData BindTarget = iota // generic data binding
)
// ── EDSL constructors ────────────────────────────────────────────
// DirNode declares a static directory node.
// Arguments can be FsNodeDecl[Ctx] children or NodeOption[Ctx] modifiers.
func DirNode[Ctx any](name string, args ...any) FsNodeDecl[Ctx] {
d := FsNodeDecl[Ctx]{Name: name}
for _, a := range args {
switch v := a.(type) {
case FsNodeDecl[Ctx]:
d.Children = append(d.Children, v)
case NodeOption[Ctx]:
v(&d)
}
}
return d
}
// Each declares a directory that is instantiated for each binding in a collection.
// The bindings function is called at list/traverse time to determine instances.
//
// Example:
//
// Each[MyCtx]("{id}", func(ctx MyCtx) ([]Binding[MyCtx], error) {
// return []Binding[MyCtx]{
// {Name: "foo", Applier: func(c MyCtx) MyCtx { c.Data = "foo"; return c }},
// }, nil
// },
// FileNode[MyCtx]("name", 0444, Read(readName)),
// )
func Each[Ctx any](name string, bindings func(Ctx) ([]Binding[Ctx], error), args ...any) FsNodeDecl[Ctx] {
d := FsNodeDecl[Ctx]{Name: name, Bindings: bindings}
for _, a := range args {
switch v := a.(type) {
case FsNodeDecl[Ctx]:
d.Children = append(d.Children, v)
case NodeOption[Ctx]:
v(&d)
}
}
return d
}
// FileNode declares a file node (non-directory).
func FileNode[Ctx any](name string, mode os.FileMode, opts ...NodeOption[Ctx]) FsNodeDecl[Ctx] {
d := FsNodeDecl[Ctx]{Name: name, Mode: mode}
for _, o := range opts {
o(&d)
}
return d
}
// ── Options ──────────────────────────────────────────────────────
// Read sets the read handler for a file node.
func Read[Ctx any](fn func(Ctx) ([]byte, error)) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.Read = fn }
}
// Write sets the write handler for a file node.
func Write[Ctx any](fn func(Ctx, []byte) error) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.Write = fn }
}
// Stream sets a streaming read handler that blocks indefinitely.
func Stream[Ctx any](fn func(Ctx, context.Context, string) ([]byte, string, error)) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.Stream = fn }
}
// BlockOnce sets a blocking read handler that returns once.
func BlockOnce[Ctx any](fn func(Ctx, context.Context, string) ([]byte, string, error)) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.BlockOnce = fn }
}
// Request sets a request-response handler (rdwr pattern).
func Request[Ctx any](fn func(Ctx, []byte) ([]byte, error)) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.Request = fn }
}
// CreateFile sets the file creation handler for a directory node.
func CreateFile[Ctx any](fn func(Ctx, string) error) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.Create = fn }
}
// RemoveNode sets the removal handler for a node.
func RemoveNode[Ctx any](fn func(Ctx) error) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.Remove = fn }
}
// StatOverride sets a custom stat handler that bypasses default metadata.
func StatOverride[Ctx any](fn func() os.FileInfo) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.Stat = fn }
}
// UID sets the owner for a node.
func UID[Ctx any](uid string) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.UID = uid }
}
// GID sets the group for a node.
func GID[Ctx any](gid string) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.GID = gid }
}
// Alias adds alternate names for resolving this node.
func Alias[Ctx any](names ...string) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.Aliases = append(d.Aliases, names...) }
}
// Bind declares how Each bindings populate the context.
// This is advisory metadata; the actual binding is done by Binding.Applier.
func Bind[Ctx any](targets ...BindTarget) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.BindTargets = append(d.BindTargets, targets...) }
}
// Doc sets a human-readable description for the node.
// Used by GenerateHelp to produce documentation.
func Doc[Ctx any](desc string) NodeOption[Ctx] {
return func(d *FsNodeDecl[Ctx]) { d.Desc = desc }
}