diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 48452b1a05..0dccad0f3e 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -431,6 +431,7 @@ Parse + model all behavioral bodies with unified fallback grammar: - `trace.go` (154 lines) — Deterministic execution trace recorder - `eval.go` — Expression evaluation (binary/unary operators, literals, feature references, qualified names, type coercion) - Lowering to execution IR lives in `internal/ir/lower/` (`ToActionGraph`, `ToStateGraph`) +- Graph-local record identity (`NodeID`, `StateID`, …, `Origin`, `GraphID`) is declared in `internal/ir/program/`, which reads no syntax; `lower` assigns it **Testing:** - **Golden ASTs**: `tests/parser/testdata/parse/` — count in [the measured counts](../project/spec-compliance.md) diff --git a/internal/exec/runtime/origin_description_test.go b/internal/exec/runtime/origin_description_test.go new file mode 100644 index 0000000000..8403024b7c --- /dev/null +++ b/internal/exec/runtime/origin_description_test.go @@ -0,0 +1,116 @@ +package runtime + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/tests/fixtures" +) + +// TestOriginStringMatchesNodeDescription pins program.Origin.String() to what +// nodeDescription says of the same node, over every action the conformance fixtures lower. +func TestOriginStringMatchesNodeDescription(t *testing.T) { + conformanceDir := filepath.Join("testdata", "conformance") + entries, err := os.ReadDir(conformanceDir) + if err != nil { + t.Fatalf("failed to read conformance directory: %v", err) + } + checked := 0 + for _, entry := range entries { + if !isConformanceCase(entry.Name()) { + continue + } + checked += originDescriptionsOfCase(t, conformanceDir, entry.Name()) + } + if checked == 0 { + t.Fatal("no action node was checked") + } + t.Logf("checked %d action nodes", checked) +} + +// originDescriptionsOfCase lowers every action a fixture declares and checks each +// node's origin, reporting how many nodes it checked. +func originDescriptionsOfCase(t *testing.T, conformanceDir, fileName string) int { + t.Helper() + caseName := strings.TrimSuffix(fileName, ".expected.json") + path := fixtures.ModelPath(conformanceDir, caseName) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + expectedData, err := os.ReadFile(filepath.Join(conformanceDir, fileName)) + if err != nil { + t.Fatalf("failed to read expectation of %s: %v", caseName, err) + } + var expected ExpectedOutcome + if err := json.Unmarshal(expectedData, &expected); err != nil { + t.Fatalf("%s: failed to parse expected.json: %v", caseName, err) + } + src := source.New(path, data) + file := parser.New(src).ParseFile() + idx, _ := indexCaseDocuments(t, conformanceDir, src, file, expected) + resolver := resolve.New(idx) + checked := 0 + var visit func(scope *symbols.Scope) + visit = func(scope *symbols.Scope) { + for _, sym := range scope.Members() { + if declaresAction(sym.Decl) { + graph, _ := lower.ToActionGraphWith(sym.Decl, DeclScope(sym), resolver) + checked += checkOriginDescriptions(t, caseName, graph) + } + } + for _, child := range scope.Children() { + visit(child) + } + } + visit(idx.DocumentRoot(path)) + return checked +} + +func declaresAction(decl ast.Node) bool { + switch d := decl.(type) { + case *ast.Definition: + return d.Kind == ast.DefAction + case *ast.Usage: + return d.Kind == ast.UsageAction + } + return false +} + +// checkOriginDescriptions checks every node of a graph and of the block graphs +// its bodies state, reporting how many it checked. +func checkOriginDescriptions(t *testing.T, caseName string, graph *lower.ActionGraph) int { + t.Helper() + if graph == nil { + return 0 + } + checked := 0 + for _, node := range graph.Nodes { + id, ok := graph.ID[node] + if !ok { + t.Errorf("%s: %s has no id", caseName, nodeDescription(node)) + continue + } + if got, want := graph.Origins[id].String(), nodeDescription(node); got != want { + t.Errorf("%s: Origin.String() = %q, nodeDescription = %q", caseName, got, want) + } + checked++ + } + for _, body := range graph.Bodies { + for _, stmt := range body { + if block, ok := stmt.(lower.Block); ok { + checked += checkOriginDescriptions(t, caseName, block.Graph) + } + } + } + return checked +} diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index 4806ee2aa0..8fb715c808 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -10,6 +10,7 @@ import ( "strings" "sync" + "github.com/Open-MBEE/OpenSysML/internal/ir/program" "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" @@ -36,6 +37,18 @@ type ActionGraph struct { // Nodes in the graph (InitialNode, FinalNode, ExecutionNode, etc.) Nodes []ast.Node + // ID is each node's graph-local id, dense from 1 in the order of Nodes. + ID map[ast.Node]program.NodeID + + // Origins is each node's origin by id; Origins[0] is unused. + Origins []program.Origin + + // Kinds is each node's kind by id; Kinds[0] is unused. + Kinds []program.NodeKind + + // Provenance maps the ids back to the declarations they were lowered from. + Provenance Provenance + // Edges: source node → successions in declaration order. Edges map[ast.Node][]ActionEdge @@ -946,15 +959,20 @@ func lowerActionFlowWithTyping(members []ast.Node, scope *symbols.Scope, resolve func lowerActionFlowWithTypingAndAncestors( members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, typing bool, ancestors []ast.Node, -) (*ActionGraph, error) { +) (graph *ActionGraph, err error) { + defer func() { + if graph != nil { + graph.assignIDs() + } + }() generals, cyclic := resolve.ActionGeneralization(scope, typing) if cyclic { - graph := newActionGraph(scope) + graph = newActionGraph(scope) graph.resolver = resolver graph.lowering = actionLoweringAncestors(scope, ancestors) return graph, fmt.Errorf("%w: %s", ErrCyclicSpecialization, actionDescription(scope)) } - graph, err := collectActionNodesWithAncestors(members, scope, resolver, ancestors) + graph, err = collectActionNodesWithAncestors(members, scope, resolver, ancestors) if err != nil { return graph, err } diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index 2d5682c505..b79c731f79 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -267,9 +267,13 @@ func TestToActionGraphInheritedActionNodeCycleTerminates(t *testing.T) { action def Derived :> A { first start then missing; } ` derived, scope, _ := inheritedActionDecl(t, src, "Derived") - if _, err := ToActionGraph(derived, scope); !errors.Is(err, ErrCyclicSpecialization) { + graph, err := ToActionGraph(derived, scope) + if !errors.Is(err, ErrCyclicSpecialization) { t.Fatalf("ToActionGraph error = %v, want ErrCyclicSpecialization", err) } + if graph != nil && (graph.ID == nil || len(graph.Origins) != len(graph.Nodes)+1) { + t.Errorf("graph returned with the error has no ids: ID %v, %d origins for %d nodes", graph.ID, len(graph.Origins), len(graph.Nodes)) + } } func inheritedActionDecl(t *testing.T, src, name string) (ast.Node, *symbols.Scope, *ast.RootNamespace) { diff --git a/internal/ir/lower/action_nodes.go b/internal/ir/lower/action_nodes.go index f6b2bf16c5..e51b8d0845 100644 --- a/internal/ir/lower/action_nodes.go +++ b/internal/ir/lower/action_nodes.go @@ -56,6 +56,7 @@ func ToActionInterface(actionDecl ast.Node, scope *symbols.Scope) (*ActionGraph, graph := newActionGraph(scope) graph.Attributes = lowerAttributes(members, scope) graph.Parameters = lowerParameters(members, scope) + graph.assignIDs() return graph, nil } diff --git a/internal/ir/lower/action_subflow.go b/internal/ir/lower/action_subflow.go index 2b97dcbba8..f2f08a3deb 100644 --- a/internal/ir/lower/action_subflow.go +++ b/internal/ir/lower/action_subflow.go @@ -422,6 +422,7 @@ func lowerTerminateNode( if blockGraph == nil { blockGraph = newActionGraph(scope) blockGraph.resolver = graph.resolver + blockGraph.assignIDs() } if err != nil { blockGraph.Invalid = err diff --git a/internal/ir/lower/block_graph.go b/internal/ir/lower/block_graph.go index 356997245d..0e70c77106 100644 --- a/internal/ir/lower/block_graph.go +++ b/internal/ir/lower/block_graph.go @@ -159,6 +159,7 @@ func lowerStatedBlock(owner ast.Node, members []ast.Node, scope *symbols.Scope, if err != nil { if graph == nil { graph = newActionGraph(scope) + graph.assignIDs() } graph.Invalid = err StartFlow(graph) @@ -228,6 +229,7 @@ func lowerBlockFlowWith(members []ast.Node, scope *symbols.Scope, resolver *reso graph.Edges[graph.Nodes[i]] = []ActionEdge{{Source: graph.Nodes[i], Target: graph.Nodes[i+1], DeclaredOrder: true}} } recordBlockNodes(graph) + graph.assignIDs() return graph } diff --git a/internal/ir/lower/case_body.go b/internal/ir/lower/case_body.go index e0e63fe513..938cac9a69 100644 --- a/internal/ir/lower/case_body.go +++ b/internal/ir/lower/case_body.go @@ -81,6 +81,7 @@ func caseSteps(owner ast.Node, body []ast.Node, scope *symbols.Scope, resolver * } addCasePerformNodes(graph, members, scope) addCaseStatementRuns(graph, body, statementRuns) + graph.assignIDs() graph.UnstatedCaseFlow = true StartFlow(graph) flow := Block{Node: owner, Scope: scope, Graph: graph, Own: true, Stated: true} diff --git a/internal/ir/lower/identity.go b/internal/ir/lower/identity.go new file mode 100644 index 0000000000..41bbc6af13 --- /dev/null +++ b/internal/ir/lower/identity.go @@ -0,0 +1,382 @@ +package lower + +import ( + "fmt" + "sort" + "strconv" + + "github.com/Open-MBEE/OpenSysML/internal/ir/program" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Provenance maps a graph's ids back to the declarations its records were +// lowered from; index zero of every table is "none". +type Provenance struct { + nodes []ast.Node + states []*ast.StateNode + regions []*ast.StateRegion + pseudos []*ast.PseudostateNode + trans []*Transition +} + +// Node is the declaration behind a node id, nil for none. +func (p *Provenance) Node(id program.NodeID) ast.Node { + if id == 0 || int(id) >= len(p.nodes) { + return nil + } + return p.nodes[id] +} + +// State is the declaration behind a state id, nil for none. +func (p *Provenance) State(id program.StateID) *ast.StateNode { + if id == 0 || int(id) >= len(p.states) { + return nil + } + return p.states[id] +} + +// Region is the declaration behind a region id, nil for none. +func (p *Provenance) Region(id program.RegionID) *ast.StateRegion { + if id == 0 || int(id) >= len(p.regions) { + return nil + } + return p.regions[id] +} + +// Pseudostate is the declaration behind a pseudostate id, nil for none. +func (p *Provenance) Pseudostate(id program.PseudoID) *ast.PseudostateNode { + if id == 0 || int(id) >= len(p.pseudos) { + return nil + } + return p.pseudos[id] +} + +// Transition is the record behind a transition id, nil for none. +func (p *Provenance) Transition(id program.TransID) *Transition { + if id == 0 || int(id) >= len(p.trans) { + return nil + } + return p.trans[id] +} + +// tableIndex is the id of the record about to be appended to a table of n entries. +func tableIndex(n int) uint32 { + return uint32(n) // #nosec G115 -- a graph has far fewer than 2^32 records +} + +// originPaths keeps the paths handed out within one graph unique. +type originPaths map[string]bool + +// unique returns base, or base with the first `[k]` that no earlier origin took. +func (p originPaths) unique(base string) string { + path := base + for k := 1; p[path]; k++ { + path = base + "[" + strconv.Itoa(k) + "]" + } + p[path] = true + return path +} + +// originPath is a declaration's path: its qualified name, else its owner's +// qualified name with its name or, unnamed, its kind word (`A::b`, `A#if`). +func originPath(scope *symbols.Scope, sym *symbols.Symbol, name, kind string) string { + if sym != nil { + if fqn := symbols.FQNOf(sym); fqn != "" { + return fqn + } + } + var owner string + if scope != nil { + owner = symbols.FQNOf(scope.Owner()) + } + if name != "" { + if owner == "" { + return name + } + return owner + "::" + name + } + return owner + "#" + kind +} + +// originName is the name a message calls a node by, "" when it has none. +func originName(node ast.Node) string { + switch n := node.(type) { + case *ast.InitialNode: + return n.Name() + case *ast.FinalNode: + return "done" + case *ast.ForkNode: + return n.Name + case *ast.JoinNode: + return n.Name + case *ast.MergeNode: + return n.Name + case *ast.DecisionNode: + return n.Name + case *ast.ActionExecutionNode: + return n.Name + case *ast.StateNode: + return n.Name + case *ast.Usage: + if name, _ := ast.EffectiveName(n); name != "" { + return name + } + return n.Ident.ShortName + case *ast.Definition: + if n.Ident.Name != "" { + return n.Ident.Name + } + return n.Ident.ShortName + } + return "" +} + +// nodeKind is what a node of the graph does when a token reaches it. +func (g *ActionGraph) nodeKind(node ast.Node) program.NodeKind { + if g.StatementRuns[node] { + return program.NodeStatement + } + switch n := node.(type) { + case *ast.InitialNode: + return program.NodeInitial + case *ast.FinalNode: + return program.NodeFinal + case *ast.ForkNode: + return program.NodeFork + case *ast.JoinNode: + return program.NodeJoin + case *ast.MergeNode: + return program.NodeMerge + case *ast.DecisionNode: + return program.NodeDecision + case *ast.ActionExecutionNode: + return program.NodeExecution + case *ast.PerformActionNode: + return program.NodePerform + case *ast.AssignmentActionNode: + return program.NodeAssign + case *ast.WhileLoopActionNode: + return program.NodeLoop + case *ast.IfActionNode: + return program.NodeIf + case *ast.Usage: + if _, ok := g.Accepts[node]; ok || n.IsAccept { + return program.NodeAccept + } + if n.IsTerminate { + return program.NodeTerminate + } + return program.NodeExecution + } + return program.NodeStatement +} + +// assignIDs gives every node of the graph its id, dense from 1 in the order of +// Nodes, and the origin and kind beside it; a graph lowered twice gets the same ids. +func (g *ActionGraph) assignIDs() { + n := len(g.Nodes) + 1 + g.ID = make(map[ast.Node]program.NodeID, len(g.Nodes)) + g.Origins = make([]program.Origin, 1, n) + g.Kinds = make([]program.NodeKind, 1, n) + g.Provenance.nodes = make([]ast.Node, 1, n) + paths := originPaths{} + for _, node := range g.Nodes { + if _, seen := g.ID[node]; seen { + continue + } + id := program.NodeID(tableIndex(len(g.Provenance.nodes))) + g.ID[node] = id + g.Provenance.nodes = append(g.Provenance.nodes, node) + kind := g.nodeKind(node) + g.Kinds = append(g.Kinds, kind) + g.Origins = append(g.Origins, g.nodeOrigin(node, kind, paths)) + } +} + +func (g *ActionGraph) nodeOrigin(node ast.Node, kind program.NodeKind, paths originPaths) program.Origin { + scope := g.DeclScope(node) + var sym *symbols.Symbol + if scope != nil { + sym = scope.MemberDeclaring(node) + } + name := originName(node) + return program.Origin{ + Doc: symbols.DocNameOf(scope), + Span: node.Span(), + Path: paths.unique(originPath(scope, sym, name, kind.String())), + Kind: fmt.Sprintf("%T", node), + Name: name, + } +} + +// assignIDs gives every state, region, pseudostate and transition of the graph +// its id, dense from 1 in declaration order, and the origin beside it. +func (g *StateGraph) assignIDs() { + paths := originPaths{} + g.assignStateIDs(paths) + g.assignRegionIDs(paths) + g.assignPseudoIDs(paths) + g.assignTransIDs(paths) +} + +func (g *StateGraph) assignStateIDs(paths originPaths) { + n := len(g.States) + 1 + g.ID = make(map[*ast.StateNode]program.StateID, len(g.States)) + g.Origins = make([]program.Origin, 1, n) + g.Provenance.states = make([]*ast.StateNode, 1, n) + for _, state := range g.States { + if _, seen := g.ID[state]; seen { + continue + } + g.ID[state] = program.StateID(tableIndex(len(g.Provenance.states))) + g.Provenance.states = append(g.Provenance.states, state) + scope := g.StateScopes[state] + var sym *symbols.Symbol + if scope != nil { + sym = scope.Owner() + } + if sym == nil { + if declared := g.declaredIn[state]; declared != nil { + scope, sym = declared, declared.MemberDeclaring(state) + } + } + g.Origins = append(g.Origins, g.vertexOrigin(state, scope, sym, state.Name, paths)) + } +} + +// regionsInOrder lists the graph's regions once each: the machine's own, then +// each state's in state order. +func (g *StateGraph) regionsInOrder() []*ast.StateRegion { + seen := make(map[*ast.StateRegion]bool) + var out []*ast.StateRegion + add := func(regions []*ast.StateRegion) { + for _, region := range regions { + if !seen[region] { + seen[region] = true + out = append(out, region) + } + } + } + add(g.TopRegions) + for _, state := range g.States { + add(g.CompositeStates[state]) + } + return out +} + +func (g *StateGraph) assignRegionIDs(paths originPaths) { + regions := g.regionsInOrder() + g.RegionID = make(map[*ast.StateRegion]program.RegionID, len(regions)) + g.RegionOrigins = make([]program.Origin, 1, len(regions)+1) + g.Provenance.regions = make([]*ast.StateRegion, 1, len(regions)+1) + for _, region := range regions { + g.RegionID[region] = program.RegionID(tableIndex(len(g.Provenance.regions))) + g.Provenance.regions = append(g.Provenance.regions, region) + scope := g.regionScopeOf[region] + if scope == nil { + scope = g.declaredIn[region] + } + g.RegionOrigins = append(g.RegionOrigins, program.Origin{ + Doc: symbols.DocNameOf(scope), + Span: region.Span(), + Path: paths.unique(originPath(scope, nil, region.Name, "region")), + Kind: "region", + Name: region.Name, + }) + } +} + +func (g *StateGraph) assignPseudoIDs(paths originPaths) { + n := len(g.Pseudostates) + 1 + g.PseudoID = make(map[*ast.PseudostateNode]program.PseudoID, len(g.Pseudostates)) + g.PseudoOrigins = make([]program.Origin, 1, n) + g.Provenance.pseudos = make([]*ast.PseudostateNode, 1, n) + for _, ps := range g.Pseudostates { + if _, seen := g.PseudoID[ps]; seen { + continue + } + g.PseudoID[ps] = program.PseudoID(tableIndex(len(g.Provenance.pseudos))) + g.Provenance.pseudos = append(g.Provenance.pseudos, ps) + scope := g.declaredIn[ps] + var sym *symbols.Symbol + if scope != nil { + sym = scope.MemberDeclaring(ps) + } + g.PseudoOrigins = append(g.PseudoOrigins, g.vertexOrigin(ps, scope, sym, ps.Name, paths)) + } +} + +func (g *StateGraph) vertexOrigin(decl ast.Node, scope *symbols.Scope, sym *symbols.Symbol, name string, paths originPaths) program.Origin { + kind := VertexKind(decl) + return program.Origin{ + Doc: g.DocOf(decl), + Span: decl.Span(), + Path: paths.unique(originPath(scope, sym, name, kind)), + Kind: kind, + Name: name, + } +} + +// transitionsInOrder lists the graph's transitions once each: those leaving each +// state in state order, then each pseudostate's, then any other source's by position. +func (g *StateGraph) transitionsInOrder() []*Transition { + var out []*Transition + seen := make(map[ast.Node]bool) + add := func(source ast.Node) { + if seen[source] { + return + } + seen[source] = true + out = append(out, g.Transitions[source]...) + } + for _, state := range g.States { + add(state) + } + for _, ps := range g.Pseudostates { + add(ps) + } + var rest []ast.Node + for source := range g.Transitions { + if !seen[source] { + rest = append(rest, source) + } + } + sort.Slice(rest, func(i, j int) bool { return rest[i].Span().Offset < rest[j].Span().Offset }) + for _, source := range rest { + add(source) + } + return out +} + +func (g *StateGraph) assignTransIDs(paths originPaths) { + trans := g.transitionsInOrder() + g.TransID = make(map[*Transition]program.TransID, len(trans)) + g.TransOrigins = make([]program.Origin, 1, len(trans)+1) + g.Provenance.trans = make([]*Transition, 1, len(trans)+1) + for _, t := range trans { + if _, seen := g.TransID[t]; seen { + continue + } + g.TransID[t] = program.TransID(tableIndex(len(g.Provenance.trans))) + g.Provenance.trans = append(g.Provenance.trans, t) + var span source.Span + var sym *symbols.Symbol + if t.Decl != nil { + span = t.Decl.Span() + if t.Scope != nil { + sym = t.Scope.MemberDeclaring(t.Decl) + } + } else if t.Source != nil { + span = t.Source.Span() + } + g.TransOrigins = append(g.TransOrigins, program.Origin{ + Doc: g.DocOf(t.Decl), + Span: span, + Path: paths.unique(originPath(t.Scope, sym, t.Name, "transition")), + Kind: "transition", + Name: t.Name, + }) + } +} diff --git a/internal/ir/lower/identity_test.go b/internal/ir/lower/identity_test.go new file mode 100644 index 0000000000..cb0c2e110d --- /dev/null +++ b/internal/ir/lower/identity_test.go @@ -0,0 +1,316 @@ +package lower + +import ( + "reflect" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/program" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +const identityActionSrc = ` + action brew { + first start; + action grind; + action pour; + fork f; + join j; + if x > 1 { action a1; } + if x > 2 { action a2; } + assign y := 1; + assign y := 2; + first start then grind; + first grind then pour; + first pour then done; + } +` + +// identityActionGraph lowers the first action usage of src within its document, +// so that origins carry the document name and qualified paths. +func identityActionGraph(t *testing.T, src string) *ActionGraph { + t.Helper() + p := parser.New(source.New("test.sysml", []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Fatalf("parse errors: %v", p.Diagnostics) + } + var usage *ast.Usage + for _, member := range root.Members { + if m, ok := member.(*ast.Membership); ok { + if u, ok := m.Member.(*ast.Usage); ok && u.Kind == ast.UsageAction { + usage = u + break + } + } + } + if usage == nil { + t.Fatal("no action usage found") + } + idx := symbols.NewIndexFromDoc("test.sysml", root) + scope := idx.DocumentRoot("test.sysml").ChildFor(usage) + if scope == nil { + t.Fatal("scope for action usage missing") + } + graph, err := ToActionGraphWith(usage, scope, resolve.New(idx)) + if err != nil { + t.Fatalf("ToActionGraphWith: %v", err) + } + return graph +} + +func TestAssignIDs_ActionDenseInDeclarationOrder(t *testing.T) { + graph := identityActionGraph(t, identityActionSrc) + if len(graph.Nodes) < 6 { + t.Fatalf("fixture lowers %d nodes, want several", len(graph.Nodes)) + } + if got := len(graph.Origins); got != len(graph.Nodes)+1 { + t.Fatalf("len(Origins) = %d, want %d", got, len(graph.Nodes)+1) + } + if len(graph.Kinds) != len(graph.Origins) || len(graph.ID) != len(graph.Nodes) { + t.Fatalf("Kinds %d, ID %d, Nodes %d", len(graph.Kinds), len(graph.ID), len(graph.Nodes)) + } + for i, node := range graph.Nodes { + id := graph.ID[node] + if id != program.NodeID(i+1) { + t.Errorf("node %d has id %d, want %d", i, id, i+1) + } + if graph.Provenance.Node(id) != node { + t.Errorf("Provenance.Node(%d) is not the node", id) + } + if graph.Kinds[id] == program.NodeNone { + t.Errorf("node %d has no kind", id) + } + if origin := graph.Origins[id]; origin.Doc != "test.sysml" || origin.Span != node.Span() { + t.Errorf("node %d origin = %#v", id, origin) + } + } + if graph.Provenance.Node(0) != nil || graph.Provenance.Node(program.NodeID(len(graph.Nodes)+1)) != nil { + t.Error("ids outside the table must map to nil") + } +} + +func TestAssignIDs_ActionOriginsNameWhatMessagesName(t *testing.T) { + graph := identityActionGraph(t, identityActionSrc) + byName := make(map[string]program.NodeID) + for _, node := range graph.Nodes { + byName[graph.Origins[graph.ID[node]].Name] = graph.ID[node] + } + grind := graph.Origins[byName["grind"]] + if grind.String() != "node grind" || grind.Kind != "*ast.Usage" || grind.Path != "brew::grind" || grind.Doc != "test.sysml" { + t.Errorf("grind origin = %#v", grind) + } + if graph.Kinds[1] != program.NodeInitial || graph.Kinds[byName["f"]] != program.NodeFork || graph.Kinds[byName["grind"]] != program.NodeExecution { + t.Errorf("kinds: initial %v, fork %v, grind %v", graph.Kinds[1], graph.Kinds[byName["f"]], graph.Kinds[byName["grind"]]) + } + done := graph.Origins[byName["done"]] + if done.String() != "node done" || done.Kind != "*ast.FinalNode" || graph.Kinds[byName["done"]] != program.NodeFinal { + t.Errorf("done origin = %#v", done) + } + for id := 1; id < len(graph.Origins); id++ { + if origin := graph.Origins[id]; origin.Name == "" && origin.String() != "the "+origin.Kind { + t.Errorf("unnamed node %d renders %q", id, origin.String()) + } + } +} + +func TestAssignIDs_ActionPathUniquePerGraph(t *testing.T) { + graph := identityActionGraph(t, identityActionSrc) + paths := make(map[string]program.NodeID) + unnamedKinds := make(map[string]int) + for id := 1; id < len(graph.Origins); id++ { + origin := graph.Origins[id] + if origin.Path == "" { + t.Errorf("node %d has an empty path", id) + } + if prev, dup := paths[origin.Path]; dup { + t.Errorf("nodes %d and %d share path %q", prev, id, origin.Path) + } + paths[origin.Path] = program.NodeID(id) + if origin.Name == "" { + unnamedKinds[origin.Kind]++ + } + } + // The fixture must make uniqueness do work: two unnamed nodes of one kind. + exercised := false + for _, n := range unnamedKinds { + exercised = exercised || n > 1 + } + if !exercised { + t.Fatalf("fixture has no two unnamed nodes of one kind: %v", unnamedKinds) + } +} + +func TestAssignIDs_ActionDeterministicAcrossLowerings(t *testing.T) { + first := identityActionGraph(t, identityActionSrc) + second := identityActionGraph(t, identityActionSrc) + if !reflect.DeepEqual(first.Origins, second.Origins) { + t.Errorf("origins differ:\n%+v\n%+v", first.Origins, second.Origins) + } + if !reflect.DeepEqual(first.Kinds, second.Kinds) { + t.Errorf("kinds differ: %v vs %v", first.Kinds, second.Kinds) + } + for i := range first.Nodes { + if first.ID[first.Nodes[i]] != second.ID[second.Nodes[i]] { + t.Errorf("node %d: ids %d and %d", i, first.ID[first.Nodes[i]], second.ID[second.Nodes[i]]) + } + } +} + +func identityStateGraph(t *testing.T) *StateGraph { + t.Helper() + graph, err := ToStateGraph(stateDefinitionIn(t, forkMachine( + `state a; state b;`, + `state c;`, + `transition first split then a; + transition first split then c; + transition maintain first a then b;`, + )), nil) + if err != nil { + t.Fatalf("ToStateGraph: %v", err) + } + return graph +} + +func TestAssignIDs_StateTablesDenseInDeclarationOrder(t *testing.T) { + graph := identityStateGraph(t) + if len(graph.States) < 4 || len(graph.Pseudostates) < 1 || len(graph.RegionID) < 2 || len(graph.TransID) < 3 { + t.Fatalf("fixture lowers %d states, %d pseudostates, %d regions, %d transitions", + len(graph.States), len(graph.Pseudostates), len(graph.RegionID), len(graph.TransID)) + } + if len(graph.Origins) != len(graph.States)+1 || len(graph.PseudoOrigins) != len(graph.Pseudostates)+1 || + len(graph.RegionOrigins) != len(graph.RegionID)+1 || len(graph.TransOrigins) != len(graph.TransID)+1 { + t.Fatal("every origin table is one longer than its records") + } + for i, state := range graph.States { + id := graph.ID[state] + if id != program.StateID(i+1) || graph.Provenance.State(id) != state { + t.Errorf("state %s: id %d at %d", state.Name, id, i) + } + if origin := graph.Origins[id]; origin.Name != state.Name || origin.Kind != "state" || origin.Span != state.Span() { + t.Errorf("state %s origin = %+v", state.Name, origin) + } + } + for i, ps := range graph.Pseudostates { + id := graph.PseudoID[ps] + if id != program.PseudoID(i+1) || graph.Provenance.Pseudostate(id) != ps { + t.Errorf("pseudostate %s: id %d at %d", ps.Name, id, i) + } + } + for region, id := range graph.RegionID { + if graph.Provenance.Region(id) != region || graph.RegionOrigins[id].Kind != "region" { + t.Errorf("region %s: id %d", region.Name, id) + } + } + for i, region := range graph.TopRegions { + if graph.RegionID[region] != program.RegionID(i+1) { + t.Errorf("top region %d has id %d", i, graph.RegionID[region]) + } + } + seen := 0 + for _, state := range graph.States { + for _, tr := range graph.Transitions[state] { + seen++ + id := graph.TransID[tr] + if id != program.TransID(seen) || graph.Provenance.Transition(id) != tr { + t.Errorf("transition %d from %s has id %d", seen, state.Name, id) + } + } + } + if origin := graph.TransOrigins[graph.TransID[graph.Transitions[stateNamed(graph, "a")][0]]]; origin.Name != "maintain" || origin.Kind != "transition" { + t.Errorf("named transition origin = %+v", origin) + } +} + +func TestAssignIDs_StatePathUniquePerGraph(t *testing.T) { + graph := identityStateGraph(t) + paths := make(map[string]bool) + check := func(table string, origins []program.Origin) { + for id := 1; id < len(origins); id++ { + path := origins[id].Path + if path == "" || paths[path] { + t.Errorf("%s %d: path %q empty or taken", table, id, path) + } + paths[path] = true + } + } + check("state", graph.Origins) + check("region", graph.RegionOrigins) + check("pseudostate", graph.PseudoOrigins) + check("transition", graph.TransOrigins) +} + +func TestAssignIDs_StateDeterministicAcrossLowerings(t *testing.T) { + first, second := identityStateGraph(t), identityStateGraph(t) + for name, pair := range map[string][2][]program.Origin{ + "states": {first.Origins, second.Origins}, + "regions": {first.RegionOrigins, second.RegionOrigins}, + "pseudostates": {first.PseudoOrigins, second.PseudoOrigins}, + "transitions": {first.TransOrigins, second.TransOrigins}, + } { + if !reflect.DeepEqual(pair[0], pair[1]) { + t.Errorf("%s differ:\n%+v\n%+v", name, pair[0], pair[1]) + } + } +} + +func TestAssignIDs_CaseFlowCoversNodesAddedAfterLowering(t *testing.T) { + body := caseBodyOf(t, ` + analysis def Among { + subject s : S; + if s.ready { + assign s.x := 1; + out r : Integer = s.x; + } + perform action p : P; + return r : Integer = s.x; + } + `) + flow, ok := body[0].(Block) + if !ok || flow.Graph == nil || len(flow.Graph.Nodes) != 2 { + t.Fatalf("body[0] = %#v, want the case flow of two nodes", body[0]) + } + graph := flow.Graph + if len(graph.ID) != len(graph.Nodes) || len(graph.Origins) != len(graph.Nodes)+1 { + t.Fatalf("%d ids and %d origins for %d nodes", len(graph.ID), len(graph.Origins), len(graph.Nodes)) + } + for i, node := range graph.Nodes { + if graph.ID[node] != program.NodeID(i+1) { + t.Errorf("node %d has id %d", i, graph.ID[node]) + } + } + if graph.Kinds[1] != program.NodeIf || graph.Kinds[2] != program.NodeExecution { + t.Errorf("kinds = %v, want the branch then the performed action", graph.Kinds[1:]) + } +} + +func TestAssignIDs_RegionOriginsCarryDocumentAndOwner(t *testing.T) { + src := forkMachine(`state a;`, `state c;`, `transition first split then a; transition first split then c;`) + p := parser.New(source.New("test.sysml", []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Fatalf("parse errors: %v", p.Diagnostics) + } + idx := symbols.NewIndexFromDoc("test.sysml", root) + machine := idx.LookupQualified("test::Machine") + if len(machine) != 1 || machine[0].Scope == nil { + t.Fatalf("test::Machine resolves to %d symbols", len(machine)) + } + graph, err := ToStateGraph(machine[0].Decl, machine[0].Scope) + if err != nil { + t.Fatalf("ToStateGraph: %v", err) + } + if len(graph.RegionOrigins) < 3 { + t.Fatalf("%d region origins, want the two regions of work", len(graph.RegionOrigins)-1) + } + for id := 1; id < len(graph.RegionOrigins); id++ { + origin := graph.RegionOrigins[id] + if origin.Doc != "test.sysml" || !strings.HasPrefix(origin.Path, "test::Machine") { + t.Errorf("region %d origin = %#v", id, origin) + } + } +} diff --git a/internal/ir/lower/state_graph.go b/internal/ir/lower/state_graph.go index 7221b6455c..017e32dc84 100644 --- a/internal/ir/lower/state_graph.go +++ b/internal/ir/lower/state_graph.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + "github.com/Open-MBEE/OpenSysML/internal/ir/program" "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" @@ -102,6 +103,30 @@ type StateGraph struct { // States in the machine (flat list, includes nested) States []*ast.StateNode + // ID is each state's graph-local id, dense from 1 in the order of States. + ID map[*ast.StateNode]program.StateID + + // Origins is each state's origin by id; Origins[0] is unused. + Origins []program.Origin + + // RegionID is each region's id, the machine's own regions first, then each + // state's in state order; RegionOrigins is its origin by id. + RegionID map[*ast.StateRegion]program.RegionID + RegionOrigins []program.Origin + + // PseudoID is each pseudostate's id in the order of Pseudostates; + // PseudoOrigins is its origin by id. + PseudoID map[*ast.PseudostateNode]program.PseudoID + PseudoOrigins []program.Origin + + // TransID is each transition's id, those leaving each state in state order, + // then each pseudostate's; TransOrigins is its origin by id. + TransID map[*Transition]program.TransID + TransOrigins []program.Origin + + // Provenance maps the ids back to the records they were lowered from. + Provenance Provenance + // Pseudostates of the machine in declaration order. Not keyed by name: // sibling regions may declare same-named pseudostates. Pseudostates []*ast.PseudostateNode @@ -417,6 +442,7 @@ func ToStateGraphWithEndpoints(stateMachineDecl ast.Node, scope *symbols.Scope, return nil, err } + graph.assignIDs() return graph, nil } diff --git a/internal/ir/program/kind.go b/internal/ir/program/kind.go new file mode 100644 index 0000000000..ca5bcef305 --- /dev/null +++ b/internal/ir/program/kind.go @@ -0,0 +1,71 @@ +package program + +import "strconv" + +// NodeKind is what a node of an action graph does when a token reaches it. +type NodeKind uint8 + +const ( + // NodeNone is the zero kind, carried by no node. + NodeNone NodeKind = iota + // NodeInitial is the flow's entry, `first start`. + NodeInitial + // NodeFinal is a node the flow ends at, `done`. + NodeFinal + // NodeFork splits a token across every outgoing succession. + NodeFork + // NodeJoin waits for a token on every incoming succession. + NodeJoin + // NodeMerge passes a token on from any incoming succession. + NodeMerge + // NodeDecision chooses one guarded outgoing succession. + NodeDecision + // NodeExecution runs a nested action, `action a { ... }`. + NodeExecution + // NodePerform performs a referenced action, `perform action a`. + NodePerform + // NodeAccept waits for an event, `accept e`. + NodeAccept + // NodeSend dispatches an event, `send e to t`. + NodeSend + // NodeAssign writes a feature, `assign x := e`. + NodeAssign + // NodeTerminate ends the enclosing behavior, `terminate`. + NodeTerminate + // NodeIf branches on a condition, `if c { ... }`. + NodeIf + // NodeLoop repeats a body, `while c { ... }` and `for i in s { ... }`. + NodeLoop + // NodeBlock runs a body-local block of nodes. + NodeBlock + // NodeStatement runs the statements written between two nodes of a block as one step. + NodeStatement +) + +var nodeKindNames = [...]string{ + NodeNone: "none", + NodeInitial: "initial", + NodeFinal: "final", + NodeFork: "fork", + NodeJoin: "join", + NodeMerge: "merge", + NodeDecision: "decision", + NodeExecution: "action", + NodePerform: "perform", + NodeAccept: "accept", + NodeSend: "send", + NodeAssign: "assign", + NodeTerminate: "terminate", + NodeIf: "if", + NodeLoop: "loop", + NodeBlock: "block", + NodeStatement: "statement", +} + +// String is the kind's word in the notation. +func (k NodeKind) String() string { + if int(k) < len(nodeKindNames) { + return nodeKindNames[k] + } + return "NodeKind(" + strconv.Itoa(int(k)) + ")" +} diff --git a/internal/ir/program/origin.go b/internal/ir/program/origin.go new file mode 100644 index 0000000000..c58cabd5e1 --- /dev/null +++ b/internal/ir/program/origin.go @@ -0,0 +1,33 @@ +package program + +import "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + +// Origin is where a lowered record came from, as plain data: what a message, a +// trace or a hover says about it once the syntax tree is gone. +type Origin struct { + // Doc is the name of the document the declaration was written in, "" outside any. + Doc string + // Span is the declaration's extent in Doc. + Span source.Span + // Path is unique within the graph: the declaration's qualified name, else its + // owner's and its position among the owner's unnamed members of its kind (`A#if[2]`). + Path string + // Element is the declaration's element id when one is known, else "". + Element string + // Kind is the word today's messages name the declaration by when it is unnamed: + // an action node's declaring type as %T renders it, a vertex's `state` or `pseudostate`. + Kind string + // Name is the declaration's name as messages use it, "" when it has none. + Name string + // Docs is the declaration's documentation prose; "" when it has none or the lowering read no source text. + Docs string +} + +// String names the record the way a message does: by name when it has one, +// else by kind. +func (o Origin) String() string { + if o.Name != "" { + return "node " + o.Name + } + return "the " + o.Kind +} diff --git a/internal/ir/program/program.go b/internal/ir/program/program.go new file mode 100644 index 0000000000..bbaf55a826 --- /dev/null +++ b/internal/ir/program/program.go @@ -0,0 +1,39 @@ +// Package program is the execution-owned IR: the identity every lowered record +// carries independent of the syntax tree, assigned by lower and read by executors. +package program + +import "encoding/hex" + +// NodeID indexes a node of an action graph. +type NodeID uint32 + +// EdgeID indexes a succession of an action graph. +type EdgeID uint32 + +// StmtID indexes a statement of an action graph. +type StmtID uint32 + +// ExprID indexes an expression, shared by the action, state machine and calculation graphs. +type ExprID uint32 + +// StateID indexes a state of a state machine graph. +type StateID uint32 + +// RegionID indexes a region of a state machine graph. +type RegionID uint32 + +// PseudoID indexes a pseudostate of a state machine graph. +type PseudoID uint32 + +// TransID indexes a transition of a state machine graph. +type TransID uint32 + +// GraphID is the digest of a graph's canonical encoding, the same for two lowerings +// of one model; its encoder is not written yet, and a zero GraphID is "not computed". +type GraphID [16]byte + +// IsZero reports whether the id was never computed. +func (id GraphID) IsZero() bool { return id == GraphID{} } + +// String renders the id as hex. +func (id GraphID) String() string { return hex.EncodeToString(id[:]) } diff --git a/internal/ir/program/program_test.go b/internal/ir/program/program_test.go new file mode 100644 index 0000000000..8b8685da34 --- /dev/null +++ b/internal/ir/program/program_test.go @@ -0,0 +1,69 @@ +package program + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +func TestOriginString(t *testing.T) { + cases := []struct { + name string + origin Origin + want string + }{ + {"named", Origin{Name: "step", Kind: "*ast.Usage"}, "node step"}, + {"final", Origin{Name: "done", Kind: "*ast.FinalNode"}, "node done"}, + {"unnamed", Origin{Kind: "*ast.IfActionNode"}, "the *ast.IfActionNode"}, + {"vertex", Origin{Kind: "state"}, "the state"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.origin.String(); got != tc.want { + t.Fatalf("String() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestOriginIsPlainData(t *testing.T) { + a := Origin{Doc: "m.sysml", Span: source.Span{Offset: 3, Len: 4}, Path: "A::b", Name: "b", Kind: "*ast.Usage"} + b := a + if a != b { + t.Fatal("Origin must be comparable and copy by value") + } +} + +func TestNodeKindString(t *testing.T) { + for k := NodeNone; k <= NodeStatement; k++ { + if s := k.String(); s == "" || strings.HasPrefix(s, "NodeKind(") { + t.Errorf("NodeKind %d has no name", k) + } + } + if got := NodeKind(200).String(); got != "NodeKind(200)" { + t.Errorf("unknown kind renders %q", got) + } + if NodeNone != 0 { + t.Error("the zero kind must be none") + } +} + +func TestGraphIDZero(t *testing.T) { + var id GraphID + if !id.IsZero() { + t.Error("the zero GraphID must report IsZero") + } + id[0] = 1 + if id.IsZero() || id.String() != "01000000000000000000000000000000" { + t.Errorf("IsZero()=%v String()=%q", id.IsZero(), id) + } +} + +func TestIDsAreGraphLocalIndices(t *testing.T) { + // Zero is "none" for every id type; the first record of a table is 1. + if NodeID(0) != 0 || EdgeID(0) != 0 || StmtID(0) != 0 || ExprID(0) != 0 || + StateID(0) != 0 || RegionID(0) != 0 || PseudoID(0) != 0 || TransID(0) != 0 { + t.Fatal("zero ids") + } +} diff --git a/tests/hygiene/layering_test.go b/tests/hygiene/layering_test.go index f2dac8c9c0..43c8d61471 100644 --- a/tests/hygiene/layering_test.go +++ b/tests/hygiene/layering_test.go @@ -62,6 +62,7 @@ var packageLayer = map[string]string{ "internal/semantic/metamodel": "semantic", "internal/ir/lower": "ir", + "internal/ir/program": "ir", "internal/ir/queryplan": "ir", "internal/ir/docplan": "ir", "internal/ir/docplan/markup": "ir", @@ -211,6 +212,7 @@ var removed = map[string][]string{ "internal/exec/runtime": {"internal/syntax/parser", "internal/check/passes"}, "internal/semantic/query": {"internal/translate/rdf"}, "internal/frontend/repl": {"internal/frontend/grpc"}, + "internal/ir/program": {"internal/syntax/ast"}, } // TestPackageLayering checks the import graph against the layer tables: every