Skip to content

Commit

Permalink
Commander
Browse files Browse the repository at this point in the history
  • Loading branch information
raed-shomali committed Nov 20, 2023
1 parent b3aebc1 commit 9fd78b4
Show file tree
Hide file tree
Showing 8 changed files with 628 additions and 0 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 ./...

91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <sentence>").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 <stuff> {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} <sentence>").Tokenize()
for _, token := range tokens {
fmt.Println(token)
}
}
```

Output:
```
&{echo NOT_PARAMETER}
&{word WORD_PARAMETER}
&{sentence SENTENCE_PARAMETER}
```
165 changes: 165 additions & 0 deletions commander.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 9fd78b4

Please sign in to comment.