From 4afb53d310d89ad596f82efbc3a654828a8607d4 Mon Sep 17 00:00:00 2001 From: gedi Date: Thu, 11 Jun 2015 10:48:26 +0300 Subject: [PATCH] support gherkin scenario outline and examples read scenario outline related tokens in gherkin lexer * e8b71e6 test lexing of scenario outline with examples * 13f73f2 update gherkin parser to parse scenario outline with examples * cfad4fe test scenario outline persing --- gherkin/gherkin.go | 89 +++++++++++++++++++++++++++------------- gherkin/lexer.go | 39 ++++++++++++++---- gherkin/lexer_test.go | 66 +++++++++++++++++++++++------ gherkin/parse_test.go | 21 +++++++++- gherkin/scenario_test.go | 77 ++++++++++++++++++++++++++++++++++ gherkin/token.go | 6 +++ 6 files changed, 247 insertions(+), 51 deletions(-) create mode 100644 gherkin/scenario_test.go diff --git a/gherkin/gherkin.go b/gherkin/gherkin.go index 82fa311..21fefe6 100644 --- a/gherkin/gherkin.go +++ b/gherkin/gherkin.go @@ -87,11 +87,21 @@ func (t Tags) Has(tag Tag) bool { } // Scenario describes the scenario details +// +// if Examples table is not nil, then it +// means that this is an outline scenario +// with a table of examples to be run for +// each and every row +// +// Scenario may have tags which later may +// be used to filter out or run specific +// initialization tasks type Scenario struct { - Title string - Steps []*Step - Tags Tags - Comment string + Title string + Steps []*Step + Tags Tags + Examples *Table + Comment string } // Background steps are run before every scenario @@ -243,34 +253,56 @@ func (p *parser) parseFeature() (ft *Feature, err error) { } // there may be tags before scenario - sc := &Scenario{} - sc.Tags = append(sc.Tags, ft.Tags...) + var tags Tags + tags = append(tags, ft.Tags...) if tok.Type == TAGS { for _, t := range p.parseTags() { - if !sc.Tags.Has(t) { - sc.Tags = append(sc.Tags, t) + if !tags.Has(t) { + tags = append(tags, t) } } tok = p.peek() } - // there must be a scenario otherwise - if tok.Type != SCENARIO { - return ft, p.err("expected a scenario, but got '"+tok.Type.String()+"' instead", tok.Line) + // there must be a scenario or scenario outline otherwise + if !tok.OfType(SCENARIO, SCENARIO_OUTLINE) { + return ft, p.err("expected a scenario or scenario outline, but got '"+tok.Type.String()+"' instead", tok.Line) } - sc.Title = tok.Value - sc.Comment = tok.Comment - p.next() // jump to scenario steps - if sc.Steps, err = p.parseSteps(); err != nil { + scenario, err := p.parseScenario() + if err != nil { return ft, err } - ft.Scenarios = append(ft.Scenarios, sc) + + scenario.Tags = tags + ft.Scenarios = append(ft.Scenarios, scenario) } return ft, nil } +func (p *parser) parseScenario() (s *Scenario, err error) { + tok := p.next() + s = &Scenario{Title: tok.Value, Comment: tok.Comment} + if s.Steps, err = p.parseSteps(); err != nil { + return s, err + } + if examples := p.peek(); examples.Type == EXAMPLES { + p.next() // jump over the peeked token + peek := p.peek() + if peek.Type != TABLE_ROW { + return s, p.err(strings.Join([]string{ + "expected a table row,", + "but got '" + peek.Type.String() + "' instead, for scenario outline examples", + }, " "), examples.Line) + } + if s.Examples, err = p.parseTable(); err != nil { + return s, err + } + } + return s, nil +} + func (p *parser) parseSteps() (steps []*Step, err error) { for tok := p.peek(); tok.OfType(allSteps...); tok = p.peek() { step := &Step{Text: tok.Value, Comment: tok.Comment} @@ -294,11 +326,11 @@ func (p *parser) parseSteps() (steps []*Step, err error) { tok = p.peek() switch tok.Type { case PYSTRING: - if err := p.parsePystring(step); err != nil { + if step.PyString, err = p.parsePystring(); err != nil { return steps, err } case TABLE_ROW: - if err := p.parseTable(step); err != nil { + if step.Table, err = p.parseTable(); err != nil { return steps, err } default: @@ -312,7 +344,7 @@ func (p *parser) parseSteps() (steps []*Step, err error) { return steps, nil } -func (p *parser) parsePystring(s *Step) error { +func (p *parser) parsePystring() (*PyString, error) { var tok *Token started := p.next() // skip the start of pystring var lines []string @@ -320,27 +352,28 @@ func (p *parser) parsePystring(s *Step) error { lines = append(lines, tok.Text) } if tok.Type == EOF { - return fmt.Errorf("pystring which was opened on %s:%d was not closed", p.path, started.Line) + return nil, fmt.Errorf("pystring which was opened on %s:%d was not closed", p.path, started.Line) } - s.PyString = &PyString{Body: strings.Join(lines, "\n")} - return nil + return &PyString{ + Body: strings.Join(lines, "\n"), + }, nil } -func (p *parser) parseTable(s *Step) error { - s.Table = &Table{} +func (p *parser) parseTable() (*Table, error) { + tbl := &Table{} for row := p.peek(); row.Type == TABLE_ROW; row = p.peek() { var cols []string for _, r := range strings.Split(strings.Trim(row.Value, "|"), "|") { cols = append(cols, strings.TrimFunc(r, unicode.IsSpace)) } // ensure the same colum number for each row - if len(s.Table.rows) > 0 && len(s.Table.rows[0]) != len(cols) { - return p.err("table row has not the same number of columns compared to previous row", row.Line) + if len(tbl.rows) > 0 && len(tbl.rows[0]) != len(cols) { + return tbl, p.err("table row has not the same number of columns compared to previous row", row.Line) } - s.Table.rows = append(s.Table.rows, cols) + tbl.rows = append(tbl.rows, cols) p.next() // jump over the peeked token } - return nil + return tbl, nil } func (p *parser) parseTags() (tags Tags) { diff --git a/gherkin/lexer.go b/gherkin/lexer.go index 5d28841..5457f4f 100644 --- a/gherkin/lexer.go +++ b/gherkin/lexer.go @@ -9,14 +9,16 @@ import ( ) var matchers = map[string]*regexp.Regexp{ - "feature": regexp.MustCompile("^(\\s*)Feature:\\s*([^#]*)(#.*)?"), - "scenario": regexp.MustCompile("^(\\s*)Scenario:\\s*([^#]*)(#.*)?"), - "background": regexp.MustCompile("^(\\s*)Background:(\\s*#.*)?"), - "step": regexp.MustCompile("^(\\s*)(Given|When|Then|And|But)\\s+([^#]*)(#.*)?"), - "comment": regexp.MustCompile("^(\\s*)#(.+)"), - "pystring": regexp.MustCompile("^(\\s*)\\\"\\\"\\\""), - "tags": regexp.MustCompile("^(\\s*)@([^#]*)(#.*)?"), - "table_row": regexp.MustCompile("^(\\s*)\\|([^#]*)(#.*)?"), + "feature": regexp.MustCompile("^(\\s*)Feature:\\s*([^#]*)(#.*)?"), + "scenario": regexp.MustCompile("^(\\s*)Scenario:\\s*([^#]*)(#.*)?"), + "scenario_outline": regexp.MustCompile("^(\\s*)Scenario Outline:\\s*([^#]*)(#.*)?"), + "examples": regexp.MustCompile("^(\\s*)Examples:(\\s*#.*)?"), + "background": regexp.MustCompile("^(\\s*)Background:(\\s*#.*)?"), + "step": regexp.MustCompile("^(\\s*)(Given|When|Then|And|But)\\s+([^#]*)(#.*)?"), + "comment": regexp.MustCompile("^(\\s*)#(.+)"), + "pystring": regexp.MustCompile("^(\\s*)\\\"\\\"\\\""), + "tags": regexp.MustCompile("^(\\s*)@([^#]*)(#.*)?"), + "table_row": regexp.MustCompile("^(\\s*)\\|([^#]*)(#.*)?"), } type lexer struct { @@ -145,6 +147,27 @@ func (l *lexer) read() *Token { Comment: strings.Trim(m[3], " #"), } } + // scenario outline + if m := matchers["scenario_outline"].FindStringSubmatch(line); len(m) > 0 { + return &Token{ + Type: SCENARIO_OUTLINE, + Indent: len(m[1]), + Line: l.lines - 1, + Value: strings.TrimSpace(m[2]), + Text: line, + Comment: strings.Trim(m[3], " #"), + } + } + // examples + if m := matchers["examples"].FindStringSubmatch(line); len(m) > 0 { + return &Token{ + Type: EXAMPLES, + Indent: len(m[1]), + Line: l.lines - 1, + Text: line, + Comment: strings.Trim(m[2], " #"), + } + } // text text := strings.TrimLeftFunc(line, unicode.IsSpace) return &Token{ diff --git a/gherkin/lexer_test.go b/gherkin/lexer_test.go index 558b62b..48c8606 100644 --- a/gherkin/lexer_test.go +++ b/gherkin/lexer_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -var lexerSamples = map[string]string{ +var testLexerSamples = map[string]string{ "feature": `Feature: gherkin lexer in order to run features as gherkin lexer @@ -27,10 +27,22 @@ var lexerSamples = map[string]string{ | name | lastname | num | | Jack | Sparrow | 4 | | John | Doe | 79 |`, + + "scenario_outline_with_examples": `Scenario Outline: ls supports kinds of options + Given I am in a directory "test" + And I have a file named "foo" + And I have a file named "bar" + When I run "ls" with options "" + Then I should see "" + + Examples: + | options | result | + | -t | bar foo | + | -tr | foo bar |`, } func Test_feature_read(t *testing.T) { - l := newLexer(strings.NewReader(lexerSamples["feature"])) + l := newLexer(strings.NewReader(testLexerSamples["feature"])) tok := l.read() if tok.Type != FEATURE { t.Fatalf("Expected a 'feature' type, but got: '%s'", tok.Type) @@ -99,16 +111,16 @@ func Test_feature_read(t *testing.T) { func Test_minimal_feature(t *testing.T) { file := strings.Join([]string{ - lexerSamples["feature"] + "\n", + testLexerSamples["feature"] + "\n", - indent(2, lexerSamples["background"]), - indent(4, lexerSamples["step_given"]) + "\n", + indent(2, testLexerSamples["background"]), + indent(4, testLexerSamples["step_given"]) + "\n", - indent(2, lexerSamples["comment"]), - indent(2, lexerSamples["scenario"]), - indent(4, lexerSamples["step_given"]), - indent(4, lexerSamples["step_when"]), - indent(4, lexerSamples["step_then"]), + indent(2, testLexerSamples["comment"]), + indent(2, testLexerSamples["scenario"]), + indent(4, testLexerSamples["step_given"]), + indent(4, testLexerSamples["step_when"]), + indent(4, testLexerSamples["step_then"]), }, "\n") l := newLexer(strings.NewReader(file)) @@ -142,9 +154,9 @@ func Test_minimal_feature(t *testing.T) { func Test_table_row_reading(t *testing.T) { file := strings.Join([]string{ - indent(2, lexerSamples["background"]), - indent(4, lexerSamples["step_given_table"]), - indent(4, lexerSamples["step_given"]), + indent(2, testLexerSamples["background"]), + indent(4, testLexerSamples["step_given_table"]), + indent(4, testLexerSamples["step_given"]), }, "\n") l := newLexer(strings.NewReader(file)) @@ -179,3 +191,31 @@ func Test_table_row_reading(t *testing.T) { t.Fatalf("table row value '%s' was not expected", values[2]) } } + +func Test_lexing_of_scenario_outline(t *testing.T) { + l := newLexer(strings.NewReader(testLexerSamples["scenario_outline_with_examples"])) + + var tokens []TokenType + for tok := l.read(); tok.Type != EOF; tok = l.read() { + tokens = append(tokens, tok.Type) + } + expected := []TokenType{ + SCENARIO_OUTLINE, + GIVEN, + AND, + AND, + WHEN, + THEN, + NEW_LINE, + + EXAMPLES, + TABLE_ROW, + TABLE_ROW, + TABLE_ROW, + } + for i := 0; i < len(expected); i++ { + if expected[i] != tokens[i] { + t.Fatalf("expected token '%s' at position: %d, is not the same as actual token: '%s'", expected[i], i, tokens[i]) + } + } +} diff --git a/gherkin/parse_test.go b/gherkin/parse_test.go index 8e69e9c..c663c5b 100644 --- a/gherkin/parse_test.go +++ b/gherkin/parse_test.go @@ -35,7 +35,9 @@ func Test_parse_feature_file(t *testing.T) { testStepSamples["step_group_another"] + "\n", // scenario - no steps yet indent(2, "@todo"), // cust - tag is repeated - indent(2, "Scenario: user is able to reset his password"), + indent(2, "Scenario: user is able to reset his password") + "\n", + // scenario outline + testLexerSamples["scenario_outline_with_examples"], }, "\n") p := &parser{ @@ -79,9 +81,22 @@ func Test_parse_feature_file(t *testing.T) { TAGS, SCENARIO, + NEW_LINE, + + SCENARIO_OUTLINE, + GIVEN, + AND, + AND, + WHEN, + THEN, + NEW_LINE, + EXAMPLES, + TABLE_ROW, + TABLE_ROW, + TABLE_ROW, }, t) - ft.assertHasNumScenarios(3, t) + ft.assertHasNumScenarios(4, t) ft.Scenarios[0].assertHasNumTags(2, t) ft.Scenarios[0].assertHasTag("global-one", t) @@ -96,4 +111,6 @@ func Test_parse_feature_file(t *testing.T) { ft.Scenarios[2].assertHasTag("global-one", t) ft.Scenarios[2].assertHasTag("cust", t) ft.Scenarios[2].assertHasTag("todo", t) + + ft.Scenarios[3].assertHasNumTags(2, t) } diff --git a/gherkin/scenario_test.go b/gherkin/scenario_test.go new file mode 100644 index 0000000..cd8cd20 --- /dev/null +++ b/gherkin/scenario_test.go @@ -0,0 +1,77 @@ +package gherkin + +import ( + "strings" + "testing" +) + +func (s *Scenario) assertTitle(title string, t *testing.T) { + if s.Title != title { + t.Fatalf("expected scenario title to be '%s', but got '%s'", title, s.Title) + } +} + +func (s *Scenario) assertStep(text string, t *testing.T) *Step { + for _, stp := range s.Steps { + if stp.Text == text { + return stp + } + } + t.Fatal("expected scenario '%s' to have step: '%s', but it did not", s.Title, text) + return nil +} + +func (s *Scenario) assertExampleRow(t *testing.T, num int, cols ...string) { + if s.Examples == nil { + t.Fatalf("outline scenario '%s' has no examples", s.Title) + } + if len(s.Examples.rows) <= num { + t.Fatalf("outline scenario '%s' table has no row: %d", s.Title, num) + } + if len(s.Examples.rows[num]) != len(cols) { + t.Fatalf("outline scenario '%s' table row length, does not match expected: %d", s.Title, len(cols)) + } + for i, col := range s.Examples.rows[num] { + if col != cols[i] { + t.Fatalf("outline scenario '%s' table row %d, column %d - value '%s', does not match expected: %s", s.Title, num, i, col, cols[i]) + } + } +} + +func Test_parse_scenario_outline(t *testing.T) { + + p := &parser{ + lx: newLexer(strings.NewReader(testLexerSamples["scenario_outline_with_examples"])), + path: "usual.feature", + ast: newAST(), + } + s, err := p.parseScenario() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + s.assertTitle("ls supports kinds of options", t) + + p.ast.assertMatchesTypes([]TokenType{ + SCENARIO_OUTLINE, + GIVEN, + AND, + AND, + WHEN, + THEN, + NEW_LINE, + EXAMPLES, + TABLE_ROW, + TABLE_ROW, + TABLE_ROW, + }, t) + + s.assertStep(`I am in a directory "test"`, t) + s.assertStep(`I have a file named "foo"`, t) + s.assertStep(`I have a file named "bar"`, t) + s.assertStep(`I run "ls" with options ""`, t) + s.assertStep(`I should see ""`, t) + + s.assertExampleRow(t, 0, "options", "result") + s.assertExampleRow(t, 1, "-t", "bar foo") + s.assertExampleRow(t, 2, "-tr", "foo bar") +} diff --git a/gherkin/token.go b/gherkin/token.go index 25c22b4..42933d5 100644 --- a/gherkin/token.go +++ b/gherkin/token.go @@ -20,6 +20,8 @@ const ( FEATURE BACKGROUND SCENARIO + SCENARIO_OUTLINE + EXAMPLES steps GIVEN @@ -51,6 +53,10 @@ func (t TokenType) String() string { return "background" case SCENARIO: return "scenario" + case SCENARIO_OUTLINE: + return "scenario outline" + case EXAMPLES: + return "examples" case GIVEN: return "given step" case WHEN: