// Package generator generates state-machine schemas and grafana dashboards.
package generator // TODO rewrite: // - repeated cli params // - AST // - embed pkg/states/states_utils.go // - optional with pkg/states/global import ( am ssam ) const ( pkgMachine = "github.com/pancsta/asyncmachine-go/pkg/machine" pkgStates = "github.com/pancsta/asyncmachine-go/pkg/states" pkgRpcStates = "github.com/pancsta/asyncmachine-go/pkg/rpc/states" pkgNodeStates = "github.com/pancsta/asyncmachine-go/pkg/node/states" pkgGlobal = "github.com/pancsta/asyncmachine-go/pkg/states/global" ) var ( ssG = states.GeneratorStates sgG = states.GeneratorGroups ) type SchemaGenerator struct { Mach *am.Machine Name string // N is the first letter of Name N string Global bool States []string StatesAuto []string StatesMulti []string Groups []string // State1 -> Rel -> State2,State3 Relations [][3]string StateTags map[string][]string } // TODO return err func ( *SchemaGenerator) ( cli.SchemaParams) { .StateTags = make(map[string][]string) for , := range .Inherit { for , := range strings.Split(, ",") { = strings.TrimSpace() if == "" { continue } // TODO enum, merge with CLI switch { case "basic": .Mach.Add1(ssG.InheritBasic, nil) case "connected": .Mach.Add1(ssG.InheritConnected, nil) case "disposed": .Mach.Add1(ssG.InheritDisposed, nil) case "rpc/statesrc": .Mach.Add1(ssG.InheritRpcStateSource, nil) case "node/worker": .Mach.Add1(ssG.InheritNodeWorker, nil) default: // TODO err panic(fmt.Sprintf("unknown inherit: %s", )) } } } // states var []string if .States != "" { = append(, strings.Split(.States, ",")...) } = append(, .State...) := regexp.MustCompile(`#([a-zA-Z0-9_\-\/]+)`) for , := range { = strings.TrimSpace() if == "" { continue } var []string := .FindAllStringSubmatch(, -1) for , := range { if len() > 1 && [1] != "" { = append(, [1]) } } = .ReplaceAllString(, "") // multi, auto, relations := strings.Split(, ":") := capitalizeFirstLetter([0]) .States = append(.States, ) if len() > 0 { .StateTags[] = } if len() < 2 { continue } = [1:] for , := range { if == "" { continue } switch { case "auto": .StatesAuto = append(.StatesAuto, ) case "multi": .StatesMulti = append(.StatesMulti, ) default: // Require( if !strings.Contains(, "(") { fmt.Printf("wrong format") os.Exit(1) } := strings.Split(strings.TrimRight(, ")"), "(") if len([0]) == 0 || len([1]) == 0 { fmt.Printf("wrong format") os.Exit(1) } := capitalizeFirstLetter([0]) := [1] .Relations = append(.Relations, [3]string{, , }) } } } // groups var []string if .Groups != "" { = append(, strings.Split(.Groups, ",")...) } = append(, .Group...) for , := range { = strings.TrimSpace() if == "" { continue } .Groups = append(.Groups, capitalizeFirstLetter()) .Mach.Add1(ssG.GroupsLocal, nil) } .Name = capitalizeFirstLetter(.Name) .N = string(.Name[0]) .Global = .Global } var _ = ssG.Inherit func ( *SchemaGenerator) ( *am.Event) bool { return .Mach.Any1(sgG.Inherit...) } var _ = ssG.Groups func ( *SchemaGenerator) ( *am.Event) bool { return .Mach.Any1(ssG.GroupsInherited, ssG.GroupsLocal) } // Output renders the generated schema file using github.com/dave/jennifer. func ( *SchemaGenerator) () string { := "ss" + .N := "sg" + .N := jen.NewFile("states") .ImportAlias(pkgMachine, "am") if .Mach.Any1(ssG.InheritBasic, ssG.InheritConnected, ssG.InheritDisposed) { .ImportAlias(pkgStates, "ssam") } if .Mach.Is1(ssG.InheritRpcStateSource) { .ImportAlias(pkgRpcStates, "ssrpc") } if .Mach.Is1(ssG.InheritNodeWorker) { .ImportAlias(pkgNodeStates, "ssnode") } if .Global { .ImportAlias(pkgGlobal, ".") // --global replaces the local states_utils.go (S, SAdd, ...) with a // dot-import of pkg/states/global. jennifer only auto-registers imports // that are referenced via Qual, but this one must always be present even // if this particular file ends up not using any of its symbols (e.g. no // groups/relations), so force it and fix up the alias below. .Anon(pkgGlobal) } .genStatesDef() .genGroupsDef() .genSchemaVar(, ) .genExports(, , ) .genConstructor() := .GoString() if .Global { = strings.Replace(, `_ "`+pkgGlobal+`"`, `. "`+pkgGlobal+`"`, 1) } return } // genStatesDef renders the {Name}StatesDef struct. func ( *SchemaGenerator) ( *jen.File) { .Commentf("%sStatesDef contains all the states of the [%s] state-machine.", .Name, .Name) .Type().Id(.Name + "StatesDef").StructFunc(func( *jen.Group) { .Op("*").Qual(pkgMachine, "StatesBase") .Line() for , := range .States { .Id().String() } := true := func(, , string) { if { .Line() = false } .Comment() .Op("*").Qual(, ) } if .Mach.Is1(ssG.InheritBasic) { ( "inherit from BasicStatesDef", pkgStates, "BasicStatesDef", ) } if .Mach.Is1(ssG.InheritConnected) { ( "inherit from ConnectedStatesDef", pkgStates, "ConnectedStatesDef", ) } if .Mach.Is1(ssG.InheritDisposed) { ( "inherit from DisposedStatesDef", pkgStates, "DisposedStatesDef", ) } if .Mach.Is1(ssG.InheritRpcStateSource) { ( "inherit from rpc/StateSourceStatesDef", pkgRpcStates, "StateSourceStatesDef", ) } if .Mach.Is1(ssG.InheritNodeWorker) { ( "inherit from node/StateSourceStatesDef", pkgNodeStates, "StateSourceStatesDef", ) } }) } // genGroupsDef renders the {Name}GroupsDef struct. func ( *SchemaGenerator) ( *jen.File) { .Commentf("%sGroupsDef contains all the state groups [%s] state-machine.", .Name, .Name) .Type().Id(.Name + "GroupsDef").StructFunc(func( *jen.Group) { if .Mach.Is1(ssG.InheritConnected) { .Op("*").Qual(pkgStates, "ConnectedGroupsDef") } if .Mach.Is1(ssG.InheritNodeWorker) { .Op("*").Qual(pkgNodeStates, "WorkerGroupsDef") } for , := range .Groups { .Id(strings.Split(, "(")[0]).Add(.idS()) } }) } // idS resolves the S type/identifier: package-qualified (dot-imported) when // the schema is generated with --global, otherwise a plain local identifier // coming from the package-local states_utils.go. Both render as the bare // "S" token; only the import registration differs. func ( *SchemaGenerator) () *jen.Statement { if .Global { return jen.Qual(pkgGlobal, "S") } return jen.Id("S") } // multiLit renders a bracketed, comma-separated, always-multiline list of // items. Unlike jen.Values/jen.Call, this keeps one item per line even when // items carry their own leading comment, so comments stay attached to the // right item once gofmt reflows the call/literal. func multiLit(, string, ...jen.Code) *jen.Statement { return jen.Custom(jen.Options{ Open: , Close: , Separator: ",", Multi: true, }, ...) } // genSchemaVar renders the {Name}Schema var, merging inherited schemas (if // any) with the local one. func ( *SchemaGenerator) ( *jen.File, string) { .Commentf("%sSchema represents all relations and properties of [%sStates].", .Name, .Name) := jen.Qual(pkgMachine, "Schema"). Add(multiLit("{", "}", .stateEntries()...)) if !.Mach.Is1(ssG.Inherit) { .Var().Id(.Name + "Schema").Op("=").Add() return } var []jen.Code := func( string, jen.Code) { = append(, jen.Comment().Line().Add()) } if .Mach.Is1(ssG.InheritBasic) { ("inherit from BasicSchema", jen.Qual(pkgStates, "BasicSchema")) } if .Mach.Is1(ssG.InheritConnected) { ("inherit from ConnectedSchema", jen.Qual(pkgStates, "ConnectedSchema")) } if .Mach.Is1(ssG.InheritDisposed) { ("inherit from DisposedSchema", jen.Qual(pkgStates, "DisposedSchema")) } if .Mach.Is1(ssG.InheritRpcStateSource) { ("inherit from rpc/StateSourceSchema", jen.Qual(pkgRpcStates, "StateSourceSchema")) } if .Mach.Is1(ssG.InheritNodeWorker) { ("inherit from node/WorkerSchema", jen.Qual(pkgNodeStates, "WorkerSchema")) } = append(, ) .Var().Id(.Name+"Schema").Op("="). Qual(pkgMachine, "Schema").Values().Dot("Merge"). Add(multiLit("(", ")", ...)) } // stateEntries renders the ordered ss{N}.State: {...} entries of a schema // literal. func ( *SchemaGenerator) ( string) []jen.Code { var []jen.Code for , := range .States { = append(, jen.Id().Dot().Op(":").Add(.stateValue(, ))) } return } // stateValue renders the {Auto, Multi, <relations>, Tags} value of a single // state. func ( *SchemaGenerator) (, string) jen.Code { var []jen.Code if slices.Contains(.StatesAuto, ) { = append(, jen.Id("Auto").Op(":").True()) } if slices.Contains(.StatesMulti, ) { = append(, jen.Id("Multi").Op(":").True()) } for , := range .Relations { if [0] != { continue } = append(, .relationValue(, )) } if , := .StateTags[]; && len() > 0 { var []jen.Code for , := range { = append(, jen.Lit()) } = append(, jen.Id("Tags").Op(":").Index().String().Values(...)) } return multiLit("{", "}", ...) } // relationValue renders a single Require/Add/Remove/etc relation, either to // other states, or to a group (optionally extended with extra states). func ( *SchemaGenerator) ( string, [3]string) jen.Code { := "sg" + .N := strings.Split([2], ";") // relation to a group TODO >1 if strings.HasPrefix([0], "_") { := jen.Id().Dot([0][1:]) var []jen.Code if len() > 1 { = append(, .statesLit(, [1:])) } return jen.Id([1]).Op(":").Add().Dot("Add").Call(...) } // relation to states only return jen.Id([1]).Op(":").Add(.statesLit(, )) } // statesLit renders an S{ss{N}.State1, ss{N}.State2, ...} literal. func ( *SchemaGenerator) ( string, []string) jen.Code { var []jen.Code for , := range { = append(, jen.Id().Dot()) } return .idS().Values(...) } // genExports renders the EXPORTS AND GROUPS var block. func ( *SchemaGenerator) ( *jen.File, , string) { .Comment("EXPORTS AND GROUPS") .Var().DefsFunc(func( *jen.Group) { .Id().Op("=").Qual(pkgMachine, "NewStates"). Call(jen.Id(.Name + "StatesDef").Values()) := []jen.Code{ jen.Id(.Name + "GroupsDef"). Add(multiLit("{", "}", .groupsDefEntries()...)), } if .Mach.Is1(ssG.InheritConnected) { = append(, jen.Qual(pkgStates, "ConnectedGroups")) } if .Mach.Is1(ssG.InheritNodeWorker) { = append(, jen.Qual(pkgNodeStates, "WorkerGroups")) } .Id().Op("=").Qual(pkgMachine, "NewStateGroups").Call(...) .Line() .Commentf("%sStates contains all the states for the [%s] state-machine.", .Name, .Name) .Id(.Name + "States").Op("=").Id() .Commentf( "%sGroups contains all the state groups for the [%s] state-machine.", .Name, .Name, ) .Id(.Name + "Groups").Op("=").Id() }) } // groupsDefEntries renders the ordered Group: S{...} entries of a // {Name}GroupsDef literal. func ( *SchemaGenerator) ( string) []jen.Code { var []jen.Code for , := range .Groups { if strings.Contains(, "(") { := strings.Split(strings.TrimRight(, ")"), "(") := strings.Split([1], ";") = append(, jen.Id([0]).Op(":").Add(.statesLit(, ))) } else { = append(, jen.Id().Op(":").Add(.idS().Values())) } } return } // genConstructor renders the New{Name} constructor function. func ( *SchemaGenerator) ( *jen.File) { .Commentf("New%s creates a new [%s] state-machine in the most basic form.", .Name, .Name) .Func().Id("New"+.Name). Params(jen.Id("ctx").Qual("context", "Context")). Op("*").Qual(pkgMachine, "Machine"). Block( jen.Return(jen.Qual(pkgMachine, "New"). Call(jen.Id("ctx"), jen.Id(.Name+"Schema"), jen.Nil())), ) } func ( context.Context, cli.SchemaParams, ) (*SchemaGenerator, error) { := &SchemaGenerator{} , := am.NewCommon(, "gen", states.GeneratorSchema, ssG.Names(), , nil, nil) if != nil { return nil, } // TODO env var? // amhelp.MachDebugEnv(mach) .Mach = .parseParams() return , nil } func () string { return ssam.StatesUtilsFile } func capitalizeFirstLetter( string) string { if len() == 0 { return } return string(unicode.ToUpper(rune([0]))) + [1:] } // SCHEMA FROM FILE // SchemaFileToParams reads the YAML schema file specified in params and // converts it to StatesParams. func ( cli.SchemaFileParams) (cli.SchemaParams, error) { var error if .File != "" { .FileContent, = os.ReadFile(.File) if != nil { return cli.SchemaParams{}, fmt.Errorf("reading YAML schema file %s: %w", .File, ) } } // Check if this is an am.Serialized machine export var am.Serialized if := yaml.Unmarshal(.FileContent, &); == nil && len(.StateNames) > 0 { := .SchemaParamsCommon if .Name == "" && .ID != "" { .Name = .ID } return cli.SchemaParams{ SchemaParamsCommon: , States: strings.Join(.StateNames, ","), }, nil } var am.Schema if := yaml.Unmarshal(.FileContent, &); != nil { return cli.SchemaParams{}, fmt.Errorf("parsing YAML schema: %w", ) } if len() == 0 { return cli.SchemaParams{}, errors.New("empty YAML schema") } // state order := stateNames(.FileContent) if len() == 0 { = slices.Collect(maps.Keys()) } var []string for , := range { , := [] if ! { continue } := genState(, ) = append(, ) } := strings.Join(, ",") return cli.SchemaParams{ SchemaParamsCommon: .SchemaParamsCommon, States: , }, nil } func genState( string, am.State) string { := []string{} if .Auto { = append(, "auto") } if .Multi { = append(, "multi") } if len(.Require) > 0 { := strings.Join(.Require, ";") = append(, fmt.Sprintf("Require(%s)", )) } if len(.Add) > 0 { := strings.Join(.Add, ";") = append(, fmt.Sprintf("Add(%s)", )) } if len(.Remove) > 0 { := strings.Join(.Remove, ";") = append(, fmt.Sprintf("Remove(%s)", )) } if len(.After) > 0 { := strings.Join(.After, ";") = append(, fmt.Sprintf("After(%s)", )) } := strings.Join(, ":") for , := range .Tags { = strings.TrimPrefix(, "#") if != "" { += "#" + } } return } func stateNames( []byte) []string { // read YAML key order var yaml.Node var []string if := yaml.Unmarshal(, &); == nil { var *yaml.Node if .Kind == yaml.DocumentNode && len(.Content) > 0 { = .Content[0] } else if .Kind == yaml.MappingNode { = & } if != nil && .Kind == yaml.MappingNode { for := 0; < len(.Content); += 2 { := strings.TrimSpace(.Content[].Value) if != "" { = append(, ) } } } } return }