420 строки
10 КиБ
Go
420 строки
10 КиБ
Go
/*
|
||
Package gherkin is a gherkin language parser based on https://cucumber.io/docs/reference
|
||
specification. It parses a feature file into the it's structural representation. It also
|
||
creates an AST tree of gherkin Tokens read from the file.
|
||
|
||
With gherkin language you can describe your application behavior as features in
|
||
human-readable and machine friendly language.
|
||
|
||
For example, imagine you’re about to create the famous UNIX ls command.
|
||
Before you begin, you describe how the feature should work, see the example below..
|
||
|
||
Example:
|
||
Feature: ls
|
||
In order to see the directory structure
|
||
As a UNIX user
|
||
I need to be able to list the current directory's contents
|
||
|
||
Scenario:
|
||
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"
|
||
Then I should get:
|
||
"""
|
||
bar
|
||
foo
|
||
"""
|
||
|
||
As a developer, your work is done as soon as you’ve made the ls command behave as
|
||
described in the Scenario.
|
||
|
||
To read the feature in the example above..
|
||
|
||
Example:
|
||
package main
|
||
|
||
import (
|
||
"log"
|
||
"os"
|
||
|
||
"github.com/DATA-DOG/godog/gherkin"
|
||
)
|
||
|
||
func main() {
|
||
feature, err := gherkin.Parse("ls.feature")
|
||
switch {
|
||
case err == gherkin.ErrEmpty:
|
||
log.Println("the feature file is empty and does not describe any feature")
|
||
return
|
||
case err != nil:
|
||
log.Println("the feature file is incorrect or could not be read:", err)
|
||
os.Exit(1)
|
||
}
|
||
log.Println("have parsed a feature:", feature.Title, "with", len(feature.Scenarios), "scenarios")
|
||
}
|
||
|
||
Now the feature is available in the structure.
|
||
*/
|
||
package gherkin
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// Tag is gherkin feature or scenario tag.
|
||
// it may be used to filter scenarios.
|
||
//
|
||
// tags may be set for a feature, in that case it will
|
||
// be merged with all scenario tags. or specifically
|
||
// to a single scenario
|
||
type Tag string
|
||
|
||
// Tags is an array of tags
|
||
type Tags []Tag
|
||
|
||
// Has checks whether the tag list has a tag
|
||
func (t Tags) Has(tag Tag) bool {
|
||
for _, tg := range t {
|
||
if tg == tag {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Outline is a scenario outline with an
|
||
// example table. Steps are listed with
|
||
// placeholders which are replaced with
|
||
// each example table row
|
||
type Outline struct {
|
||
*Token
|
||
Scenario *Scenario
|
||
Steps []*Step
|
||
Examples *Table
|
||
}
|
||
|
||
// 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 {
|
||
*Token
|
||
Title string
|
||
Steps []*Step
|
||
Tags Tags
|
||
Outline *Outline
|
||
Feature *Feature
|
||
}
|
||
|
||
// Background steps are run before every scenario
|
||
type Background struct {
|
||
*Token
|
||
Steps []*Step
|
||
Feature *Feature
|
||
}
|
||
|
||
// Step describes a Scenario or Background step
|
||
type Step struct {
|
||
*Token
|
||
Text string
|
||
Type string
|
||
PyString *PyString
|
||
Table *Table
|
||
Scenario *Scenario
|
||
Background *Background
|
||
}
|
||
|
||
// Feature describes the whole feature
|
||
type Feature struct {
|
||
*Token
|
||
Path string
|
||
Tags Tags
|
||
Description string
|
||
Title string
|
||
Background *Background
|
||
Scenarios []*Scenario
|
||
AST []*Token
|
||
}
|
||
|
||
// PyString is a multiline text object used with step definition
|
||
type PyString struct {
|
||
*Token
|
||
Raw string // raw multiline string body
|
||
Lines []string // trimmed lines
|
||
Step *Step
|
||
}
|
||
|
||
// String returns raw multiline string
|
||
func (p *PyString) String() string {
|
||
return p.Raw
|
||
}
|
||
|
||
// Table is a row group object used with
|
||
// step definition or outline scenario
|
||
type Table struct {
|
||
*Token
|
||
Step *Step
|
||
Rows [][]string
|
||
}
|
||
|
||
var allSteps = []TokenType{
|
||
GIVEN,
|
||
WHEN,
|
||
THEN,
|
||
AND,
|
||
BUT,
|
||
}
|
||
|
||
// ErrEmpty is returned in case if feature file
|
||
// is completely empty. May be ignored in some use cases
|
||
var ErrEmpty = errors.New("the feature file is empty")
|
||
|
||
type parser struct {
|
||
lx *lexer
|
||
path string
|
||
ast []*Token
|
||
peeked *Token
|
||
}
|
||
|
||
// ParseFile parses a feature file on the given
|
||
// path into the Feature struct
|
||
// Returns a Feature struct and error if there is any
|
||
func ParseFile(path string) (*Feature, error) {
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer file.Close()
|
||
|
||
return Parse(file, path)
|
||
}
|
||
|
||
// Parse the feature as a given name to the Feature struct
|
||
// Returns a Feature struct and error if there is any
|
||
func Parse(in io.Reader, name string) (*Feature, error) {
|
||
return (&parser{
|
||
lx: newLexer(in),
|
||
path: name,
|
||
}).parseFeature()
|
||
}
|
||
|
||
// reads tokens into AST and skips comments or new lines
|
||
func (p *parser) next() *Token {
|
||
if len(p.ast) > 0 && p.ast[len(p.ast)-1].Type == EOF {
|
||
return p.ast[len(p.ast)-1] // has reached EOF, do not record it more than once
|
||
}
|
||
tok := p.peek()
|
||
p.ast = append(p.ast, tok)
|
||
p.peeked = nil
|
||
return tok
|
||
}
|
||
|
||
// peaks into next token, skips comments or new lines
|
||
func (p *parser) peek() *Token {
|
||
if p.peeked != nil {
|
||
return p.peeked
|
||
}
|
||
|
||
for p.peeked = p.lx.read(); p.peeked.OfType(COMMENT, NEWLINE); p.peeked = p.lx.read() {
|
||
p.ast = append(p.ast, p.peeked) // record comments and newlines
|
||
}
|
||
|
||
return p.peeked
|
||
}
|
||
|
||
func (p *parser) err(s string, l int) error {
|
||
return fmt.Errorf("%s on %s:%d", s, p.path, l)
|
||
}
|
||
|
||
func (p *parser) parseFeature() (ft *Feature, err error) {
|
||
|
||
ft = &Feature{Path: p.path, AST: p.ast}
|
||
switch p.peek().Type {
|
||
case EOF:
|
||
return ft, ErrEmpty
|
||
case TAGS:
|
||
ft.Tags = p.parseTags()
|
||
}
|
||
|
||
tok := p.next()
|
||
if tok.Type != FEATURE {
|
||
return ft, p.err("expected a file to begin with a feature definition, but got '"+tok.Type.String()+"' instead", tok.Line)
|
||
}
|
||
ft.Title = tok.Value
|
||
ft.Token = tok
|
||
|
||
var desc []string
|
||
for ; p.peek().Type == TEXT; tok = p.next() {
|
||
desc = append(desc, p.peek().Text)
|
||
}
|
||
ft.Description = strings.Join(desc, "\n")
|
||
|
||
for tok = p.peek(); tok.Type != EOF; tok = p.peek() {
|
||
// there may be a background
|
||
if tok.Type == BACKGROUND {
|
||
if ft.Background != nil {
|
||
return ft, p.err("there can only be a single background section, but found another", tok.Line)
|
||
}
|
||
|
||
ft.Background = &Background{Token: tok, Feature: ft}
|
||
p.next() // jump to background steps
|
||
if ft.Background.Steps, err = p.parseSteps(); err != nil {
|
||
return ft, err
|
||
}
|
||
for _, step := range ft.Background.Steps {
|
||
step.Background = ft.Background
|
||
}
|
||
tok = p.peek() // peek to scenario or tags
|
||
}
|
||
|
||
// there may be tags before scenario
|
||
var tags Tags
|
||
tags = append(tags, ft.Tags...)
|
||
if tok.Type == TAGS {
|
||
for _, t := range p.parseTags() {
|
||
if !tags.Has(t) {
|
||
tags = append(tags, t)
|
||
}
|
||
}
|
||
tok = p.peek()
|
||
}
|
||
|
||
// there must be a scenario or scenario outline otherwise
|
||
if !tok.OfType(SCENARIO, OUTLINE) {
|
||
if tok.Type == EOF {
|
||
return ft, nil // there may not be a scenario defined after background
|
||
}
|
||
return ft, p.err("expected a scenario or scenario outline, but got '"+tok.Type.String()+"' instead", tok.Line)
|
||
}
|
||
|
||
scenario, err := p.parseScenario()
|
||
if err != nil {
|
||
return ft, err
|
||
}
|
||
|
||
scenario.Tags = tags
|
||
scenario.Feature = ft
|
||
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, Token: tok}
|
||
if s.Steps, err = p.parseSteps(); err != nil {
|
||
return s, err
|
||
}
|
||
for _, step := range s.Steps {
|
||
step.Scenario = s
|
||
}
|
||
if examples := p.peek(); examples.Type == EXAMPLES {
|
||
p.next() // jump over the peeked token
|
||
peek := p.peek()
|
||
if peek.Type != TABLEROW {
|
||
return s, p.err(strings.Join([]string{
|
||
"expected a table row,",
|
||
"but got '" + peek.Type.String() + "' instead, for scenario outline examples",
|
||
}, " "), examples.Line)
|
||
}
|
||
s.Outline = &Outline{
|
||
Token: examples,
|
||
Scenario: s,
|
||
Steps: s.Steps,
|
||
}
|
||
s.Steps = []*Step{} // move steps to outline
|
||
if s.Outline.Examples, err = p.parseTable(); err != nil {
|
||
return s, err
|
||
}
|
||
if len(s.Outline.Examples.Rows) < 2 {
|
||
return s, p.err("expected an example table to have at least two rows: header and at least one example", examples.Line)
|
||
}
|
||
}
|
||
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, Token: tok}
|
||
|
||
p.next() // have read a peeked step
|
||
if step.Text[len(step.Text)-1] == ':' {
|
||
tok = p.peek()
|
||
switch tok.Type {
|
||
case PYSTRING:
|
||
if step.PyString, err = p.parsePystring(); err != nil {
|
||
return steps, err
|
||
}
|
||
step.PyString.Step = step
|
||
case TABLEROW:
|
||
if step.Table, err = p.parseTable(); err != nil {
|
||
return steps, err
|
||
}
|
||
step.Table.Step = step
|
||
default:
|
||
return steps, p.err("pystring or table row was expected, but got: '"+tok.Type.String()+"' instead", tok.Line)
|
||
}
|
||
}
|
||
|
||
steps = append(steps, step)
|
||
}
|
||
|
||
return steps, nil
|
||
}
|
||
|
||
func (p *parser) parsePystring() (*PyString, error) {
|
||
var tok *Token
|
||
started := p.next() // skip the start of pystring
|
||
var lines, trimmed []string
|
||
for tok = p.next(); !tok.OfType(EOF, PYSTRING); tok = p.next() {
|
||
lines = append(lines, tok.Text)
|
||
trimmed = append(trimmed, strings.TrimSpace(tok.Text))
|
||
}
|
||
if tok.Type == EOF {
|
||
return nil, fmt.Errorf("pystring which was opened on %s:%d was not closed", p.path, started.Line)
|
||
}
|
||
return &PyString{
|
||
Raw: strings.Join(lines, "\n"),
|
||
Lines: trimmed,
|
||
}, nil
|
||
}
|
||
|
||
func (p *parser) parseTable() (*Table, error) {
|
||
tbl := &Table{Token: p.peek()}
|
||
for row := p.peek(); row.Type == TABLEROW; 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(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)
|
||
}
|
||
tbl.Rows = append(tbl.Rows, cols)
|
||
p.next() // jump over the peeked token
|
||
}
|
||
return tbl, nil
|
||
}
|
||
|
||
func (p *parser) parseTags() (tags Tags) {
|
||
for _, tag := range strings.Split(p.next().Value, " ") {
|
||
t := Tag(strings.Trim(tag, "@ "))
|
||
if len(t) > 0 && !tags.Has(t) {
|
||
tags = append(tags, t)
|
||
}
|
||
}
|
||
return
|
||
}
|