diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml new file mode 100644 index 0000000..47fa70f --- /dev/null +++ b/.github/workflows/push.yaml @@ -0,0 +1,33 @@ +name: CI + +on: + create: + pull_request: + push: + branches: + - main + - wip-2.0.0 + +jobs: + test: + name: Unit Tests + runs-on: [ ubuntu-latest ] + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + # Setup Go + - name: Setup Go + uses: actions/setup-go@v2 + with: + go-version: '1.20.x' # The Go version to download (if necessary) and use. + + # Run gofmt on the code + - name: Run gofmt + run: gofmt -d + + # Run unit tests for the code + - name: Run tests + run: | + go test -v ./... + diff --git a/.gitignore b/.gitignore index d693dce..bbc0dba 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ TAGS *~ *.db tmp/ + +vendor/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 03f859f..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: go -go: - - 1.9 -before_script: - - go get -t ./... -script: - - go test -v ./... diff --git a/Dockerfile b/Dockerfile index 523bfd7..4ac50e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:latest +FROM golang:1.20.14 RUN mkdir -p $GOPATH/src/github.com/sheens diff --git a/Makefile b/Makefile index 6691941..66b9618 100644 --- a/Makefile +++ b/Makefile @@ -3,13 +3,14 @@ all: test prereqs: - (which stringer > /dev/null) || go get golang.org/x/tools/cmd/stringer - (which jsonenums > /dev/null) || go get github.com/campoy/jsonenums + (which stringer > /dev/null) || go install golang.org/x/tools/cmd/stringer@latest + (which jsonenums > /dev/null) || go install github.com/campoy/jsonenums@latest test: prereqs cd core && go generate && go test cd crew && go test cd tools && go test + cd tools/expect && go test cd interpreters/ecmascript && go test cd interpreters/noop && go test cd cmd/patmatch && go test @@ -17,6 +18,7 @@ test: prereqs cd cmd/mexpect && go test cd cmd/mcrew && go test cd cmd/spectool && go test + cd cmd/sheensio && go test cd cmd/mdb && go test install: prereqs diff --git a/README.md b/README.md index 60d033a..f20f5c9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,21 @@ [![Build Status](https://travis-ci.org/Comcast/sheens.svg?branch=master)](https://travis-ci.org/Comcast/sheens) -[![Documentation](https://godoc.org/github.com/Comcast/sheens/core?status.svg)](http://godoc.org/github.com/Comcast/sheens/core) +[![Documentation](https://godoc.org/github.com/Comcast/sheens?status.svg)](http://godoc.org/github.com/Comcast/sheens) ![Sheens logo](doc/logo.png) +# Welcome to Sheens +For implementing state machines. + +Sheens is a lightweight and flexible framework for building and executing state machines. It is written in Go and is designed to be simple to use and highly scalable. + +With Sheens, you can define state machines using a simple JSON-based syntax. You can then execute these using the Sheens runtime, which provides a highly optimized execution engine for running state machines at scale. + +Workflows are a concept that can be supported by finite state machines. As a finite state machine engine, Sheens can be used to define a series of states and transitions between them, which can represent the different stages of a process or a sequence of events. Together, you might call them a workflow. Sheens working together like this can range from simple, linear sequences to more complex, event-driven systems, and they can be used to automate a wide range of tasks and processes. By using a well-designed library, such as the one Sheens provides, you can build workflows that are scalable, efficient, and easy to manage. + +In addition to its powerful state machine capabilities, Sheens also includes support for timers, allowing you to define timed events and trigger actions based on them. Sheens also includes a number of other features and capabilities, including support for dynamic loading, automatic state machine validation, and much more. + +If you're looking for a flexible and powerful tool for implementing these ideas, Sheens is the perfect choice. Whether you're building a simple sequential workflow or a complex, asynchronous system, Sheens provides the tools and capabilities you need to get the job done. + # Messaging machines @@ -91,6 +104,8 @@ Other objectives languages. 6. Modest resource requirements. + + ## Design @@ -150,10 +165,11 @@ Each branch consists of an optional _pattern_, optional _guard_, and a required _target_, which is the name of a node in the machine specification. -A pattern is a structure object that can include pattern variables. -(See below for more about pattern.) A _guard_ is an optional -procedure that generates bindings (perhaps nil) from bindings. If a -guard returns no bindings, then the branch isn't followed. +A _pattern_, which can include pattern variables, can be matched +against an incoming message or current bindings. See below for more +about patterns. A _guard_ is an optional procedure that generates +bindings (perhaps nil) from bindings. If a guard returns no bindings, +then the branch isn't followed. A machine consists of its _current state_: the name of the current node, the current bindings, and a pointer to the machine's @@ -174,8 +190,13 @@ A _crew_ is a group of machines associated with some agent. Transitions from one node to another are driven by pattern matching, either against the current set of bindings or a pending message. -A _pattern_ is a map (perhaps with deep structure) that might contain -some strings that start with a `?`. Example: +A _pattern_ is a string, array, or map (perhaps with deep structure) +that can be matched against an incoming message or the current +bindings. A string in a pattern that starts with a `?` is a _pattern +variable_ that will be bound when a message or bindinings matches the +pattern. + +Here's a pattern with two pattern variables (`?here` and `?address`): ```Javascript {"person": "homer", @@ -183,10 +204,9 @@ some strings that start with a `?`. Example: "at": {"type": "residence", "address": "?address"}} ``` -A string that starts with a `?` is a _pattern variable_. (The string -`?` (without anything else) is an anonymous pattern variable that -matches anything and is not based on input bindings or included in -output bindings.) +The string `?` (without anything else) is an anonymous pattern +variable that matches anything and is not based on input bindings or +included in output bindings. A message matched against a pattern results in zero or more sets of variable bindings. @@ -240,7 +260,7 @@ with the bindings [{"?x":1},{"?x":2}] ``` -For some more examples, see [`core/match.md`](core/match.md), which is +For some more examples, see [`match/match.md`](match/match.md), which is generated by test cases. The utility [`patmatch`](cmd/patmatch) is handy for testing patterns: @@ -293,7 +313,7 @@ For example, given input bindings `{"? specs/double.js cat< crew.json {"id":"simpsons", "machines":{ - "m1":{"spec":"double","node":"start","bs":{}}}} + "m1":{"spec":"double.js","node":"start","bs":{}}}} EOF echo '{"double":3}' | sheensio diff --git a/cmd/sheensio/main.go b/cmd/sheensio/main.go index 7dfa16b..df81eba 100644 --- a/cmd/sheensio/main.go +++ b/cmd/sheensio/main.go @@ -10,6 +10,8 @@ * limitations under the License. */ +// Package main is a program to run machines that listen on stdin and +// write to stdout. package main import ( @@ -26,12 +28,13 @@ import ( "github.com/Comcast/sheens/core" "github.com/Comcast/sheens/interpreters" + "github.com/Comcast/sheens/match" ) type Machine struct { - Spec interface{} `json:"spec"` - Node string `json:"node"` - Bs core.Bindings `json:"bs"` + Spec interface{} `json:"spec"` + Node string `json:"node"` + Bs match.Bindings `json:"bs"` spec *core.Spec Id string `json:"id"` diff --git a/cmd/spectool/.gitignore b/cmd/spectool/.gitignore index 8b009f1..3f07908 100644 --- a/cmd/spectool/.gitignore +++ b/cmd/spectool/.gitignore @@ -1,3 +1,2 @@ *.dot -*.png - +*.mermaid diff --git a/cmd/spectool/README.md b/cmd/spectool/README.md index f847a76..fee9cbb 100644 --- a/cmd/spectool/README.md +++ b/cmd/spectool/README.md @@ -1,6 +1,12 @@ # A tool to work with specs -Docs ToDo. +A machine specification is of course a data structure, so we can +analyze and manipulate it. Though this tool mostly serves as a +demonstration, some of its functionality might actually be useful. + +`spectool` expects a specification (in either YAML or JSON) from +`stdin`, and `spectool` writes out a spec (by default in YAML). So +you can chain `spectool` invocations via pipes. ## Example @@ -13,7 +19,49 @@ cat demo.yaml | \ spectool addGenericCancelNode | \ spectool addMessageBranches -P -p '{"ctl":"cancel"}' -t cancel | \ spectool dot | \ + spectool mermaid | \ spectool analyze | spectool yamltojson > \ demo.json ``` + +The `analysis` function generates output like + +``` +errors: [] +nodecount: 16 +branches: 24 +actions: 9 +guards: 0 +terminalnodes: [] +orphans: +- lunch_00 +- demo +emptytargets: [] +missingtargets: +- there +- lunch_start +branchtargetvariables: +- '@from' +interpreters: +- ecmascript +``` + +If you have [Mermaid](https://mermaidjs.github.io/) installed, you can +render that output spec as an [SVG](demo.svg): + +```Shell +./node_modules/.bin/mmdc -i spec.mermaid -o demo.svg +``` +![mermaid](./demo.svg) + + +If you have [Graphviz]() instaled, you can render that output spec +with + +```Shell +dot -Tpng spec.dot -o demo.png +``` + +![graphviz](demo.png) + diff --git a/cmd/spectool/demo.png b/cmd/spectool/demo.png new file mode 100644 index 0000000..bba54d6 Binary files /dev/null and b/cmd/spectool/demo.png differ diff --git a/cmd/spectool/demo.svg b/cmd/spectool/demo.svg new file mode 100644 index 0000000..338c21f --- /dev/null +++ b/cmd/spectool/demo.svg @@ -0,0 +1,33 @@ +
{'get':'lunch'}
+
{'ctl':'cancel'}
+
{'likes':'queso'}
+
{'deliver':'beer'}
+
{'ctl':'enable'}
+
{'ctl':'cancel'}
+
{'deliver':'tacos'}
+
{'deliver':'queso'}
+
{
+  'at': 'lunch_01_recv',
+  'from': 'lunch_01_emit',
+  'timeout': 'FpLSjFbcXoEFfRsWxPLDnJObCsNVlgTe'
+}
+
{'ctl':'cancel'}
+
{'deliver':'queso'}
+
{'deliver':'beer'}
+
{
+  'at': 'lunch_00_recv',
+  'from': 'lunch_00_emit',
+  'timeout': 'XVlBzgbaiCMRAjWwhTHctcuAxhxKQFDa'
+}
+
{'ctl':'cancel'}
+
{'deliver':'tacos'}
+
{
+  'at': 'lunch_02_recv',
+  'from': 'lunch_02_emit',
+  'timeout': 'MaPEZQleQYhYzRyWJjPjzpfRFEgmotaF'
+}
+
{'ctl':'cancel'}
+
start
lunch_start
cancel
lunch_02_emit
lunch_02_recv
demo
there
lunch_00_remt
lunch_01_emit
listenForEnable
lunch_00
lunch_00_emit
lunch_02_remt
done
lunch_01_recv
lunch_01_remt
timedout
lunch_00_recv
@from
\ No newline at end of file diff --git a/cmd/spectool/demo.yaml b/cmd/spectool/demo.yaml index 17f7340..749e9eb 100644 --- a/cmd/spectool/demo.yaml +++ b/cmd/spectool/demo.yaml @@ -1,6 +1,7 @@ name: toolcompdemo doc: |- A demo machine that's almost all generated by tool invocation. +patternsyntax: json nodes: start: branching: diff --git a/cmd/spectool/main.go b/cmd/spectool/main.go index ca6eacf..b80731b 100644 --- a/cmd/spectool/main.go +++ b/cmd/spectool/main.go @@ -10,9 +10,12 @@ * limitations under the License. */ +// Package main is a program that can modify and analyze machine +// specifications. package main import ( + "context" "encoding/json" "flag" "fmt" @@ -32,6 +35,9 @@ func main() { os.Exit(1) } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + switch os.Args[1] { case "inline", "inlines": @@ -185,6 +191,11 @@ func main() { os.Exit(1) } + if err = s.ParsePatterns(ctx); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + if err := mod.F(s); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) diff --git a/core/actions.go b/core/actions.go index a7eb0fc..d0f87be 100644 --- a/core/actions.go +++ b/core/actions.go @@ -1,4 +1,4 @@ -/* Copyright 2018 Comcast Cable Communications Management, LLC +/* Copyright 2021 Comcast Cable Communications Management, LLC * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -16,6 +16,8 @@ import ( "context" "errors" "strings" + + . "github.com/Comcast/sheens/match" ) var ( @@ -90,11 +92,7 @@ func NewInterpretersMap() InterpretersMap { } func (m InterpretersMap) Find(name string) Interpreter { - i, have := m[name] - if !have { - return nil - } - return i + return m[name] } // Action returns Bindings based on the given (current) Bindings. diff --git a/core/actions_test.go b/core/actions_test.go index 635507d..bcf85a1 100644 --- a/core/actions_test.go +++ b/core/actions_test.go @@ -1,4 +1,4 @@ -/* Copyright 2018 Comcast Cable Communications Management, LLC +/* Copyright 2021 Comcast Cable Communications Management, LLC * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -14,9 +14,43 @@ package core import ( "context" + "errors" "testing" + + . "github.com/Comcast/sheens/match" ) +var ( + // ensures that the InterpretersMap is an Interpreters. + _ Interpreters = InterpretersMap{} + + goodAS = &ActionSource{ + Interpreter: "test interpreter", + Source: "test source", + Binds: []Bindings{ + {"a": "b", "c": 5, "d": false, "e": 1.25}, + {"test": true, "binding": true, "two": false}, + }, + } + + errTestCompile = errors.New("test interpreter failed to compile") +) + +type testInterpreter struct { + errOnCompile bool +} + +func (i *testInterpreter) Compile(_ context.Context, _ interface{}) (interface{}, error) { + if i.errOnCompile { + return nil, errTestCompile + } + return nil, nil +} + +func (*testInterpreter) Exec(_ context.Context, _ Bindings, _ StepProps, _ interface{}, _ interface{}) (*Execution, error) { + return nil, nil +} + func TestPermanentBindings(t *testing.T) { if !Exp_PermanentBindings { return @@ -47,3 +81,168 @@ func TestPermanentBindings(t *testing.T) { t.Fatal("permament wasn't") } } + +func TestInterpretersMap(t *testing.T) { + goodInterpreter := &testInterpreter{} + iMap := NewInterpretersMap() + if iMap == nil { + t.Fatalf("NewInterpretersMap should return non-nil value") + } + iMap["good"] = goodInterpreter + iMap["bad"] = nil + tests := []struct { + description string + name string + expectedResult Interpreter + }{ + { + description: "Success", + name: "good", + expectedResult: goodInterpreter, + }, + { + description: "Nil key", + name: "bad", + expectedResult: nil, + }, + { + description: "Missing key", + name: "missing", + expectedResult: nil, + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + r := iMap.Find(tc.name) + if r != tc.expectedResult { + t.Fatalf("expected %v interpreter but received %v", + tc.expectedResult, r) + } + }) + } +} + +func TestActionSourceCopy(t *testing.T) { + var ( + nilAS *ActionSource = nil + ) + tests := []struct { + description string + actionsource *ActionSource + expectedCopy *ActionSource + }{ + { + description: "Success", + actionsource: goodAS, + expectedCopy: goodAS, + }, + { + description: "Empty values", + actionsource: &ActionSource{}, + expectedCopy: &ActionSource{}, + }, + { + description: "Nil action source", + actionsource: nilAS, + expectedCopy: nil, + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + copy := tc.actionsource.Copy() + + // deal with anything nil first. + if tc.expectedCopy == nil && copy != nil || + tc.expectedCopy != nil && copy == nil { + t.Fatalf("expected %v copy but received %v", tc.expectedCopy, + copy) + } + if tc.expectedCopy == nil || copy == nil { + return + } + + if &tc.expectedCopy == © { + t.Fatalf("expected new address for copy") + } + if tc.expectedCopy.Interpreter != copy.Interpreter || + tc.expectedCopy.Source != copy.Source || + len(tc.expectedCopy.Binds) != len(copy.Binds) { + t.Fatalf("copies don't match; expected %v but received %v", + tc.expectedCopy, copy) + } + + // is this too much? we can remove it. + for i, b := range tc.expectedCopy.Binds { + if len(b) != len(copy.Binds[i]) { + t.Fatalf("copies don't match; expected %v but received %v", + tc.expectedCopy, copy) + } + for k, v := range b { + if v != copy.Binds[i][k] { + t.Fatalf("copies don't match; expected %v but received %v", + tc.expectedCopy, copy) + } + } + } + }) + } +} + +func TestActionSourceCompile(t *testing.T) { + interpreters := InterpretersMap{ + "good": &testInterpreter{}, + "compile issues": &testInterpreter{errOnCompile: true}, + } + tests := []struct { + description string + actionsource *ActionSource + interpreters Interpreters + expectedErr error + }{ + { + description: "Success", + actionsource: &ActionSource{ + Interpreter: "good", + Binds: goodAS.Binds, + }, + interpreters: interpreters, + }, + { + description: "Not found error", + actionsource: &ActionSource{Interpreter: "nope"}, + interpreters: interpreters, + expectedErr: InterpreterNotFound, + }, + { + description: "Not found error with default interpreters", + actionsource: &ActionSource{Interpreter: "good"}, + expectedErr: InterpreterNotFound, + }, + { + description: "Compile error", + actionsource: &ActionSource{Interpreter: "compile issues"}, + interpreters: interpreters, + expectedErr: errTestCompile, + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + action, err := tc.actionsource.Compile(context.Background(), tc.interpreters) + if tc.expectedErr != err { + t.Fatalf("expected error %v but received %v", tc.expectedErr, err) + } + if tc.expectedErr != nil { + if action != nil { + t.Fatalf("expected nil action but received %v", action) + } + return + } + if action == nil { + t.Fatalf("expected non-nil action") + } + if len(tc.actionsource.Binds) != len(action.Binds()) { + t.Errorf("expected binds %v but received %v", tc.actionsource.Binds, action.Binds()) + } + }) + } +} diff --git a/core/doc_test.go b/core/doc_test.go index c2b6d53..2e56459 100644 --- a/core/doc_test.go +++ b/core/doc_test.go @@ -16,6 +16,7 @@ import ( "context" "fmt" + . "github.com/Comcast/sheens/match" . "github.com/Comcast/sheens/util/testutil" ) diff --git a/core/spec.go b/core/spec.go index f4f5ee1..dc21442 100644 --- a/core/spec.go +++ b/core/spec.go @@ -1,4 +1,4 @@ -/* Copyright 2018 Comcast Cable Communications Management, LLC +/* Copyright 2021 Comcast Cable Communications Management, LLC * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -19,37 +19,42 @@ import ( ) var ( - // DefaultPatternParser is used during Spec.Compile if the - // given Spec has no PatternParser. - // - // This function is useful to allow a Spec to provide branch - // patterns in whatever syntax is convenient. For example, if - // a Spec is authored in YAML, patterns in JSON might be more - // convenient (or easier to read) that patterns in YAML. - DefaultPatternParser = func(syntax string, p interface{}) (interface{}, error) { - switch syntax { - case "none", "": - return p, nil - case "json": - if js, is := p.(string); is { - var x interface{} - if err := json.Unmarshal([]byte(js), &x); err != nil { - return nil, err - } - return x, nil - } - return p, nil - default: - return nil, errors.New("unsupposed pattern syntax: " + syntax) - } - } - // DefaultBranchType is used for Branches.Type when - // Branches.Type is zero. This var should probably be a - // const. + // Branches.Type is zero. DefaultBranchType = "bindings" + + // DefaultErrorNodeName is the name of the node state + // switched to in the event of an internal error. + DefaultErrorNodeName = "error" + + defaultErrorNode = &Node{} ) +// DefaultPatternParser is used during Spec.Compile if the +// given Spec has no PatternParser. +// +// This function is useful to allow a Spec to provide branch +// patterns in whatever syntax is convenient. For example, if +// a Spec is authored in YAML, patterns in JSON might be more +// convenient (or easier to read) that patterns in YAML. +var DefaultPatternParser = func(syntax string, p interface{}) (interface{}, error) { + switch syntax { + case "none", "": + return p, nil + case "json": + if js, is := p.(string); is { + var x interface{} + if err := json.Unmarshal([]byte(js), &x); err != nil { + return nil, err + } + return x, nil + } + return p, nil + default: + return nil, errors.New("unsupposed pattern syntax: " + syntax) + } +} + // Spec is a specification used to build a machine. // // A specification gives the structure of the machine. This data does @@ -97,7 +102,7 @@ type Spec struct { Nodes map[string]*Node `json:"nodes,omitempty" yaml:",omitempty"` // ErrorNode is an optional name of a node for the machine in - // the even of an internal error. + // the event of an internal error. // // Probably should just always assume the convention that a // node named 'error' is the error node. ToDo: Consider. @@ -174,14 +179,52 @@ func (spec *Spec) Copy(version string) *Spec { } } -// Compile compiles all action-like sources into actions. Might also -// do some other things. +// ParsePatterns parses branch patterns. +// +// The method Compile calls this method. ParsePatterns is exposed to +// tools that might need to parse patterns without wanted to Compile +// them. +func (spec *Spec) ParsePatterns(ctx context.Context) error { + if spec.PatternParser == nil { + spec.PatternParser = DefaultPatternParser + } + + if spec.Nodes == nil { + return nil + } + + for _, n := range spec.Nodes { + if n == nil || n.Branches == nil { + continue + } + + for _, b := range n.Branches.Branches { + if b == nil { + continue + } + x, err := spec.PatternParser(spec.PatternSyntax, b.Pattern) + if err != nil { + return err + } + // ToDo: Remove + if x, err = Canonicalize(x); err != nil { + return err + } + b.Pattern = x + } + } + return nil +} + +// Compile compiles all action-like sources into actions. Might also do some +// other things. When force is true, everything is built from source even if +// it's been built before. // // Action-like sources include Actions, Boot, Toob, and Guards. func (spec *Spec) Compile(ctx context.Context, interpreters Interpreters, force bool) error { - if spec.PatternParser == nil { - spec.PatternParser = DefaultPatternParser + if err := spec.ParsePatterns(ctx); err != nil { + return err } if spec.BootSource != nil && (force || spec.Boot == nil) { @@ -201,7 +244,7 @@ func (spec *Spec) Compile(ctx context.Context, interpreters Interpreters, force } if spec.ErrorNode == "" { - spec.ErrorNode = "error" + spec.ErrorNode = DefaultErrorNodeName } if spec.Nodes == nil { @@ -209,7 +252,7 @@ func (spec *Spec) Compile(ctx context.Context, interpreters Interpreters, force } if _, have := spec.Nodes[spec.ErrorNode]; !have && !spec.NoAutoErrorNode { - spec.Nodes[spec.ErrorNode] = &Node{} + spec.Nodes[spec.ErrorNode] = defaultErrorNode } for name, n := range spec.Nodes { @@ -232,9 +275,6 @@ func (spec *Spec) Compile(ctx context.Context, interpreters Interpreters, force } if n.Branches == nil { - // No possibility of guards that we need to - // compile or string patterns we need to - // parse. continue } diff --git a/core/spec_test.go b/core/spec_test.go new file mode 100644 index 0000000..5c8f468 --- /dev/null +++ b/core/spec_test.go @@ -0,0 +1,261 @@ +/* Copyright 2021 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package core + +import ( + "context" + "errors" + "strings" + "testing" +) + +func mockPatternParser(val interface{}, err error) func(string, interface{}) (interface{}, error) { + return func(_ string, _ interface{}) (interface{}, error) { + return val, err + } +} + +func TestParsePatterns(t *testing.T) { + goodNodes := map[string]*Node{ + "yay": { + Branches: &Branches{ + Branches: []*Branch{ + {Pattern: "something nice"}, + {Pattern: "something else"}, + {Pattern: "something??"}, + }, + }, + }, + "next thing": { + Branches: &Branches{ + Branches: []*Branch{{Pattern: 5}}, + }, + }, + } + emptyNodes := map[string]*Node{ + "nowhere": {}, + "a little further": { + Branches: &Branches{}, + }, + "getting there": { + Branches: &Branches{ + Branches: []*Branch{ + nil, + nil, + {Pattern: nil}, + }, + }, + }, + } + testErr := errors.New("test parser error") + tests := []struct { + description string + parser func(string, interface{}) (interface{}, error) + nodes map[string]*Node + expectedNodes map[string]*Node + expectedErr error + }{ + { + description: "Success", + parser: mockPatternParser("a", nil), + nodes: goodNodes, + expectedNodes: map[string]*Node{ + "yay": { + Branches: &Branches{ + Branches: []*Branch{{Pattern: "a"}, + {Pattern: "a"}, {Pattern: "a"}, + }, + }, + }, + "next thing": { + Branches: &Branches{ + Branches: []*Branch{{Pattern: "a"}}, + }, + }, + }, + }, + { + description: "Success with default parser", + nodes: goodNodes, + expectedNodes: goodNodes, + }, + { + description: "Success with no nodes", + parser: mockPatternParser("c", nil), + }, + { + description: "Success with no branches", + parser: mockPatternParser("d", nil), + nodes: emptyNodes, + expectedNodes: emptyNodes, + }, + { + description: "Parse failure", + parser: mockPatternParser("e", testErr), + nodes: goodNodes, + expectedErr: testErr, + }, + { + description: "Canonicalize failure", + parser: mockPatternParser([]interface{}{func() string { return ":(" }}, nil), + nodes: goodNodes, + expectedErr: errors.New("unsupported type"), + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + s := &Spec{ + PatternParser: tc.parser, + Nodes: tc.nodes, + } + err := s.ParsePatterns(context.Background()) + if s.PatternParser == nil { + t.Error("pattern parser shouldn't be nil") + } + + if err == nil || tc.expectedErr == nil { + if err != tc.expectedErr { + t.Errorf("expected %v error but received %v", + tc.expectedErr, err) + } + } else if !strings.Contains(err.Error(), tc.expectedErr.Error()) { + t.Errorf("error %s doesn't include expected string %s", + err, tc.expectedErr) + } + + // if we expected an error, leave before the nightmare begins. + if tc.expectedErr != nil { + return + } + + // start checking nodes' branches' patterns... + if len(tc.expectedNodes) != len(s.Nodes) { + t.Fatalf("nodes don't match; expected %v but received %v", + tc.expectedNodes, s.Nodes) + } + for k, n := range tc.expectedNodes { + if n == nil { + if s.Nodes[k] != nil { + t.Fatalf("nodes don't match; expected %v but received %v", + tc.expectedNodes, s.Nodes) + } + continue + } + if n.Branches == nil { + if s.Nodes[k].Branches != nil { + t.Fatalf("nodes don't match; expected %v but received %v", + tc.expectedNodes, s.Nodes) + } + continue + } + if s.Nodes[k] == nil || s.Nodes[k].Branches == nil { + t.Fatalf("nodes don't match; expected %v but received %v", + tc.expectedNodes, s.Nodes) + } + expectedBranches := n.Branches.Branches + sBranches := s.Nodes[k].Branches.Branches + if len(expectedBranches) != len(sBranches) { + t.Fatalf("nodes don't match; expected %v but received %v", + tc.expectedNodes, s.Nodes) + } + for j, b := range expectedBranches { + if b == nil { + if sBranches[j] != nil { + t.Fatalf("nodes don't match; expected %v but received %v", + tc.expectedNodes, s.Nodes) + } + continue + } + if sBranches[j] == nil { + t.Fatalf("nodes don't match; expected %v but received %v", + tc.expectedNodes, s.Nodes) + } + if b.Pattern != sBranches[j].Pattern { + t.Fatalf("nodes don't match; expected %v but received %v", + tc.expectedNodes, s.Nodes) + } + } + } + }) + } +} + +func TestCompile(t *testing.T) { + s := &Spec{} + err := s.Compile(context.Background(), nil, false) + if err != nil { + t.Fatalf("expected no error but received %v", err) + } +} + +func TestDefaultPatternParser(t *testing.T) { + tests := []struct { + description string + syntax string + val interface{} + expectedResult interface{} + expectedErr error + }{ + { + description: "Default error", + syntax: "clearly invalid", + val: "testing123", + expectedErr: errors.New("unsupposed pattern syntax"), + }, + { + description: "None syntax success", + syntax: "none", + val: struct{}{}, + expectedResult: struct{}{}, + }, + { + description: "Empty syntax success", + }, + { + description: "JSON syntax success", + syntax: "json", + val: `"test"`, + expectedResult: "test", + }, + { + description: "JSON syntax non-string success", + syntax: "json", + }, + { + description: "JSON unmarshal error", + syntax: "json", + val: "", + expectedErr: errors.New("unexpected end of JSON input"), + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + r, err := DefaultPatternParser(tc.syntax, tc.val) + if tc.expectedResult != r { + t.Errorf("expected %v pattern but received %v", + tc.expectedResult, r) + } + if err == nil || tc.expectedErr == nil { + if err != tc.expectedErr { + t.Errorf("expected %v error but received %v", + tc.expectedErr, err) + } + return + } + if !strings.Contains(err.Error(), tc.expectedErr.Error()) { + t.Errorf("error %s doesn't include expected string %s", + err, tc.expectedErr) + } + }) + } +} diff --git a/core/step.go b/core/step.go index d56f10e..ae9f875 100644 --- a/core/step.go +++ b/core/step.go @@ -16,6 +16,8 @@ import ( "context" "encoding/json" "errors" + + . "github.com/Comcast/sheens/match" ) var ( @@ -51,8 +53,13 @@ var ( Exp_BranchTargetVariables = true ) +// StepProps is an object that can hold optional information +// It can be used for example, to output the results of an +// of an operation. It is usually used inside an action +// interperter where you would typically Exec() type StepProps map[string]interface{} +// Copy will return you a literal copy of the StepProps func (ps StepProps) Copy() StepProps { acc := make(StepProps, len(ps)) for p, v := range ps { @@ -113,6 +120,7 @@ type Control struct { Breakpoints map[string]Breakpoint `json:"-"` } +// Copy will return you a copy of the Control object func (c *Control) Copy() *Control { bs := make(map[string]Breakpoint, len(c.Breakpoints)) for id, b := range c.Breakpoints { @@ -138,6 +146,7 @@ func NewTraces() *Traces { } } +// Add will append more Messages to Traces func (ts *Traces) Add(xs ...interface{}) { ts.Messages = append(ts.Messages, xs...) } @@ -194,6 +203,7 @@ type Stride struct { Consumed interface{} `json:"consumed,omitempty" yaml:",omitempty"` } +// New Stride will return an default Stride func NewStride() *Stride { return &Stride{ Events: newEvents(), @@ -211,6 +221,10 @@ func (s *Spec) Step(ctx context.Context, st *State, pending interface{}, c *Cont c = DefaultControl } + // Remember what we were given in case we need this + // information to generate an error transition. + givenState := st + // Each error case should be scrutinized. It might be // possible (and desirable?) to have any error transition to // an "error" node, which should have been added during Spec @@ -259,6 +273,12 @@ func (s *Spec) Step(ctx context.Context, st *State, pending interface{}, c *Cont e, err = n.Action.Exec(ctx, bs, props) if e != nil { stride.AddEvents(e.Events) + if e.Bs == nil { + // If the action returned nil + // bindings, use empty bindings. + // ToDo: Reconsider. + e.Bs = NewBindings() + } } if err == nil { @@ -296,6 +316,49 @@ func (s *Spec) Step(ctx context.Context, st *State, pending interface{}, c *Cont stride.To = st.Copy() } + if st == nil && haveAction { + // Important case: We followed no branch but this node + // had an action. As is, this situation leaves us in + // a bad place. A Walk would continue at this node, + // and this node has bindings-type branching, which + // means the node will be processed (again) + // erroneously. + // + // In the past, this condition was prevented by a + // specification check that made sure there was a + // default branch in every action node. That + // requirement disappeared and therefore created the + // present problem. + // + // Rather than enforcing a default branch for action + // nodes now (which would not be backwards compatible + // for many specs), we handle this situation as a spec + // error, sending the machine to the error node + // (whether it exists or not). + // + // Background: In the early days of Sheens, a spec was + // built from of a somewhat richer set of types, and + // these types made this situation a compile-time + // error (in a sense). There was a desire (note + // passive voice) for easier-on-the-eyes specs, which + // loosed the structure and allowed this circumstance + // to be a runtime problem. + // + // In a new major version, we should consider + // (re-)enforcing a default branch for action nodes + // (or moving back to the richer type system). + if bs == nil { + bs = NewBindings() + } + bs, _ = bs.Extendm("error", "Action node followed no branch", + "lastNode", givenState.NodeName, + "lastBindings", givenState.Bs.Copy()) + stride.To = &State{ + NodeName: "error", + Bs: bs, + } + } + return stride, err } @@ -361,6 +424,8 @@ func (b *Branches) consider(ctx context.Context, bs Bindings, pending interface{ return nil, ts, consumer, nil } +// IsBranchTargetVariable determines if the Branch Target +// is actually a variable you can pass around, or not func IsBranchTargetVariable(s string) bool { if len(s) == 0 { return false @@ -393,7 +458,7 @@ func (b *Branch) try(ctx context.Context, bs Bindings, against interface{}, prop if b.Pattern != nil { var err error - if bss, err = Match(nil, b.Pattern, against, bs); err != nil { + if bss, err = DefaultMatcher.Match(b.Pattern, against, bs); err != nil { ts.Add(map[string]interface{}{ "error": err.Error(), "pattern": b.Pattern, diff --git a/core/step_test.go b/core/step_test.go index 72a81ad..e2fa75b 100644 --- a/core/step_test.go +++ b/core/step_test.go @@ -19,6 +19,7 @@ import ( "testing" "time" + . "github.com/Comcast/sheens/match" . "github.com/Comcast/sheens/util/testutil" ) @@ -543,3 +544,51 @@ func TestTerminalBindingsNode(t *testing.T) { t.Fatalf("stopped because %s", walked.StoppedBecause) } } + +func TestActionStuck(t *testing.T) { + spec := &Spec{ + Name: "test", + PatternSyntax: "json", + Nodes: map[string]*Node{ + "start": { + Action: &FuncAction{ + F: func(ctx context.Context, bs Bindings, props StepProps) (*Execution, error) { + return NewExecution(nil), nil + }, + }, + Branches: &Branches{ + Branches: []*Branch{ + { + Target: "there", + }, + }, + }, + }, + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := spec.Compile(ctx, nil, true); err != nil { + t.Fatal(err) + } + + st := &State{ + NodeName: "start", + Bs: make(Bindings), + } + + c := &Control{ + Limit: 10, + } + + walked, err := spec.Walk(ctx, st, nil, c, nil) + if err != nil { + t.Fatal(err) + } + + if walked.To() == nil { + t.Fatal("Walked.To is nil") + } +} diff --git a/core/stopreason_jsonenums.go b/core/stopreason_jsonenums.go index 948607b..406b4ae 100644 --- a/core/stopreason_jsonenums.go +++ b/core/stopreason_jsonenums.go @@ -1,4 +1,4 @@ -// generated by jsonenums -type=StopReason; DO NOT EDIT +// Code generated by jsonenums -type=StopReason; DO NOT EDIT. package core diff --git a/core/stopreason_string.go b/core/stopreason_string.go index 7d4f50a..b881e02 100644 --- a/core/stopreason_string.go +++ b/core/stopreason_string.go @@ -4,6 +4,16 @@ package core import "strconv" +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[Done-0] + _ = x[Limited-1] + _ = x[InternalError-2] + _ = x[BreakpointReached-3] +} + const _StopReason_name = "DoneLimitedInternalErrorBreakpointReached" var _StopReason_index = [...]uint8{0, 4, 11, 24, 41} diff --git a/crew/crew.go b/crew/crew.go index c7fc63f..5c0c6a9 100644 --- a/crew/crew.go +++ b/crew/crew.go @@ -10,20 +10,29 @@ * limitations under the License. */ +// Package crew is a simple, example foundation for gathering a set of +// machines. package crew import ( "sync" ) +// Crew is a simple collection of Machines. type Crew struct { sync.RWMutex - Id string `json:"id"` + // Id is an optional name for this crew. + Id string `json:"id"` + + // Machines is the collection of Machines indexed by their + // ids. Machines map[string]*Machine `json:"machines"` } -// Copy gets a read lock and returns a copy of the crew. +// Copy gets a read lock and returns a deep copy of the crew. +// +// Each Machine is itself Copy()ed, too. func (c *Crew) Copy() *Crew { c.RLock() ms := make(map[string]*Machine, len(c.Machines)) diff --git a/crew/machine.go b/crew/machine.go index bef26ea..300b17a 100644 --- a/crew/machine.go +++ b/crew/machine.go @@ -23,7 +23,7 @@ import ( // Side-effecting operations can occur during the second arrow. When // there are side-effecting operations, we write new state. -// Machine is a triple: id, core.Spec, and core.State. +// Machine is a triple: id, core.Spec, and core.State. type Machine struct { Id string `json:"id,omitempty"` Specter core.Specter `json:"-" yaml:"-"` @@ -78,12 +78,14 @@ type SpecSource struct { Inline *core.Spec `json:"inline,omitempty" yaml:",omitempty"` } +// NewSpecSource creates a SpecSource with the given name. func NewSpecSource(name string) *SpecSource { return &SpecSource{ Name: name, } } +// Copy makes a (deep?) copy of the given SpecSource. func (s *SpecSource) Copy() *SpecSource { return &SpecSource{ Name: s.Name, @@ -93,6 +95,7 @@ func (s *SpecSource) Copy() *SpecSource { } } +// SpecProvider can FindSpec given a SpecSource. type SpecProvider interface { FindSpec(ctx context.Context, s *SpecSource) (*core.Spec, error) } diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..0daf3e0 --- /dev/null +++ b/doc.go @@ -0,0 +1,6 @@ +// Package sheens provides specification-driven message-processing machinery. +// +// The core code is in package 'core', and some command-line tools are in `cmd`. +// +// See https://github.com/Comcast/sheens/blob/master/README.md for more. +package sheens diff --git a/doc/by-example.md b/doc/by-example.md index 6e9af3c..5eb0d0a 100644 --- a/doc/by-example.md +++ b/doc/by-example.md @@ -37,7 +37,8 @@ nodes: interpreter: ecmascript source: |- _.out({send: _.bindings["?wanted"]}); - return _.bindings.Remove("?wanted"); + delete _.bindings["?wanted"]; + return _.bindings; branching: branches: - target: start @@ -106,9 +107,7 @@ populated with an object at `_`, which has two important properties: 1. `_.out(x)`: A function adds the given argument to the list of messages that the action will emit (if action execution does not result in an error). This function _does not block_. -2. `_.bindings`: The current set of bindings. This value has methods - `Remove(p1, p2, ... )` and `Extend(p, v)` to remove and add - properties respectively. +2. `_.bindings`: The current set of bindings. The value of `_` also has some additional properties: @@ -133,7 +132,8 @@ The "deliver" node's ECMAscript action is ```Javascript _.out({send: _.bindings["?wanted"]}); -_.bindings.Remove("?wanted"); +delete _.bindings["?wanted"]; +return _.bindings; ``` The first line ends up queuing the message `{"send":"tacos"}`. @@ -241,7 +241,9 @@ nodes: interpreter: ecmascript source: |- _.out({send: _.bindings["?wanted"], count: _.bindings["?n"]}); - return _.bindings.Remove("?wanted","?n"); + delete _.bindings["?wanted"]; + delete _.bindings["?n"]; + return _.bindings; branching: branches: - target: start @@ -249,7 +251,9 @@ nodes: action: interpreter: ecmascript source: |- - return _.bindings.Remove("?wanted","?n"); + delete _.bindings["?wanted"]; + delete _.bindings["?n"]; + return _.bindings; branching: branches: - target: start @@ -274,7 +278,9 @@ bindings and return the machine to the `start` node. In this example, we clean up bindings twice and in the same manner: ```Javascript -return _.bindings.Remove("?wanted","?n"); +delete _.bindings["?wanted"]; +delete _.bindings["?n"]; +return _.bindings; ``` In both cases, we just head back to start. @@ -316,7 +322,9 @@ nodes: action: interpreter: ecmascript source: |- - return _.bindings.Remove("?wanted","?n"); + delete _.bindings["?wanted"]; + delete _.bindings["?n"]; + return _.bindings; branching: branches: - target: start @@ -344,7 +352,9 @@ nodes: if (3 >= _.bindings["?n"]) { _.out({send: _.bindings["?wanted"], count: _.bindings["?n"]}); } - return _.bindings.Remove("?wanted","?n"); + delete _.bindings["?wanted"]; + delete _.bindings["?n"]; + return _.bindings; branching: branches: - target: start @@ -390,7 +400,10 @@ nodes: action: interpreter: ecmascript source: |- - return _.bindings.Remove("?wanted","?n","allowed"); + delete _.bindings["?wanted"]; + delete _.bindings["?n"]; + delete _.bindings["allowed"]; + return _.bindings; branching: branches: - target: start diff --git a/doc/homeassistant.svg b/doc/homeassistant.svg new file mode 100644 index 0000000..22f40ce --- /dev/null +++ b/doc/homeassistant.svg @@ -0,0 +1,13 @@ +
{'ctl':'start'}
+
{'type':'auth_ok'}
+
{'type':'auth_invalid'}
+
{'send':'?send'}
+
{'ctl':'stop'}
+
{'type':'auth_ok'}
+
{'type':'auth_required'}
+
{'password':'?pass'}
+
{'ctl':'start'}
+
start
login
auth_wait
subscribe
denied
listen
send
unsubscribe
waitForStart
maybe_auth
auth
\ No newline at end of file diff --git a/doc/patmatch.v b/doc/patmatch.v new file mode 100644 index 0000000..2d84c66 --- /dev/null +++ b/doc/patmatch.v @@ -0,0 +1,504 @@ +(* Matching verification (in progress) + + See https://github.com/Comcast/sheens#pattern-matching. + + The start of a verification of that pattern matching algorithm. + This file is written for the Coq Proof Assistant: + https://coq.inria.fr/. + + The matching here gives a set (list) of returned bindings, but + currently the matching doesn't support arrays (as sets). + + Status: A work in progress. + + *) + +Require Import String Bool Arith List. + +(* Require Import SetoidDec. *) + +(* for jsCoq: Comments "pkgs: coq-arith". Then From Coq Require Import String. *) + +(* Require Import Cpdt.CpdtTactics. + Set Implicit Arguments. + Set Asymmetric Patterns. *) + +Module patmatch. + + Open Scope string_scope. + + Section experiment. + + Inductive exp : Set := + | Atom: string -> exp + | Nil : exp + | Assoc: (string*exp*exp) -> exp. + + Fixpoint some (k:string) (P:exp->Prop) (alist:list(string*exp)) : Prop := + match alist with + | nil => False + | (k',v)::more => + if string_dec k k' + then P v + else some k P more + end. + + Fixpoint subexp (p:exp) (m:exp) : Prop := + match p with + | Atom s => + match m with + | Atom s' => + if string_dec s s' then True else False + | _ => False + end + | Nil => + match m with + | Nil => True + | _ => False + end + | Assoc (k,v,more) => + match m with + | Assoc (k',v',more') => + (if string_dec k k' + then subexp v v' + else subexp p more') + /\ + subexp more m + | _ => False + end + end. + + Compute subexp (Atom "tacos") (Atom "queso"). + + Compute (Assoc (("likes",(Atom "tacos"))::nil)). + + Compute let e := (Assoc (("likes",(Atom "tacos"))::nil)) in + subexp e e. + + + End experiment. + + (* We define our own association list to help with + well-foundedness arguments for important functions. *) + + Section alists. + + Definition alist (T:Set) := list (string*T). + + (* Our association lists. *) + Definition new_alist {T:Set} : (alist T) := nil. + + (* Add a pair to an alist. *) + Fixpoint acons {T:Set} (a: (alist T)) (k:string) (v:T) : (alist T) := + match a with + | nil => (k,v)::nil + | (k',v')::more => + if string_dec k k' + then acons more k v + else (k',v')::(acons more k v) + end. + + (* Get the value for a given key. *) + Fixpoint assoc {T:Set} (a: (alist T)) (k: string) : option T := + match a with + | nil => None + | (p,v)::more => + if string_dec k p + then Some v + else assoc more k + end. + + End alists. + + Section patterns_and_messages. + + (* Expression experiment *) + + Inductive atom : Set := + | AStr : string -> atom + | AVar : string -> atom. + + Check AStr "foo". + + Inductive exp {A:Set} : Set := + | EAtom : A -> exp + | EMap : (alist (@exp A)) -> exp. + + Check EAtom (AStr "foo"). + + Check EAtom "tacos". + + Check EMap new_alist. + + (* Definition mexp := exp (A:=string). *) + + Definition mexp := @exp string. + + Definition pexp := @exp atom. + + End patterns_and_messages. + + Section matching. + + (* Is the first message is a sub-message of the second message? *) + Fixpoint submsg (p:mexp) (m:mexp) : bool := + match p, m with + | EAtom s, EAtom s' => + if string_dec s s' then true else false + | EMap xs, EMap ys => + let fix f xs := + match xs with + | nil => true + | (k,x)::more => + match assoc ys k with + | None => false + | Some y => + if submsg x y + then f more + else false + end + end + in f xs + | _, _ => false + end. + + Compute submsg (EAtom "tacos") (EAtom "tacos"). + + Compute submsg (EAtom "tacos") (EAtom "queso"). + + Compute submsg (EMap (acons (new_alist) "likes" (EAtom "tacos"))) (EAtom "chips"). + + Fixpoint All (p:(string*mexp)->Prop) (xs:list (string*mexp)) : Prop := + match xs with + | nil => True + | x::more => p x /\ All p more + end. + + (* Is the first message is a sub-message of the second message? *) + Program Fixpoint submsgp (p:mexp) (m:mexp) : Prop := + match p, m with + | EAtom s, EAtom s' => + if string_dec s s' then True else False + | EMap xs, EMap ys => + let fix p kv := + match kv with + | (k,v) => + match assoc ys k with + | None => False + | Some y => submsgp v y + end + end + in All p xs + | _, _ => False + end. + + Compute submsgp (EAtom "tacos") (EAtom "tacos"). + + Compute submsgp (EAtom "tacos") (EAtom "queso"). + + Compute submsgp (EMap (acons (new_alist) "likes" (EAtom "tacos"))) (EAtom "chips"). + + Lemma submsgp_refl : forall x:mexp, submsgp x x. + Proof. + intros. + induction x. + { + simpl. + destruct (string_dec a a). + reflexivity. + contradiction. + } + { + induction a. + { + simpl. + trivial. + } + { + + induction a. + simpl. + destruct (string_dec a a). + + + induction a0. + reflexivity. + simpl. + + Definition bindings := alist mexp. + + Definition apply_app {A:Type} (lsts:list (list A)) := + fold_right (fun x y => x ++ y) nil lsts. + + (* The main function. *) + Fixpoint patmatch (p:pexp) (bs:bindings) (m:mexp) : list bindings := + match p with + | EAtom a => + match a with + | AStr s => + match m with + | EAtom s' => + if string_dec s s' then bs::nil else nil + | _ => nil + end + | AVar v => + match assoc bs v with + | None => + (acons bs v m)::nil + | Some m' => + if submsg m' m then bs::nil else nil + end + end + | _ => nil + end. + + Definition apply_app {A:Type} (lsts:list (list A)) := + fold_right (fun x y => x ++ y) nil lsts. + + + | PStr ps, Str ms => + if string_dec ps ms then bs::nil else nil + | Var v, _ => + match assoc bs v with + | None => (acons bs v m)::nil + | Some x => if submsg x m then bs::nil else nil + end + | PMap pm, Map mm => + let fix f pm bs := + match pm with + | nil => bs::nil + | (k,v)::pm' => + match assoc mm k with + | None => nil + | Some v' => + apply_app + (map (fun (bs:bindings) => + (f pm' bs)) + (patmatch v bs v')) + end + end + in f pm bs + | _, _ => nil + end. + + End matching. + + (* Just some computations to take a look around. *) + Section patmatch_tests. + + Compute patmatch (PStr "chips") nil (Str "chips") . + + Compute let p := (PMap nil) in + let m := (Map nil) in + patmatch p nil m. + + Compute let p := (PMap (acons nil "likes" (PStr "tacos"))) in + let m := (Map (acons nil "likes" (Str "tacos"))) in + patmatch p nil m. + + Compute let p := (PMap (acons nil "likes" (Var "x"))) in + let m := (Map (acons nil "likes" (Str "tacos"))) in + patmatch p nil m. + + Compute let p := (PMap (acons nil "likes" (Var "x"))) in + let m := (Map (acons (acons nil "wants" (Str "chips")) + "likes" (Str "tacos"))) in + patmatch p nil m. + + Compute let p := (PMap (acons (acons nil "wants" (Var "y")) + "likes" (Var "x"))) in + let m := (Map (acons (acons nil "wants" (Str "chips")) + "likes" (Str "tacos"))) in + patmatch p nil m. + + Compute let p := (PMap (acons (acons (acons nil "needs" (Var "y")) + "wants" (Var "y")) + "likes" (Var "x"))) in + let m := (Map (acons (acons (acons nil "needs" (Str "chips")) + "wants" (Str "chips")) + "likes" (Str "tacos"))) in + patmatch p nil m. + + Compute let p := (PMap (acons (acons (acons nil "needs" (Var "y")) + "wants" (Var "y")) + "likes" (Var "x"))) in + let m := (Map (acons (acons (acons nil "needs" (Str "queso")) + "wants" (Str "chips")) + "likes" (Str "tacos"))) in + patmatch p nil m. + + + End patmatch_tests. + + Section verification. + + (* assoc after an acons does what you'd expect. *) + Remark acons_assoc : + forall m:list (string*msg), + forall k:string, + forall v:msg, + (assoc (acons m k v) k) = Some v. + intros. + induction m. + simpl. + destruct (string_dec k k). + reflexivity. + intuition. + simpl. + induction a. + destruct (string_dec k a). + assumption. + simpl. + destruct (string_dec k a). + contradiction. + assumption. + Defined. + + (* Do the bindings applied to the pattern give a submsg of the + message? *) + Fixpoint psubmsg (p:pat) (bs:bindings) (m:msg) : bool := + match p, m with + | PStr ps, Str ms => + if string_dec ps ms then true else false + | Var k, _ => + match assoc bs k with + | None => false + | Some v => submsg v m + end + | PMap pm, Map mm => + let fix f pm := + match pm with + | nil => true + | (k,v)::pm' => + match assoc mm k with + | None => false + | Some v' => + if psubmsg v bs v' + then f pm' + else false + end + end + in f pm + | _, _ => false + end. + + (* Submsg is reflexive. *) + Lemma submsg_refl : forall x:msg, submsg x x = true. + Admitted. + + (* Using bindings from a map match results in a submsg. *) + Lemma patmatch_submsg_maps : + forall l: list (string * pat), + forall l0 : list (string * msg), + forall bs : bindings, + In bs (patmatch (PMap l) nil (Map l0)) -> + psubmsg (PMap l) bs (Map l0) = true. + Admitted. + + (* Big theorem #1: Bindings from a patmatch given a submsg. *) + Theorem patmatch_submsg : + forall p:pat, + forall m:msg, + forall bs:bindings, + In bs (patmatch p nil m) -> + psubmsg p bs m = true. + Proof. + intros. + induction p. + { + induction m. + { + unfold psubmsg. + destruct (string_dec s s0). + { + reflexivity. + } + { + unfold patmatch in H. + destruct (string_dec s s0). + { + unfold In in H. + intuition. + } + { + unfold In in H. + contradiction. + } + } + } + { + unfold psubmsg. + unfold patmatch in H. + unfold In in H. + contradiction. + } + } + { + induction m. + { + unfold psubmsg. + unfold patmatch in H. + unfold assoc in H. + unfold acons in H. + unfold In in H. + intuition. + rewrite <- H0. + simpl. + destruct (string_dec s s). + { + simpl. + destruct (string_dec s0 s0). + reflexivity. + intuition. + } + { + intuition. + } + } + { + unfold psubmsg. + unfold patmatch in H. + unfold assoc in H. + unfold acons in H. + unfold In in H. + intuition. + rewrite <- H0. + unfold assoc. + destruct (string_dec s s). + { + apply submsg_refl. + } + { + intuition. + } + } + } + { + induction m. + { + unfold psubmsg. + unfold patmatch in H. + intuition. + } + { + apply patmatch_submsg_maps in H. + trivial. + } + } + Qed. + + (* Big theorem #2: If some bindings give a submsg, then patmatch + should find those bindings. *) + Theorem submsg_patmatch : + forall p:pat, + forall m:msg, + forall bs:bindings, + psubmsg p bs m = true -> + In bs (patmatch p nil m). + Admitted. + + End verification. + +End patmatch. + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..990dc33 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/Comcast/sheens + +go 1.25.1 + +require ( + github.com/dop251/goja v0.0.0-20240220182346-e401ed450204 + github.com/eclipse/paho.mqtt.golang v1.3.1 + github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 + github.com/gorilla/websocket v1.4.2 + github.com/jsccast/yaml v0.0.0-20171213031114-31aa0bbd42f2 + github.com/russross/blackfriday/v2 v2.1.0 + go.etcd.io/bbolt v1.3.6 + golang.org/x/net v0.17.0 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/dlclark/regexp2 v1.7.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..393ade2 --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= +github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= +github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= +github.com/dop251/goja v0.0.0-20240220182346-e401ed450204 h1:O7I1iuzEA7SG+dK8ocOBSlYAA9jBUmCYl/Qa7ey7JAM= +github.com/dop251/goja v0.0.0-20240220182346-e401ed450204/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= +github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= +github.com/eclipse/paho.mqtt.golang v1.3.1 h1:6F5FYb1hxVSZS+p0ji5xBQamc5ltOolTYRy5R15uVmI= +github.com/eclipse/paho.mqtt.golang v1.3.1/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 h1:f0n1xnMSmBLzVfsMMvriDyA75NB/oBgILX2GcHXIQzY= +github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75/go.mod h1:g2644b03hfBX9Ov0ZBDgXXens4rxSxmqFBbhvKv2yVA= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= +github.com/jsccast/yaml v0.0.0-20171213031114-31aa0bbd42f2 h1:D+cOkSEaJ8UlCVTmx89zA74R+MqQXB0pBr5VSJR2ZvY= +github.com/jsccast/yaml v0.0.0-20171213031114-31aa0bbd42f2/go.mod h1:fyktCuIsvb3ovBTwCPTDoYkZ2hs7xg3AnIEsNXS2o/k= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/interpreters/ecmascript/ecmascript.go b/interpreters/ecmascript/ecmascript.go index 854d975..6c9faad 100644 --- a/interpreters/ecmascript/ecmascript.go +++ b/interpreters/ecmascript/ecmascript.go @@ -10,6 +10,8 @@ * limitations under the License. */ +// Package ecmascript provides an ECMAScript-compatible action +// interpreter. package ecmascript import ( @@ -22,6 +24,7 @@ import ( "time" "github.com/Comcast/sheens/core" + "github.com/Comcast/sheens/match" "github.com/dop251/goja" "github.com/gorhill/cronexpr" @@ -104,6 +107,10 @@ func protest(o *goja.Runtime, x interface{}) { panic(o.ToValue(x)) } +func deepCopy(x interface{}) (interface{}, error) { + return core.Canonicalize(x) +} + // Exec implements the Interpreter method of the same name. // // The following properties are available from the runtime at _. @@ -128,7 +135,7 @@ func protest(o *goja.Runtime, x interface{}) { // exit(msg): Terminate the process after printing the given message. // For testing. // -func (i *Interpreter) Exec(ctx context.Context, bs core.Bindings, props core.StepProps, src interface{}, compiled interface{}) (*core.Execution, error) { +func (i *Interpreter) Exec(ctx context.Context, bs match.Bindings, props core.StepProps, src interface{}, compiled interface{}) (*core.Execution, error) { exe := core.NewExecution(nil) var p *goja.Program @@ -153,7 +160,18 @@ func (i *Interpreter) Exec(ctx context.Context, bs core.Bindings, props core.Ste } if bs != nil { - env["bindings"] = map[string]interface{}(bs.Copy()) + // This particular action interpreter allows code to + // modify values, and we don't want any side effects. + // So: + x, err := deepCopy(bs) + if err != nil { + return nil, err + } + bsCopy, is := x.(map[string]interface{}) + if !is { + return nil, fmt.Errorf("internal error: %#v copy failed; %s", bs, err) + } + env["bindings"] = bsCopy } o := goja.New() @@ -207,10 +225,10 @@ func (i *Interpreter) Exec(ctx context.Context, bs core.Bindings, props core.Ste // match is a utility that invokes the pattern matcher. env["match"] = func(pat, mess, bs goja.Value) interface{} { - var bindings core.Bindings + var bindings match.Bindings if bs == nil { - bindings = core.NewBindings() + bindings = match.NewBindings() } else { // Having some trouble here. Please don't @@ -224,7 +242,7 @@ func (i *Interpreter) Exec(ctx context.Context, bs core.Bindings, props core.Ste if !is { panic("bad bindings") } - bindings = core.Bindings(m) + bindings = match.Bindings(m) } var ( @@ -241,7 +259,7 @@ func (i *Interpreter) Exec(ctx context.Context, bs core.Bindings, props core.Ste panic(err) } - bss, err := core.Match(nil, p, m, bindings) + bss, err := match.Match(p, m, bindings) if err != nil { panic(err) } @@ -333,13 +351,13 @@ func (i *Interpreter) Exec(ctx context.Context, bs core.Bindings, props core.Ste x := v.Export() - var result core.Bindings + var result match.Bindings switch vv := x.(type) { case *goja.InterruptedError: return nil, vv case map[string]interface{}: - result = core.Bindings(vv) - case core.Bindings: + result = match.Bindings(vv) + case match.Bindings: result = vv case nil: default: diff --git a/interpreters/ecmascript/ecmascript_test.go b/interpreters/ecmascript/ecmascript_test.go index ef232b1..ebb9529 100644 --- a/interpreters/ecmascript/ecmascript_test.go +++ b/interpreters/ecmascript/ecmascript_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/Comcast/sheens/core" + "github.com/Comcast/sheens/match" . "github.com/Comcast/sheens/util/testutil" ) @@ -217,7 +218,7 @@ func TestActionsMachinePrimitive(t *testing.T) { st := &core.State{ NodeName: "start", - Bs: make(core.Bindings), + Bs: make(match.Bindings), } ctl := &core.Control{ Limit: 10, @@ -279,7 +280,7 @@ func TestActionsMachineFancy(t *testing.T) { st := &core.State{ NodeName: "start", - Bs: core.NewBindings(), + Bs: match.NewBindings(), } ctl := &core.Control{ Limit: 10, @@ -419,3 +420,47 @@ func TestActionsOutNaN(t *testing.T) { t.Fatal("expected an error") } } + +func TestActionsModifyBindingValue(t *testing.T) { + bs := match.NewBindings() + bs["likes"] = map[string]interface{}{ + "weekdays": "tacos", + "weekends": "chips", + } + + code := `_.bindings.likes.weekends = "queso"; throw "a fit";` + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + i := NewInterpreter() + i.Test = true + compiled, err := i.Compile(ctx, code) + if err != nil { + t.Fatal(err) + } + + exe, _ := i.Exec(ctx, bs, nil, code, compiled) + // Ignore the error. We want to see if the action had a side + // effect. + + x, have := bs["likes"] + if !have { + t.Fatalf("nothing liked in %#v", exe.Bs) + } + m, is := x.(map[string]interface{}) + if !is { + t.Fatalf("liked %#v is a %T, not a %T", x, x, m) + } + y, have := m["weekends"] + if !have { + t.Fatalf("nothing liked on weekends in %#v", exe.Bs) + } + s, is := y.(string) + if !is { + t.Fatalf("liked %#v is a %T, not a %T", y, y, s) + } + if s != "chips" { + t.Fatalf("didn't want \"%s\"", s) + } +} diff --git a/interpreters/interpreters.go b/interpreters/interpreters.go index a17bd0c..3613f26 100644 --- a/interpreters/interpreters.go +++ b/interpreters/interpreters.go @@ -1,3 +1,17 @@ +/* Copyright 2018 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package interpreters is an example set of action interpreters that +// are available in this repo. package interpreters import ( @@ -6,6 +20,10 @@ import ( "github.com/Comcast/sheens/interpreters/noop" ) +// Standard returns a map of interpreters that includes ECMAScript, +// ECMAScript with some extensions, and a no-op interpreter. +// +// See the code and subdirectories for details. func Standard() core.InterpretersMap { is := core.NewInterpretersMap() diff --git a/interpreters/noop/noop.go b/interpreters/noop/noop.go index 2ee8672..2930cdb 100644 --- a/interpreters/noop/noop.go +++ b/interpreters/noop/noop.go @@ -10,6 +10,8 @@ * limitations under the License. */ +// Package noop provides a no-op interpreter that can be handy for +// some tests. package noop import ( @@ -17,6 +19,7 @@ import ( "log" "github.com/Comcast/sheens/core" + . "github.com/Comcast/sheens/match" ) // Interpreter is an core.Interpreter which just returns the bindings @@ -33,7 +36,8 @@ func (i *Interpreter) Compile(ctx context.Context, code interface{}) (interface{ return nil, nil } -func (i *Interpreter) Exec(ctx context.Context, bs core.Bindings, props core.StepProps, code interface{}, compiled interface{}) (*core.Execution, error) { +// Exec returns the given bindings and emits no messages. +func (i *Interpreter) Exec(ctx context.Context, bs Bindings, props core.StepProps, code interface{}, compiled interface{}) (*core.Execution, error) { if !i.Silent { log.Printf("warning: Using Interpreter for execution") } diff --git a/core/match.go b/match/match.go similarity index 83% rename from core/match.go rename to match/match.go index 0786b50..7116aea 100644 --- a/core/match.go +++ b/match/match.go @@ -10,18 +10,18 @@ * limitations under the License. */ -package core +// Package match implements the core pattern matcher. +package match import ( "errors" "strings" ) -var ( - +type Matcher struct { // AllowPropertyVariables enables the experimental support for a // property variable in a pattern that contains only one property. - AllowPropertyVariables = true + AllowPropertyVariables bool // CheckForBadPropertyVariables runs a test to verify that a pattern // does not contain a property variable along with other properties. @@ -33,7 +33,7 @@ var ( // not report the problem. In order to report the problem always, // turn on this switch. Performance will suffer, but any bad property // variable will at least be caught. - CheckForBadPropertyVariables = true + CheckForBadPropertyVariables bool // Inequalities is a switch to turn on experimental binding // inequality support. @@ -86,18 +86,24 @@ var ( // functionality (albeit with inefficiencies and without the // message-oriented timer protocol that's been offered // elsewhere). - Inequalities = true -) + Inequalities bool +} -func checkForBadPropertyVariables(pattern map[string]interface{}) error { - if !CheckForBadPropertyVariables { +var DefaultMatcher = &Matcher{ + AllowPropertyVariables: true, + CheckForBadPropertyVariables: true, + Inequalities: true, +} + +func (m *Matcher) checkForBadPropertyVariables(pattern map[string]interface{}) error { + if !m.CheckForBadPropertyVariables { return nil } if len(pattern) <= 1 { return nil } for k := range pattern { - if IsVariable(k) { + if m.IsVariable(k) { return errors.New(`can't have a variable as a key ("` + k + `") with other keys`) } } @@ -177,11 +183,11 @@ func (bs Bindings) Copy() Bindings { // IsVariable reports if the string represents a pattern variable. // // All pattern variables start with a '?". -func IsVariable(s string) bool { +func (m *Matcher) IsVariable(s string) bool { return strings.HasPrefix(s, "?") } -func IsOptionalVariable(x interface{}) bool { +func (m *Matcher) IsOptionalVariable(x interface{}) bool { if s, is := x.(string); is { return strings.HasPrefix(s, "??") } @@ -190,26 +196,26 @@ func IsOptionalVariable(x interface{}) bool { // IsAnonymousVariable detects a variable of the form '?'. An binding // for an anonymous variable shouldn't ever make it into bindins. -func IsAnonymousVariable(s string) bool { +func (m *Matcher) IsAnonymousVariable(s string) bool { return s == "?" } // IsConstant reports if the string represents a constant (and not a // pattern variable). -func IsConstant(s string) bool { - return !IsVariable(s) +func (m *Matcher) IsConstant(s string) bool { + return !m.IsVariable(s) } // mapcatMatch attempts to extend the given bindingss 'bss' based on // pair-wise matching of the pattern to the fact. -func mapcatMatch(ctx *Context, bss []Bindings, pattern map[string]interface{}, fact map[string]interface{}) ([]Bindings, error) { - if err := checkForBadPropertyVariables(pattern); err != nil { +func (m *Matcher) mapcatMatch(bss []Bindings, pattern map[string]interface{}, fact map[string]interface{}) ([]Bindings, error) { + if err := m.checkForBadPropertyVariables(pattern); err != nil { return nil, err } for k, v := range pattern { - if IsVariable(k) { - if AllowPropertyVariables { + if m.IsVariable(k) { + if m.AllowPropertyVariables { if len(pattern) == 1 { // Iterate over the fact keys and collect match results. gather := make([]Bindings, 0, 0) @@ -217,7 +223,7 @@ func mapcatMatch(ctx *Context, bss []Bindings, pattern map[string]interface{}, f ext := copyBindingss(bss) // Try to match keys. - ext, err := matchWithBindingss(ctx, ext, k, fk) + ext, err := m.matchWithBindingss(ext, k, fk) if err != nil { return nil, err } @@ -226,7 +232,7 @@ func mapcatMatch(ctx *Context, bss []Bindings, pattern map[string]interface{}, f continue } // Matched keys. Now check values. - ext, err = matchWithBindingss(ctx, ext, v, fv) + ext, err = m.matchWithBindingss(ext, v, fv) if err != nil { return nil, err } @@ -249,14 +255,14 @@ func mapcatMatch(ctx *Context, bss []Bindings, pattern map[string]interface{}, f } else { fv, found := fact[k] if !found { - if IsOptionalVariable(v) { + if m.IsOptionalVariable(v) { continue } return nil, nil } - acc, err := matchWithBindingss(ctx, bss, v, fv) + acc, err := m.matchWithBindingss(bss, v, fv) if nil != err { return nil, err } @@ -275,19 +281,19 @@ func mapcatMatch(ctx *Context, bss []Bindings, pattern map[string]interface{}, f // // An array represents a set; therefore, this function can backtrack, // which can be scary. -func arraycatMatch(ctx *Context, bsss [][]Bindings, pattern interface{}, fxas []map[int]interface{}) ([][]Bindings, []map[int]interface{}, error) { +func (m *Matcher) arraycatMatch(bsss [][]Bindings, pattern interface{}, fxas []map[int]interface{}) ([][]Bindings, []map[int]interface{}, error) { var nbsss [][]Bindings var nfxas []map[int]interface{} for i, bss := range bsss { - m := fxas[i] - for j, fact := range m { - acc, err := matchWithBindingss(ctx, copyBindingss(bss), pattern, fact) + mm := fxas[i] + for j, fact := range mm { + acc, err := m.matchWithBindingss(copyBindingss(bss), pattern, fact) if nil != err { return nil, nil, err } if 0 != len(acc) { nbsss = append(nbsss, acc) - copy := copyMap(m) + copy := copyMap(mm) delete(copy, j) nfxas = append(nfxas, copy) } @@ -308,10 +314,10 @@ func copyMap(source map[int]interface{}) map[int]interface{} { // matches of the fact against the pattern. // // Ths function mostly just calls 'Match()'. -func matchWithBindingss(ctx *Context, bss []Bindings, pattern interface{}, fact interface{}) ([]Bindings, error) { +func (m *Matcher) matchWithBindingss(bss []Bindings, pattern interface{}, fact interface{}) ([]Bindings, error) { acc := make([]Bindings, 0, len(bss)) for _, bs := range bss { - matches, err := Match(ctx, pattern, fact, bs) + matches, err := m.Match(pattern, fact, bs) if nil != err { return nil, err } @@ -327,14 +333,14 @@ func matchWithBindingss(ctx *Context, bss []Bindings, pattern interface{}, fact // For now, we look for at most one variable. // // ToDo: Improve. -func getVariable(ctx *Context, xs []interface{}) (string, []interface{}, error) { +func (m *Matcher) getVariable(xs []interface{}) (string, []interface{}, error) { var v string acc := make([]interface{}, 0, len(xs)) for _, x := range xs { switch x.(type) { case string: s := x.(string) - if IsVariable(s) { + if m.IsVariable(s) { if v == "" { v = s continue @@ -358,8 +364,8 @@ func getVariable(ctx *Context, xs []interface{}) (string, []interface{}, error) // Note that this function returns multiple (sets of) bindings. This // ambiguity is introduced when a pattern contains an array that // contains a variable. -func Matches(ctx *Context, pattern interface{}, fact interface{}) ([]Bindings, error) { - return Match(ctx, pattern, fact, make(Bindings)) +func (m *Matcher) Matches(pattern interface{}, fact interface{}) ([]Bindings, error) { + return m.Match(pattern, fact, make(Bindings)) } // fudge is a hack to cast numbers to float64s. @@ -383,13 +389,13 @@ func fudge(x interface{}) interface{} { // Match is a verion of 'Matches' that takes initial bindings. // // Those initial bindings are not modified. -func Match(ctx *Context, pattern interface{}, fact interface{}, bindings Bindings) ([]Bindings, error) { - return match(ctx, pattern, fact, bindings.Copy()) +func (m *Matcher) Match(pattern interface{}, fact interface{}, bindings Bindings) ([]Bindings, error) { + return m.match(pattern, fact, bindings.Copy()) } // match is a verion of 'Matches' that takes initial bindings (which // can be modified). -func match(ctx *Context, pattern interface{}, fact interface{}, bindings Bindings) ([]Bindings, error) { +func (m *Matcher) match(pattern interface{}, fact interface{}, bindings Bindings) ([]Bindings, error) { pattern = fudge(pattern) fact = fudge(fact) @@ -414,7 +420,7 @@ func match(ctx *Context, pattern interface{}, fact interface{}, bindings Binding } // case reflect.Value: - // return match(ctx, vv.Interface(), fact, bindings) + // return match(vv.Interface(), fact, bindings) case bool: switch f.(type) { @@ -442,7 +448,7 @@ func match(ctx *Context, pattern interface{}, fact interface{}, bindings Binding } case string: - if IsConstant(vv) { + if m.IsConstant(vv) { switch f.(type) { case string: fs := f.(string) @@ -455,17 +461,17 @@ func match(ctx *Context, pattern interface{}, fact interface{}, bindings Binding return nil, nil } } else { // IsVariable - if IsAnonymousVariable(vv) { + if m.IsAnonymousVariable(vv) { return []Bindings{bs}, nil } - if using, bss, err := inequal(ctx, fact, bindings, vv); err != nil { + if using, bss, err := m.inequal(fact, bindings, vv); err != nil { return nil, err } else if using { return bss, err } binding, found := bs[vv] if found { - return match(ctx, binding, fact, bindings) + return m.match(binding, fact, bindings) } else { // add new binding bs[vv] = fact @@ -474,9 +480,9 @@ func match(ctx *Context, pattern interface{}, fact interface{}, bindings Binding } case map[string]interface{}: - m, ok := p.(map[string]interface{}) + mm, ok := p.(map[string]interface{}) if !ok { - m = map[string]interface{}(p.(mmap)) + mm = map[string]interface{}(p.(mmap)) } switch f.(type) { case map[string]interface{}: @@ -484,18 +490,18 @@ func match(ctx *Context, pattern interface{}, fact interface{}, bindings Binding if !ok { fm = map[string]interface{}(f.(mmap)) } - if 0 == len(m) { + if 0 == len(mm) { // Empty map pattern matched any given map. return []Bindings{bs}, nil } - return mapcatMatch(ctx, []Bindings{bs}, m, fm) + return m.mapcatMatch([]Bindings{bs}, mm, fm) default: return nil, nil } case []interface{}: //separate variable and constants - v, xs, err := getVariable(ctx, vv) + v, xs, err := m.getVariable(vv) if nil != err { return nil, err } @@ -532,7 +538,7 @@ func match(ctx *Context, pattern interface{}, fact interface{}, bindings Binding if 0 == len(fxa) { return nil, nil } else { - bsss, fxas, err = arraycatMatch(ctx, bsss, x, fxas) + bsss, fxas, err = m.arraycatMatch(bsss, x, fxas) if nil != err { return nil, err } @@ -557,11 +563,11 @@ func match(ctx *Context, pattern interface{}, fact interface{}, bindings Binding return combine(bsss), nil } else { previous := bsss - bsss, fxas, err = arraycatMatch(ctx, bsss, v, fxas) + bsss, fxas, err = m.arraycatMatch(bsss, v, fxas) if nil != err { return nil, err } - if len(bsss) == 0 && IsOptionalVariable(v) { + if len(bsss) == 0 && m.IsOptionalVariable(v) { bsss = previous } return combine(bsss), nil @@ -613,8 +619,8 @@ func (e *UnknownPatternType) Error() string { // mmap is now a mystery to me. type mmap map[string]interface{} -func inequal(ctx *Context, fact interface{}, bs Bindings, v string) (bool, []Bindings, error) { - if !Inequalities { +func (m *Matcher) inequal(fact interface{}, bs Bindings, v string) (bool, []Bindings, error) { + if !m.Inequalities { return false, nil, nil } @@ -700,3 +706,7 @@ func inequal(ctx *Context, fact interface{}, bs Bindings, v string) (bool, []Bin bs[vv] = a return true, []Bindings{bs}, nil } + +func Match(pattern interface{}, fact interface{}, bindings Bindings) ([]Bindings, error) { + return DefaultMatcher.Match(pattern, fact, bindings) +} diff --git a/core/match.md b/match/match.md similarity index 93% rename from core/match.md rename to match/match.md index 9ed9c2c..d56c0fb 100644 --- a/core/match.md +++ b/match/match.md @@ -515,6 +515,12 @@ matched against ``` +with bindings +```JSON +{"?=n":11} + +``` + should return ```JSON [{"?>=n":11,"?n":11}] @@ -575,6 +593,12 @@ matched against ``` +with bindings +```JSON +{"?>=n":12} + +``` + should return ```JSON [] @@ -595,6 +619,12 @@ matched against ``` +with bindings +```JSON +{"?>` is replaced by the `stdout` +of executing `SHELL_COMMAND`. + +## Javascript + +Yes, there's an embedded Javascript interpreter. + +Some available functions: + +1. `print` +1. `subscribe(topic, qos, handler)`, where `hander` is a `function(topic,payload)` +1. `unsubscribe(topic)` +1. `publish(topic,qos,retained,msg)` +1. `setInterval` +1. `setTimeout` + diff --git a/sio/mqshell/main.go b/sio/mqshell/main.go new file mode 100644 index 0000000..fbf5011 --- /dev/null +++ b/sio/mqshell/main.go @@ -0,0 +1,575 @@ +/* Copyright 2021 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package main is a (command-line) MQTT shell. +// +// See README.md for documentation. +package main + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/dop251/goja" + + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +func main() { + + var ( + // Try to follow mosquitto_sub command line args? + + broker = flag.String("b", "tcp://localhost", "MQTT broker (like 'tcps://hostname')") + port = flag.Int("p", 443, "MQTT broker port") + + keepAlive = flag.Int("k", 60, "MQTT Keep-alive in seconds") + willTopic = flag.String("will-topic", "", "MQTT will topic (optional)") + willPayload = flag.String("will-payload", "", "MQTT will message (optional)") + willQoS = flag.Int("will-qos", 0, "MQTT will QoS (optional)") + willRetain = flag.Bool("will-retain", false, "MQTT will retention (optional)") + reconnect = flag.Bool("reconnect", false, "Automatically attempt to reconnect to broker") + clean = flag.Bool("c", true, "MQTT clean session ") + quiesce = flag.Int("quiesce", 100, "MQTT disconnection quiescence (in milliseconds)") + alpn = flag.String("alpn", "x-amzn-mqtt-ca", "ALPB next protocol (optional, maybe 'x-amzn-mqtt-ca')") + + clientId = flag.String("i", "", "MQTT client id (optional)") + userName = flag.String("u", "", "MQTT username (optional)") + password = flag.String("P", "", "MQTT password (optional)") + certFilename = flag.String("cert", "cred/aws-mtls-client-cert", "MQTT cert filename (optional)") + keyFilename = flag.String("key", "cred/aws-mtls-private-key", "MQTT key filename (optional)") + insecure = flag.Bool("insecure", false, "Skip MQTT broker cert checking") + caFilename = flag.String("cafile", "", "MQTT CA cert filename (optional)") + + tokenKey = flag.String("token-key-name", "CustAuth", "AWS custom authorizer token key") + token = flag.String("token", "", "AWS custom authorizer token") + tokenSig = flag.String("token-sig", "", "AWS custom authorizer token signature") + authorizerName = flag.String("authorizer-name", "", "AWS custom authorizer name") + + connectRetryInterval = flag.Duration("connect-retry-interval", time.Second, "Connection retry interval") + connectRetry = flag.Bool("connect-retry", true, "Connection retry") + connectTimeout = flag.Duration("connect-timeout", time.Second, "Connection timeout") + + shellExpand = flag.Bool("sh", true, "Enable shell expansion (<<...>>)") + ) + + flag.Parse() + + mqtt.ERROR = log.New(os.Stderr, "mqtt.error", 0) + + opts := mqtt.NewClientOptions() + + *broker = fmt.Sprintf("%s:%d", *broker, *port) + opts.AddBroker(*broker) + opts.SetClientID(*clientId) + opts.SetKeepAlive(time.Second * time.Duration(*keepAlive)) + opts.SetPingTimeout(10 * time.Second) + + opts.ConnectRetry = *connectRetry + opts.ConnectRetryInterval = *connectRetryInterval + opts.ConnectTimeout = *connectTimeout + + opts.Username = *userName + opts.Password = *password + opts.AutoReconnect = *reconnect + opts.CleanSession = *clean + + if *token != "" { + var ( + bs = make([]byte, 16) + _, err = rand.Read(bs) + key = hex.EncodeToString(bs) + ) + if err != nil { + panic(err) + } + + opts.HTTPHeaders = http.Header{ + "x-amz-customauthorizer-name": []string{*authorizerName}, + "x-amz-customauthorizer-signature": []string{*tokenSig}, + *tokenKey: []string{*token}, + "sec-WebSocket-Key": []string{key}, + "sec-websocket-protocol": []string{"mqtt"}, + "sec-WebSocket-Version": []string{"13"}, + } + + } + if *willTopic != "" { + if *willPayload == "" { + log.Fatal("will topic without payload") + } + log.Printf("configuring will") + opts.WillEnabled = true + opts.WillTopic = *willTopic + opts.WillPayload = []byte(*willPayload) + opts.WillRetained = *willRetain + opts.WillQos = byte(*willQoS) + } + + var rootCAs *x509.CertPool + if rootCAs, _ = x509.SystemCertPool(); rootCAs == nil { + rootCAs = x509.NewCertPool() + log.Printf("Including system CA certs") + } + if *caFilename != "" { + certs, err := ioutil.ReadFile(*caFilename) + if err != nil { + log.Fatalf("couldn't read '%s': %s", *caFilename, err) + } + + if ok := rootCAs.AppendCertsFromPEM(certs); !ok { + log.Println("No certs appended, using system certs only") + } + } + + var certs []tls.Certificate + if *keyFilename != "" { + cert, err := tls.LoadX509KeyPair(*certFilename, *keyFilename) + if err != nil { + log.Fatal(err) + } + certs = []tls.Certificate{cert} + } + + tlsConf := &tls.Config{ + InsecureSkipVerify: *insecure, + } + + if *alpn != "" { + // https://docs.aws.amazon.com/iot/latest/developerguide/protocols.html + tlsConf.NextProtos = []string{ + *alpn, + } + } + if rootCAs != nil { + tlsConf.RootCAs = rootCAs + } + + if certs != nil { + tlsConf.Certificates = certs + } + + opts.SetTLSConfig(tlsConf) + + opts.OnConnectionLost = func(client mqtt.Client, err error) { + log.Printf("MQTT connection lost") + } + + opts.DefaultPublishHandler = func(client mqtt.Client, msg mqtt.Message) { + fmt.Printf("> %s %s\n", msg.Topic(), msg.Payload()) + } + + var ( + c = mqtt.NewClient(opts) + in = bufio.NewReader(os.Stdin) + qos byte + retain bool + + space = regexp.MustCompile(" +") + ) + + if t := c.Connect(); t.Wait() && t.Error() != nil { + log.Fatal(t.Error()) + } + + log.Printf("Connected") + + // Set up our embedded Javascript interpreter! Yes, this + // capability is pretty weird. Why not just use Nodejs? + // That's a good question, and the answer is beyond the scope + // of this comment. That's all I'm going to say about that + // topic. + + var ( + // js is our Javascript interpreter. + js = goja.New() + + // jsMutex will allow us to serialize access to the + // Javascript interpreter. + jsMutex = sync.Mutex{} + + // exec executes the given Javascript (after getting + // the lock). + exec = func(src string) { + log.Printf("exec\n%s\n", src) + jsMutex.Lock() + v, err := js.RunString(src) + if err != nil { + fmt.Printf("error: %s\n", err) + } + s := JS(v) + jsMutex.Unlock() + fmt.Printf("%s\n", s) + } + ) + + { + + // Define some globals in the Javascript environment. + + // publish to an MQTT topic. + js.Set("publish", func(topic string, qos int, retain bool, msg goja.Value) { + log.Printf("publishing to %s", topic) + s := msg.String() + t := c.Publish(topic, byte(qos), retain, []byte(s)) + if t.Wait() && t.Error() != nil { + fmt.Printf("publish error: %s", t.Error()) + } + }) + + defaultHandler := func(c mqtt.Client, m mqtt.Message) { + fmt.Printf("heard %s %s\n", m.Topic(), m.Payload()) + } + + // subscribe to an MQTT topic pattern. + js.Set("subscribe", func(topic string, qos int, h goja.Value) { + log.Printf("subscribing to %s", topic) + var f goja.Callable + if h != nil { + var ok bool + if f, ok = goja.AssertFunction(h); !ok { + fmt.Printf("error: not callable: %T\n", h) + } + } + t := c.Subscribe(topic, byte(qos), func(_ mqtt.Client, m mqtt.Message) { + if h == nil { + defaultHandler(c, m) + return + } + f(nil, js.ToValue(m.Topic()), js.ToValue(string(m.Payload()))) + }) + if t.Wait() && t.Error() != nil { + fmt.Printf("subscribe error: %s", t.Error()) + } + }) + + // unsubscribe from an MQTT topic pattern. + js.Set("unsubscribe", func(topic string, qos int, msg interface{}) { + log.Printf("unsubscribing from %s", topic) + t := c.Unsubscribe(topic) + if t.Wait() && t.Error() != nil { + fmt.Printf("unsubscribe error: %s", t.Error()) + } + }) + + // A bad version of Javascript's setTimeout(). + // + // ToDo: Support cancelation. + js.Set("setTimeout", func(ms int, f goja.Value) { + if callable, ok := goja.AssertFunction(f); !ok { + log.Printf("error: setTimeout: %T not callable", f) + } else { + go func() { + time.Sleep(time.Duration(ms) * time.Millisecond) + callable(nil) + }() + } + }) + + // A bad version of Javascript's setInterval(). + // + // ToDo: Support cancelation. + js.Set("setInterval", func(ms int, f goja.Value) { + if callable, ok := goja.AssertFunction(f); !ok { + log.Printf("error: setTimeout: %T not callable", f) + } else { + // Can't cancel. Good luck! + go func() { + for { + time.Sleep(time.Duration(ms) * time.Millisecond) + callable(nil) + } + }() + } + }) + + // print() + js.Set("print", func(args ...interface{}) { + var acc string + for i, x := range args { + if 0 < i { + acc += " " + } + acc += fmt.Sprintf("%s", JS(x)) + } + fmt.Printf("%s\n", acc) + }) + + } + +LOOP: // REPL + for { + line, err := in.ReadBytes('\n') + if err == io.EOF { + break + } + if err != nil { + log.Fatal(err) + } + s := string(line) + if *shellExpand { + if s, err = ShellExpand(string(line)); err != nil { + fmt.Printf("shell expansion error: %s", err) + continue + } + } + parts := space.Split(strings.TrimSpace(s), 3) + switch parts[0] { + + case "testsub": // topic, number + var ( + topic = "test" + count = 10 + history = make(map[int]*TestMsg, count) + previous *TestMsg + ) + + if 1 < len(parts) { + topic = parts[1] + } + + if 2 < len(parts) { + if count, err = strconv.Atoi(parts[2]); err != nil { + fmt.Printf("error: bad integer %s", parts[2]) + continue + } + } + + h := func(_ mqtt.Client, m mqtt.Message) { + js := m.Payload() + var msg TestMsg + if err = json.Unmarshal(js, &msg); err != nil { + log.Printf("testsub message error: %s on %s", err, js) + return + } + q := msg.QoS(previous, history) + log.Printf("latency: %f ms, order delta: %d", float64(q.Latency)/1000/1000, q.Delta) + previous = &msg + if len(history) == count { + log.Printf("testsub terminating") + go func() { + if t := c.Unsubscribe(topic); t.Wait() && t.Error() != nil { + fmt.Printf("unsubscribe error: %s", t.Error()) + } + }() + } + } + + if t := c.Subscribe(topic, qos, h); t.Wait() && t.Error() != nil { + fmt.Printf("subscribe error: %s", t.Error()) + } + + case "testpub": // topic, number, interval + var ( + topic = "test" + count = 10 + interval = time.Second + ) + + if 1 < len(parts) { + topic = parts[1] + } + + if 2 < len(parts) { + if count, err = strconv.Atoi(parts[2]); err != nil { + fmt.Printf("error: bad integer %s", parts[2]) + continue + } + } + + if 3 < len(parts) { + if interval, err = time.ParseDuration(parts[3]); err != nil { + fmt.Printf("error: bad duration %s", parts[3]) + continue + } + } + + for i := 0; i < count; i++ { + msg, err := NewTestMsg(i, 64) + js, err := json.Marshal(&msg) + if err != nil { + fmt.Printf("serialization error %s", err) + break + } + log.Printf("publishing test message %d to %s", i, topic) + if t := c.Publish(topic, qos, retain, js); t.Wait() && t.Error() != nil { + fmt.Printf("publish error: %s", t.Error()) + } + time.Sleep(interval) + } + + case "jsfile": // Read and execute a Javascript file. + if len(parts) != 2 { + fmt.Printf("error: jsfile FILENAME\n") + continue + } + bs, err := ioutil.ReadFile(parts[1]) + if err != nil { + fmt.Printf("error: %s\n", err) + continue + } + exec(string(bs)) + + case "js": // Execute some Javascript. + if len(parts) < 2 { + fmt.Printf("error: js CODE\n") + continue + } + src := strings.Join(parts[1:], " ") + + exec(src) + + case "echo": // Print the input line. + fmt.Printf("%s\n", space.Split(strings.TrimSpace(s), 2)[1]) + + case "sleep": // Sleep for the given duration. + if len(parts) != 2 { + fmt.Printf("error: sleep DURATION\n") + continue + } + d, err := time.ParseDuration(parts[1]) + if err != nil { + fmt.Printf("error: sleep DURATION: %s\n", err) + continue + } + time.Sleep(d) + + case "qos": + // Set the MQTT QoS for all subsequent + // subscribes/publishes. + if len(parts) != 2 { + fmt.Printf("error: qos [0-9]\n") + continue + } + n, err := strconv.Atoi(parts[1]) + if err != nil { + fmt.Printf("error: qos [0-9]: %s\n", err) + continue + } + qos = byte(n) + + case "retain": + // Set message retention for all subsequent + // MQTT publishes. + if len(parts) != 2 { + fmt.Printf("error: retain (true|false)") + continue + } + switch parts[1] { + case "true": + retain = true + case "false": + retain = false + default: + fmt.Printf("error: retain (true|false)") + continue + } + + case "sub", "subscribe": // MQTT subscribe + if len(parts) != 2 { + fmt.Printf("error: sub TOPIC") + continue + } + if t := c.Subscribe(parts[1], qos, nil); t.Wait() && t.Error() != nil { + fmt.Printf("subscribe error: %s", t.Error()) + } + + case "unsub", "unsubscribe": // MQTT unsubscribe + if len(parts) != 2 { + fmt.Printf("error: unsub TOPIC") + continue + } + if t := c.Unsubscribe(parts[1]); t.Wait() && t.Error() != nil { + fmt.Printf("unsubscribe error: %s", t.Error()) + } + + case "pub", "publish": // MQTT publish + if len(parts) < 3 { + fmt.Printf("error: pub TOPIC MSG") + continue + } + if t := c.Publish(parts[1], qos, retain, parts[2]); t.Wait() && t.Error() != nil { + fmt.Printf("subscribe error: %s", t.Error()) + } + + case "quit": + break LOOP + } + + } + + log.Printf("Disconnecting") + + c.Disconnect(uint(*quiesce)) +} + +// shell is a regexp for the notation for finding shell expansions. +// +// The first group is eventually passed to ShellExpand() if the flag +// 'shellExpand' is true. +var shell = regexp.MustCompile(`<<(.*?)>>`) + +// ShellExpand expands shell commands delimited by '<<' and '>>'. Use +// at your wown risk, of course! +// +// Only called if the flag 'shellExpand' is true. +func ShellExpand(msg string) (string, error) { + literals := shell.Split(msg, -1) + ss := shell.FindAllStringSubmatch(msg, -1) + acc := literals[0] + for i, s := range ss { + var sh = s[1] + cmd := exec.Command("bash", "-c", sh) + // cmd.Stdin = strings.NewReader("") + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("shell error %s on %s", err, sh) + } + got := out.String() + acc += got + acc += literals[i+1] + } + return acc, nil +} + +// JS tries to return a one-line JSON representation. Failing that, +// returns some JSON representing the marshalling error. +func JS(x interface{}) string { + bs, err := json.Marshal(&x) + if err != nil { + bs, _ = json.Marshal(map[string]interface{}{ + "error": err.Error(), + "on": fmt.Sprintf("%#v", x), + }) + } + return string(bs) +} diff --git a/sio/mqshell/qos.go b/sio/mqshell/qos.go new file mode 100644 index 0000000..9629410 --- /dev/null +++ b/sio/mqshell/qos.go @@ -0,0 +1,88 @@ +/* Copyright 2021 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +type TestMsg struct { + State struct { + Reported struct { + N int + S string + T time.Time + } + } + + received int +} + +func NewTestMsg(sequence, size int) (*TestMsg, error) { + t := &TestMsg{} + t.State.Reported.N = sequence + + if err := t.GenS(size); err != nil { + return nil, err + } + t.State.Reported.T = time.Now().UTC() + return t, nil +} + +type QoS struct { + Latency time.Duration + Delta int + Duplicate bool +} + +func (t *TestMsg) QoS(previous *TestMsg, history map[int]*TestMsg) *QoS { + n := -1 + if previous != nil { + n = previous.State.Reported.N + } + q := &QoS{ + Latency: time.Now().Sub(t.State.Reported.T), + Delta: t.State.Reported.N - n - 1, + } + if history != nil { + if u, have := history[t.State.Reported.N]; have { + q.Duplicate = 0 < u.received + u.received++ + } else { + history[t.State.Reported.N] = t + t.received = 1 + } + } + + return q +} + +func (t *TestMsg) GenS(n int) error { + var ( + buf = make([]byte, n) + r, err = rand.Read(buf) + ) + if err != nil { + return err + } + if r != n { + return fmt.Errorf("bad rand.Read: %d != %d", n, r) + } + + t.State.Reported.S = hex.EncodeToString(buf) + + return nil +} diff --git a/sio/mqshell/qos_test.go b/sio/mqshell/qos_test.go new file mode 100644 index 0000000..2154f07 --- /dev/null +++ b/sio/mqshell/qos_test.go @@ -0,0 +1,23 @@ +/* Copyright 2021 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "testing" +) + +func TestTestMsg(t *testing.T) { + if _, err := NewTestMsg(3, 42); err != nil { + t.Fatal(err) + } +} diff --git a/sio/siomq/README.md b/sio/siomq/README.md new file mode 100644 index 0000000..4f02d8c --- /dev/null +++ b/sio/siomq/README.md @@ -0,0 +1,17 @@ +# Crew that talks to an MQTT broker + +Doesn't provide any persistence. + +Also see [`mqshell`](../mqshell). + +## Usage + +Command-line arguments follow `mosquitto_sub`'s. Run `siomq -h` for +details. + +For an example session, see `run.sh`, which expects an insecure MQTT +broker on 1883. (See the _insecure_ +[`../siost/mosquitto.conf`](mosquitto.conf) to use with `mosquitto -c +mosquitto.conf`.) + + diff --git a/sio/siomq/main.go b/sio/siomq/main.go new file mode 100644 index 0000000..b89cf7c --- /dev/null +++ b/sio/siomq/main.go @@ -0,0 +1,426 @@ +/* Copyright 2019 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package main is a simple single-crew sheens process that talks to +// an MQTT broker. +// +// The command line args follow those for mosquitto_sub. +package main + +import ( + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/Comcast/sheens/core" + "github.com/Comcast/sheens/crew" + "github.com/Comcast/sheens/sio" + + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +func main() { + + var ( + // Follow mosquitto_sub command line args. + + broker = flag.String("h", "tcp://localhost", "Broker hostname") + clientId = flag.String("i", "", "Client id") + port = flag.Int("p", 1883, "Broker port") + keepAlive = flag.Int("k", 600, "Keep-alive in seconds") + userName = flag.String("u", "", "Username") + password = flag.String("P", "", "Password") + willTopic = flag.String("will-topic", "", "Optional will topic") + willPayload = flag.String("will-payload", "", "Optional will message") + willQoS = flag.Int("will-qos", 1, "Optional will QoS") + willRetain = flag.Bool("will-retain", false, "Optional will retention") + reconnect = flag.Bool("reconnect", false, "Automatically attempt to reconnect") + clean = flag.Bool("c", true, "Clean session") + quiesce = flag.Int("quiesce", 100, "Disconnection quiescence (in milliseconds)") + + certFilename = flag.String("cert", "", "Optional cert filename") + keyFilename = flag.String("key", "", "Optional key filename") + insecure = flag.Bool("insecure", false, "Skip broker cert checking") + caFilename = flag.String("cafile", "", "Optional CA cert filename") + + tokenKey = flag.String("token-key-name", "CustAuth", "AWS custom authorizer token key") + token = flag.String("token", "", "AWS custom authorizer token") + tokenSig = flag.String("token-sig", "", "AWS custom authorizer token signature") + authorizerName = flag.String("authorizer-name", "", "AWS custom authorizer name") + + subTopics = flag.String("t", "", "subscription topic(s)") + initFile = flag.String("init", "", "File containing mqshell 'pub' commands to execute") + + injectTopic = flag.Bool("inject-topic", true, "put topic in map of incoming messages") + wrapWithTopic = flag.Bool("wrap-with-topic", false, "wrap non-maps in a map along with the topic") + defaultOutboundTopic = flag.String("def-outbound-topic", "misc", "Default out-bound message topic") + inTimeout = flag.Duration("in-timeout", 5*time.Second, "timeout for in-bound queuing") + ) + + flag.Parse() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mqtt.ERROR = log.New(os.Stderr, "mqtt.error", 0) + + opts := mqtt.NewClientOptions() + + if *port != 0 { + *broker = fmt.Sprintf("%s:%d", *broker, *port) + } + log.Printf("broker: %s", *broker) + opts.AddBroker(*broker) + opts.SetClientID(*clientId) + opts.SetKeepAlive(time.Second * time.Duration(*keepAlive)) + opts.SetPingTimeout(10 * time.Second) + + opts.Username = *userName + opts.Password = *password + opts.AutoReconnect = *reconnect + opts.CleanSession = *clean + + if *token != "" { + var ( + bs = make([]byte, 16) + _, err = rand.Read(bs) + key = hex.EncodeToString(bs) + ) + if err != nil { + panic(err) + } + + opts.HTTPHeaders = http.Header{ + "x-amz-customauthorizer-name": []string{*authorizerName}, + "x-amz-customauthorizer-signature": []string{*tokenSig}, + *tokenKey: []string{*token}, + "sec-WebSocket-Key": []string{key}, + "sec-websocket-protocol": []string{"mqtt"}, + "sec-WebSocket-Version": []string{"13"}, + } + + } + + if *willTopic != "" { + if *willPayload == "" { + log.Fatal("will topic without payload") + } + opts.WillEnabled = true + opts.WillTopic = *willTopic + opts.WillPayload = []byte(*willPayload) + opts.WillRetained = *willRetain + opts.WillQos = byte(*willQoS) + } + + var rootCAs *x509.CertPool + if rootCAs, _ = x509.SystemCertPool(); rootCAs == nil { + rootCAs = x509.NewCertPool() + log.Printf("Including system CA certs") + } + if *caFilename != "" { + certs, err := ioutil.ReadFile(*caFilename) + if err != nil { + log.Fatalf("couldn't read '%s': %s", *caFilename, err) + } + + if ok := rootCAs.AppendCertsFromPEM(certs); !ok { + log.Println("No certs appended, using system certs only") + } + } + + var certs []tls.Certificate + if *keyFilename != "" { + cert, err := tls.LoadX509KeyPair(*certFilename, *keyFilename) + if err != nil { + log.Fatal(err) + } + certs = []tls.Certificate{cert} + } + + tlsConf := &tls.Config{ + InsecureSkipVerify: *insecure, + } + + if rootCAs != nil { + tlsConf.RootCAs = rootCAs + } + + if certs != nil { + tlsConf.Certificates = certs + } + + opts.SetTLSConfig(tlsConf) + + opts.OnConnectionLost = func(client mqtt.Client, err error) { + log.Printf("MQTT connection lost") + } + + io := &Couplings{ + Quiesce: uint(*quiesce), + SubTopics: *subTopics, + InjectTopic: *injectTopic, + WrapWithTopic: *wrapWithTopic, + DefaultOutboundTopic: *defaultOutboundTopic, + InTimeout: *inTimeout, + + incoming: make(chan interface{}), + outbound: make(chan *sio.Result), + } + + opts.DefaultPublishHandler = func(client mqtt.Client, msg mqtt.Message) { + io.inHandler(ctx, client, msg) + } + + io.Client = mqtt.NewClient(opts) + + conf := &sio.CrewConf{ + Ctl: core.DefaultControl, + } + + c, err := sio.NewCrew(ctx, conf, io) + if err != nil { + panic(err) + } + c.Verbose = true + + if err = io.Start(ctx); err != nil { + panic(err) + } + + ms, err := io.Read(ctx) + if err != nil { + panic(err) + } + for mid, m := range ms { + if err := c.SetMachine(ctx, mid, m.SpecSource, m.State); err != nil { + panic(err) + } + } + + go io.outLoop(ctx) + + go func() { + if *initFile != "" { + in, err := ioutil.ReadFile(*initFile) + if err != nil { + panic(err) + } + for _, line := range strings.Split(string(in), "\n") { + line, err = sio.ShellExpand(line) // ToDo: Warn/switch! + if err != nil { + panic(fmt.Errorf("shell expansion error %s", err)) + } + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, " ", 3) + switch strings.TrimSpace(parts[0]) { + case "pub": + if len(parts) != 3 { + log.Printf("bad init line '%s'", line) + continue + } + topic := parts[1] + msg := parts[2] + io.consume(ctx, topic, []byte(msg)) + case "echo": + default: + log.Printf("ignoring line '%s'", line) + } + } + } + }() + + if err := c.Loop(ctx); err != nil { + panic(err) + } + + if false { + + if err = io.Stop(context.Background()); err != nil { + panic(err) + } + } +} + +// Couplings is an sio.Couplings. +type Couplings struct { + Client mqtt.Client + Quiesce uint + SubTopics string + InjectTopic bool + WrapWithTopic bool + DefaultOutboundTopic string + + InTimeout time.Duration + + c *sio.Crew + incoming chan interface{} + outbound chan *sio.Result +} + +func (c *Couplings) consume(ctx context.Context, topic string, payload []byte) { + var x interface{} + if err := json.Unmarshal(payload, &x); err != nil { + log.Printf("Couldn't JSON-parse payload: %s", payload) + x = string(payload) + } else { + if m, is := x.(map[string]interface{}); is { + if c.InjectTopic { + m["topic"] = topic + } + } else { + if c.WrapWithTopic { + x = map[string]interface{}{ + "topic": topic, + "payload": string(payload), + } + } + } + } + + to := time.NewTimer(c.InTimeout) + + select { + case <-ctx.Done(): + log.Printf("Publisher not publishing due to ctx.Done()") + case c.incoming <- x: + log.Printf("Couplings forwarded incoming %s", payload) + case <-to.C: + log.Printf("Publisher not publishing due to stall ('%s','%s')", topic, payload) + } +} + +// inHandler is a Paho publish handler, which is used to handle +// messages send to us from the MQTT broker due to our subscriptions. +func (c *Couplings) inHandler(ctx context.Context, client mqtt.Client, msg mqtt.Message) { + log.Printf("incoming: %s %s\n", msg.Topic(), msg.Payload()) + c.consume(ctx, msg.Topic(), msg.Payload()) +} + +// Start creates the MQTT session. +func (c *Couplings) Start(ctx context.Context) error { + log.Printf("Attempting to connected to broker") + if token := c.Client.Connect(); token.Wait() && token.Error() != nil { + return token.Error() + } + log.Printf("Connected to broker") + + for _, topic := range strings.Split(c.SubTopics, ",") { + topic, qos := parseTopic(topic) + if topic == "" { + continue + } + log.Printf("Subscribing to %s (%d)", topic, qos) + if t := c.Client.Subscribe(topic, qos, nil); t.Wait() && t.Error() != nil { + return t.Error() + } + log.Printf("Subscribed to %s (%d)", topic, qos) + } + log.Printf("Couplings started") + + return nil +} + +// IO starts a loop to publish out-bound Results and forward incoming +// messages. +func (c *Couplings) IO(ctx context.Context) (chan interface{}, chan *sio.Result, error) { + return c.incoming, c.outbound, nil +} + +// outLoop forwards messages outbound from the Crew to the MQTT +// broker. +func (c *Couplings) outLoop(ctx context.Context) error { +LOOP: + for { + select { + case <-ctx.Done(): + break LOOP + case r := <-c.outbound: + for _, xs := range r.Emitted { + for _, x := range xs { + topic, qos := parseTopic(c.DefaultOutboundTopic) + if m, is := x.(map[string]interface{}); is { + if t, have := m["topic"]; have { + if s, is := t.(string); is { + topic = s + } + } + if n, have := m["qos"]; have { + if f, is := n.(float64); is { + qos = byte(f) + } else { + log.Printf("Warning: ignoring qos %#v %T", n, n) + } + } + } + js, err := json.Marshal(x) + if err != nil { + log.Printf("Failed to marshal %#v", x) + continue + } + log.Printf("Publishing %s %s", topic, js) + token := c.Client.Publish(topic, qos, false, js) + token.Wait() + if token.Error() != nil { + log.Fatalf("Publish error: %s", token.Error()) + } + log.Printf("Published to %s", topic) + } + + // Where we could store state changes. + for mid, m := range r.Changed { + if false { + log.Printf("Update %s %s\n", mid, sio.JShort(m)) + } + } + } + } + } + return nil +} + +// Read currently does nothing. +// +// No persistence yet. +func (c *Couplings) Read(context.Context) (map[string]*crew.Machine, error) { + return nil, nil +} + +// Stop terminates the MQTT session. +func (c *Couplings) Stop(context.Context) error { + log.Printf("Disconnecting") + c.Client.Disconnect(c.Quiesce) + return nil +} + +// parseTopic can extract QoS from a topic name of the form TOPIC:QOS. +func parseTopic(s string) (string, byte) { + var topic string + var qos byte + if _, err := fmt.Sscanf(strings.Replace(s, ":", " ", 1), "%s %d", &topic, &qos); err != nil { + return topic, qos + } + return s, 0 +} diff --git a/sio/siomq/run.sh b/sio/siomq/run.sh new file mode 100755 index 0000000..694e440 --- /dev/null +++ b/sio/siomq/run.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Copyright 2019 Comcast Cable Communications Management, LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +go install && (cd ../mqshell && go install) + +siomq -t foo & + +sleep 1 + +cat<>}}}} +pub foo {"to":"captain","update":{"dc":{"spec":{"inline":<>}}}} +pub foo {"to":"captain","update":{"d":{"spec":{"inline":<>}}}} +pub foo {"double":10} +pub foo {"double":100} +pub foo {"double":1000} +pub foo {"collatz":17} +pub foo {"collatz":5} +pub foo {"to":"timers","makeTimer":{"in":"2s","msg":{"double":10000},"id":"t0"}} +pub foo {"to":"timers","makeTimer":{"in":"4s","msg":{"collatz":21},"id":"t1"}} +pub foo {"to":"d","double":3} +pub foo {"to":"dc","double":4} +sleep 5s +EOF + +kill %1 diff --git a/sio/siostd/README.md b/sio/siostd/README.md new file mode 100644 index 0000000..59cb2f9 --- /dev/null +++ b/sio/siostd/README.md @@ -0,0 +1,80 @@ +# Crew on stdin/stdout + +Command-line arguments follow `mosquitto_sub`'s. Run `siostd -h` for +details. + +For an example session, see `run.sh`, which expects an insecure MQTT +broker on 1883. (See the _insecure_ +[`mosquitto.conf`](mosquitto.conf) to use with `mosquitto -c +mosquitto.conf`.) + +Example: + +``` +./run.sh +2019-01-18T20:09:36.144206381Z input {"to":"captain","update":{"c":{"spec":{"inline":<>}}}} +2019-01-18T20:09:36.147201666Z input {"to":"captain","update":{"dc":{"spec":{"inline":<>}}}} +2019-01-18T20:09:36.147962269Z update c {"SpecSrc":{"inline":{"name":"collatz","doc":"https://en.wikipedia.org... +2019-01-18T20:09:36.160938267Z input {"to":"captain","update":{"d":{"spec":{"inline":<>}}}} +2019-01-18T20:09:36.161580593Z update dc {"SpecSrc":{"inline":{"name":"doublecount","doc":"A machine that doubl... +2019-01-18T20:09:36.172865244Z input {"double":10} +2019-01-18T20:09:36.173278107Z update d {"SpecSrc":{"inline":{"name":"double","doc":"A machine that double num... +2019-01-18T20:09:36.173406501Z input {"double":100} +2019-01-18T20:09:36.175959368Z emit 0,0 {"doubled":20} +2019-01-18T20:09:36.176216236Z emit 1,0 {"doubled":20} +2019-01-18T20:09:36.176324663Z update dc {"State":{"node":"listen","bs":{"count":1}}} +2019-01-18T20:09:36.176338506Z update d {"State":{"node":"start","bs":{}}} +2019-01-18T20:09:36.177022452Z input {"double":1000} +2019-01-18T20:09:36.177697157Z emit 0,0 {"doubled":200} +2019-01-18T20:09:36.177713769Z emit 1,0 {"doubled":200} +2019-01-18T20:09:36.177719201Z update dc {"State":{"node":"listen","bs":{"count":2}}} +2019-01-18T20:09:36.177724241Z emit 0,0 {"doubled":2000} +2019-01-18T20:09:36.17772758Z emit 1,0 {"doubled":2000} +2019-01-18T20:09:36.177731266Z update dc {"State":{"node":"listen","bs":{"count":3}}} +2019-01-18T20:09:36.177735923Z input {"collatz":17} +2019-01-18T20:09:36.177741917Z input {"collatz":5} +2019-01-18T20:09:36.188311423Z emit 0,0 {"collatz":52} +2019-01-18T20:09:36.188336983Z emit 1,0 {"collatz":26} +2019-01-18T20:09:36.188342489Z emit 2,0 {"collatz":13} +2019-01-18T20:09:36.18834623Z emit 3,0 {"collatz":40} +2019-01-18T20:09:36.188349907Z emit 4,0 {"collatz":20} +2019-01-18T20:09:36.188353459Z emit 5,0 {"collatz":10} +2019-01-18T20:09:36.188356994Z emit 6,0 {"collatz":5} +2019-01-18T20:09:36.188360173Z emit 7,0 {"collatz":16} +2019-01-18T20:09:36.188363379Z emit 8,0 {"collatz":8} +2019-01-18T20:09:36.18836662Z emit 9,0 {"collatz":4} +2019-01-18T20:09:36.188370333Z emit 10,0 {"collatz":2} +2019-01-18T20:09:36.188373525Z emit 11,0 {"collatz":1} +2019-01-18T20:09:36.188394591Z update c {"State":{"node":"start","bs":{}}} +2019-01-18T20:09:36.188400833Z emit 0,0 {"collatz":16} +2019-01-18T20:09:36.188408299Z emit 1,0 {"collatz":8} +2019-01-18T20:09:36.188413067Z emit 2,0 {"collatz":4} +2019-01-18T20:09:36.188418272Z emit 3,0 {"collatz":2} +2019-01-18T20:09:36.188423512Z emit 4,0 {"collatz":1} +2019-01-18T20:09:36.188431733Z input {"to":"timers","makeTimer":{"in":"2s","msg":{"double":10000},"id":"t0"}} +2019-01-18T20:09:36.188462256Z input {"to":"timers","makeTimer":{"in":"4s","msg":{"collatz":21},"id":"t1"}} +2019-01-18T20:09:36.189217647Z update timers {"State":{"node":"start","bs":{"timers":{"t0":{"Id":"t0","Msg":{"doubl... +2019-01-18T20:09:36.189245799Z update timers {"State":{"node":"start","bs":{"timers":{"t0":{"Id":"t0","Msg":{"doubl... +2019-01-18T20:09:36.195110404Z input {"to":"d","double":3} +2019-01-18T20:09:36.195144402Z input {"to":"dc","double":4} +2019-01-18T20:09:36.195542312Z emit 0,0 {"doubled":6} +2019-01-18T20:09:36.195559767Z emit 0,0 {"doubled":8} +2019-01-18T20:09:36.195565262Z update dc {"State":{"node":"listen","bs":{"count":4}}} +2019-01-18T20:09:38.213091627Z emit 0,0 {"doubled":20000} +2019-01-18T20:09:38.213120276Z emit 1,0 {"doubled":20000} +2019-01-18T20:09:38.213141372Z update timers {"State":{"node":"","bs":{"timers":{"t1":{"Id":"t1","Msg":{"collatz":2... +2019-01-18T20:09:38.213148878Z update dc {"State":{"node":"listen","bs":{"count":5}}} +2019-01-18T20:09:40.192858345Z emit 0,0 {"collatz":64} +2019-01-18T20:09:40.192884791Z emit 1,0 {"collatz":32} +2019-01-18T20:09:40.192890293Z emit 2,0 {"collatz":16} +2019-01-18T20:09:40.192894858Z emit 3,0 {"collatz":8} +2019-01-18T20:09:40.192898685Z emit 4,0 {"collatz":4} +2019-01-18T20:09:40.192902395Z emit 5,0 {"collatz":2} +2019-01-18T20:09:40.192906046Z emit 6,0 {"collatz":1} +2019-01-18T20:09:40.19294322Z update timers {"State":{"node":"","bs":{"timers":{}}}} + +["c",{"node":"start","bs":{}}] +["d",{"node":"start","bs":{}}] +["dc",{"node":"listen","bs":{"count":5}}] +["timers",{"node":"","bs":{"timers":{}}}] +``` diff --git a/sio/siostd/main.go b/sio/siostd/main.go new file mode 100644 index 0000000..142c767 --- /dev/null +++ b/sio/siostd/main.go @@ -0,0 +1,80 @@ +/* Copyright 2019 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package main is a simple single-crew sheens process that reads from +// stdin and writes to stdout. +package main + +import ( + "context" + "flag" + "time" + + "github.com/Comcast/sheens/core" + "github.com/Comcast/sheens/sio" +) + +func main() { + io := sio.NewStdio(true) + + flag.BoolVar(&io.EchoInput, "echo", false, "echo input") + flag.BoolVar(&io.Timestamps, "ts", false, "print timestamps") + flag.BoolVar(&io.ShellExpand, "sh", false, "shell-expand input") + flag.BoolVar(&io.PadTags, "pad", false, "pad tags") + flag.StringVar(&io.StateOutputFilename, "state-out", "", "state output filename") + flag.BoolVar(&io.WriteStatePerMsg, "write-state-msg", false, "write state after each msg") + + wait := flag.Duration("wait", 0, "wait this long before shutting down couplings") + + flag.Parse() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + conf := &sio.CrewConf{ + Ctl: core.DefaultControl, + } + + c, err := sio.NewCrew(ctx, conf, io) + if err != nil { + panic(err) + } + + if err = io.Start(ctx); err != nil { + panic(err) + } + + ms, err := io.Read(ctx) + if err != nil { + panic(err) + } + for mid, m := range ms { + if err := c.SetMachine(ctx, mid, m.SpecSource, m.State); err != nil { + panic(err) + } + } + + go func() { + <-io.InputEOF + time.Sleep(*wait) + cancel() + }() + + if err := c.Loop(ctx); err != nil { + panic(err) + } + + if err = io.Stop(context.Background()); err != nil { + panic(err) + } + +} diff --git a/sio/siostd/mosquitto.conf b/sio/siostd/mosquitto.conf new file mode 100644 index 0000000..edc316a --- /dev/null +++ b/sio/siostd/mosquitto.conf @@ -0,0 +1,3 @@ +port 2883 +use_username_as_clientid true +allow_anonymous true diff --git a/sio/siostd/run.sh b/sio/siostd/run.sh new file mode 100755 index 0000000..e47edc4 --- /dev/null +++ b/sio/siostd/run.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Copyright 2019 Comcast Cable Communications Management, LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +go install + +cat<>}}}} +{"to":"captain","update":{"dc":{"spec":{"inline":<>}}}} +{"to":"captain","update":{"d":{"spec":{"inline":<>}}}} +{"double":10} +{"double":100} +{"double":1000} +{"collatz":17} +{"collatz":5} +{"to":"timers","makeTimer":{"in":"2s","msg":{"double":10000},"id":"t0"}} +{"to":"timers","makeTimer":{"in":"4s","msg":{"collatz":21},"id":"t1"}} +{"to":"d","double":3} +{"to":"dc","double":4} +EOF + +echo + +cat state.json | jq -c 'to_entries[]|([.key,.value.state])' diff --git a/sio/stdio.go b/sio/stdio.go new file mode 100644 index 0000000..07d325f --- /dev/null +++ b/sio/stdio.go @@ -0,0 +1,243 @@ +/* Copyright 2019 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sio + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "strings" + "time" + + "github.com/Comcast/sheens/crew" +) + +// Stdio is a fairly simple Couplings that uses stdin for input and +// stdout for output. +// +// State is optionally written as JSON to a file. +type Stdio struct { + // In is coupled to crew input. + In io.Reader + + // Out is coupled to crew output. + Out io.Writer + + // ShellExpand enables input to include inline shell commands + // delimited by '<<' and '>>'. Use at your wown risk, of + // course! + ShellExpand bool + + // Timestamps prepends a timestamp to each output line. + Timestamps bool + + // EchoInput writes input lines (prepended with "input") to + // the output. + EchoInput bool + + // PadTags adds some padding to tags ("input", "emit", + // "update") used in output. + PadTags bool + + JSONStore + + // WriteStatePerMsg will write out all state after every input + // message is processed. + // + // Inefficient! + WriteStatePerMsg bool + + // InputEOF will be closed on EOF from stdin. + InputEOF chan bool +} + +// NewStdio creates a new Stdio. +// +// ShellExpand enables input to include inline shell commands +// delimited by '<<' and '>>'. Use at your wown risk, of course! +// +// In and Out are initialized with os.Stdin and os.Stdout +// respectively. +func NewStdio(shellExpand bool) *Stdio { + return &Stdio{ + In: os.Stdin, + Out: os.Stdout, + ShellExpand: shellExpand, + InputEOF: make(chan bool), + } +} + +// Start does nothing. +func (s *Stdio) Start(ctx context.Context) error { + return nil +} + +// Stop writes out the state if requested by StateInputFilename. +// +// This function waits until IO is complete or was terminated via its +// context. +func (s *Stdio) Stop(ctx context.Context) error { + s.WG.Wait() + return s.writeState(ctx) +} + +// Read reads s.StateInputFilename, which should contain a JSON +// representation of the crew's state. +func (s *Stdio) Read(ctx context.Context) (map[string]*crew.Machine, error) { + if s.StateInputFilename != "" { + js, err := ioutil.ReadFile(s.StateInputFilename) + if err != nil { + return nil, err + } + if err = json.Unmarshal(js, &s.state); err != nil { + return nil, err + } + return s.state, nil + + } + return make(map[string]*crew.Machine), nil +} + +// IO returns channels for reading from stdin and writing to stdout. +func (s *Stdio) IO(ctx context.Context) (chan interface{}, chan *Result, error) { + in := make(chan interface{}) + + if s.StateOutputFilename != "" { + s.state = make(map[string]*crew.Machine) + } + + printf := func(tag, format string, args ...interface{}) { + if s.PadTags { + tag = fmt.Sprintf("% 10s", tag) + } + format = tag + " " + format + if s.Timestamps { + ts := fmt.Sprintf("%-31s", time.Now().UTC().Format(time.RFC3339Nano)) + format = ts + " " + format + } + + fmt.Fprintf(s.Out, format, args...) + } + + s.WG.Add(1) + go func() { + defer s.WG.Done() + stdin := bufio.NewReader(s.In) + for { + select { + case <-ctx.Done(): + return + default: + line, err := stdin.ReadString('\n') + if err == io.EOF || strings.TrimSpace(line) == "quit" { + close(s.InputEOF) + return + } + if err != nil { + log.Printf("stdin error %s", err) + return + } + if strings.HasPrefix(line, "#") { + continue + } + if s.EchoInput { + printf("input", "%s", line) + } + if s.ShellExpand { + line, err = ShellExpand(line) + if err != nil { + log.Printf("stdin error %s", err) + return + } + } + + var msg interface{} + if err := json.Unmarshal([]byte(line), &msg); err != nil { + fmt.Fprintf(os.Stderr, "bad input: %s\n", err) + continue + } + in <- msg + } + } + }() + + out := make(chan *Result) + + s.WG.Add(1) + go func() { + defer s.WG.Done() + for { + select { + case <-ctx.Done(): + return + case r := <-out: + if r == nil { + return + } + for i, emitted := range r.Emitted { + for j, msg := range emitted { + printf("emit", "%d,%d %s\n", i, j, JS(msg)) + } + } + for mid, m := range r.Changed { + printf("update", "%s %s\n", mid, JShort(m)) + if s.state != nil { + if m.Deleted { + delete(s.state, mid) + } else { + n, have := s.state[mid] + if !have { + n = &crew.Machine{} + s.state[mid] = n + } + if m.State != nil { + n.State = m.State.Copy() + } + if m.SpecSrc != nil { + n.SpecSource = m.SpecSrc.Copy() + } + } + } + } + + if s.WriteStatePerMsg { + if err := s.writeState(ctx); err != nil { + panic(err) + } + } + } + } + + }() + + return in, out, nil +} + +// writeState writes the entire crew as JSON. +func (s *Stdio) writeState(ctx context.Context) error { + if s.state != nil { + js, err := json.MarshalIndent(&s.state, "", " ") + if err != nil { + return err + } + if err = ioutil.WriteFile(s.StateOutputFilename, js, 0644); err != nil { + return err + } + } + return nil +} diff --git a/sio/timers.go b/sio/timers.go new file mode 100644 index 0000000..60bbe3f --- /dev/null +++ b/sio/timers.go @@ -0,0 +1,185 @@ +/* Copyright 2019 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sio + +// ToDo: Timers.Suspend, Timers.Resune + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/Comcast/sheens/core" + "github.com/Comcast/sheens/match" +) + +var ( + // TimersMachine is the id of the timers machine. + TimersMachine = "timers" +) + +// TimerEntry represents a pending timer. +type TimerEntry struct { + Id string + Msg interface{} + At time.Time + Ctl chan bool `json:"-"` + + timers *Timers +} + +// Timers represents pending timers. +type Timers struct { + Map map[string]*TimerEntry + Emitter func(context.Context, *TimerEntry) `json:"-"` + + sync.Mutex + + c *Crew +} + +// NewTimers creates a Timers with the given function that the +// TimerEntries will use to emit their messages. +func NewTimers(emitter func(context.Context, *TimerEntry)) *Timers { + return &Timers{ + Map: make(map[string]*TimerEntry, 8), + Emitter: emitter, + } +} + +// withMap populates the Timers based on the given raw map. +// +// This method is used to initialize the Timers from a timers +// machine's state, which is generated via Timers.State(). +func (ts *Timers) withMap(x interface{}) error { + js, err := json.Marshal(&x) + if err != nil { + return err + } + if err = json.Unmarshal(js, &ts.Map); err != nil { + return err + } + for _, te := range ts.Map { + te.timers = ts + te.Ctl = make(chan bool) + } + + return nil +} + +// State creates a machine state that Timers.withMap can use. +func (ts *Timers) State() *core.State { + return &core.State{ + Bs: match.NewBindings().Extend("timers", ts.Map), + } +} + +// Start starts all known timers. +// +// Call this method when your have just created a Timers with existing +// data. +func (ts *Timers) Start(ctx context.Context) error { + ts.c.Logf("Timers.Start") + for _, t := range ts.Map { + go t.run(ctx) + } + return nil +} + +func (ts *Timers) add(ctx context.Context, e *TimerEntry) error { + if _, have := ts.Map[e.Id]; have { + return ts.cancel(ctx, e.Id) + } + + ts.Map[e.Id] = e + e.timers = ts + ts.changed() + + go e.run(ctx) + + return nil +} + +// Add creates a new Timer that will emit the given message later (if +// the timer isn't cancelled first). +func (ts *Timers) Add(ctx context.Context, id string, msg interface{}, d time.Duration) error { + ts.c.Logf("Timers.Add %s", id) + + ts.Lock() + + e := &TimerEntry{ + Id: id, + At: time.Now().UTC().Add(d), + Msg: msg, + Ctl: make(chan bool), + timers: ts, + } + + ts.add(ctx, e) + + ts.Unlock() + + return nil +} + +// run starts a timer that will execute the TimerEntry at the +// appointed time if the TimerEntry isn't cancelled first. +func (te *TimerEntry) run(ctx context.Context) error { + te.timers.c.Logf("TimerEntry %s run", te.Id) + + t := time.NewTimer(te.At.Sub(time.Now())) + select { + case <-t.C: + te.timers.c.Logf("Firing timer '%s'", te.Id) + te.timers.Emitter(ctx, te) + te.timers.Lock() + delete(te.timers.Map, te.Id) + te.timers.Unlock() + te.timers.c.Lock() + te.timers.changed() + te.timers.c.Unlock() + case <-te.Ctl: + te.timers.c.Logf("Canceling timer '%s'", te.Id) + case <-ctx.Done(): + } + return nil +} + +func (ts *Timers) changed() { + ts.c.change(TimersMachine).State = ts.State() +} + +func (ts *Timers) cancel(ctx context.Context, id string) error { + ts.c.Logf("Timers.cancel %s", id) + + t, have := ts.Map[id] + if !have { + return fmt.Errorf("timer '%s' doesn't exist", id) + } + delete(ts.Map, id) + ts.changed() + + close(t.Ctl) + + return nil +} + +// Cancel attepts to cancel the timer with the given id. +func (ts *Timers) Cancel(ctx context.Context, id string) error { + ts.Lock() + err := ts.cancel(ctx, id) + ts.Unlock() + return err +} diff --git a/sio/timers_glue.go b/sio/timers_glue.go new file mode 100644 index 0000000..535a292 --- /dev/null +++ b/sio/timers_glue.go @@ -0,0 +1,86 @@ +/* Copyright 2019 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sio + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Comcast/sheens/crew" + "github.com/Comcast/sheens/match" +) + +// GetTimers gets the Timers for the crew. +func (c *Crew) GetTimers(ctx context.Context) (*Timers, error) { + + emitter := func(ctx context.Context, te *TimerEntry) { + c.Logf("queuing timed message: %s", JS(te.Msg)) + c.in <- func(c *Crew) interface{} { + timers, err := c.GetTimers(ctx) + if err != nil { + // ToDo + c.Errorf("emitter GetTimers error %s", err) + } else { + timers.Cancel(ctx, te.Id) + } + return te.Msg + } + } + + ms := c.Machines + tm, have := ms[TimersMachine] + if !have { + tm = &crew.Machine{ + Id: TimersMachine, + } + ms[TimersMachine] = tm + } + if tm.State == nil { + tm.State = DefaultState(nil) + } + if tm.State.Bs == nil { + tm.State.Bs = match.NewBindings() + } + x, have := tm.State.Bs[TimersMachine] + if !have { + c.Logf("no state for timers") + timers := c.timers + timers.c = c + timers.Emitter = emitter + tm.State.Bs[TimersMachine] = timers + return timers, nil + } + + switch vv := x.(type) { + case *Timers: + return vv, nil + case interface{}: + c.Logf("raw state for timers: %s", JS(x)) + js, err := json.Marshal(&x) + if err != nil { + return nil, fmt.Errorf("couldn't serialize %v: %v", x, err) + } + var timers Timers + if err = json.Unmarshal(js, &timers); err != nil { + return nil, fmt.Errorf("couldn't deserialize %s: %v", js, err) + } + timers.Emitter = emitter + + // "go vet" said "assignment copies lock value to tm.State.Bs[TimersMachine]: sio.Timers"! + tm.State.Bs[TimersMachine] = &timers + return &timers, nil + default: + return nil, fmt.Errorf("Bad timers: %T", x) + } +} diff --git a/sio/timersspec.go b/sio/timersspec.go new file mode 100644 index 0000000..c4c932b --- /dev/null +++ b/sio/timersspec.go @@ -0,0 +1,173 @@ +/* Copyright 2019 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sio + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/Comcast/sheens/core" + "github.com/Comcast/sheens/match" +) + +// TimerMsg is a command that the timers machine can execute. +type TimerMsg struct { + + // Add the given timer. + Add struct { + Id string `json:"id"` + Msg interface{} `json:"msg"` + In string `json:"in"` + To string `json:"to"` // ToDo: Support array + } `json:"makeTimer"` + + // Cancel the given timer. + Cancel struct { + Id string + } `json:"cancelTimer"` +} + +// NewTimersSpec creates a new spec that can process a TimerMsg. +func (c *Crew) NewTimersSpec() *core.Spec { + + onlyTimers := func(bs match.Bindings) match.Bindings { + acc := match.NewBindings() + acc["timers"] = bs["timers"] + return acc + } + + spec := &core.Spec{ + Name: "timers", + Doc: "A machine that makes in-memory timers that send messages.", + Nodes: map[string]*core.Node{ + "start": { + Doc: "Wait to hear a request to create or delete a timer.", + Branches: &core.Branches{ + Type: "message", + Branches: []*core.Branch{ + { + Pattern: mustParse(`{"makeTimer":{"in":"?in", "msg":"?msg", "id":"?id"}}`), + Target: "make", + }, + { + Pattern: mustParse(`{"cancelTimer":"?id"}`), + Target: "cancel", + }, + }, + }, + }, + "make": { + Doc: "Try to make the timer.", + Action: &core.FuncAction{ + F: func(ctx context.Context, bs match.Bindings, props core.StepProps) (*core.Execution, error) { + x, have := bs["?in"] + if !have { + return core.NewExecution(bs.Extend("error", "no in")), nil + } + in, is := x.(string) + if !is { + return core.NewExecution(bs.Extend("error", fmt.Sprintf("non-string in: %T %#v", x, x))), nil + } + + d, err := time.ParseDuration(in) + if err != nil { + msg := fmt.Sprintf("bad in '%s': %v", in, err) + return core.NewExecution(bs.Extend("error", msg)), nil + } + + x, have = bs["?id"] + if !have { + return core.NewExecution(bs.Extend("error", "no id")), nil + } + id, is := x.(string) + if !is { + return core.NewExecution(bs.Extend("error", fmt.Sprintf("non-string id: %T %#v", x, x))), nil + } + + msg, have := bs["?msg"] + if !have { + return core.NewExecution(bs.Extend("error", "no message")), nil + } + + if err = c.timers.Add(ctx, id, msg, d); err != nil { + return core.NewExecution(bs.Extend("error", err.Error())), nil + } + + c.timers.changed() + + return core.NewExecution(onlyTimers(bs)), nil + }, + }, + Branches: &core.Branches{ + Type: "bindings", + Branches: []*core.Branch{ + { + Target: "start", + }, + }, + }, + }, + "cancel": { + Doc: "Try to delete the timer.", + Action: &core.FuncAction{ + F: func(ctx context.Context, bs match.Bindings, props core.StepProps) (*core.Execution, error) { + x, have := bs["?id"] + if !have { + return core.NewExecution(bs.Extend("error", "no id")), nil + } + id, is := x.(string) + if !is { + return core.NewExecution(bs.Extend("error", fmt.Sprintf("non-string id: %T %#v", x, x))), nil + } + + if err := c.timers.Cancel(ctx, id); err != nil { + return core.NewExecution(bs.Extend("error", err.Error())), nil + } + + c.timers.changed() + + return core.NewExecution(onlyTimers(bs)), nil + }, + }, + Branches: &core.Branches{ + Type: "bindings", + Branches: []*core.Branch{ + { + Target: "start", + }, + }, + }, + }, + }, + } + + return spec +} + +func mustParse(x interface{}) interface{} { + switch vv := x.(type) { + case []byte: + var y interface{} + err := json.Unmarshal(vv, &y) + if err != nil { + panic(err) + } + return y + case string: + return mustParse([]byte(vv)) + default: + return x + } +} diff --git a/sio/util.go b/sio/util.go new file mode 100644 index 0000000..fb9f5e1 --- /dev/null +++ b/sio/util.go @@ -0,0 +1,80 @@ +/* Copyright 2019 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sio + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "regexp" +) + +// JS renders its argument as JSON or as '%#v'. +func JS(x interface{}) string { + if x == nil { + return "null" + } + js, err := json.Marshal(&x) + if err != nil { + return fmt.Sprintf("%#v", x) + } + return string(js) +} + +// JSON renders its argument as pretty JSON or as '%#v". +func JSON(x interface{}) string { + if x == nil { + return "null" + } + js, err := json.MarshalIndent(&x, "", " ") + if err != nil { + return fmt.Sprintf("%#v", x) + } + return string(js) +} + +// JShort renders its argument as JS() but only up to 73 characters. +func JShort(x interface{}) string { + js := []byte(JS(x)) + if 70 < len(js) { + js = js[0:70] + js = append(js, []byte("...")...) + } + return string(js) +} + +var shell = regexp.MustCompile(`<<(.*?)>>`) + +// ShellExpand expands shell commands delimited by '<<' and '>>'. Use +// at your wown risk, of course! +func ShellExpand(msg string) (string, error) { + literals := shell.Split(msg, -1) + ss := shell.FindAllStringSubmatch(msg, -1) + acc := literals[0] + for i, s := range ss { + var sh = s[1] + cmd := exec.Command("bash", "-c", sh) + // cmd.Stdin = strings.NewReader("") + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("shell error %s on %s", err, sh) + } + got := out.String() + acc += got + acc += literals[i+1] + } + return acc, nil +} diff --git a/tools/analysis.go b/tools/analysis.go index e41738b..00c3a24 100644 --- a/tools/analysis.go +++ b/tools/analysis.go @@ -10,17 +10,22 @@ * limitations under the License. */ +// Imagine a world where ideas and processes interweave to create something as simple yet complex as a pencil. Here, we delve into the heart of a system designed to scrutinize the blueprint of such creation, not of a pencil, but of digital orchestrations called specifications, or "specs" for short. + package tools import ( - "fmt" + "sort" "github.com/Comcast/sheens/core" ) +// SpecAnalysis embodies our endeavor to understand and critique the structure of a spec, much like examining the blueprint of a pencil, identifying every component from wood to graphite, and noting any imperfections or marvels. + type SpecAnalysis struct { - spec *core.Spec + spec *core.Spec // The blueprint itself, holding secrets of its creation. + // Observations and findings, detailing the intricacies and potential flaws within. Errors []string NodeCount int Branches int @@ -31,125 +36,103 @@ type SpecAnalysis struct { EmptyTargets []string MissingTargets []string BranchTargetVariables []string - - Interpreters []string + Interpreters []string // The artisans and their tools, bringing the spec to life. } +// Analyze embarks on a journey to scrutinize the spec, seeking to uncover the harmony and discord within its design. func Analyze(s *core.Spec) (*SpecAnalysis, error) { - // Check for timeout branches in nodes with actions. - - // Check for mutually exclusive branches (which would not - // necessarily be errors). - - // Check for obvious infinite loops. For example, a node with - // no action and a default branch that targets the same node - // -- or similar or indirectly or ... - + // Begin with an empty canvas, ready to be marked with observations. a := SpecAnalysis{ spec: s, - NodeCount: len(s.Nodes), - Errors: make([]string, 0, 8), + NodeCount: len(s.Nodes), // Counting the nodes, akin to counting every sliver of wood in a pencil. + Errors: make([]string, 0, 8), // Preparing to note any flaws or missteps. } - var ( - terminal = make([]string, 0, len(s.Nodes)) - targeted = make(map[string]bool) - interpreters = make(map[string]bool) - hasEmptyTargets = make(map[string]bool) - missingTargets = make(map[string]bool) - branchTargetVariables = make(map[string]bool) - ) - - // ToDo: Check that ErrorNode exists. + // Various collections to capture the nuances of our analysis. + terminal, targeted, interpreters := make([]string, 0, len(s.Nodes)), make(map[string]bool), make(map[string]bool) + hasEmptyTargets, missingTargets, branchTargetVariables := make(map[string]bool), make(map[string]bool), make(map[string]bool) + // Delve into each node, akin to inspecting every component of a pencil, from its wood to the graphite core. for name, n := range s.Nodes { - haveAction := false + // Actions are deliberate steps, like the precise cutting of wood or molding of graphite. if n.Action != nil || n.ActionSource != nil { a.Actions++ - haveAction = true if n.ActionSource != nil { - interpreters[n.ActionSource.Interpreter] = true + interpreters[n.ActionSource.Interpreter] = true // Note the craftsmen and their techniques. } } + + // Terminal nodes are like pencil ends; they signify completion or a pause. if n.Branches == nil || len(n.Branches.Branches) == 0 { terminal = append(terminal, name) } - if haveAction && n.Branches != nil && n.Branches.Type == "message" { - a.Errors = append(a.Errors, - fmt.Sprintf(`node "%s" has an action with "%s" branching"`, - name, n.Branches.Type)) - } + + // Check for nodes that make decisions, akin to choosing the path for a pencil's creation. if n.Branches != nil { for _, b := range n.Branches.Branches { targeted[b.Target] = true a.Branches++ if b.Target == "" { - hasEmptyTargets[name] = true + hasEmptyTargets[name] = true // Note any paths that lead nowhere, like a misdirected pencil stroke. } if core.IsBranchTargetVariable(b.Target) { branchTargetVariables[b.Target] = true } else { if _, have := s.Nodes[b.Target]; !have { - missingTargets[b.Target] = true + missingTargets[b.Target] = true // Missing targets are like missing ingredients in our pencil recipe. } } if b.Guard != nil || b.GuardSource != nil { a.Guards++ if b.GuardSource != nil { - interpreters[b.GuardSource.Interpreter] = true + interpreters[b.GuardSource.Interpreter] = true // Further noting the artisans and their methods. } } } } - - // ToDo: If ActionErrorBranches, then see if any - // branches explicitly binding a variable to - // "actionError". If not, warn? Would be nice if we - // had OCaml-style type inference! - } - a.TerminalNodes = terminal - emptyTargets := make([]string, 0, len(hasEmptyTargets)) - for name := range hasEmptyTargets { - emptyTargets = append(emptyTargets, name) - } - a.EmptyTargets = emptyTargets + // Compile our findings, cataloging every detail and anomaly discovered in the spec's design. + a.TerminalNodes, a.EmptyTargets = terminal, keysToStringSlice(hasEmptyTargets) + a.Orphans = keysToStringSlice(diffKeys(s.Nodes, targeted)) + a.MissingTargets = keysToStringSlice(missingTargets) + a.BranchTargetVariables = keysToStringSlice(branchTargetVariables) + a.Interpreters = keysToStringSlice(interpreters, "default") - all := make(map[string]bool, len(s.Nodes)) - for name := range s.Nodes { - all[name] = true - } - for name := range targeted { - delete(all, name) - } - orphans := make([]string, 0, len(all)) - for name := range all { - orphans = append(orphans, name) - } - a.Orphans = orphans + // Our analysis is complete, a comprehensive examination of the spec, akin to unveiling the story behind a pencil's creation. + return &a, nil +} - missing := make([]string, 0, len(missingTargets)) - for name := range missingTargets { - missing = append(missing, name) +// keysToStringSlice converts the keys from a map into a slice of strings. +// Optionally, it can add a default value if the map is empty. +// A helper function to convert a map's keys to a sorted string slice, revealing the elements involved in our creation process. +func keysToStringSlice(m map[string]bool, defaultValue ...string) []string { + var list []string + for key := range m { + list = append(list, key) } - a.MissingTargets = missing + // Sort the slice for consistency and readability. + sort.Strings(list) - vars := make([]string, 0, len(branchTargetVariables)) - for name := range branchTargetVariables { - vars = append(vars, name) + // If the map is empty and a default value is provided, use the default value. + if len(list) == 0 && len(defaultValue) > 0 { + return []string{defaultValue[0]} } - a.BranchTargetVariables = vars - interps := make([]string, 0, len(interpreters)) - for name := range interpreters { - if name == "" { - name = "default" + return list +} + +// diffKeys identifies the keys present in 'all' but not in 'used'. +// Another helper to identify the elements that were not targeted or used, +// much like finding unused pieces in our pencil-making process. +// It's akin to discovering unused resources in our process, highlighting efficiency or oversight. +func diffKeys(all map[string]*core.Node, used map[string]bool) map[string]bool { + diff := make(map[string]bool) + for key := range all { + if _, found := used[key]; !found { + diff[key] = true } - interps = append(interps, name) } - a.Interpreters = interps - - return &a, nil + return diff } diff --git a/tools/doc.go b/tools/doc.go new file mode 100644 index 0000000..04a7f87 --- /dev/null +++ b/tools/doc.go @@ -0,0 +1,14 @@ +/* Copyright 2018 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package tools includes miscellaneous tooling. +package tools diff --git a/tools/dot.go b/tools/dot.go index a032205..ec5e306 100644 --- a/tools/dot.go +++ b/tools/dot.go @@ -56,7 +56,7 @@ func Dot(spec *Spec, w io.WriteCloser, fromNode, toNode string) error { seen := make(map[string]bool) node := func(name string, n *Node) error { if n == nil { - return fmt.Errorf("Unknown node '%s'", name) + return fmt.Errorf("unknown node '%s'", name) } if _, already := seen[name]; already { @@ -143,7 +143,6 @@ func Dot(spec *Spec, w io.WriteCloser, fromNode, toNode string) error { } if err != nil { js = []byte(err.Error()) - } else { } label = string(js) label = strings.Replace(label, "\n", `
`, -1) @@ -237,13 +236,3 @@ func PNG(spec *Spec, basename string, fromNode, toNode string) (string, error) { } return pngname, nil } - -func escape(s string) string { - return strings.Replace(s, `"`, `\"`, -1) -} - -func escbraces(s string) string { - s = strings.Replace(s, "{", "\\{", -1) - s = strings.Replace(s, "}", "\\}", -1) - return s -} diff --git a/tools/expect.go b/tools/expect/expect.go similarity index 89% rename from tools/expect.go rename to tools/expect/expect.go index 8cf9309..04043e5 100644 --- a/tools/expect.go +++ b/tools/expect/expect.go @@ -10,7 +10,20 @@ * limitations under the License. */ -package tools +// Package expect is a tool for testing machine specifications. +// +// You construct a Session, which has inputs and expected outputs. +// Then run the session to see if the expected outputs actually +// appeared. +// +// Specifying what's expect can be simple, as in some literal output, +// or fairly fancy, as in code that computes some property. +// +// This package also has support for delays, timeouts, and other +// time-driven behavior. +// +// See ../../cmd/mexpect for command-line use. +package expect import ( "bufio" @@ -25,6 +38,7 @@ import ( "time" "github.com/Comcast/sheens/core" + "github.com/Comcast/sheens/match" . "github.com/Comcast/sheens/util/testutil" ) @@ -47,7 +61,7 @@ type Output struct { // Bindings, which is the result of a match (and optional // guard) is written during processing. Just for diagnostics. - Bindingss []core.Bindings `json:"bs,omitempty" yaml:"bs,omitempty"` + Bindingss []match.Bindings `json:"bs,omitempty" yaml:"bs,omitempty"` // Inverted means that matching output isn't desired! Inverted bool `json:"inverted,omitempty" yaml:"inverted,omitempty"` @@ -55,6 +69,9 @@ type Output struct { // IO is a package of input messages and required output message // specifications. +// +// This struct includes a list of messages to send and a set of expect +// output messages. type IO struct { // Doc is an opaque documentation string. Doc string `json:"doc,omitempty" yaml:"doc,omitempty"` @@ -102,8 +119,12 @@ type Session struct { // logged. ShowStderr bool `json:"showStderr,omitempty" yaml:"showStderr,omitempty"` + // ShowStdin controls whether the subprocess's stdin is + // logged. ShowStdin bool `json:"showStdin,omitempty" yaml:"showStdin,omitempty"` + // ShowStdout controls whether the subprocess's stdout is + // logged. ShowStdout bool `json:"showStdout,omitempty" yaml:"showStdout,omitempty"` Verbose bool `json:"verbose,omitempty" yaml:"verbose,omitempty"` @@ -115,7 +136,10 @@ type Session struct { // restored). // // The subprocess is given by the args. The first arg is the -// executable. +// executable. Example args: +// +// "mcrew", "-v", "-s", "specs", "-d", "", "-I", "-O", "-h", "" +// func (s *Session) Run(ctx context.Context, dir string, args ...string) error { if dir != "" { @@ -243,7 +267,7 @@ func (s *Session) Run(ctx context.Context, dir string, args ...string) error { } } - bss, err := core.Match(nil, pattern, message, core.NewBindings()) + bss, err := match.Match(pattern, message, match.NewBindings()) if err != nil { return err } @@ -263,7 +287,7 @@ func (s *Session) Run(ctx context.Context, dir string, args ...string) error { if err != nil { return err } - bss = []core.Bindings{exe.Bs} + bss = []match.Bindings{exe.Bs} } } if bss != nil { diff --git a/tools/expect_test.go b/tools/expect/expect_test.go similarity index 95% rename from tools/expect_test.go rename to tools/expect/expect_test.go index 16f9352..11a9dd0 100644 --- a/tools/expect_test.go +++ b/tools/expect/expect_test.go @@ -10,7 +10,7 @@ * limitations under the License. */ -package tools +package expect import ( "context" @@ -86,7 +86,7 @@ func TestExpectBasic(t *testing.T) { s.ShowStdout = true s.ShowStdin = true - if err := s.Run(ctx, "..", "mcrew", "-v", "-s", "specs", "-d", "", "-I", "-O", "-h", ""); err != nil { + if err := s.Run(ctx, "../..", "mcrew", "-v", "-s", "specs", "-d", "", "-I", "-O", "-h", ""); err != nil { panic(err) } } diff --git a/tools/inline.go b/tools/inline.go index 1050144..12044ae 100644 --- a/tools/inline.go +++ b/tools/inline.go @@ -1,8 +1,19 @@ +/* Copyright 2018 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package tools import ( "io" - "io/ioutil" "log" "os" "path/filepath" @@ -43,32 +54,32 @@ func Inline(bs []byte, f func(string) ([]byte, error)) ([]byte, error) { // '%inline("NAME")' is replaced with ReadFile(NAME). func ReadFileWithInlines(filename string) ([]byte, error) { - bs, err := ioutil.ReadFile(filename) + bs, err := os.ReadFile(filename) if err != nil { return nil, err } dir := filepath.Dir(filename) f := func(name string) ([]byte, error) { - return ioutil.ReadFile(dir + string(os.PathSeparator) + name) + return os.ReadFile(dir + string(os.PathSeparator) + name) } return Inline(bs, f) } -// ReadFileWithInlines is a replacement for ioutil.ReadAll that adds +// ReadFileWithInlines is a replacement for io.ReadAll that adds // automation Inline()ing based on the given directory. // // '%inline("NAME")' is replaced with ReadFile(NAME). func ReadAllWithInlines(in io.Reader, dir string) ([]byte, error) { - bs, err := ioutil.ReadAll(in) + bs, err := io.ReadAll(in) if err != nil { return nil, err } f := func(name string) ([]byte, error) { - return ioutil.ReadFile(dir + string(os.PathSeparator) + name) + return os.ReadFile(dir + string(os.PathSeparator) + name) } return Inline(bs, f) diff --git a/tools/inline_test.go b/tools/inline_test.go index 877a16c..fcc8300 100644 --- a/tools/inline_test.go +++ b/tools/inline_test.go @@ -1,3 +1,15 @@ +/* Copyright 2018 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package tools import ( diff --git a/tools/mermaid.go b/tools/mermaid.go index 19cb41d..daae530 100644 --- a/tools/mermaid.go +++ b/tools/mermaid.go @@ -36,6 +36,8 @@ type MermaidOpts struct { // ActionClass will be the CSS class for action nodes. Not // yet implemented. ActionClass string `json:"actionClass,omitempty"` + + PrettyPatterns bool `json:"prettyPatterns,omitempty"` } // Mermaid makes a Mermaid (https://mermaidjs.github.io/) input file @@ -44,8 +46,9 @@ func Mermaid(spec *Spec, w io.WriteCloser, opts *MermaidOpts, fromNode, toNode s if opts == nil { opts = &MermaidOpts{ - ShowPatterns: true, - ActionFill: "#bcf2db", + ShowPatterns: true, + ActionFill: "#bcf2db", + PrettyPatterns: true, } } @@ -71,10 +74,10 @@ func Mermaid(spec *Spec, w io.WriteCloser, opts *MermaidOpts, fromNode, toNode s nid := fmt.Sprintf("n%d", num) nids[name] = nid - if n.Action == nil { - fmt.Fprintf(w, " %s(%s)\n", nid, name) + if n != nil && n.Action == nil { + fmt.Fprintf(w, " %s(\"%s\")\n", nid, name) } else { - fmt.Fprintf(w, " %s[%s]\n", nid, name) + fmt.Fprintf(w, " %s[\"%s\"]\n", nid, name) if opts.ActionClass == "" { if opts.ActionFill == "" { } else { @@ -106,13 +109,19 @@ func Mermaid(spec *Spec, w io.WriteCloser, opts *MermaidOpts, fromNode, toNode s label := "" if opts.ShowPatterns && b.Pattern != nil { - bs, err := json.Marshal(b.Pattern) + var bs []byte + if opts.PrettyPatterns { + bs, err = json.Marshal(b.Pattern) + if 40 < len(bs) { + bs, err = json.MarshalIndent(b.Pattern, "", " ") + } + } if err != nil { return err } js := string(bs) js = strings.Replace(js, `"`, `'`, -1) - label = fmt.Sprintf(`-- "%s"`, js) + label = fmt.Sprintf(`-- "
%s
"`, js) } fmt.Fprintf(w, " %s %s --> %s\n", nid, label, to) diff --git a/tools/mermaid_test.go b/tools/mermaid_test.go index 1f06680..62ed465 100644 --- a/tools/mermaid_test.go +++ b/tools/mermaid_test.go @@ -1,3 +1,15 @@ +/* Copyright 2018 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package tools import ( diff --git a/tools/spec-html.go b/tools/spec-html.go index 4fee19b..01c47f4 100644 --- a/tools/spec-html.go +++ b/tools/spec-html.go @@ -1,51 +1,47 @@ -/* Copyright 2018 Comcast Cable Communications Management, LLC - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - +// Yo, check this out! We're setting up the building blocks for our cool tool. package tools import ( - "context" - "encoding/json" - "fmt" - "io" - "io/ioutil" - - "github.com/Comcast/sheens/core" - "github.com/Comcast/sheens/interpreters/noop" - . "github.com/Comcast/sheens/util/testutil" - "github.com/jsccast/yaml" - - md "gopkg.in/russross/blackfriday.v2" + "context" // Gotta have this for managing goroutine lifetimes. + "encoding/json" // JSON is everywhere, so we'll need this to read and write it. + "fmt" // Basic input/output - can't live without it. + "io" // Working with input/output streams. Think of it like a data river! + "os" // New import for reading files in Go 1.21. + + "github.com/Comcast/sheens/core" // This is where the magic happens in Sheens. + "github.com/Comcast/sheens/interpreters/noop" // A noop interpreter for when we want to do... nothing? + . "github.com/Comcast/sheens/util/testutil" // Test utils, but using the dot import for direct access. + "gopkg.in/yaml.v2" // YAML is JSON's cool cousin. We're using it for configuration. + + md "github.com/russross/blackfriday/v2" // Markdown processor because text is boring without formatting. ) +// RenderSpecHTML takes a Sheens Spec and turns it into HTML. Think of it as making a plain text look fancy on the web. func RenderSpecHTML(s *core.Spec, out io.Writer) error { f := func(format string, args ...interface{}) { - fmt.Fprintf(out, format+"\n", args...) + fmt.Fprintf(out, format+"\n", args...) // Writing formatted output to our writer, adding a newline for readability. } + // Using Blackfriday to turn Markdown into HTML. It's like magic for text! f(`
%s
`, md.Run([]byte(s.Doc))) - { // Nodes + { // Nodes section. Here we're gonna layout all the nodes in a neat table. f(`
`) - // Need to try to order these nodes sensibly. + + // This inner function is a neat way to process each node. Encapsulation, baby! fn := func(id string, node *core.Node) { + // For each node, we're creating a row in our HTML table. f(`
%s`, id, id) + // If a node has documentation, let's include that too. More info is always better! if node.Doc != "" { f(`
%s
`, md.Run([]byte(node.Doc))) } + // If there's action source code, we'll show that in a
 tag for formatting.
 			if node.ActionSource != nil {
 				f(`
%s
`, node.ActionSource.Source) } + // Branches are tricky. They decide where to go next based on messages. It's like a choose-your-own-adventure book! if node.Branches != nil { if node.Branches.Type == "message" { f(`
type: message
`) @@ -53,12 +49,11 @@ func RenderSpecHTML(s *core.Spec, out io.Writer) error { f(`
`) f(``) for i, b := range node.Branches.Branches { + // Each branch gets its own row. It's like laying out options on a table. f(`
%d
`, i) f(``) - // if b.Doc != "" { - // f(``) - // f(``, JS(b.Pattern)) - // } + + // Showing the pattern and guard of each branch. It's like setting rules for the adventure paths! if b.Pattern != nil { f(``) f(``, JS(b.Pattern)) @@ -67,6 +62,7 @@ func RenderSpecHTML(s *core.Spec, out io.Writer) error { f(``) f(``, b.GuardSource.Source) } + // If the branch has a target, we make it a clickable link. Fancy navigation! if b.Target != "" { f(``) f(``, b.Target, b.Target) @@ -79,10 +75,11 @@ func RenderSpecHTML(s *core.Spec, out io.Writer) error { } f(``) } + // Making sure we always show the start node first. It's like starting at the beginning of the book. if n, has := s.Nodes["start"]; has { fn("start", n) } - // ToDo: Order. + // Looping through all nodes except "start" since we already handled it. for id, node := range s.Nodes { if id == "start" { continue @@ -95,17 +92,19 @@ func RenderSpecHTML(s *core.Spec, out io.Writer) error { return nil } +// RenderSpecPage takes a spec and makes a whole web page out of it. This is where things get real. func RenderSpecPage(s *core.Spec, out io.Writer, cssFiles []string, includeGraph bool) error { if cssFiles == nil { - cssFiles = []string{"/static/spec-html.css"} + cssFiles = []string{"/static/spec-html.css"} // Default styling if none provided. Gotta make it look good! } - js, err := json.Marshal(s) + js, err := json.Marshal(s) // Turning our spec into JSON because JavaScript can understand it. if err != nil { return err } + // Basic HTML structure. This is like the skeleton of our web page. fmt.Fprintf(out, ` @@ -113,6 +112,7 @@ func RenderSpecPage(s *core.Spec, out io.Writer, cssFiles []string, includeGraph %s `, s.Name) + // If we want to include a graph, we load up some extra JavaScript libraries for drawing. if includeGraph { fmt.Fprintf(out, ` @@ -124,6 +124,7 @@ func RenderSpecPage(s *core.Spec, out io.Writer, cssFiles []string, includeGraph `, js) } + // Linking to CSS files for styling. Everyone wants their page to be the prettiest, right? for _, cssFile := range cssFiles { fmt.Fprintf(out, " \n", cssFile) } @@ -134,10 +135,12 @@ func RenderSpecPage(s *core.Spec, out io.Writer, cssFiles []string, includeGraph

%s

`, s.Name) + // If we decided to include a graph, here's where it will show up. Like a map of our adventure! if includeGraph { fmt.Fprintf(out, `
`) } + // Here's where we render the spec into HTML. It's like filling the skeleton with muscles and skin. if err = RenderSpecHTML(s, out); err != nil { return err } @@ -150,26 +153,32 @@ func RenderSpecPage(s *core.Spec, out io.Writer, cssFiles []string, includeGraph return nil } +// ReadAndRenderSpecPage reads a spec from a file, does some processing, and turns it into a web page. func ReadAndRenderSpecPage(filename string, cssFiles []string, out io.Writer, includeGraph bool) error { - specSrc, err := ioutil.ReadFile(filename) + // Reading the spec from a file. It's like opening a treasure chest! + specSrc, err := os.ReadFile(filename) // Updated to use os.ReadFile which is the recommended way since Go 1.16. if err != nil { return err } var spec core.Spec + // Unmarshalling YAML. It's like translating ancient scrolls into modern language. if err = yaml.Unmarshal(specSrc, &spec); err != nil { return err } + // Setting up a noop interpreter. Sometimes, doing nothing is an important step. interpreters := noop.NewInterpreters() interpreters.I.Silent = true + // Contexts are great for managing go routines, like directing traffic in your code. ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + defer cancel() // Always clean up after yourself. Cancel the context when you're done. + // Compiling the spec with our context and interpreter. It's like putting puzzle pieces together. if err = spec.Compile(ctx, interpreters, true); err != nil { return err } + // Finally, we render our spec into a beautiful web page. Showtime! return RenderSpecPage(&spec, out, cssFiles, includeGraph) - } diff --git a/tools/spec-html.js b/tools/spec-html.js index 6b20c5f..0220e6a 100644 --- a/tools/spec-html.js +++ b/tools/spec-html.js @@ -3,94 +3,106 @@ // // Returns a function that takes state as an argument and updates the // display. -function renderMachine(mid, spec) { - console.log("renderMachine", mid, spec) +// initializeMachineRenderer doth conjure the visual semblance of a contraption, drawn from the quill of machineId and the tome of specification. +// It trumpets the onset of this grand performance, erects a stage for the machine's display, and weaves the fabric of its graphical representation. +// Hark! From its depths, it bestows upon us a function, updateDisplay, which, with great alacrity, alters the machine's visage based on the state's decree. +function initializeMachineRenderer(machineId, specification) { + console.log("Initializing machine renderer", machineId, specification); - var divid = "m_" + mid; + // machineDivId, by the machineId's whisper, doth craft a moniker unique for the machine's div sanctuary. + const machineDivId = `m_${machineId}`; - var div = d3.select("#graph") - .append("div") - .attr("id", divid); - if (mid) { - div.append("div") - .classed("machineId", true) - .text(mid); - } - var gdivid = divid + "_graph"; - var gfiv = div.append("div") - .classed("graph", true) - .attr("id", gdivid) + // machineDiv, with a gesture grand, summons the parent graph to embrace a new div, anointing it with the sacred ID. + const machineDiv = d3.select("#graph") + .append("div") + .attr("id", machineDivId); - var elements = []; - if (spec && spec.nodes) { - for (var nodeName in spec.nodes) { - elements.push({data: {id: nodeName, link: "#" + nodeName}}); - var node = spec.nodes[nodeName]; - if (node.branching && node.branching.branches) { - var branches = node.branching.branches; - for (var i = 0; i < branches.length; i++) { - var branch = branches[i]; - if (branch.target) { - elements.push({data: - {id: nodeName + "_" + i, - source: nodeName, - target: branch.target}}); - } - } - } - } + // Should the machineId grace us with its presence, a div shall rise to herald its name, adorned with "machineId" for distinction. + if (machineId) { + machineDiv.append("div") + .classed("machineId", true) + .text(machineId); } - console.log("elements", elements); - - var cy = cytoscape({ - container: document.getElementById(gdivid), - elements: elements, - style: [ - { - selector: 'node', - style: { - 'content': 'data(label)', - 'background-color': '#666', - 'label': 'data(id)' - } - }, - - { - selector: 'edge', - style: { - 'curve-style': 'bezier', - 'target-arrow-shape': 'triangle', - 'width': 1, - 'line-color': 'blue', - 'target-arrow-color': 'orange', - 'target-arrow-shape': 'triangle', - 'label': 'data(label)', - } - } - ], + // graphDivId, with foresight clear, foretells the ID of the graph's own chamber within the machine's domain. + const graphDivId = `${machineDivId}_graph`; + // graphDiv, with delicate craft, nestles a div within the machine's embrace, bestowing upon it the name graphDivId, and "graph" as its title. + machineDiv.append("div") + .classed("graph", true) + .attr("id", graphDivId); - layout: { - name: 'breadthfirst', - directed: true, - rows: 1 - } + // graphElements, a troupe of shadows, awaits in silence to play their parts as nodes and edges in this visual feast. + const graphElements = []; + + // This solemn act reads from the specification's script, casting nodes and edges to take their places upon our stage. + if (specification && specification.nodes) { + for (const nodeName in specification.nodes) { + graphElements.push({data: {id: nodeName, link: `#${nodeName}`}}); + + const node = specification.nodes[nodeName]; + if (node.branching && node.branching.branches) { + node.branching.branches.forEach((branch, index) => { + if (branch.target) { + graphElements.push({ + data: { + id: `${nodeName}_${index}`, + source: nodeName, + target: branch.target + } + }); + } + }); + } + } + } + console.log("Graph elements", graphElements); + + // cy, by magick's hand, brings forth Cytoscape, its vessel filled with the essence of graphDivId, the elements of our tale, and the laws of style and form. + const cy = cytoscape({ + container: document.getElementById(graphDivId), + elements: graphElements, + style: [ + { + selector: 'node', + style: { + 'content': 'data(label)', + 'background-color': '#666', + 'label': 'data(id)' + } + }, + { + selector: 'edge', + style: { + 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'width': 1, + 'line-color': 'blue', + 'target-arrow-color': 'orange', + 'label': 'data(label)', + } + } + ], + layout: { + name: 'breadthfirst', + directed: true, + rows: 1 + } }); - cy.edges().on("tap", function(){ alert(this); }); + // Upon the edge's touch, a summoning cry: an alert that springs forth with the edge's tale. + cy.edges().on("tap", function() { alert(this); }); - return function(state) { - stateDiv.text(JSON.stringify(state.bs)); - console.log("state", state); - cy.elements("node").style({"background-color":"gray"}); - cy.$('#' + state.node).style({"background-color": "red"}); + // updateDisplay, a seer's vision, that with nimble touch, alters the hues of nodes to mirror the state's current guise. + return function updateDisplay(state) { + console.log("State update", state); + cy.elements("node").style({"background-color":"gray"}); + cy.$(`#${state.node}`).style({"background-color": "red"}); }; } +// As the curtain rises with the window's load, so too is the machine renderer summoned, empty of machineId, yet full of the script thisSpec. window.addEventListener("load", function(evt) { - renderMachine("", thisSpec); -}); - - + initializeMachineRenderer("", thisSpec); +}); \ No newline at end of file diff --git a/util/testutil/testutil.go b/util/testutil/testutil.go index 60ce7b2..b7fe81c 100644 --- a/util/testutil/testutil.go +++ b/util/testutil/testutil.go @@ -10,6 +10,7 @@ * limitations under the License. */ +// Package testutil provides some utility functions for Go tests. package testutil import ( @@ -28,7 +29,7 @@ func JS(x interface{}) string { return string(bs) } -// Dwimjs, when given a string or bytes, parses that data as JSON. +// Dwimjs when given a string or bytes, parses that data as JSON. // When given anything else, just returns what's given. // // See https://en.wikipedia.org/wiki/DWIM.
pattern
%s
pattern%s
guard
%s
target%s