diff --git a/cucumber-expressions/go/Makefile b/cucumber-expressions/go/Makefile new file mode 100644 index 0000000000..94dc4cd045 --- /dev/null +++ b/cucumber-expressions/go/Makefile @@ -0,0 +1,19 @@ +SHELL := /usr/bin/env bash +export GOPATH = $(realpath ./lib) + +default: test +.PHONY: default + +# Use env variable ARGS to pass arguments to 'go test' +# (for running only a specific test or using verbose mode) +# Example: ARGS='-v -run TestCucumberExpression' make test +test: lib/src/github.com/stretchr/testify + go test ${ARGS} +.PHONY: clean + +lib/src/github.com/stretchr/testify: + go get github.com/stretchr/testify + +clean: + rm -rf lib/src lib/pkg +.PHONY: clean diff --git a/cucumber-expressions/go/argument.go b/cucumber-expressions/go/argument.go new file mode 100644 index 0000000000..1b4159acdd --- /dev/null +++ b/cucumber-expressions/go/argument.go @@ -0,0 +1,43 @@ +package cucumberexpressions + +import "fmt" + +type Argument struct { + group *Group + parameterType *ParameterType +} + +func BuildArguments(treeRegexp *TreeRegexp, text string, parameterTypes []*ParameterType) []*Argument { + group := treeRegexp.Match(text) + if group == nil { + return nil + } + argGroups := group.Children() + if len(argGroups) != len(parameterTypes) { + panic(fmt.Errorf("%s has %d capture groups (%v), but there were %d parameter types (%v)", treeRegexp.Regexp().String(), len(argGroups), argGroups, len(parameterTypes), parameterTypes)) + } + arguments := make([]*Argument, len(parameterTypes)) + for i, parameterType := range parameterTypes { + arguments[i] = NewArgument(argGroups[i], parameterType) + } + return arguments +} + +func NewArgument(group *Group, parameterType *ParameterType) *Argument { + return &Argument{ + group: group, + parameterType: parameterType, + } +} + +func (a *Argument) Group() *Group { + return a.group +} + +func (a *Argument) GetValue() interface{} { + return a.parameterType.Transform(a.group.Values()) +} + +func (a *Argument) ParameterType() *ParameterType { + return a.parameterType +} diff --git a/cucumber-expressions/go/combinatorial_generated_expression_factory.go b/cucumber-expressions/go/combinatorial_generated_expression_factory.go new file mode 100644 index 0000000000..4e74063982 --- /dev/null +++ b/cucumber-expressions/go/combinatorial_generated_expression_factory.go @@ -0,0 +1,48 @@ +package cucumberexpressions + +type CombinatorialGeneratedExpressionFactory struct { + expressionTemplate string + parameterTypeCombinations [][]*ParameterType +} + +func NewCombinatorialGeneratedExpressionFactory(expressionTemplate string, parameterTypeCombinations [][]*ParameterType) *CombinatorialGeneratedExpressionFactory { + return &CombinatorialGeneratedExpressionFactory{ + expressionTemplate: expressionTemplate, + parameterTypeCombinations: parameterTypeCombinations, + } +} + +func (c *CombinatorialGeneratedExpressionFactory) GenerateExpressions() []*GeneratedExpression { + generatedExpressions := &GeneratedExpressionList{} + c.generatePermutations(generatedExpressions, 0, nil) + return generatedExpressions.ToArray() +} + +func (c *CombinatorialGeneratedExpressionFactory) generatePermutations(generatedExpressions *GeneratedExpressionList, depth int, currentParameterTypes []*ParameterType) { + if depth == len(c.parameterTypeCombinations) { + generatedExpressions.Push( + NewGeneratedExpression(c.expressionTemplate, currentParameterTypes), + ) + return + } + + for _, parameterType := range c.parameterTypeCombinations[depth] { + c.generatePermutations( + generatedExpressions, + depth+1, + append(currentParameterTypes, parameterType), + ) + } +} + +type GeneratedExpressionList struct { + elements []*GeneratedExpression +} + +func (g *GeneratedExpressionList) Push(expr *GeneratedExpression) { + g.elements = append(g.elements, expr) +} + +func (g *GeneratedExpressionList) ToArray() []*GeneratedExpression { + return g.elements +} diff --git a/cucumber-expressions/go/combinatorial_generated_expression_factory_test.go b/cucumber-expressions/go/combinatorial_generated_expression_factory_test.go new file mode 100644 index 0000000000..1d27659eba --- /dev/null +++ b/cucumber-expressions/go/combinatorial_generated_expression_factory_test.go @@ -0,0 +1,76 @@ +package cucumberexpressions_test + +import ( + "regexp" + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +func TestCombinatorialGeneratedExpressionFactory(t *testing.T) { + t.Run("generates multiple expressions", func(t *testing.T) { + colorParameterType, err := cucumberexpressions.NewParameterType( + "color", + []*regexp.Regexp{regexp.MustCompile("red|blue|yellow")}, + "", + func(arg3 ...string) interface{} { return arg3[0] }, + false, + true, + ) + require.NoError(t, err) + csscolorParameterType, err := cucumberexpressions.NewParameterType( + "csscolor", + []*regexp.Regexp{regexp.MustCompile("red|blue|yellow")}, + "", + func(arg3 ...string) interface{} { return arg3[0] }, + false, + true, + ) + require.NoError(t, err) + dateParameterType, err := cucumberexpressions.NewParameterType( + "date", + []*regexp.Regexp{regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)}, + "", + func(arg3 ...string) interface{} { return arg3[0] }, + false, + true, + ) + require.NoError(t, err) + datetimeParameterType, err := cucumberexpressions.NewParameterType( + "datetime", + []*regexp.Regexp{regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)}, + "", + func(arg3 ...string) interface{} { return arg3[0] }, + false, + true, + ) + require.NoError(t, err) + timestampParameterType, err := cucumberexpressions.NewParameterType( + "timestamp", + []*regexp.Regexp{regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)}, + "", + func(arg3 ...string) interface{} { return arg3[0] }, + false, + true, + ) + require.NoError(t, err) + parameterTypeCombinations := [][]*cucumberexpressions.ParameterType{ + {colorParameterType, csscolorParameterType}, + {dateParameterType, datetimeParameterType, timestampParameterType}, + } + factory := cucumberexpressions.NewCombinatorialGeneratedExpressionFactory("I bought a {%s} ball on {%s}", parameterTypeCombinations) + var expressions []string + for _, g := range factory.GenerateExpressions() { + expressions = append(expressions, g.Source()) + } + require.Equal(t, expressions, []string{ + "I bought a {color} ball on {date}", + "I bought a {color} ball on {datetime}", + "I bought a {color} ball on {timestamp}", + "I bought a {csscolor} ball on {date}", + "I bought a {csscolor} ball on {datetime}", + "I bought a {csscolor} ball on {timestamp}", + }) + }) +} diff --git a/cucumber-expressions/go/cucumber_expression.go b/cucumber-expressions/go/cucumber_expression.go new file mode 100644 index 0000000000..c839d9ca17 --- /dev/null +++ b/cucumber-expressions/go/cucumber_expression.go @@ -0,0 +1,98 @@ +package cucumberexpressions + +import ( + "fmt" + "regexp" + "strings" +) + +var ESCAPE_REGEXP = regexp.MustCompile(`([\\^[$.|?*+])`) +var PARAMETER_REGEXP = regexp.MustCompile(`(\\\\\\\\)?{([^}]+)}`) +var OPTIONAL_REGEXP = regexp.MustCompile(`(\\\\\\\\)?\([^)]+\)`) +var ALTERNATIVE_NON_WHITESPACE_TEXT_REGEXP = regexp.MustCompile(`([^\s^/]+)((\/[^\s^/]+)+)`) +var DOUBLE_ESCAPE = `\\\\` + +type CucumberExpression struct { + source string + parameterTypes []*ParameterType + treeRegexp *TreeRegexp +} + +func NewCucumberExpression(expression string, parameterTypeRegistry *ParameterTypeRegistry) (*CucumberExpression, error) { + result := &CucumberExpression{source: expression} + + expression = result.processEscapes(expression) + expression = result.processOptional(expression) + expression = result.processAlteration(expression) + expression, err := result.processParameters(expression, parameterTypeRegistry) + if err != nil { + return nil, err + } + expression = "^" + expression + "$" + + result.treeRegexp = NewTreeRegexp(regexp.MustCompile(expression)) + return result, nil +} + +func (c *CucumberExpression) Match(text string) ([]*Argument, error) { + return BuildArguments(c.treeRegexp, text, c.parameterTypes), nil +} + +func (c *CucumberExpression) Regexp() *regexp.Regexp { + return c.treeRegexp.Regexp() +} + +func (c *CucumberExpression) Source() string { + return c.source +} + +func (c *CucumberExpression) processEscapes(expression string) string { + return ESCAPE_REGEXP.ReplaceAllString(expression, `\$1`) +} + +func (c *CucumberExpression) processOptional(expression string) string { + return OPTIONAL_REGEXP.ReplaceAllStringFunc(expression, func(match string) string { + if strings.HasPrefix(match, DOUBLE_ESCAPE) { + return fmt.Sprintf(`\(%s\)`, match[5:len(match)-1]) + } + return fmt.Sprintf("(?:%s)?", match[1:len(match)-1]) + }) +} + +func (c *CucumberExpression) processAlteration(expression string) string { + return ALTERNATIVE_NON_WHITESPACE_TEXT_REGEXP.ReplaceAllStringFunc(expression, func(match string) string { + return fmt.Sprintf("(?:%s)", strings.Replace(match, "/", "|", -1)) + }) +} + +func (c *CucumberExpression) processParameters(expression string, parameterTypeRegistry *ParameterTypeRegistry) (string, error) { + var err error + result := PARAMETER_REGEXP.ReplaceAllStringFunc(expression, func(match string) string { + if strings.HasPrefix(match, DOUBLE_ESCAPE) { + return fmt.Sprintf(`\{%s\}`, match[5:len(match)-1]) + } + + typeName := match[1 : len(match)-1] + parameterType := parameterTypeRegistry.LookupByTypeName(typeName) + if parameterType == nil { + err = NewUndefinedParameterTypeError(typeName) + return match + } + c.parameterTypes = append(c.parameterTypes, parameterType) + return buildCaptureRegexp(parameterType.regexps) + }) + return result, err +} + +func buildCaptureRegexp(regexps []*regexp.Regexp) string { + if len(regexps) == 1 { + return fmt.Sprintf("(%s)", regexps[0].String()) + } + + captureGroups := make([]string, len(regexps)) + for i, r := range regexps { + captureGroups[i] = fmt.Sprintf("(?:%s)", r.String()) + } + + return fmt.Sprintf("(%s)", strings.Join(captureGroups, "|")) +} diff --git a/cucumber-expressions/go/cucumber_expression_generator.go b/cucumber-expressions/go/cucumber_expression_generator.go new file mode 100644 index 0000000000..3e27cb399f --- /dev/null +++ b/cucumber-expressions/go/cucumber_expression_generator.go @@ -0,0 +1,101 @@ +package cucumberexpressions + +import ( + "sort" + "strings" +) + +type CucumberExpressionGenerator struct { + parameterTypeRegistry *ParameterTypeRegistry +} + +func NewCucumberExpressionGenerator(parameterTypeRegistry *ParameterTypeRegistry) *CucumberExpressionGenerator { + return &CucumberExpressionGenerator{ + parameterTypeRegistry: parameterTypeRegistry, + } +} + +func (c *CucumberExpressionGenerator) GenerateExpressions(text string) []*GeneratedExpression { + parameterTypeCombinations := [][]*ParameterType{} + parameterTypeMatchers := c.createParameterTypeMatchers(text) + expressionTemplate := "" + pos := 0 + + for { + matchingParameterTypeMatchers := []*ParameterTypeMatcher{} + for _, parameterTypeMatcher := range parameterTypeMatchers { + advancedParameterTypeMatcher := parameterTypeMatcher.AdvanceTo(pos) + if advancedParameterTypeMatcher.Find() { + matchingParameterTypeMatchers = append(matchingParameterTypeMatchers, advancedParameterTypeMatcher) + } + } + if len(matchingParameterTypeMatchers) > 0 { + sort.Slice(matchingParameterTypeMatchers, func(i int, j int) bool { + return CompareParameterTypeMatchers(matchingParameterTypeMatchers[i], matchingParameterTypeMatchers[j]) <= 0 + }) + + // Find all the best parameter type matchers, they are all candidates. + bestParameterTypeMatcher := matchingParameterTypeMatchers[0] + bestParameterTypeMatchers := []*ParameterTypeMatcher{} + for _, parameterTypeMatcher := range matchingParameterTypeMatchers { + if CompareParameterTypeMatchers(parameterTypeMatcher, bestParameterTypeMatcher) == 0 { + bestParameterTypeMatchers = append(bestParameterTypeMatchers, parameterTypeMatcher) + } + } + + // Build a list of parameter types without duplicates. The reason there + // might be duplicates is that some parameter types have more than one regexp, + // which means multiple ParameterTypeMatcher objects will have a reference to the + // same ParameterType. + // We're sorting the list so preferential parameter types are listed first. + // Users are most likely to want these, so they should be listed at the top. + parameterTypesMap := map[*ParameterType]bool{} + for _, parameterTypeMatcher := range bestParameterTypeMatchers { + parameterTypesMap[parameterTypeMatcher.parameterType] = true + } + parameterTypes := []*ParameterType{} + for parameterType := range parameterTypesMap { + parameterTypes = append(parameterTypes, parameterType) + } + sort.Slice(parameterTypes, func(i int, j int) bool { + return CompareParameterTypes(parameterTypes[i], parameterTypes[j]) <= 0 + }) + + parameterTypeCombinations = append(parameterTypeCombinations, parameterTypes) + expressionTemplate += escape(text[pos:bestParameterTypeMatcher.Start()]) + "{%s}" + pos = bestParameterTypeMatcher.Start() + len(bestParameterTypeMatcher.Group()) + } else { + break + } + + if pos > len(text) { + break + } + } + expressionTemplate += escape(text[pos:]) + return NewCombinatorialGeneratedExpressionFactory(expressionTemplate, parameterTypeCombinations).GenerateExpressions() +} + +func (c *CucumberExpressionGenerator) createParameterTypeMatchers(text string) []*ParameterTypeMatcher { + result := []*ParameterTypeMatcher{} + for _, parameterType := range c.parameterTypeRegistry.ParamaterTypes() { + if parameterType.UseForSnippets() { + result = append(result, c.createParameterTypeMatchers2(parameterType, text)...) + } + } + return result +} + +func (c *CucumberExpressionGenerator) createParameterTypeMatchers2(parameterType *ParameterType, text string) []*ParameterTypeMatcher { + result := make([]*ParameterTypeMatcher, len(parameterType.Regexps())) + for i, r := range parameterType.Regexps() { + result[i] = NewParameterTypeMatcher(parameterType, r, text, 0) + } + return result +} + +func escape(s string) string { + result := strings.Replace(s, "%", "%%", -1) + result = strings.Replace(result, `(`, `\(`, -1) + return strings.Replace(result, `{`, `\{`, -1) +} diff --git a/cucumber-expressions/go/cucumber_expression_generator_test.go b/cucumber-expressions/go/cucumber_expression_generator_test.go new file mode 100644 index 0000000000..5572ce166c --- /dev/null +++ b/cucumber-expressions/go/cucumber_expression_generator_test.go @@ -0,0 +1,225 @@ +package cucumberexpressions_test + +import ( + "regexp" + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +type Currency struct { + ISO4217 string +} + +func TestCucumberExpressionGeneratory(t *testing.T) { + t.Run("documents expression generation", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + + /// [generate-expression] + generator := cucumberexpressions.NewCucumberExpressionGenerator(parameterTypeRegistry) + undefinedStepText := "I have 2 cucumbers and 1.5 tomato" + generatedExpression := generator.GenerateExpressions(undefinedStepText)[0] + require.Equal(t, generatedExpression.Source(), "I have {int} cucumbers and {float} tomato") + require.Equal(t, generatedExpression.ParameterNames()[0], "int") + require.Equal(t, generatedExpression.ParameterTypes()[1].Name(), "float") + /// [generate-expression] + }) + + t.Run("generates expression for no args", func(t *testing.T) { + assertExpression(t, "hello", []string{}, "hello") + }) + + t.Run("generates expression with escaped left parenthesis", func(t *testing.T) { + assertExpression(t, `\(iii)`, []string{}, "(iii)") + }) + + t.Run("generates expression with escaped left curly brace", func(t *testing.T) { + assertExpression(t, `\{iii}`, []string{}, "{iii}") + }) + + t.Run("generates expression for int float arg", func(t *testing.T) { + assertExpression( + t, + "I have {int} cukes and {float} euro", + []string{"int", "float"}, + "I have 2 cukes and 1.5 euro", + ) + }) + + t.Run("generates expression for strings with % sign", func(t *testing.T) { + assertExpression(t, "I am {int}%% foobar", []string{"int"}, "I am 20%% foobar") + }) + + t.Run("generates expression for int float arg", func(t *testing.T) { + assertExpression( + t, + "I like {string} and {string}", + []string{"string", "string2"}, + `I like "bangers" and 'mash'`, + ) + }) + + t.Run("generates expression for just int", func(t *testing.T) { + assertExpression(t, "{int}", []string{"int"}, "99999") + }) + + t.Run("numbers only second argument when builtin type is not reserved keyword", func(t *testing.T) { + assertExpression( + t, + "I have {float} cukes and {float} euro", + []string{"float", "float2"}, + "I have 2.5 cukes and 1.5 euro", + ) + }) + + t.Run("generates expression for custom type", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + currencyParameterType, err := cucumberexpressions.NewParameterType( + "currency", + []*regexp.Regexp{regexp.MustCompile("[A-Z]{3}")}, + "Currency", + func(arg3 ...string) interface{} { + return Currency{ISO4217: arg3[0]} + }, + true, + false, + ) + require.NoError(t, err) + parameterTypeRegistry.DefineParameterType(currencyParameterType) + + assertExpressionWithParameterTypeRegistry( + t, + parameterTypeRegistry, + "I have a {currency} account", + []string{"currency"}, + "I have a EUR account", + ) + }) + + t.Run("prefers leftmost match when there is overlap", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + parameterType1, err := cucumberexpressions.NewParameterType( + "type1", + []*regexp.Regexp{regexp.MustCompile("cd")}, + "type1", + nil, + true, + false, + ) + require.NoError(t, err) + parameterTypeRegistry.DefineParameterType(parameterType1) + parameterType2, err := cucumberexpressions.NewParameterType( + "type2", + []*regexp.Regexp{regexp.MustCompile("bc")}, + "type2", + nil, + true, + false, + ) + require.NoError(t, err) + parameterTypeRegistry.DefineParameterType(parameterType2) + + assertExpressionWithParameterTypeRegistry( + t, + parameterTypeRegistry, + "a{type2}defg", + []string{"type2"}, + "abcdefg", + ) + }) + + // TODO: prefers widest match + + t.Run("generates all combinations of expressions when several parameter types match", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + parameterType1, err := cucumberexpressions.NewParameterType( + "type1", + []*regexp.Regexp{regexp.MustCompile("x")}, + "type1", + nil, + true, + false, + ) + require.NoError(t, err) + parameterTypeRegistry.DefineParameterType(parameterType1) + parameterType2, err := cucumberexpressions.NewParameterType( + "type2", + []*regexp.Regexp{regexp.MustCompile("x")}, + "type2", + nil, + true, + false, + ) + require.NoError(t, err) + parameterTypeRegistry.DefineParameterType(parameterType2) + generator := cucumberexpressions.NewCucumberExpressionGenerator(parameterTypeRegistry) + generatedExpressions := generator.GenerateExpressions("I have x and x and another x") + sources := make([]string, len(generatedExpressions)) + for i, generatedExpression := range generatedExpressions { + sources[i] = generatedExpression.Source() + } + require.Equal(t, sources, []string{ + "I have {type1} and {type1} and another {type1}", + "I have {type1} and {type1} and another {type2}", + "I have {type1} and {type2} and another {type1}", + "I have {type1} and {type2} and another {type2}", + "I have {type2} and {type1} and another {type1}", + "I have {type2} and {type1} and another {type2}", + "I have {type2} and {type2} and another {type1}", + "I have {type2} and {type2} and another {type2}", + }) + }) + + t.Run("exposes parameter type names in generated expression", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + generator := cucumberexpressions.NewCucumberExpressionGenerator(parameterTypeRegistry) + generatedExpression := generator.GenerateExpressions("I have 2 cukes and 1.5 euro")[0] + typeNames := make([]string, len(generatedExpression.ParameterTypes())) + for i, parameterType := range generatedExpression.ParameterTypes() { + typeNames[i] = parameterType.Name() + } + require.Equal(t, typeNames, []string{"int", "float"}) + }) + + t.Run("ignores parameter types with optional capture groups", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + optionalFlightParameterType, err := cucumberexpressions.NewParameterType( + "optional-flight", + []*regexp.Regexp{regexp.MustCompile("(1st flight)?")}, + "optional-flight", + nil, + true, + false, + ) + require.NoError(t, err) + parameterTypeRegistry.DefineParameterType(optionalFlightParameterType) + optionalHotelParameterType, err := cucumberexpressions.NewParameterType( + "optional-hotel", + []*regexp.Regexp{regexp.MustCompile("(1st hotel)?")}, + "optional-hotel", + nil, + true, + false, + ) + require.NoError(t, err) + parameterTypeRegistry.DefineParameterType(optionalHotelParameterType) + generator := cucumberexpressions.NewCucumberExpressionGenerator(parameterTypeRegistry) + generatedExpression := generator.GenerateExpressions("I reach Stage4: 1st flight-1st hotel")[0] + require.Equal(t, generatedExpression.Source(), "I reach Stage{int}: {int}st flight{int}st hotel") + }) +} + +func assertExpression(t *testing.T, expectedExpression string, expectedArgumentNames []string, text string) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + assertExpressionWithParameterTypeRegistry(t, parameterTypeRegistry, expectedExpression, expectedArgumentNames, text) +} + +func assertExpressionWithParameterTypeRegistry(t *testing.T, parameterTypeRegistry *cucumberexpressions.ParameterTypeRegistry, expectedExpression string, expectedArgumentNames []string, text string) { + generator := cucumberexpressions.NewCucumberExpressionGenerator(parameterTypeRegistry) + generatedExpressions := generator.GenerateExpressions(text) + require.Len(t, generatedExpressions, 1) + generatedExpression := generatedExpressions[0] + require.Equal(t, generatedExpression.ParameterNames(), expectedArgumentNames) + require.Equal(t, generatedExpression.Source(), expectedExpression) +} diff --git a/cucumber-expressions/go/cucumber_expression_regexp_test.go b/cucumber-expressions/go/cucumber_expression_regexp_test.go new file mode 100644 index 0000000000..29ad12b025 --- /dev/null +++ b/cucumber-expressions/go/cucumber_expression_regexp_test.go @@ -0,0 +1,57 @@ +package cucumberexpressions_test + +import ( + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +func TestCucumberExpressionRegExpTranslation(t *testing.T) { + t.Run("translates no arguments", func(t *testing.T) { + assertRegexp( + t, + "I have 10 cukes in my belly now", + "^I have 10 cukes in my belly now$", + ) + }) + + t.Run("translates alternation", func(t *testing.T) { + assertRegexp( + t, + "I had/have a great/nice/charming friend", + "^I (?:had|have) a (?:great|nice|charming) friend$", + ) + }) + + t.Run("translates alternation with non-alpha", func(t *testing.T) { + assertRegexp( + t, + "I said Alpha1/Beta1", + "^I said (?:Alpha1|Beta1)$", + ) + }) + + t.Run("translates parameters", func(t *testing.T) { + assertRegexp( + t, + "I have {float} cukes at {int} o'clock", + `^I have (-?\d*\.\d+) cukes at ((?:-?\d+)|(?:\d+)) o'clock$`, + ) + }) + + t.Run("translates parenthesis to non-capturing optional capture group", func(t *testing.T) { + assertRegexp( + t, + "I have many big(ish) cukes", + `^I have many big(?:ish)? cukes$`, + ) + }) +} + +func assertRegexp(t *testing.T, expression string, expectedRegexp string) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + generator, err := cucumberexpressions.NewCucumberExpression(expression, parameterTypeRegistry) + require.NoError(t, err) + require.Equal(t, generator.Regexp().String(), expectedRegexp) +} diff --git a/cucumber-expressions/go/cucumber_expression_test.go b/cucumber-expressions/go/cucumber_expression_test.go new file mode 100644 index 0000000000..c6f42ac243 --- /dev/null +++ b/cucumber-expressions/go/cucumber_expression_test.go @@ -0,0 +1,207 @@ +package cucumberexpressions_test + +import ( + "fmt" + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +func TestCucumberExpression(t *testing.T) { + t.Run("documents expression generation", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + + /// [capture-match-arguments] + expr := "I have {int} cuke(s)" + expression, err := cucumberexpressions.NewCucumberExpression(expr, parameterTypeRegistry) + require.NoError(t, err) + args, err := expression.Match("I have 7 cukes") + require.NoError(t, err) + require.Equal(t, args[0].GetValue(), 7) + /// [capture-match-arguments] + }) + + t.Run("matches word", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three {word} mice", "three blind mice"), + []interface{}{"blind"}, + ) + }) + + t.Run("matches double quoted string", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three {string} mice", `three "blind" mice`), + []interface{}{"blind"}, + ) + }) + + t.Run("matches multiple double quoted strings", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three {string} and {string} mice", `three "blind" and "crippled" mice`), + []interface{}{"blind", "crippled"}, + ) + }) + + t.Run("matches single quoted string", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three {string} mice", `three 'blind' mice`), + []interface{}{"blind"}, + ) + }) + + t.Run("matches multiple single quoted strings", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three {string} and {string} mice", `three 'blind' and 'crippled' mice`), + []interface{}{"blind", "crippled"}, + ) + }) + + t.Run("does not match misquoted string", func(t *testing.T) { + require.Nil( + t, + MatchCucumberExpression(t, "three {string} mice", `three "blind' mice`), + ) + }) + + t.Run("matches single quoted strings with double quotes", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three {string} mice", `three '"blind"' mice`), + []interface{}{`"blind"`}, + ) + }) + + t.Run("matches double quoted strings with single quotes", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three {string} mice", `three "'blind'" mice`), + []interface{}{`'blind'`}, + ) + }) + + t.Run("matches double quoted string with escaped double quote", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three {string} mice", `three "bl\"nd" mice`), + []interface{}{`bl\"nd`}, + ) + }) + + t.Run("matches single quoted string with escaped single quote", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three {string} mice", `three 'bl\'nd' mice`), + []interface{}{`bl\'nd`}, + ) + }) + + t.Run("matches escaped parenthesis", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "three \\\\(exceptionally) {string} mice", `three (exceptionally) "blind" mice`), + []interface{}{"blind"}, + ) + }) + + t.Run("doesn't match float as int", func(t *testing.T) { + require.Nil( + t, + MatchCucumberExpression(t, "{int}", "1.22"), + ) + }) + + t.Run("matches float", func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression(t, "{float}", "0.22"), + []interface{}{0.22}, + ) + require.Equal( + t, + MatchCucumberExpression(t, "{float}", ".22"), + []interface{}{0.22}, + ) + }) + + t.Run("returns error for unknown parameter float", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + _, err := cucumberexpressions.NewCucumberExpression("{unknown}", parameterTypeRegistry) + require.Error(t, err) + require.Equal(t, err.Error(), "Undefined parameter type {unknown}") + }) + + t.Run("exposes source", func(t *testing.T) { + expr := "I have {int} cuke(s)" + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + expression, err := cucumberexpressions.NewCucumberExpression(expr, parameterTypeRegistry) + require.NoError(t, err) + require.Equal(t, expression.Source(), expr) + }) + + t.Run("escapes special characters", func(t *testing.T) { + for _, char := range []string{"\\", "[", "]", "^", "$", ".", "|", "?", "*", "+"} { + t.Run(fmt.Sprintf("escapes %s", char), func(t *testing.T) { + require.Equal( + t, + MatchCucumberExpression( + t, + fmt.Sprintf("I have {int} cuke(s) and %s", char), + fmt.Sprintf("I have 800 cukes and %s", char), + ), + []interface{}{800}, + ) + }) + } + + t.Run("escapes .", func(t *testing.T) { + expr := "I have {int} cuke(s) and ." + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + expression, err := cucumberexpressions.NewCucumberExpression(expr, parameterTypeRegistry) + require.NoError(t, err) + args, err := expression.Match("I have 800 cukes and 3") + require.NoError(t, err) + require.Nil(t, args) + args, err = expression.Match("I have 800 cukes and .") + require.NoError(t, err) + require.NotNil(t, args) + }) + + t.Run("escapes |", func(t *testing.T) { + expr := "I have {int} cuke(s) and a|b" + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + expression, err := cucumberexpressions.NewCucumberExpression(expr, parameterTypeRegistry) + require.NoError(t, err) + args, err := expression.Match("I have 800 cukes and a") + require.NoError(t, err) + require.Nil(t, args) + args, err = expression.Match("I have 800 cukes and b") + require.NoError(t, err) + require.Nil(t, args) + args, err = expression.Match("I have 800 cukes and a|b") + require.NoError(t, err) + require.NotNil(t, args) + }) + }) +} + +func MatchCucumberExpression(t *testing.T, expr string, text string) []interface{} { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + expression, err := cucumberexpressions.NewCucumberExpression(expr, parameterTypeRegistry) + require.NoError(t, err) + args, err := expression.Match(text) + require.NoError(t, err) + if args == nil { + return nil + } + result := make([]interface{}, len(args)) + for i, arg := range args { + result[i] = arg.GetValue() + } + return result +} diff --git a/cucumber-expressions/go/custom_parameter_type_test.go b/cucumber-expressions/go/custom_parameter_type_test.go new file mode 100644 index 0000000000..2c47a9442f --- /dev/null +++ b/cucumber-expressions/go/custom_parameter_type_test.go @@ -0,0 +1,213 @@ +package cucumberexpressions_test + +import ( + "fmt" + "regexp" + "strconv" + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +/// [color-constructor] +type Color struct { + name string +} + +/// [color-constructor] + +type CSSColor struct { + name string +} + +func CreateParameterTypeRegistry(t *testing.T) *cucumberexpressions.ParameterTypeRegistry { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + /// [add-color-parameter-type] + colorParameterType, err := cucumberexpressions.NewParameterType( + "color", // name + []*regexp.Regexp{regexp.MustCompile("red|blue|yellow")}, // regexps + "color", // type + func(args ...string) interface{} { return &Color{name: args[0]} }, // transformer + false, // useForSnippets + true, // preferForRegexpMatch + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(colorParameterType) + /// [add-color-parameter-type] + require.NoError(t, err) + return parameterTypeRegistry +} + +func TestCustomParameterTypes(t *testing.T) { + t.Run("CucumberExpression", func(t *testing.T) { + t.Run("matches parameters with custom parameter type", func(t *testing.T) { + parameterTypeRegistry := CreateParameterTypeRegistry(t) + expression, err := cucumberexpressions.NewCucumberExpression("I have a {color} ball", parameterTypeRegistry) + require.NoError(t, err) + args, err := expression.Match("I have a red ball") + require.NoError(t, err) + require.Equal(t, &Color{name: "red"}, args[0].GetValue()) + }) + + t.Run("matches parameters with multiple capture groups", func(t *testing.T) { + type Coordinate struct { + x int + y int + z int + } + parameterTypeRegistry := CreateParameterTypeRegistry(t) + coordinateParameterType, err := cucumberexpressions.NewParameterType( + "coordinate", + []*regexp.Regexp{regexp.MustCompile(`(\d+),\s*(\d+),\s*(\d+)`)}, + "coordinate", + func(args ...string) interface{} { + x, err := strconv.Atoi(args[0]) + if err != nil { + panic(err) + } + y, err := strconv.Atoi(args[1]) + if err != nil { + panic(err) + } + z, err := strconv.Atoi(args[2]) + if err != nil { + panic(err) + } + return &Coordinate{x: x, y: y, z: z} + }, + true, + true, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(coordinateParameterType) + require.NoError(t, err) + expression, err := cucumberexpressions.NewCucumberExpression("A {int} thick line from {coordinate} to {coordinate}", parameterTypeRegistry) + require.NoError(t, err) + args, err := expression.Match("A 5 thick line from 10,20,30 to 40,50,60") + require.NoError(t, err) + require.Equal(t, 5, args[0].GetValue()) + require.Equal(t, &Coordinate{x: 10, y: 20, z: 30}, args[1].GetValue()) + require.Equal(t, &Coordinate{x: 40, y: 50, z: 60}, args[2].GetValue()) + }) + + t.Run("matches parameters with custom parameter type using optional capture group", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + colorParameterType, err := cucumberexpressions.NewParameterType( + "color", + []*regexp.Regexp{ + regexp.MustCompile("red|blue|yellow"), + regexp.MustCompile("(?:dark|light) (?:red|blue|yellow)"), + }, + "color", + func(args ...string) interface{} { return &Color{name: args[0]} }, + false, + true, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(colorParameterType) + require.NoError(t, err) + expression, err := cucumberexpressions.NewCucumberExpression("I have a {color} ball", parameterTypeRegistry) + require.NoError(t, err) + args, err := expression.Match("I have a dark red ball") + require.NoError(t, err) + require.Equal(t, &Color{name: "dark red"}, args[0].GetValue()) + }) + + t.Run("defers transformation until queried from argument", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + colorParameterType, err := cucumberexpressions.NewParameterType( + "throwing", + []*regexp.Regexp{regexp.MustCompile("bad")}, + "throwing", + func(args ...string) interface{} { panic(fmt.Sprintf("Can't transform [%s]", args[0])) }, + false, + true, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(colorParameterType) + require.NoError(t, err) + expression, err := cucumberexpressions.NewCucumberExpression("I have a {throwing} parameter", parameterTypeRegistry) + require.NoError(t, err) + args, err := expression.Match("I have a bad parameter") + require.NoError(t, err) + require.NotNil(t, args) + require.PanicsWithValue(t, "Can't transform [bad]", func() { + args[0].GetValue() + }) + }) + + t.Run("conflicting parameter type", func(t *testing.T) { + t.Run("is detected for type name", func(t *testing.T) { + parameterTypeRegistry := CreateParameterTypeRegistry(t) + colorParameterType, err := cucumberexpressions.NewParameterType( + "color", + []*regexp.Regexp{regexp.MustCompile(".*")}, + "CSSColor", + func(args ...string) interface{} { return &CSSColor{name: args[0]} }, + false, + true, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(colorParameterType) + require.Error(t, err) + require.Equal(t, "There is already a parameter type with name color", err.Error()) + }) + + t.Run("is not detected for type", func(t *testing.T) { + parameterTypeRegistry := CreateParameterTypeRegistry(t) + colorParameterType, err := cucumberexpressions.NewParameterType( + "whatever", + []*regexp.Regexp{regexp.MustCompile(".*")}, + "Color", + func(args ...string) interface{} { return &Color{name: args[0]} }, + false, + true, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(colorParameterType) + require.NoError(t, err) + }) + + t.Run("is not detected for regexp", func(t *testing.T) { + parameterTypeRegistry := CreateParameterTypeRegistry(t) + colorParameterType, err := cucumberexpressions.NewParameterType( + "css-color", + []*regexp.Regexp{regexp.MustCompile("red|blue|yellow")}, + "CSSColor", + func(args ...string) interface{} { return &CSSColor{name: args[0]} }, + true, + false, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(colorParameterType) + require.NoError(t, err) + + cssColorExpression, err := cucumberexpressions.NewCucumberExpression("I have a {css-color} ball", parameterTypeRegistry) + require.NoError(t, err) + cssColorArgs, err := cssColorExpression.Match("I have a blue ball") + require.NoError(t, err) + require.NotNil(t, cssColorArgs) + require.Equal(t, &CSSColor{name: "blue"}, cssColorArgs[0].GetValue()) + + colorExpression, err := cucumberexpressions.NewCucumberExpression("I have a {color} ball", parameterTypeRegistry) + require.NoError(t, err) + colorArgs, err := colorExpression.Match("I have a blue ball") + require.NoError(t, err) + require.NotNil(t, colorArgs) + require.Equal(t, &Color{name: "blue"}, colorArgs[0].GetValue()) + }) + }) + + t.Run("RegularExpression", func(t *testing.T) { + t.Run("matches arguments with custom parameter type", func(t *testing.T) { + parameterTypeRegistry := CreateParameterTypeRegistry(t) + expression := cucumberexpressions.NewRegularExpression(regexp.MustCompile("I have a (red|blue|yellow) ball"), parameterTypeRegistry) + args, err := expression.Match("I have a red ball") + require.NoError(t, err) + require.NotNil(t, args) + require.Equal(t, &Color{name: "red"}, args[0].GetValue()) + }) + }) + }) +} diff --git a/cucumber-expressions/go/errors.go b/cucumber-expressions/go/errors.go new file mode 100644 index 0000000000..754a444a6a --- /dev/null +++ b/cucumber-expressions/go/errors.go @@ -0,0 +1,69 @@ +package cucumberexpressions + +import ( + "fmt" + "strings" +) + +type CucumberExpressionError struct { + s string +} + +func NewCucumberExpressionError(text string) error { + return &CucumberExpressionError{s: text} +} + +func (e *CucumberExpressionError) Error() string { + return e.s +} + +type AmbiguousParameterTypeError struct { + s string +} + +func NewAmbiguousParameterTypeError(parameterTypeRegexp, expressionRegexp string, parameterTypes []*ParameterType, generatedExpressions []*GeneratedExpression) error { + parameterTypeNames := make([]string, len(parameterTypes)) + for i, parameterType := range parameterTypes { + parameterTypeNames[i] = "{" + parameterType.Name() + "}" + } + generatedExpressionSources := make([]string, len(generatedExpressions)) + for i, generatedExpression := range generatedExpressions { + generatedExpressionSources[i] = generatedExpression.Source() + } + return &AmbiguousParameterTypeError{ + s: fmt.Sprintf( + `Your Regular Expression /%s/ +matches multiple parameter types with regexp /%s/: + %s + +I couldn't decide which one to use. You have two options: + +1) Use a Cucumber Expression instead of a Regular Expression. Try one of these: + %s + +2) Make one of the parameter types preferential and continue to use a Regular Expression. + +`, + expressionRegexp, + parameterTypeRegexp, + strings.Join(parameterTypeNames, "\n "), + strings.Join(generatedExpressionSources, "\n "), + ), + } +} + +func (e *AmbiguousParameterTypeError) Error() string { + return e.s +} + +type UndefinedParameterTypeError struct { + s string +} + +func NewUndefinedParameterTypeError(typeName string) error { + return &UndefinedParameterTypeError{s: fmt.Sprintf("Undefined parameter type {%s}", typeName)} +} + +func (e *UndefinedParameterTypeError) Error() string { + return e.s +} diff --git a/cucumber-expressions/go/examples.txt b/cucumber-expressions/go/examples.txt new file mode 100644 index 0000000000..317e7ff514 --- /dev/null +++ b/cucumber-expressions/go/examples.txt @@ -0,0 +1,19 @@ +I have {int} cuke(s) +I have 22 cukes +[22] +--- +I have {int} cuke(s) and some \[]^$.|?*+ +I have 1 cuke and some \[]^$.|?*+ +[1] +--- +/I have (\d+) cukes? in my (\w+) now/ +I have 22 cukes in my belly now +[22,"belly"] +--- +/I have (-?\d+) cukes? in my (.*) now/ +I have 1 cuke in my belly now +[1,"belly"] +--- +/^Something( with an optional argument)?$/ +Something +[""] diff --git a/cucumber-expressions/go/expression.go b/cucumber-expressions/go/expression.go new file mode 100644 index 0000000000..47dc499806 --- /dev/null +++ b/cucumber-expressions/go/expression.go @@ -0,0 +1,11 @@ +package cucumberexpressions + +import ( + "regexp" +) + +type Expression interface { + Match(text string) ([]*Argument, error) + Regexp() *regexp.Regexp + Source() string +} diff --git a/cucumber-expressions/go/expression_examples_test.go b/cucumber-expressions/go/expression_examples_test.go new file mode 100644 index 0000000000..c0340aea7f --- /dev/null +++ b/cucumber-expressions/go/expression_examples_test.go @@ -0,0 +1,53 @@ +package cucumberexpressions_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "regexp" + "strings" + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +func TestExamples(t *testing.T) { + examples, err := ioutil.ReadFile("./examples.txt") + require.NoError(t, err) + + chunks := strings.Split(string(examples), "---") + for _, chunk := range chunks { + lines := strings.Split(strings.TrimSpace(chunk), "\n") + expressionText, text, expectedArgs := lines[0], lines[1], lines[2] + t.Run(fmt.Sprintf("works with %s", expressionText), func(t *testing.T) { + args := MatchExample(t, expressionText, text) + argsJson, err := json.Marshal(args) + require.NoError(t, err) + require.Equal(t, expectedArgs, string(argsJson)) + }) + } +} + +func MatchExample(t *testing.T, expressionText, text string) []interface{} { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + var expression cucumberexpressions.Expression + if strings.HasPrefix(expressionText, "/") { + r := regexp.MustCompile(expressionText[1 : len(expressionText)-1]) + expression = cucumberexpressions.NewRegularExpression(r, parameterTypeRegistry) + } else { + var err error + expression, err = cucumberexpressions.NewCucumberExpression(expressionText, parameterTypeRegistry) + require.NoError(t, err) + } + args, err := expression.Match(text) + require.NoError(t, err) + if args == nil { + return nil + } + result := make([]interface{}, len(args)) + for i, arg := range args { + result[i] = arg.GetValue() + } + return result +} diff --git a/cucumber-expressions/go/generated_expression.go b/cucumber-expressions/go/generated_expression.go new file mode 100644 index 0000000000..8b6bdca658 --- /dev/null +++ b/cucumber-expressions/go/generated_expression.go @@ -0,0 +1,50 @@ +package cucumberexpressions + +import "fmt" + +type GeneratedExpression struct { + expressionTemplate string + parameterTypes []*ParameterType +} + +func NewGeneratedExpression(expressionTemplate string, parameterTypes []*ParameterType) *GeneratedExpression { + return &GeneratedExpression{ + expressionTemplate: expressionTemplate, + parameterTypes: parameterTypes, + } +} + +func (g *GeneratedExpression) Source() string { + names := make([]interface{}, len(g.parameterTypes)) + for i, p := range g.parameterTypes { + names[i] = p.Name() + } + return fmt.Sprintf(g.expressionTemplate, names...) +} + +func (g *GeneratedExpression) ParameterNames() []string { + usageByTypeName := map[string]int{} + result := make([]string, len(g.parameterTypes)) + for i, p := range g.parameterTypes { + result[i] = getParameterName(p.Name(), usageByTypeName) + } + return result +} + +func (g *GeneratedExpression) ParameterTypes() []*ParameterType { + return g.parameterTypes +} + +func getParameterName(typeName string, usageByTypeName map[string]int) string { + count, ok := usageByTypeName[typeName] + if !ok { + count = 1 + } else { + count++ + } + usageByTypeName[typeName] = count + if count == 1 { + return typeName + } + return fmt.Sprintf("%s%d", typeName, count) +} diff --git a/cucumber-expressions/go/group.go b/cucumber-expressions/go/group.go new file mode 100644 index 0000000000..f57e2c8ae3 --- /dev/null +++ b/cucumber-expressions/go/group.go @@ -0,0 +1,44 @@ +package cucumberexpressions + +type Group struct { + value string + start int + end int + children []*Group +} + +func NewGroup(value string, start, end int, children []*Group) *Group { + return &Group{ + value: value, + start: start, + end: end, + children: children, + } +} + +func (g *Group) Value() string { + return g.value +} + +func (g *Group) Start() int { + return g.start +} + +func (g *Group) End() int { + return g.end +} + +func (g *Group) Children() []*Group { + return g.children +} + +func (g *Group) Values() []string { + if len(g.children) == 0 { + return []string{g.value} + } + result := make([]string, len(g.children)) + for i, child := range g.children { + result[i] = child.Value() + } + return result +} diff --git a/cucumber-expressions/go/group_builder.go b/cucumber-expressions/go/group_builder.go new file mode 100644 index 0000000000..3ecc92847d --- /dev/null +++ b/cucumber-expressions/go/group_builder.go @@ -0,0 +1,52 @@ +package cucumberexpressions + +type GroupBuilder struct { + groupBuilders []*GroupBuilder + capturing bool + source string +} + +func NewGroupBuilder() *GroupBuilder { + return &GroupBuilder{ + capturing: true, + } +} + +func (g *GroupBuilder) Add(groupBuilder *GroupBuilder) { + g.groupBuilders = append(g.groupBuilders, groupBuilder) +} + +func (g *GroupBuilder) Build(submatches []*Submatch, indexIterator *IntIterator) *Group { + submatch := submatches[indexIterator.Next()] + children := make([]*Group, len(g.groupBuilders)) + for i, child := range g.groupBuilders { + children[i] = child.Build(submatches, indexIterator) + } + return NewGroup(submatch.value, submatch.start, submatch.end, children) +} + +func (g *GroupBuilder) SetNonCapturing() { + g.capturing = false +} + +func (g *GroupBuilder) Capturing() bool { + return g.capturing +} + +func (g *GroupBuilder) Children() []*GroupBuilder { + return g.groupBuilders +} + +func (g *GroupBuilder) MoveChildrenTo(groupBuilder *GroupBuilder) { + for _, child := range g.groupBuilders { + groupBuilder.Add(child) + } +} + +func (g *GroupBuilder) SetSource(value string) { + g.source = value +} + +func (g *GroupBuilder) Source() string { + return g.source +} diff --git a/cucumber-expressions/go/iterator.go b/cucumber-expressions/go/iterator.go new file mode 100644 index 0000000000..71d38535de --- /dev/null +++ b/cucumber-expressions/go/iterator.go @@ -0,0 +1,35 @@ +package cucumberexpressions + +type InterfaceIterator struct { + elements []interface{} + index int +} + +func (i *InterfaceIterator) Next() interface{} { + if i.index >= len(i.elements) { + panic("cannot get next") + } + oldIndex := i.index + i.index++ + return i.elements[oldIndex] +} + +type IntIterator struct { + iterator InterfaceIterator +} + +func NewIntIterator(size int) *IntIterator { + elements := make([]interface{}, size) + for i := 0; i < size; i++ { + elements[i] = i + } + return &IntIterator{ + iterator: InterfaceIterator{ + elements: elements, + }, + } +} + +func (s *IntIterator) Next() int { + return s.iterator.Next().(int) +} diff --git a/cucumber-expressions/go/lib/.gitignore b/cucumber-expressions/go/lib/.gitignore new file mode 100644 index 0000000000..f34a96c51a --- /dev/null +++ b/cucumber-expressions/go/lib/.gitignore @@ -0,0 +1,2 @@ +pkg +src diff --git a/cucumber-expressions/go/parameter_type.go b/cucumber-expressions/go/parameter_type.go new file mode 100644 index 0000000000..a4932a85f6 --- /dev/null +++ b/cucumber-expressions/go/parameter_type.go @@ -0,0 +1,78 @@ +package cucumberexpressions + +import ( + "errors" + "regexp" +) + +var HAS_FLAG_REGEKP = regexp.MustCompile(`\(\?[imsU-]+(\:.*)?\)`) + +type ParameterType struct { + name string + regexps []*regexp.Regexp + type1 string // Cannot have a field named type as hit a compile error + transform func(...string) interface{} + useForSnippets bool + preferForRegexpMatch bool +} + +func NewParameterType(name string, regexps []*regexp.Regexp, type1 string, transform func(...string) interface{}, useForSnippets bool, preferForRegexpMatch bool) (*ParameterType, error) { + if transform == nil { + transform = func(s ...string) interface{} { + return s[0] + } + } + for _, r := range regexps { + if HAS_FLAG_REGEKP.MatchString(r.String()) { + return nil, errors.New("ParameterType Regexps can't use flags") + } + } + return &ParameterType{ + name: name, + regexps: regexps, + type1: type1, + transform: transform, + useForSnippets: useForSnippets, + preferForRegexpMatch: preferForRegexpMatch, + }, nil +} + +func (p *ParameterType) Name() string { + return p.name +} + +func (p *ParameterType) Regexps() []*regexp.Regexp { + return p.regexps +} + +func (p *ParameterType) Type() string { + return p.type1 +} + +func (p *ParameterType) UseForSnippets() bool { + return p.useForSnippets +} + +func (p *ParameterType) PreferForRegexpMatch() bool { + return p.preferForRegexpMatch +} + +func (p *ParameterType) Transform(groupValues []string) interface{} { + return p.transform(groupValues...) +} + +func CompareParameterTypes(pt1, pt2 *ParameterType) int { + if pt1.PreferForRegexpMatch() && !pt2.PreferForRegexpMatch() { + return -1 + } + if pt2.PreferForRegexpMatch() && !pt1.PreferForRegexpMatch() { + return 1 + } + if pt1.Name() < pt2.Name() { + return -1 + } + if pt1.Name() > pt2.Name() { + return 1 + } + return 0 +} diff --git a/cucumber-expressions/go/parameter_type_matcher.go b/cucumber-expressions/go/parameter_type_matcher.go new file mode 100644 index 0000000000..57b5377691 --- /dev/null +++ b/cucumber-expressions/go/parameter_type_matcher.go @@ -0,0 +1,57 @@ +package cucumberexpressions + +import ( + "fmt" + "regexp" +) + +type ParameterTypeMatcher struct { + parameterType *ParameterType + regexp *regexp.Regexp + text string + matchPosition int + match []int +} + +func NewParameterTypeMatcher(parameterType *ParameterType, r *regexp.Regexp, text string, matchPosition int) *ParameterTypeMatcher { + captureGroupRegexp := regexp.MustCompile(fmt.Sprintf("(%s)", r.String())) + return &ParameterTypeMatcher{ + parameterType: parameterType, + regexp: r, + text: text, + matchPosition: matchPosition, + match: captureGroupRegexp.FindStringIndex(text[matchPosition:]), + } +} + +func (p *ParameterTypeMatcher) ParameterType() *ParameterType { + return p.parameterType +} + +func (p *ParameterTypeMatcher) AdvanceTo(newMatchPosition int) *ParameterTypeMatcher { + return NewParameterTypeMatcher(p.parameterType, p.regexp, p.text, newMatchPosition) +} + +func (p *ParameterTypeMatcher) Find() bool { + return p.match != nil && p.Group() != "" +} + +func (p *ParameterTypeMatcher) Start() int { + return p.matchPosition + p.match[0] +} + +func (p *ParameterTypeMatcher) Group() string { + return p.text[p.matchPosition:][p.match[0]:p.match[1]] +} + +func CompareParameterTypeMatchers(a, b *ParameterTypeMatcher) int { + posComparison := a.Start() - b.Start() + if posComparison != 0 { + return posComparison + } + lengthComparison := len(b.Group()) - len(a.Group()) + if lengthComparison != 0 { + return lengthComparison + } + return 0 +} diff --git a/cucumber-expressions/go/parameter_type_registry.go b/cucumber-expressions/go/parameter_type_registry.go new file mode 100644 index 0000000000..7275e75d2c --- /dev/null +++ b/cucumber-expressions/go/parameter_type_registry.go @@ -0,0 +1,150 @@ +package cucumberexpressions + +import ( + "fmt" + "regexp" + "sort" + "strconv" +) + +var INTEGER_REGEXPS = []*regexp.Regexp{ + regexp.MustCompile(`-?\d+`), + regexp.MustCompile(`\d+`), +} +var FLOAT_REGEXPS = []*regexp.Regexp{ + regexp.MustCompile(`-?\d*\.\d+`), +} +var WORD_REGEXPS = []*regexp.Regexp{ + regexp.MustCompile(`\w+`), +} +var STRING_REGEXPS = []*regexp.Regexp{ + regexp.MustCompile(`"([^"\\]*(\\.[^"\\]*)*)"|'([^'\\]*(\\.[^'\\]*)*)'`), +} + +type ParameterTypeRegistry struct { + parameterTypeByName map[string]*ParameterType + parameterTypesByRegexp map[string][]*ParameterType +} + +func NewParameterTypeRegistry() *ParameterTypeRegistry { + result := &ParameterTypeRegistry{ + parameterTypeByName: map[string]*ParameterType{}, + parameterTypesByRegexp: map[string][]*ParameterType{}, + } + intParameterType, err := NewParameterType( + "int", + INTEGER_REGEXPS, + "int", + func(args ...string) interface{} { + i, err := strconv.Atoi(args[0]) + if err != nil { + panic(err) + } + return i + }, + true, + true, + ) + if err != nil { + panic(err) + } + result.DefineParameterType(intParameterType) + floatParameterType, err := NewParameterType( + "float", + FLOAT_REGEXPS, + "float", + func(args ...string) interface{} { + f, err := strconv.ParseFloat(args[0], 64) + if err != nil { + panic(err) + } + return f + }, + true, + false, + ) + if err != nil { + panic(err) + } + result.DefineParameterType(floatParameterType) + wordParameterType, err := NewParameterType( + "word", + WORD_REGEXPS, + "string", + func(args ...string) interface{} { + return args[0] + }, + false, + false, + ) + if err != nil { + panic(err) + } + result.DefineParameterType(wordParameterType) + stringParameterType, err := NewParameterType( + "string", + STRING_REGEXPS, + "string", + func(args ...string) interface{} { + if args[0] == "" && args[1] != "" { + return args[1] + } + return args[0] + }, + true, + false, + ) + if err != nil { + panic(err) + } + result.DefineParameterType(stringParameterType) + return result +} + +func (p *ParameterTypeRegistry) ParamaterTypes() []*ParameterType { + result := make([]*ParameterType, len(p.parameterTypeByName)) + index := 0 + for _, parameterType := range p.parameterTypeByName { + result[index] = parameterType + index++ + } + return result +} + +func (p *ParameterTypeRegistry) LookupByTypeName(name string) *ParameterType { + return p.parameterTypeByName[name] +} + +func (p *ParameterTypeRegistry) LookupByRegexp(parameterTypeRegexp string, expressionRegexp string, text string) (*ParameterType, error) { + parameterTypes, ok := p.parameterTypesByRegexp[parameterTypeRegexp] + if !ok { + return nil, nil + } + if len(parameterTypes) > 1 && !parameterTypes[0].PreferForRegexpMatch() { + generatedExpressions := NewCucumberExpressionGenerator(p).GenerateExpressions(text) + return nil, NewAmbiguousParameterTypeError(parameterTypeRegexp, expressionRegexp, parameterTypes, generatedExpressions) + } + return parameterTypes[0], nil +} + +func (p *ParameterTypeRegistry) DefineParameterType(parameterType *ParameterType) error { + if _, ok := p.parameterTypeByName[parameterType.Name()]; ok { + return fmt.Errorf("There is already a parameter type with name %s", parameterType.Name()) + } + p.parameterTypeByName[parameterType.Name()] = parameterType + for _, parameterTypeRegexp := range parameterType.Regexps() { + if _, ok := p.parameterTypesByRegexp[parameterTypeRegexp.String()]; !ok { + p.parameterTypesByRegexp[parameterTypeRegexp.String()] = []*ParameterType{} + } + parameterTypes := p.parameterTypesByRegexp[parameterTypeRegexp.String()] + if len(parameterTypes) > 0 && parameterTypes[0].PreferForRegexpMatch() && parameterType.PreferForRegexpMatch() { + return fmt.Errorf("There can only be one preferential parameter type per regexp. The regexp /%s/ is used for two preferential parameter types, {%s} and {%s}", parameterTypeRegexp.String(), parameterTypes[0].Name(), parameterType.Name()) + } + parameterTypes = append(parameterTypes, parameterType) + sort.Slice(parameterTypes, func(i int, j int) bool { + return CompareParameterTypes(parameterTypes[i], parameterTypes[j]) <= 0 + }) + p.parameterTypesByRegexp[parameterTypeRegexp.String()] = parameterTypes + } + return nil +} diff --git a/cucumber-expressions/go/parameter_type_registry_test.go b/cucumber-expressions/go/parameter_type_registry_test.go new file mode 100644 index 0000000000..ce28087722 --- /dev/null +++ b/cucumber-expressions/go/parameter_type_registry_test.go @@ -0,0 +1,156 @@ +package cucumberexpressions_test + +import ( + "fmt" + "regexp" + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +var CAPITALISED_WORD_REGEXPS = []*regexp.Regexp{ + regexp.MustCompile(`[A-Z]+\w+`), +} + +func TestParameterTypeRegistry(t *testing.T) { + t.Run("does not allow more than one preferential parameter type for each regexp", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + nameParameterType, err := cucumberexpressions.NewParameterType( + "name", + CAPITALISED_WORD_REGEXPS, + "name", + nil, + true, + true, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(nameParameterType) + require.NoError(t, err) + personParameterType, err := cucumberexpressions.NewParameterType( + "person", + CAPITALISED_WORD_REGEXPS, + "person", + nil, + true, + false, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(personParameterType) + require.NoError(t, err) + placeParameterType, err := cucumberexpressions.NewParameterType( + "place", + CAPITALISED_WORD_REGEXPS, + "place", + nil, + true, + true, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(placeParameterType) + require.EqualError(t, err, fmt.Sprintf("There can only be one preferential parameter type per regexp. The regexp /%s/ is used for two preferential parameter types, {name} and {place}", CAPITALISED_WORD_REGEXPS[0].String())) + }) + + t.Run("looks up preferential parameter type by regexp", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + nameParameterType, err := cucumberexpressions.NewParameterType( + "name", + CAPITALISED_WORD_REGEXPS, + "name", + nil, + true, + false, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(nameParameterType) + require.NoError(t, err) + personParameterType, err := cucumberexpressions.NewParameterType( + "person", + CAPITALISED_WORD_REGEXPS, + "person", + nil, + true, + true, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(personParameterType) + require.NoError(t, err) + placeParameterType, err := cucumberexpressions.NewParameterType( + "place", + CAPITALISED_WORD_REGEXPS, + "place", + nil, + true, + false, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(placeParameterType) + require.NoError(t, err) + matchingParameterType, err := parameterTypeRegistry.LookupByRegexp(`[A-Z]+\w+`, `([A-Z]+\w+) and ([A-Z]+\w+)`, "Lisa and Bob") + require.NoError(t, err) + require.Equal(t, matchingParameterType, personParameterType) + }) + + t.Run("throws ambiguous exception on lookup when no parameter types are preferential", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + nameParameterType, err := cucumberexpressions.NewParameterType( + "name", + CAPITALISED_WORD_REGEXPS, + "name", + nil, + true, + false, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(nameParameterType) + require.NoError(t, err) + personParameterType, err := cucumberexpressions.NewParameterType( + "person", + CAPITALISED_WORD_REGEXPS, + "person", + nil, + true, + false, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(personParameterType) + require.NoError(t, err) + placeParameterType, err := cucumberexpressions.NewParameterType( + "place", + CAPITALISED_WORD_REGEXPS, + "place", + nil, + true, + false, + ) + require.NoError(t, err) + err = parameterTypeRegistry.DefineParameterType(placeParameterType) + require.NoError(t, err) + _, err = parameterTypeRegistry.LookupByRegexp(`[A-Z]+\w+`, `([A-Z]+\w+) and ([A-Z]+\w+)`, "Lisa and Bob") + require.EqualError( + t, + err, + "Your Regular Expression /([A-Z]+\\w+) and ([A-Z]+\\w+)/\n"+ + "matches multiple parameter types with regexp /[A-Z]+\\w+/:\n"+ + " {name}\n"+ + " {person}\n"+ + " {place}\n"+ + "\n"+ + "I couldn't decide which one to use. You have two options:\n"+ + "\n"+ + "1) Use a Cucumber Expression instead of a Regular Expression. Try one of these:\n"+ + " {name} and {name}\n"+ + " {name} and {person}\n"+ + " {name} and {place}\n"+ + " {person} and {name}\n"+ + " {person} and {person}\n"+ + " {person} and {place}\n"+ + " {place} and {name}\n"+ + " {place} and {person}\n"+ + " {place} and {place}\n"+ + "\n"+ + "2) Make one of the parameter types preferential and continue to use a Regular Expression.\n"+ + "\n", + ) + }) +} diff --git a/cucumber-expressions/go/parameter_type_test.go b/cucumber-expressions/go/parameter_type_test.go new file mode 100644 index 0000000000..c6e48e5787 --- /dev/null +++ b/cucumber-expressions/go/parameter_type_test.go @@ -0,0 +1,23 @@ +package cucumberexpressions_test + +import ( + "regexp" + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +func TestParameterType(t *testing.T) { + t.Run("does not allow ignore flag on regexp", func(t *testing.T) { + _, err := cucumberexpressions.NewParameterType( + "case-insensitive", + []*regexp.Regexp{regexp.MustCompile("(?i)[a-z]+")}, + "case-insensitive", + nil, + true, + true, + ) + require.EqualError(t, err, "ParameterType Regexps can't use flags") + }) +} diff --git a/cucumber-expressions/go/regular_expression.go b/cucumber-expressions/go/regular_expression.go new file mode 100644 index 0000000000..2ef49a7764 --- /dev/null +++ b/cucumber-expressions/go/regular_expression.go @@ -0,0 +1,55 @@ +package cucumberexpressions + +import ( + "regexp" +) + +type RegularExpression struct { + expressionRegexp *regexp.Regexp + parameterTypeRegistry *ParameterTypeRegistry + treeRegexp *TreeRegexp +} + +func NewRegularExpression(expressionRegexp *regexp.Regexp, parameterTypeRegistry *ParameterTypeRegistry) *RegularExpression { + return &RegularExpression{ + expressionRegexp: expressionRegexp, + parameterTypeRegistry: parameterTypeRegistry, + treeRegexp: NewTreeRegexp(expressionRegexp), + } +} + +func (r *RegularExpression) Match(text string) ([]*Argument, error) { + parameterTypes := []*ParameterType{} + for _, groupBuilder := range r.treeRegexp.GroupBuilder().Children() { + parameterTypeRegexp := groupBuilder.Source() + parameterType, err := r.parameterTypeRegistry.LookupByRegexp(parameterTypeRegexp, r.expressionRegexp.String(), text) + if err != nil { + return nil, err + } + if parameterType == nil { + parameterType, err = NewParameterType( + parameterTypeRegexp, + []*regexp.Regexp{regexp.MustCompile(parameterTypeRegexp)}, + "string", + func(arg3 ...string) interface{} { + return arg3[0] + }, + false, + false, + ) + if err != nil { + panic(err) + } + } + parameterTypes = append(parameterTypes, parameterType) + } + return BuildArguments(r.treeRegexp, text, parameterTypes), nil +} + +func (r *RegularExpression) Regexp() *regexp.Regexp { + return r.expressionRegexp +} + +func (r *RegularExpression) Source() string { + return r.expressionRegexp.String() +} diff --git a/cucumber-expressions/go/regular_expression_test.go b/cucumber-expressions/go/regular_expression_test.go new file mode 100644 index 0000000000..66dda1c51e --- /dev/null +++ b/cucumber-expressions/go/regular_expression_test.go @@ -0,0 +1,88 @@ +package cucumberexpressions_test + +import ( + "regexp" + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +func TestRegularExpression(t *testing.T) { + t.Run("documents match arguments", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + + /// [capture-match-arguments] + expr := regexp.MustCompile(`I have (\d+) cukes? in my (\w+) now`) + expression := cucumberexpressions.NewRegularExpression(expr, parameterTypeRegistry) + args, err := expression.Match("I have 7 cukes in my belly now") + require.NoError(t, err) + require.Equal(t, args[0].GetValue(), 7) + require.Equal(t, args[1].GetValue(), "belly") + /// [capture-match-arguments] + }) + + t.Run("does no transform by default", func(t *testing.T) { + require.Equal(t, Match(t, `(\d\d)`, "22")[0], "22") + }) + + t.Run("transforms negative int", func(t *testing.T) { + require.Equal(t, Match(t, `(-?\d+)`, "-22")[0], -22) + }) + + t.Run("transforms positive int", func(t *testing.T) { + require.Equal(t, Match(t, `(-?\d+)`, "22")[0], 22) + }) + + t.Run("transforms float without integer part", func(t *testing.T) { + require.Equal(t, Match(t, `(-?\d*\.\d+)`, ".22")[0], 0.22) + }) + + t.Run("transforms float with sign", func(t *testing.T) { + require.Equal(t, Match(t, `(-?\d*\.\d+)`, "-1.22")[0], -1.22) + }) + + t.Run("returns empty array when no match float with sign", func(t *testing.T) { + require.Empty(t, Match(t, "hello", "world")) + }) + + t.Run("ignores non capturing groups", func(t *testing.T) { + require.Equal( + t, + Match( + t, + `(\S+) ?(can|cannot)? (?:delete|cancel) the (\d+)(?:st|nd|rd|th) (attachment|slide) ?(?:upload)?`, + "I can cancel the 1st slide upload", + ), + []interface{}{"I", "can", 1, "slide"}, + ) + }) + + t.Run("works with escaped parenthesis", func(t *testing.T) { + require.Empty(t, Match(t, `Across the line\(s\)`, "Across the line(s)")) + }) + + t.Run("exposes regexp and source", func(t *testing.T) { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + expr := regexp.MustCompile(`I have (\d+) cukes? in my (\w+) now`) + expression := cucumberexpressions.NewRegularExpression(expr, parameterTypeRegistry) + require.Equal(t, expression.Regexp(), expr) + require.Equal(t, expression.Source(), expr.String()) + }) + +} + +func Match(t *testing.T, expr, text string) []interface{} { + parameterTypeRegistry := cucumberexpressions.NewParameterTypeRegistry() + expression := cucumberexpressions.NewRegularExpression(regexp.MustCompile(expr), parameterTypeRegistry) + args, err := expression.Match(text) + require.NoError(t, err) + if len(args) == 0 { + return []interface{}{} + } + result := make([]interface{}, len(args)) + for i, arg := range args { + result[i] = arg.GetValue() + } + return result +} diff --git a/cucumber-expressions/go/stack.go b/cucumber-expressions/go/stack.go new file mode 100644 index 0000000000..ddf5a6aef9 --- /dev/null +++ b/cucumber-expressions/go/stack.go @@ -0,0 +1,69 @@ +package cucumberexpressions + +type InterfaceStack struct { + elements []interface{} +} + +func (i *InterfaceStack) Len() int { + return len(i.elements) +} + +func (i *InterfaceStack) Peek() interface{} { + if i.Len() == 0 { + panic("cannot peek") + } + return i.elements[i.Len()-1] +} + +func (i *InterfaceStack) Pop() interface{} { + if i.Len() == 0 { + panic("cannot pop") + } + value := i.elements[i.Len()-1] + i.elements = i.elements[:i.Len()-1] + return value +} + +func (i *InterfaceStack) Push(value interface{}) { + i.elements = append(i.elements, value) +} + +type GroupBuilderStack struct { + interfaceStack InterfaceStack +} + +func (s *GroupBuilderStack) Len() int { + return s.interfaceStack.Len() +} + +func (s *GroupBuilderStack) Peek() *GroupBuilder { + return s.interfaceStack.Peek().(*GroupBuilder) +} + +func (s *GroupBuilderStack) Pop() *GroupBuilder { + return s.interfaceStack.Pop().(*GroupBuilder) +} + +func (s *GroupBuilderStack) Push(value *GroupBuilder) { + s.interfaceStack.Push(value) +} + +type IntStack struct { + interfaceStack InterfaceStack +} + +func (s *IntStack) Len() int { + return s.interfaceStack.Len() +} + +func (s *IntStack) Peek() int { + return s.interfaceStack.Peek().(int) +} + +func (s *IntStack) Pop() int { + return s.interfaceStack.Pop().(int) +} + +func (s *IntStack) Push(value int) { + s.interfaceStack.Push(value) +} diff --git a/cucumber-expressions/go/submatch.go b/cucumber-expressions/go/submatch.go new file mode 100644 index 0000000000..8e71770cc3 --- /dev/null +++ b/cucumber-expressions/go/submatch.go @@ -0,0 +1,7 @@ +package cucumberexpressions + +type Submatch struct { + value string + start int + end int +} diff --git a/cucumber-expressions/go/tree_regexp.go b/cucumber-expressions/go/tree_regexp.go new file mode 100644 index 0000000000..cae3198d5c --- /dev/null +++ b/cucumber-expressions/go/tree_regexp.go @@ -0,0 +1,91 @@ +package cucumberexpressions + +import ( + "regexp" +) + +type TreeRegexp struct { + regexp *regexp.Regexp + groupBuilder *GroupBuilder +} + +func NewTreeRegexp(regexp *regexp.Regexp) *TreeRegexp { + stack := GroupBuilderStack{} + stack.Push(NewGroupBuilder()) + groupStartStack := IntStack{} + var last rune + escaping := false + nonCapturingMaybe := false + for n, c := range regexp.String() { + if c == '(' && !escaping { + stack.Push(NewGroupBuilder()) + groupStartStack.Push(n + 1) + nonCapturingMaybe = false + } else if c == ')' && !escaping { + gb := stack.Pop() + groupStart := groupStartStack.Pop() + if gb.Capturing() { + gb.SetSource(regexp.String()[groupStart:n]) + stack.Peek().Add(gb) + } else { + gb.MoveChildrenTo(stack.Peek()) + } + nonCapturingMaybe = false + } else if c == '?' && last == '(' { + nonCapturingMaybe = true + } else if nonCapturingMaybe { + if c == ':' || isFlagCharacter(c) { + stack.Peek().SetNonCapturing() + } + nonCapturingMaybe = false + } + escaping = c == '\\' && !escaping + last = c + } + + return &TreeRegexp{ + regexp: regexp, + groupBuilder: stack.Pop(), + } +} + +func (t *TreeRegexp) Regexp() *regexp.Regexp { + return t.regexp +} + +func (t *TreeRegexp) GroupBuilder() *GroupBuilder { + return t.groupBuilder +} + +func (t *TreeRegexp) Match(s string) *Group { + indicies := t.Regexp().FindAllStringSubmatchIndex(s, -1) + if indicies == nil { + return nil + } + var submatches []*Submatch + for i := range indicies[0] { + if i%2 == 0 { + continue + } + start, end := indicies[0][i-1], indicies[0][i] + value := "" + if start != -1 { + value = s[start:end] + } + submatches = append(submatches, &Submatch{ + value: value, + start: start, + end: end, + }) + } + return t.groupBuilder.Build(submatches, NewIntIterator(len(submatches))) +} + +func isFlagCharacter(c rune) bool { + switch c { + case 'i', 'm', 's', 'U', '-': + return true + default: + return false + } +} diff --git a/cucumber-expressions/go/tree_regexp_test.go b/cucumber-expressions/go/tree_regexp_test.go new file mode 100644 index 0000000000..a8cd5df71e --- /dev/null +++ b/cucumber-expressions/go/tree_regexp_test.go @@ -0,0 +1,106 @@ +package cucumberexpressions_test + +import ( + "regexp" + "testing" + + cucumberexpressions "." + "github.com/stretchr/testify/require" +) + +func TestTreeRegexp(t *testing.T) { + t.Run("exposes group source", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile("(a(?:b)?)(c)")) + var gbSources []string + for _, gb := range tr.GroupBuilder().Children() { + gbSources = append(gbSources, gb.Source()) + } + require.Equal(t, gbSources, []string{"a(?:b)?", "c"}) + }) + + t.Run("builds tree, ignoring non-capturing groups", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile("(a(?:b)?)(c)")) + group := tr.Match("ac") + require.Equal(t, group.Value(), "ac") + require.Equal(t, group.Children()[0].Value(), "a") + require.Empty(t, group.Children()[0].Children()) + require.Equal(t, group.Children()[1].Value(), "c") + }) + + t.Run("matches optional group", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile("^Something( with an optional argument)?")) + group := tr.Match("Something") + require.Equal(t, group.Children()[0].Value(), "") + }) + + t.Run("matches nested groups", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile(`^A (\d+) thick line from ((\d+),\s*(\d+),\s*(\d+)) to ((\d+),\s*(\d+),\s*(\d+))`)) + group := tr.Match("A 5 thick line from 10,20,30 to 40,50,60") + require.Equal(t, group.Children()[0].Value(), "5") + require.Equal(t, group.Children()[1].Value(), "10,20,30") + require.Equal(t, group.Children()[1].Children()[0].Value(), "10") + require.Equal(t, group.Children()[1].Children()[1].Value(), "20") + require.Equal(t, group.Children()[1].Children()[2].Value(), "30") + require.Equal(t, group.Children()[2].Value(), "40,50,60") + require.Equal(t, group.Children()[2].Children()[0].Value(), "40") + require.Equal(t, group.Children()[2].Children()[1].Value(), "50") + require.Equal(t, group.Children()[2].Children()[2].Value(), "60") + }) + + t.Run("detects multiple non capturing groups", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile(`(?:a)(:b)(\?c)(d)`)) + group := tr.Match("a:b?cd") + require.Len(t, group.Children(), 3) + }) + + t.Run("works with escaped backslash", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile(`foo\\(bar|baz)`)) + group := tr.Match("foo\\bar") + require.Len(t, group.Children(), 1) + }) + + t.Run("works with escaped slash", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile(`I go to '\/(.+)'$`)) + group := tr.Match("I go to '/hello'") + require.Len(t, group.Children(), 1) + }) + + t.Run("works with digit and word", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile(`^(\d) (\w+)$`)) + group := tr.Match("2 you") + require.Len(t, group.Children(), 2) + }) + + t.Run("captures non capturing groups with capturing groups inside", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile(`the stdout(?: from "(.*?)")?`)) + group := tr.Match("the stdout") + require.Equal(t, group.Value(), "the stdout") + require.Equal(t, group.Children()[0].Value(), "") + require.Len(t, group.Children(), 1) + }) + + t.Run("works with flags", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile("(?i)HELLO")) + var gbSources []string + for _, gb := range tr.GroupBuilder().Children() { + gbSources = append(gbSources, gb.Source()) + } + require.Empty(t, gbSources) + group := tr.Match("hello") + require.Equal(t, group.Value(), "hello") + }) + + t.Run("works with disabled flags", func(t *testing.T) { + tr := cucumberexpressions.NewTreeRegexp(regexp.MustCompile("(?i)HELL(?-i:O)")) + var gbSources []string + for _, gb := range tr.GroupBuilder().Children() { + gbSources = append(gbSources, gb.Source()) + } + require.Empty(t, gbSources) + group := tr.Match("hello") + require.Nil(t, group) + group = tr.Match("hellO") + require.Equal(t, group.Value(), "hellO") + }) + +}