diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e0d55e2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +# This is a basic workflow to help you get started with Actions + +name: Build + +# Controls when the action will run. Triggers the workflow on push or pull request +on: [push, pull_request] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # The "build" workflow + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + 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.21' + + # Run build of the application + - name: Run build + run: go build ./... + + # Run testing on the code + - name: Run testing + run: go test -v ./... + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d62d631 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# commander [![Build Status](https://github.com/slack-io/commander/actions/workflows/ci.yml/badge.svg)](https://github.com/slack-io/commander/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/slack-io/commander)](https://goreportcard.com/report/github.com/slack-io/commander) [![GoDoc](https://godoc.org/github.com/slack-io/commander?status.svg)](https://godoc.org/github.com/slack-io/commander) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +Command evaluator and parser + +## Features + +* Matches commands against provided text +* Extracts parameters from matching input +* Provides default values for missing parameters +* Supports String, Integer, Float and Boolean parameters +* Supports "word" {} vs "sentence" <> parameter matching + +## Dependencies + +* `proper` [github.com/slack-io/proper](https://github.com/slack-io/proper) + + +# Examples + +## Example 1 + +In this example, we are matching a few strings against a command format, then parsing parameters if found or returning default values. + +```go +package main + +import ( + "fmt" + "github.com/slack-io/commander" +) + +func main() { + properties, isMatch := commander.NewCommand("ping").Match("ping") + fmt.Println(isMatch) // true + fmt.Println(properties) // {} + + properties, isMatch = commander.NewCommand("ping").Match("pong") + fmt.Println(isMatch) // false + fmt.Println(properties) // nil + + properties, isMatch = commander.NewCommand("echo {word}").Match("echo hello world!") + fmt.Println(isMatch) // true + fmt.Println(properties.StringParam("word", "")) // hello + + properties, isMatch = commander.NewCommand("echo ").Match("echo hello world!") + fmt.Println(isMatch) // true + fmt.Println(properties.StringParam("sentence", "")) // hello world! + + properties, isMatch = commander.NewCommand("repeat {word} {number}").Match("repeat hey 5") + fmt.Println(isMatch) // true + fmt.Println(properties.StringParam("word", "")) // hey + fmt.Println(properties.IntegerParam("number", 0)) // 5 + + properties, isMatch = commander.NewCommand("repeat {word} {number}").Match("repeat hey") + fmt.Println(isMatch) // true + fmt.Println(properties.StringParam("word", "")) // hey + fmt.Println(properties.IntegerParam("number", 0)) // 0 + + properties, isMatch = commander.NewCommand("search {size}").Match("search hello there everyone 10") + fmt.Println(isMatch) // true + fmt.Println(properties.StringParam("stuff", "")) // hello there everyone + fmt.Println(properties.IntegerParam("size", 0)) // 10 +} +``` + +## Example 2 + +In this example, we are tokenizing the command format and returning each token with a number that determines whether it is a parameter (word vs sentence) or not + +```go +package main + +import ( + "fmt" + "github.com/slack-io/commander" +) + +func main() { + tokens := commander.NewCommand("echo {word} ").Tokenize() + for _, token := range tokens { + fmt.Println(token) + } +} +``` + +Output: +``` +&{echo NOT_PARAMETER} +&{word WORD_PARAMETER} +&{sentence SENTENCE_PARAMETER} +``` \ No newline at end of file diff --git a/commander.go b/commander.go new file mode 100644 index 0000000..6f39672 --- /dev/null +++ b/commander.go @@ -0,0 +1,165 @@ +package commander + +import ( + "regexp" + "strings" + + "github.com/slack-io/proper" +) + +const ( + escapeCharacter = "\\" + ignoreCase = "(?i)" + wordParameterPattern = "{\\S+}" + sentenceParameterPattern = "<\\S+>" + spacePattern = "\\s+" + wordInputPattern = "(.+?)" + sentenceInputPattern = "(.+)" + preCommandPattern = "(\\s|^)" + postCommandPattern = "(\\s|$)" +) + +const ( + notParameter = "NOT_PARAMETER" + wordParameter = "WORD_PARAMETER" + sentenceParameter = "SENTENCE_PARAMETER" +) + +var ( + regexCharacters = []string{"\\", "(", ")", "{", "}", "[", "]", "?", ".", "+", "|", "^", "$"} +) + +// NewCommand creates a new Command object from the format passed in +func NewCommand(format string) *Command { + tokens := tokenize(format) + expressions := generate(tokens) + return &Command{tokens: tokens, expressions: expressions} +} + +// Token represents the Token object +type Token struct { + Word string + Type string +} + +func (t Token) IsParameter() bool { + return t.Type != notParameter +} + +// Command represents the Command object +type Command struct { + tokens []*Token + expressions []*regexp.Regexp +} + +// Match takes in the command and the text received, attempts to find the pattern and extract the parameters +func (c *Command) Match(text string) (*proper.Properties, bool) { + if len(c.expressions) == 0 { + return nil, false + } + + for _, expression := range c.expressions { + matches := expression.FindStringSubmatch(text) + if len(matches) == 0 { + continue + } + + values := matches[2 : len(matches)-1] + + valueIndex := 0 + parameters := make(map[string]string) + for i := 0; i < len(c.tokens) && valueIndex < len(values); i++ { + token := c.tokens[i] + if !token.IsParameter() { + continue + } + + parameters[token.Word] = values[valueIndex] + valueIndex++ + } + return proper.NewProperties(parameters), true + } + return nil, false +} + +// Tokenize returns Command info as tokens +func (c *Command) Tokenize() []*Token { + return c.tokens +} + +func escape(text string) string { + for _, character := range regexCharacters { + text = strings.Replace(text, character, escapeCharacter+character, -1) + } + return text +} + +func tokenize(format string) []*Token { + parameterRegex := regexp.MustCompile(sentenceParameterPattern) + lazyParameterRegex := regexp.MustCompile(wordParameterPattern) + words := strings.Fields(format) + tokens := make([]*Token, len(words)) + for i, word := range words { + switch { + case lazyParameterRegex.MatchString(word): + tokens[i] = &Token{Word: word[1 : len(word)-1], Type: wordParameter} + case parameterRegex.MatchString(word): + tokens[i] = &Token{Word: word[1 : len(word)-1], Type: sentenceParameter} + default: + tokens[i] = &Token{Word: word, Type: notParameter} + } + } + return tokens +} + +func generate(tokens []*Token) []*regexp.Regexp { + regexps := []*regexp.Regexp{} + if len(tokens) == 0 { + return regexps + } + + for index := len(tokens) - 1; index >= -1; index-- { + regex := compile(create(tokens, index)) + if regex != nil { + regexps = append(regexps, regex) + } + } + + return regexps +} + +func create(tokens []*Token, boundary int) []*Token { + newTokens := []*Token{} + for i := 0; i < len(tokens); i++ { + if !tokens[i].IsParameter() || i <= boundary { + newTokens = append(newTokens, tokens[i]) + } + } + return newTokens +} + +func compile(tokens []*Token) *regexp.Regexp { + if len(tokens) == 0 { + return nil + } + + pattern := preCommandPattern + getInputPattern(tokens[0]) + for index := 1; index < len(tokens); index++ { + currentToken := tokens[index] + pattern += spacePattern + getInputPattern(currentToken) + } + pattern += postCommandPattern + + return regexp.MustCompile(ignoreCase + pattern) +} + +func getInputPattern(token *Token) string { + switch token.Type { + case wordParameter: + return wordInputPattern + case sentenceParameter: + return sentenceInputPattern + default: + return escape(token.Word) + } +} diff --git a/commander_test.go b/commander_test.go new file mode 100644 index 0000000..3213219 --- /dev/null +++ b/commander_test.go @@ -0,0 +1,261 @@ +package commander + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTokenize(t *testing.T) { + tokens := NewCommand("say ").Tokenize() + assert.Equal(t, len(tokens), 2) + assert.Equal(t, tokens[0].Word, "say") + assert.False(t, tokens[0].IsParameter()) + assert.Equal(t, tokens[1].Word, "input") + assert.True(t, tokens[1].IsParameter()) + + tokens = NewCommand("search ").Tokenize() + assert.Equal(t, len(tokens), 2) + assert.Equal(t, tokens[0].Word, "search") + assert.False(t, tokens[0].IsParameter()) + assert.Equal(t, tokens[1].Word, "pattern") + assert.True(t, tokens[1].IsParameter()) + + tokens = NewCommand(" <123> b> ") + assert.False(t, tokens[5].IsParameter()) + assert.Equal(t, tokens[6].Word, "") + assert.False(t, tokens[9].IsParameter()) + assert.Equal(t, tokens[10].Word, "").Match("echo") + assert.True(t, isMatch) + assert.NotNil(t, properties) + + properties, isMatch = NewCommand("echo ").Match("echo.") + assert.False(t, isMatch) + assert.Nil(t, properties) + + properties, isMatch = NewCommand("echo ").Match("echoing") + assert.False(t, isMatch) + assert.Nil(t, properties) + + properties, isMatch = NewCommand("echo ").Match("echo hey") + assert.True(t, isMatch) + assert.Equal(t, properties.StringParam("text", ""), "hey") + + properties, isMatch = NewCommand("echo ").Match("echo hello world") + assert.True(t, isMatch) + assert.Equal(t, properties.StringParam("text", ""), "hello world") + + properties, isMatch = NewCommand("echo ").Match("echoing hey") + assert.False(t, isMatch) + assert.Nil(t, properties) + + properties, isMatch = NewCommand("search ").Match("search *") + assert.True(t, isMatch) + assert.Equal(t, properties.StringParam("pattern", ""), "*") + + properties, isMatch = NewCommand("repeat ").Match("repeat hey 5") + assert.True(t, isMatch) + assert.Equal(t, properties.StringParam("word", ""), "hey") + assert.Equal(t, properties.IntegerParam("number", 0), 5) + + properties, isMatch = NewCommand("repeat ").Match("repeat hey") + assert.True(t, isMatch) + assert.Equal(t, properties.StringParam("word", ""), "hey") + assert.Equal(t, properties.IntegerParam("number", 0), 0) + + properties, isMatch = NewCommand("repeat ").Match("repeat hello world 10") + assert.True(t, isMatch) + assert.Equal(t, properties.StringParam("text", ""), "hello world") + assert.Equal(t, properties.IntegerParam("number", 0), 10) + + properties, isMatch = NewCommand("math {operation} ").Match("math + 2 10 56") + assert.True(t, isMatch) + assert.Equal(t, properties.StringParam("operation", ""), "+") + assert.Equal(t, properties.StringParam("numbers", ""), "2 10 56") + + properties, isMatch = NewCommand("math {number} {operation} {number2}").Match("math 2 + 10 56") + assert.True(t, isMatch) + assert.Equal(t, properties.StringParam("operation", ""), "+") + assert.Equal(t, properties.StringParam("number", ""), "2") + assert.Equal(t, properties.StringParam("number2", ""), "10") + + properties, isMatch = NewCommand("calculate plus ").Match("calculate 10 plus 5") + assert.True(t, isMatch) + assert.Equal(t, properties.IntegerParam("number1", 0), 10) + assert.Equal(t, properties.IntegerParam("number2", 0), 5) + + properties, isMatch = NewCommand(" + ").Match("10 + 5") + assert.True(t, isMatch) + assert.Equal(t, properties.IntegerParam("number1", 0), 10) + assert.Equal(t, properties.IntegerParam("number2", 0), 5) + + properties, isMatch = NewCommand(" + ").Match("+") + assert.True(t, isMatch) + assert.Equal(t, properties.IntegerParam("number1", 0), 0) + assert.Equal(t, properties.IntegerParam("number2", 0), 0) + + properties, isMatch = NewCommand("\\ ( ) { } [ ] ? . + | ^ $").Match("\\ ( ) { } [ ] ? . + | ^ $") + assert.True(t, isMatch) + assert.NotNil(t, properties) +} + +func TestNewCommand(t *testing.T) { + tests := []struct { + name string + in string + wantTokens int + wantExpresions int + }{ + {"simple command", "ping", 1, 2}, + {"command and parameter", "say ", 2, 3}, + {"no command", "", 0, 0}, + {"all tokens are parameter", " <123> ", 5, 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewCommand(tt.in) + if got := len(cmd.tokens); got != tt.wantTokens { + t.Errorf("got tokens %v, want %v", got, tt.wantTokens) + } + if got := len(cmd.expressions); got != tt.wantExpresions { + t.Errorf("got expressions %v, want %v", got, tt.wantExpresions) + } + // Check no panic + _, _ = cmd.Match("") + }) + } +} diff --git a/examples/1/example.go b/examples/1/example.go new file mode 100644 index 0000000..a966ed7 --- /dev/null +++ b/examples/1/example.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "github.com/slack-io/commander" +) + +func main() { + properties, isMatch := commander.NewCommand("ping").Match("ping") + fmt.Println(isMatch) + fmt.Println(properties) + + properties, isMatch = commander.NewCommand("ping").Match("pong") + fmt.Println(isMatch) + fmt.Println(properties) + + properties, isMatch = commander.NewCommand("echo {word}").Match("echo hello world!") + fmt.Println(isMatch) + fmt.Println(properties.StringParam("word", "")) + + properties, isMatch = commander.NewCommand("echo ").Match("echo hello world!") + fmt.Println(isMatch) + fmt.Println(properties.StringParam("sentence", "")) + + properties, isMatch = commander.NewCommand("repeat {word} {number}").Match("repeat hey 5") + fmt.Println(isMatch) + fmt.Println(properties.StringParam("word", "")) + fmt.Println(properties.IntegerParam("number", 0)) + + properties, isMatch = commander.NewCommand("repeat {word} {number}").Match("repeat hey") + fmt.Println(isMatch) + fmt.Println(properties.StringParam("word", "")) + fmt.Println(properties.IntegerParam("number", 0)) + + properties, isMatch = commander.NewCommand("search {size}").Match("search hello there everyone 10") + fmt.Println(isMatch) + fmt.Println(properties.StringParam("stuff", "")) + fmt.Println(properties.IntegerParam("size", 0)) +} diff --git a/examples/2/example.go b/examples/2/example.go new file mode 100644 index 0000000..2ab541e --- /dev/null +++ b/examples/2/example.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + "github.com/slack-io/commander" +) + +func main() { + tokens := commander.NewCommand("echo {word} ").Tokenize() + for _, token := range tokens { + fmt.Println(token) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..283fea7 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/slack-io/commander + +go 1.21 + +require ( + github.com/slack-io/proper v0.0.0-20231119200853-f78ba4fc878f + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8e48bbf --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/slack-io/proper v0.0.0-20231119200853-f78ba4fc878f h1:wiEJBKJKvMOeE9KtLV7iwtHOsNXDBZloL+FPlkZ6vnA= +github.com/slack-io/proper v0.0.0-20231119200853-f78ba4fc878f/go.mod h1:q+erLGESzGsEP/cJeGoDxfdLKDstT4caj/JvAShLEt4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=