seatgen: a seat's states give a constant, key builders and parsers and strict value types, Go and TypeScript (ADR 0634 §6; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -355,9 +355,15 @@ func declareAll(d seatdef.Definition, fromPart map[string]string) (*typer, error
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, r := range d.Records {
|
||||
if err := decl(ident(r.Name)+"Record", "is one value of the record "+r.Name+".", r.Value); err != nil {
|
||||
return nil, err
|
||||
for _, st := range d.States {
|
||||
for _, kf := range st.KeyForms {
|
||||
vn, err := stateValueName(st.Name, kf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("state %s: %w", st.Name, err)
|
||||
}
|
||||
if err := decl(vn, "is one value of the state "+st.Name+" under the key "+kf.Key+".", kf.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range d.Provisions {
|
||||
@@ -413,6 +419,9 @@ func goWhole(m *Module, d seatdef.Definition) ([]byte, []byte, error) {
|
||||
b.WriteString(header)
|
||||
fmt.Fprintf(&b, "// Package %s is the contract of the seat %q, defined whole (seats/%s/seat.json): %s\npackage %s\n\n",
|
||||
pkg, d.Seat, d.Seat, oneLine(d.Description), pkg)
|
||||
if len(d.States) > 0 {
|
||||
b.WriteString(goStatesImports(d.States))
|
||||
}
|
||||
fmt.Fprintf(&b, "// Seat is the seat's name.\nconst Seat = %q\n\n", d.Seat)
|
||||
writeNames(&b, "Verb", seatdef.VerbNames(d.Verbs))
|
||||
writeNames(&b, "Accept", seatdef.MessageNames(d.Accepts))
|
||||
@@ -455,8 +464,10 @@ func goWhole(m *Module, d seatdef.Definition) ([]byte, []byte, error) {
|
||||
fmt.Fprintf(&b, ".\nfunc HolderSubject%s(holder string) string { return %q + holder + %q }\n\n", ident(e.Name), before, after)
|
||||
}
|
||||
}
|
||||
if len(d.Records) > 0 {
|
||||
writeNames(&b, "Record", recordNames(d.Records))
|
||||
if len(d.States) > 0 {
|
||||
if err := goStates(&b, d.States); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if err := goTypes(&b, tp, map[string]*typ{}); err != nil {
|
||||
return nil, nil, err
|
||||
@@ -469,14 +480,6 @@ func goWhole(m *Module, d seatdef.Definition) ([]byte, []byte, error) {
|
||||
return src, test, err
|
||||
}
|
||||
|
||||
func recordNames(rs []seatdef.Record) []string {
|
||||
out := make([]string, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = r.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// goTypes writes every declared type; also are the kinds of types declared elsewhere (a part's), by name.
|
||||
func goTypes(b *bytes.Buffer, tp *typer, also map[string]*typ) error {
|
||||
kinds := map[string]*typ{}
|
||||
@@ -661,6 +664,10 @@ func fixtureTarget(d seatdef.Definition, kind, name string) (string, string, err
|
||||
return ident(name) + suffix, p + "$", nil
|
||||
}
|
||||
}
|
||||
case "state":
|
||||
if n, ok := stateFormFor(d, name); ok {
|
||||
return n, "", nil
|
||||
}
|
||||
case "holder-event":
|
||||
for _, e := range d.HolderEvents {
|
||||
if e.Name == name {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package seatgen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/token"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
)
|
||||
|
||||
// A keyToken is one piece of a key form's pattern: a literal, or a named part (`<asker>`).
|
||||
type keyToken struct {
|
||||
part bool
|
||||
text string
|
||||
}
|
||||
|
||||
// keyTokens reads a key form's pattern into its pieces. Two parts with no literal between them are refused: a reader
|
||||
// could not tell where the first ends.
|
||||
func keyTokens(key string) ([]keyToken, error) {
|
||||
var out []keyToken
|
||||
rest := key
|
||||
for rest != "" {
|
||||
i := strings.Index(rest, "<")
|
||||
if i < 0 {
|
||||
out = append(out, keyToken{text: rest})
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
out = append(out, keyToken{text: rest[:i]})
|
||||
}
|
||||
j := strings.Index(rest[i:], ">")
|
||||
if j < 0 {
|
||||
return nil, fmt.Errorf("key %q: a part is not closed", key)
|
||||
}
|
||||
out = append(out, keyToken{part: true, text: rest[i+1 : i+j]})
|
||||
rest = rest[i+j+1:]
|
||||
}
|
||||
for i := 1; i < len(out); i++ {
|
||||
if out[i].part && out[i-1].part {
|
||||
return nil, fmt.Errorf("key %q: two parts with no literal between them", key)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func keyParts(ts []keyToken) []string {
|
||||
var out []string
|
||||
for _, t := range ts {
|
||||
if t.part {
|
||||
out = append(out, t.text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formIdent is the name a key form's functions and value type are built from: the state's name, then "By" and the
|
||||
// form's parts and literals in order (asks, `<asker>.<id>` → AsksByAskerID).
|
||||
func formIdent(state, key string, ts []keyToken) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(ident(state) + "By")
|
||||
for _, t := range ts {
|
||||
b.WriteString(ident(t.text))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var reservedWords = map[string]bool{"default": true, "new": true, "delete": true, "class": true, "function": true, "var": true, "let": true, "const": true, "in": true, "of": true}
|
||||
|
||||
// paramName is a part's name as a function parameter: local-name → localName.
|
||||
func paramName(part string) string {
|
||||
n := ident(part)
|
||||
n = strings.ToLower(n[:1]) + n[1:]
|
||||
if strings.HasPrefix(ident(part), "ID") {
|
||||
n = "id" + ident(part)[2:]
|
||||
}
|
||||
if token.IsKeyword(n) || reservedWords[n] {
|
||||
n += "Part"
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// stateValueName is the Go and TypeScript type of one value of a state under a key form.
|
||||
func stateValueName(state string, kf seatdef.KeyForm) (string, error) {
|
||||
ts, err := keyTokens(kf.Key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return formIdent(state, kf.Key, ts) + "Value", nil
|
||||
}
|
||||
|
||||
// stateFormFor is the state and key form a fixture's `state:<name>/<key>` names.
|
||||
func stateFormFor(d seatdef.Definition, name string) (string, bool) {
|
||||
state, key, _ := strings.Cut(name, "/")
|
||||
for _, s := range d.States {
|
||||
if s.Name != state {
|
||||
continue
|
||||
}
|
||||
for _, kf := range s.KeyForms {
|
||||
if kf.Key == key {
|
||||
n, err := stateValueName(s.Name, kf)
|
||||
return n, err == nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// goStates writes a seat's states: a constant for each local name, and for each key form its builder, its parser and
|
||||
// the strict reader of a value (a field the type does not know is refused).
|
||||
func goStates(b *bytes.Buffer, states []seatdef.State) error {
|
||||
names := make([]string, len(states))
|
||||
for i, s := range states {
|
||||
names[i] = s.Name
|
||||
}
|
||||
writeNames(b, "State", names)
|
||||
for _, s := range states {
|
||||
for _, kf := range s.KeyForms {
|
||||
ts, err := keyTokens(kf.Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("state %s: %w", s.Name, err)
|
||||
}
|
||||
fi := formIdent(s.Name, kf.Key, ts)
|
||||
params := keyParts(ts)
|
||||
var plist, expr []string
|
||||
for _, t := range ts {
|
||||
if t.part {
|
||||
expr = append(expr, paramName(t.text))
|
||||
} else {
|
||||
expr = append(expr, fmt.Sprintf("%q", t.text))
|
||||
}
|
||||
}
|
||||
for _, p := range params {
|
||||
plist = append(plist, paramName(p))
|
||||
}
|
||||
sig := ""
|
||||
if len(plist) > 0 {
|
||||
sig = strings.Join(plist, ", ") + " string"
|
||||
}
|
||||
fmt.Fprintf(b, "// %sKey is the key of one value of the state %s under the form %q.\nfunc %sKey(%s) string { return %s }\n\n",
|
||||
fi, s.Name, kf.Key, fi, sig, strings.Join(expr, " + "))
|
||||
rets := make([]string, 0, len(plist)+1)
|
||||
zero := make([]string, 0, len(plist)+1)
|
||||
for range plist {
|
||||
rets = append(rets, "string")
|
||||
zero = append(zero, `""`)
|
||||
}
|
||||
rets = append(rets, "bool")
|
||||
zero = append(zero, "false")
|
||||
fmt.Fprintf(b, "// Parse%sKey reads the parts of a key of the form %q, in the order of the form; the last result is false for a key of another form.\nfunc Parse%sKey(key string) (%s) {\n",
|
||||
fi, kf.Key, fi, strings.Join(rets, ", "))
|
||||
fail := "return " + strings.Join(zero, ", ")
|
||||
b.WriteString("\trest := key\n")
|
||||
var got []string
|
||||
for i := 0; i < len(ts); {
|
||||
t := ts[i]
|
||||
switch {
|
||||
case !t.part:
|
||||
fmt.Fprintf(b, "\tif !strings.HasPrefix(rest, %q) {\n\t\t%s\n\t}\n\trest = rest[%d:]\n", t.text, fail, len(t.text))
|
||||
i++
|
||||
case i+1 < len(ts):
|
||||
fmt.Fprintf(b, "\tv%d, after%d, found%d := strings.Cut(rest, %q)\n\tif !found%d || v%d == \"\" {\n\t\t%s\n\t}\n\trest = after%d\n",
|
||||
i, i, i, ts[i+1].text, i, i, fail, i)
|
||||
got = append(got, fmt.Sprintf("v%d", i))
|
||||
i += 2
|
||||
default:
|
||||
fmt.Fprintf(b, "\tif rest == \"\" {\n\t\t%s\n\t}\n\tv%d := rest\n\trest = \"\"\n", fail, i)
|
||||
got = append(got, fmt.Sprintf("v%d", i))
|
||||
i++
|
||||
}
|
||||
}
|
||||
b.WriteString("\tif rest != \"\" {\n\t\t" + fail + "\n\t}\n")
|
||||
fmt.Fprintf(b, "\treturn %s\n}\n\n", strings.Join(append(got, "true"), ", "))
|
||||
vn := fi + "Value"
|
||||
fmt.Fprintf(b, "// Decode%s reads one value of the state %s under the form %q, refusing a field %s does not know and any text after the value.\n"+
|
||||
"func Decode%s(raw []byte) (%s, error) {\n\tvar v %s\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.DisallowUnknownFields()\n"+
|
||||
"\tif err := dec.Decode(&v); err != nil {\n\t\treturn v, err\n\t}\n\tif dec.More() {\n\t\treturn v, errors.New(\"text after the value\")\n\t}\n\treturn v, nil\n}\n\n",
|
||||
vn, s.Name, kf.Key, vn, vn, vn, vn)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// goStatesImports is the import block a seat's states need: the strict reader always, the parser's string functions
|
||||
// where a key form has a literal or a part that is not last.
|
||||
func goStatesImports(states []seatdef.State) string {
|
||||
needStrings := false
|
||||
for _, s := range states {
|
||||
for _, kf := range s.KeyForms {
|
||||
ts, _ := keyTokens(kf.Key)
|
||||
for i, t := range ts {
|
||||
if !t.part || i+1 < len(ts) {
|
||||
needStrings = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := "import (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n"
|
||||
if needStrings {
|
||||
out += "\t\"strings\"\n"
|
||||
}
|
||||
return out + ")\n\n"
|
||||
}
|
||||
|
||||
// tsStates writes a seat's states for TypeScript: the local names, and for each key form its builder and parser (the
|
||||
// value's type is declared with the seat's other types).
|
||||
func tsStates(b *bytes.Buffer, states []seatdef.State) error {
|
||||
names := make([]string, len(states))
|
||||
for i, s := range states {
|
||||
names[i] = s.Name
|
||||
}
|
||||
tsNames(b, "states", names)
|
||||
for _, s := range states {
|
||||
for _, kf := range s.KeyForms {
|
||||
ts, err := keyTokens(kf.Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("state %s: %w", s.Name, err)
|
||||
}
|
||||
fn := tsName(formIdent(s.Name, kf.Key, ts))
|
||||
var args, expr, fields, got []string
|
||||
for _, t := range ts {
|
||||
if t.part {
|
||||
p := paramName(t.text)
|
||||
args = append(args, p+": string")
|
||||
expr = append(expr, p)
|
||||
fields = append(fields, p+": string")
|
||||
got = append(got, p)
|
||||
} else {
|
||||
expr = append(expr, strconv.Quote(t.text))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(b, "/** The key of one value of the state %s under the form %s. */\nexport function %sKey(%s): string {\n return %s;\n}\n\n",
|
||||
s.Name, strconv.Quote(kf.Key), fn, strings.Join(args, ", "), strings.Join(expr, " + "))
|
||||
fmt.Fprintf(b, "/** The parts of a key of the form %s, or undefined for a key of another form. */\nexport function parse%sKey(key: string): { %s } | undefined {\n let rest = key;\n",
|
||||
strconv.Quote(kf.Key), ident(fn), strings.Join(fields, "; "))
|
||||
for i := 0; i < len(ts); {
|
||||
t := ts[i]
|
||||
switch {
|
||||
case !t.part:
|
||||
fmt.Fprintf(b, " if (!rest.startsWith(%s)) return undefined;\n rest = rest.slice(%d);\n", strconv.Quote(t.text), len(t.text))
|
||||
i++
|
||||
case i+1 < len(ts):
|
||||
fmt.Fprintf(b, " const at%d = rest.indexOf(%s);\n if (at%d <= 0) return undefined;\n const %s = rest.slice(0, at%d);\n rest = rest.slice(at%d + %d);\n",
|
||||
i, strconv.Quote(ts[i+1].text), i, paramName(t.text), i, i, len(ts[i+1].text))
|
||||
i += 2
|
||||
default:
|
||||
fmt.Fprintf(b, " if (rest === \"\") return undefined;\n const %s = rest;\n rest = \"\";\n", paramName(t.text))
|
||||
i++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(b, " if (rest !== \"\") return undefined;\n return { %s };\n}\n\n", strings.Join(got, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package seatgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
re "regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAStateHasItsNamesKeyFormsAndStrictValues is ADR 0634 §6's generator check for `states`: a seat's states give a
|
||||
// constant per local name, per key form a builder, a parser and a strict reader of the value, and the fixtures of
|
||||
// `state:` decode into those types. The generated seat package is written out and run with `go vet` and `go test`, so
|
||||
// what the generator writes is compiled, not only read.
|
||||
func TestAStateHasItsNamesKeyFormsAndStrictValues(t *testing.T) {
|
||||
m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := Generate(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(files["go/seats/aregistry/contract.go"])
|
||||
for _, want := range []string{
|
||||
"func AsksByAskerIDKey(asker, id string) string", "func ParseAsksByAskerIDKey(key string) (string, string, bool)",
|
||||
"func DecodeAsksByAskerIDValue(raw []byte)", "type AsksByAskerIDValue struct",
|
||||
"func AsksByAskerIDDoneKey(", "func AsksByDefaultsKey() string",
|
||||
"func LocalNameBySeenIDOfAskKey(idOfAsk string) string",
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("the seat's package lacks %q:\n%s", want, src)
|
||||
}
|
||||
}
|
||||
for _, c := range []string{`StateAsks\s+= "asks"`, `StateLocalName\s+= "local-name"`} {
|
||||
if !re.MustCompile(c).MatchString(src) {
|
||||
t.Errorf("the seat's package lacks a constant %s", c)
|
||||
}
|
||||
}
|
||||
if strings.Contains(src, "Record") {
|
||||
t.Errorf("the seat's package still names a record:\n%s", src)
|
||||
}
|
||||
ts := string(files["ts/seats/aregistry/src/index.ts"])
|
||||
for _, want := range []string{"export const states = {", "export function asksByAskerIDKey(asker: string, id: string): string",
|
||||
"export function parseAsksByAskerIDKey(key: string)", "export interface AsksByAskerIDValue"} {
|
||||
if !strings.Contains(ts, want) {
|
||||
t.Errorf("the seat's npm package lacks %q:\n%s", want, ts)
|
||||
}
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
for p, b := range files {
|
||||
if !strings.HasPrefix(p, "go/seats/aregistry/") {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(dir, strings.TrimPrefix(p, "go/seats/aregistry/"))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
extra := `package aregistry
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestKeysAndValues(t *testing.T) {
|
||||
if k := AsksByAskerIDKey("a", "7"); k != "a.7" {
|
||||
t.Fatal(k)
|
||||
}
|
||||
if a, id, ok := ParseAsksByAskerIDKey("a.7"); !ok || a != "a" || id != "7" {
|
||||
t.Fatal(a, id, ok)
|
||||
}
|
||||
for _, bad := range []string{"", "a", ".7", "a.", "a.7.done"} {
|
||||
if bad == "a.7.done" {
|
||||
continue // the id takes the rest: another form is told apart by its own parser
|
||||
}
|
||||
if _, _, ok := ParseAsksByAskerIDKey(bad); ok {
|
||||
t.Errorf("%q parsed", bad)
|
||||
}
|
||||
}
|
||||
if a, id, ok := ParseAsksByAskerIDDoneKey("a.7.done"); !ok || a != "a" || id != "7" {
|
||||
t.Fatal(a, id, ok)
|
||||
}
|
||||
if _, _, ok := ParseAsksByAskerIDDoneKey("a.7"); ok {
|
||||
t.Error("a key without its literal parsed")
|
||||
}
|
||||
if !ParseAsksByDefaultsKey("defaults") || ParseAsksByDefaultsKey("defaultsx") {
|
||||
t.Error("a literal key")
|
||||
}
|
||||
if _, err := DecodeAsksByAskerIDValue([]byte(` + "`" + `{"state":"open","extra":1}` + "`" + `)); err == nil {
|
||||
t.Error("a field the type does not know was taken")
|
||||
}
|
||||
if _, err := DecodeAsksByAskerIDValue([]byte(` + "`" + `{"state":"open"} {}` + "`" + `)); err == nil {
|
||||
t.Error("text after the value was taken")
|
||||
}
|
||||
if v, err := DecodeAsksByAskerIDValue([]byte(` + "`" + `{"state":"open","since":3}` + "`" + `)); err != nil || v.State != "open" {
|
||||
t.Error(v, err)
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "extra_test.go"), []byte(extra), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{{"vet", "./..."}, {"test", "./..."}} {
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("go %s on the generated package: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
[{"name":"an ask","of":"state:asks/<asker>.<id>","given":{"state":"open","since":3}},
|
||||
{"name":"a finished ask","of":"state:asks/<asker>.<id>.done","given":{"outcome":"refused"}},
|
||||
{"name":"the defaults","of":"state:asks/defaults","given":{"any":"thing"}}]
|
||||
+9
-1
@@ -1,3 +1,11 @@
|
||||
{"format":1,"seat":"a-registry","scope":"mesh","grain":"whole","decision":"ADR 1","includes":["registry"],
|
||||
"verbs":[{"name":"hold","input":{"type":"object"},"answer":{"type":"object"}}],
|
||||
"family":{"name":"a-registry.events","max-age-days":7}}
|
||||
"family":{"name":"a-registry.events","max-age-days":7},
|
||||
"states":[
|
||||
{"name":"asks","template":"state","description":"the asks held, one key per asker and ask",
|
||||
"key-forms":[
|
||||
{"key":"<asker>.<id>","value":{"type":"object","properties":{"state":{"type":"string"},"since":{"type":"integer"}},"required":["state"]}},
|
||||
{"key":"<asker>.<id>.done","value":{"type":"object","properties":{"outcome":{"type":"string"}},"required":["outcome"]}},
|
||||
{"key":"defaults","value":{"type":"object","description":"free"}}]},
|
||||
{"name":"local-name","template":"events","max-age-days":3,
|
||||
"key-forms":[{"key":"seen.<id-of-ask>","value":{"type":"string"}}]}]}
|
||||
|
||||
@@ -131,6 +131,11 @@ func tsFiles(m *Module, out map[string][]byte) error {
|
||||
tsName(e.Name), before, after)
|
||||
}
|
||||
}
|
||||
if len(d.States) > 0 {
|
||||
if err := tsStates(&b, d.States); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, n := range sortedKeys(fromPart) {
|
||||
fmt.Fprintf(&b, "export type %s = %s.%s;\n", n, tsName(fromPart[n]), n)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user