Switch to workgraph for symlib

Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
This commit is contained in:
Christian Mesh
2026-04-22 14:36:44 -04:00
parent 156d6ac10f
commit 4e0cbe8bbf
3 changed files with 365 additions and 95 deletions

View File

@@ -0,0 +1,320 @@
package symlib
import (
"fmt"
"maps"
"strings"
"sync"
"github.com/apparentlymart/go-workgraph/workgraph"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/typeexpr"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
)
type scope struct {
vars map[string]map[string]result[cty.Value]
types map[string]result[typeWithDefault]
funcs map[string]result[function.Function]
builtinFuncs map[string]function.Function
libraries map[string]*scope
// TODO parent scope for functions
}
type typeWithDefault struct {
ty cty.Type
def *typeexpr.Defaults
}
func newScope(builtinFuncs map[string]function.Function) *scope {
return &scope{
vars: map[string]map[string]result[cty.Value]{"const": {}},
types: map[string]result[typeWithDefault]{},
funcs: map[string]result[function.Function]{},
libraries: map[string]*scope{},
builtinFuncs: builtinFuncs,
}
}
func once[V any](fn result[V]) result[V] {
var mu sync.Mutex
type T withDiags[V]
var promise workgraph.Promise[T]
var resolver workgraph.Resolver[T]
needsSetup := true
return func(w *workgraph.Worker) (V, hcl.Diagnostics) {
mu.Lock()
if needsSetup {
resolver, promise = workgraph.NewRequest[T](w)
}
neededSetup := needsSetup
needsSetup = false
mu.Unlock()
if neededSetup {
val, diags := fn(w)
resolver.Report(w, T{val, diags}, nil)
}
val, err := promise.Await(w)
if err != nil {
val.diags = val.diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Workgraph error",
Detail: err.Error(),
Extra: err,
})
}
return val.value, val.diags
}
}
func (s *scope) clone() *scope {
ns := &scope{
vars: map[string]map[string]result[cty.Value]{},
types: s.types,
funcs: s.funcs,
libraries: s.libraries,
builtinFuncs: s.builtinFuncs,
}
for rk, rv := range s.vars {
ns.vars[rk] = map[string]result[cty.Value]{}
maps.Copy(ns.vars[rk], rv)
}
return ns
}
func (s *scope) typeContext(w *workgraph.Worker, typeExpr hcl.Expression) (typeexpr.TypeContext, hcl.Diagnostics) {
var diags hcl.Diagnostics
prefix := "library::"
suffix := "types"
selfNs := prefix + suffix
typeCtx := typeexpr.TypeContext{
Types: map[string]map[string]cty.Type{selfNs: {}},
Defaults: map[string]map[string]*typeexpr.Defaults{selfNs: {}},
}
// Add all libraries, we could do less work based on missing below
// Minor opt at this point, not worth it
for lname, lib := range s.libraries {
lNs := prefix + lname + "::" + suffix
typeCtx.Types[lNs] = map[string]cty.Type{}
typeCtx.Defaults[lNs] = map[string]*typeexpr.Defaults{}
for tname, fn := range lib.types {
val, vDiags := fn(w)
diags = diags.Extend(vDiags)
typeCtx.Types[lNs][tname] = val.ty
typeCtx.Defaults[lNs][tname] = val.def
}
}
if typeExpr != nil {
missing, mDiags := typeCtx.TypeDependencies(typeExpr)
diags = diags.Extend(mDiags)
for _, ty := range missing[selfNs] {
if fn, ok := s.types[ty]; ok {
val, vDiags := fn(w)
diags = diags.Extend(vDiags)
typeCtx.Types[selfNs][ty] = val.ty
typeCtx.Defaults[selfNs][ty] = val.def
}
}
} else {
for tn, fn := range s.types {
val, vDiags := fn(w)
diags = diags.Extend(vDiags)
typeCtx.Types[selfNs][tn] = val.ty
typeCtx.Defaults[selfNs][tn] = val.def
}
}
return typeCtx, diags
}
func (s *scope) evalContext(w *workgraph.Worker, expr hcl.Expression) (*hcl.EvalContext, hcl.Diagnostics) {
var diags hcl.Diagnostics
vars := map[string]map[string]cty.Value{"library": {}}
for lname, lib := range s.libraries {
// TODO opt with expr deps
consts := map[string]cty.Value{}
for cn, fn := range lib.vars["const"] {
val, vDiags := fn(w)
diags = diags.Extend(vDiags)
consts[cn] = val
}
// TODO consts in name?
vars["library"][lname] = cty.ObjectVal(consts)
}
if expr != nil {
for _, trav := range expr.Variables() {
if len(trav) < 2 {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "TODO unformed1",
Detail: fmt.Sprintf("%#v", trav),
Subject: trav.SourceRange().Ptr(),
})
continue
}
root := trav[0].(hcl.TraverseRoot)
if root.Name == "library" {
continue
}
attr, ok := trav[1].(hcl.TraverseAttr)
if !ok {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "TODO unformed2",
Detail: fmt.Sprintf("%#v", trav),
Subject: trav.SourceRange().Ptr(),
})
continue
}
vRes := s.vars[root.Name][attr.Name]
if vRes == nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "TODO unformed3",
Detail: fmt.Sprintf("%s.%s", root.Name, attr.Name),
Subject: trav.SourceRange().Ptr(),
})
continue
}
val, vDiags := vRes(w)
diags = diags.Extend(vDiags)
rootVars, ok := vars[root.Name]
if !ok {
rootVars = map[string]cty.Value{}
vars[root.Name] = rootVars
}
rootVars[attr.Name] = val
}
} else {
for rootName, entries := range s.vars {
vars[rootName] = map[string]cty.Value{}
for varName, entry := range entries {
val, vDiags := entry(w)
diags = diags.Extend(vDiags)
vars[rootName][varName] = val
}
}
}
evalCtx := &hcl.EvalContext{
Variables: map[string]cty.Value{},
Functions: map[string]function.Function{},
}
maps.Copy(evalCtx.Functions, s.builtinFuncs)
for name, vars := range vars {
evalCtx.Variables[name] = cty.ObjectVal(vars)
}
if expr != nil {
for _, trav := range expr.(hcl.ExpressionWithFunctions).Functions() {
funcIdent := trav.RootName()
parts := strings.Split(funcIdent, "::")
if parts[0] == "library" {
// library::func
if len(parts) == 2 {
funcName := parts[1]
fn, ok := s.funcs[funcName]
if ok {
impl, fDiags := fn(w)
diags = diags.Extend(fDiags)
evalCtx.Functions[funcIdent] = impl
}
}
// library::lib::func
if len(parts) == 3 {
libName := parts[1]
funcName := parts[2]
if lib, ok := s.libraries[libName]; ok {
if fn, ok := lib.funcs[funcName]; ok {
impl, fDiags := fn(w)
diags = diags.Extend(fDiags)
evalCtx.Functions[funcIdent] = impl
}
}
}
}
}
} else {
for funcName, fn := range s.funcs {
funcIdent := "library::" + funcName
impl, fDiags := fn(w)
diags = diags.Extend(fDiags)
evalCtx.Functions[funcIdent] = impl
}
for libName, lib := range s.libraries {
for funcName, fn := range lib.funcs {
funcIdent := "library::" + libName + "::" + funcName
impl, fDiags := fn(w)
diags = diags.Extend(fDiags)
evalCtx.Functions[funcIdent] = impl
}
}
}
return evalCtx, diags
}
func (s *scope) addType(name string, typeExpr hcl.Expression) {
s.types[name] = once(func(w *workgraph.Worker) (typeWithDefault, hcl.Diagnostics) {
typeCtx, diags := s.typeContext(w, typeExpr)
varType, typeDefault, vDiags := typeCtx.TypeConstraintWithDefaults(typeExpr)
diags = diags.Extend(vDiags)
return typeWithDefault{varType, typeDefault}, diags
})
}
func (s *scope) addVar(namespace string, name string, expr hcl.Expression) {
ns, ok := s.vars[namespace]
if !ok {
ns = map[string]result[cty.Value]{}
s.vars[namespace] = ns
}
ns[name] = once(func(w *workgraph.Worker) (cty.Value, hcl.Diagnostics) {
evalCtx, diags := s.evalContext(w, expr)
val, vDiags := expr.Value(evalCtx)
diags = diags.Extend(vDiags)
return val, diags
})
}
func (s *scope) addFunction(name string, fn func(*workgraph.Worker, *scope) (function.Function, hcl.Diagnostics)) {
s.funcs[name] = once(func(w *workgraph.Worker) (function.Function, hcl.Diagnostics) {
return fn(w, s)
})
}
type result[T any] func(w *workgraph.Worker) (T, hcl.Diagnostics)
type withDiags[T any] struct {
value T
diags hcl.Diagnostics
}

View File

@@ -1,8 +1,7 @@
package symlib
import (
"fmt"
"github.com/apparentlymart/go-workgraph/workgraph"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/typeexpr"
"github.com/hashicorp/hcl/v2/gohcl"
@@ -140,17 +139,18 @@ func decodeFunctionBlock(block *hcl.Block) (*Function, hcl.Diagnostics) {
return fn, diags
}
func (fn *Function) Impl(lib *Library) (function.Function, hcl.Diagnostics) {
func (fn *Function) Impl(w *workgraph.Worker, s *scope) (function.Function, hcl.Diagnostics) {
var diags hcl.Diagnostics
typeCtx := lib.TypeContext
spec := &function.Spec{
Description: fn.Description,
}
returnType := cty.DynamicPseudoType
if fn.ReturnType != nil {
typeCtx, tDiags := s.typeContext(w, fn.ReturnType)
diags = diags.Extend(tDiags)
var valDiags hcl.Diagnostics
returnType, _, valDiags = typeCtx.TypeConstraintWithDefaults(fn.ReturnType)
diags = append(diags, valDiags...)
@@ -169,6 +169,9 @@ func (fn *Function) Impl(lib *Library) (function.Function, hcl.Diagnostics) {
}
if param.TypeExpr != nil {
typeCtx, tDiags := s.typeContext(w, *param.TypeExpr)
diags = diags.Extend(tDiags)
var valDiags hcl.Diagnostics
fnp.Type, defaults[fnp.Name], valDiags = typeCtx.TypeConstraintWithDefaults(*param.TypeExpr)
return fnp, valDiags
@@ -188,65 +191,35 @@ func (fn *Function) Impl(lib *Library) (function.Function, hcl.Diagnostics) {
}
spec.Impl = func(args []cty.Value, retType cty.Type) (cty.Value, error) {
// This is bad and I should feel bad
// This could also be accomplished by creating a full evalcontext
// and building deps internally via workgraph
s := s.clone()
hclCtx := &hcl.EvalContext{
Variables: map[string]cty.Value{},
Functions: lib.builtinFuncs,
}
paramObj := map[string]cty.Value{}
for i, arg := range args[:len(spec.Params)] {
param := spec.Params[i]
if defaults[param.Name] != nil && !arg.IsNull() {
arg = defaults[param.Name].Apply(arg)
}
paramObj[param.Name] = arg
s.addVar("param", param.Name, &hclsyntax.LiteralValueExpr{Val: arg})
}
if spec.VarParam != nil && len(spec.Params) != len(args) {
paramObj[spec.VarParam.Name] = cty.ListVal(args[len(spec.Params):])
}
hclCtx.Variables["param"] = cty.ObjectVal(paramObj)
localObj := map[string]cty.Value{}
hclCtx.Variables["local"] = cty.ObjectVal(localObj)
// TODO track stack / circuilar dependencies
var computeValue func(expr hcl.Expression) (cty.Value, error)
computeValue = func(expr hcl.Expression) (cty.Value, error) {
for _, trav := range expr.Variables() {
if len(trav) < 2 {
return cty.NilVal, fmt.Errorf("Bad traversal: %#v", trav)
}
switch trav[0].(hcl.TraverseRoot).Name {
case "param":
// Could validate if we care
case "local":
localName := trav[1].(hcl.TraverseAttr).Name
if _, ok := localObj[localName]; !ok {
var computeErr error
localObj[localName], computeErr = computeValue(fn.Locals[localName])
hclCtx.Variables["local"] = cty.ObjectVal(localObj)
if computeErr != nil {
return cty.NilVal, computeErr
}
}
default:
return cty.NilVal, fmt.Errorf("Bad traversal: %#v", trav)
}
}
val, vDiags := expr.Value(hclCtx)
if vDiags.HasErrors() {
return val, vDiags
}
return val, nil
s.addVar("param", spec.VarParam.Name, &hclsyntax.LiteralValueExpr{Val: cty.ListVal(args[len(spec.Params):])})
}
return computeValue(fn.Return)
for name, expr := range fn.Locals {
s.addVar("local", name, expr)
}
hclCtx, diags := s.evalContext(w, fn.Return)
val, vDiags := fn.Return.Value(hclCtx)
diags = diags.Extend(vDiags)
if diags.HasErrors() {
return val, error(diags)
}
return val, nil
}
return function.New(spec), diags

View File

@@ -3,6 +3,7 @@ package symlib
import (
"fmt"
"github.com/apparentlymart/go-workgraph/workgraph"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/typeexpr"
"github.com/zclconf/go-cty/cty"
@@ -10,11 +11,7 @@ import (
)
type Library struct {
functions map[string]function.Function
types map[string]cty.Type
typeDefaults map[string]*typeexpr.Defaults
builtinFuncs map[string]function.Function
scope *scope
TypeContext typeexpr.TypeContext
Functions map[string]function.Function
@@ -25,11 +22,7 @@ type LibraryLoader func(*LibraryCall) (*Library, hcl.Diagnostics)
func NewLibrary(contents *LibraryContents, loader LibraryLoader, builtinFuncs map[string]function.Function) (*Library, hcl.Diagnostics) {
var diags hcl.Diagnostics
l := &Library{
functions: map[string]function.Function{},
types: map[string]cty.Type{},
typeDefaults: map[string]*typeexpr.Defaults{},
builtinFuncs: builtinFuncs,
scope: newScope(builtinFuncs),
Functions: map[string]function.Function{},
TypeContext: typeexpr.TypeContext{
@@ -38,9 +31,6 @@ func NewLibrary(contents *LibraryContents, loader LibraryLoader, builtinFuncs ma
},
}
// TODO This is where complex interdependencies can happen!
// TODO We ignore this problem for now!
// Load libraries
for libName, call := range contents.LibraryCalls {
lib, lDiags := loader(call)
@@ -51,41 +41,28 @@ func NewLibrary(contents *LibraryContents, loader LibraryLoader, builtinFuncs ma
continue
}
// Imported functions
for name, fn := range lib.functions {
l.Functions["library::"+libName+"::"+name] = fn
}
// Imported types
l.TypeContext.Types["library::"+libName+"::types"] = lib.types
l.TypeContext.Defaults["library::"+libName+"::types"] = lib.typeDefaults
l.scope.libraries[libName] = lib.scope
}
// Declared types
// Build scope
for _, typeDef := range contents.TypeDefs {
varType, typeDefault, valDiags := l.TypeContext.TypeConstraintWithDefaults(typeDef.TypeExpr)
diags = diags.Extend(valDiags)
l.types[typeDef.Name] = varType
if typeDefault != nil {
l.typeDefaults[typeDef.Name] = typeDefault
}
l.scope.addType(typeDef.Name, typeDef.TypeExpr)
}
// Exported types
l.TypeContext.Types["library::types"] = l.types
l.TypeContext.Defaults["library::types"] = l.typeDefaults
// Declared functions
for _, fn := range contents.Functions {
impl, moreDiags := fn.Impl(l)
diags = diags.Extend(moreDiags)
l.scope.addFunction(fn.Name, fn.Impl)
}
// TODO consts
l.functions[fn.Name] = impl
}
// Exported functions
for name, fn := range l.functions {
l.Functions["library::"+name] = fn
}
worker := workgraph.NewWorker()
typeCtx, mDiags := l.scope.typeContext(worker, nil)
diags = diags.Extend(mDiags)
evalCtx, mDiags := l.scope.evalContext(worker, nil)
diags = diags.Extend(mDiags)
l.TypeContext = typeCtx
l.Functions = evalCtx.Functions
return l, diags
}