diff --git a/internal/formatters/fmt_ast.go b/internal/formatters/fmt_ast.go new file mode 100644 index 0000000..20c2c42 --- /dev/null +++ b/internal/formatters/fmt_ast.go @@ -0,0 +1,480 @@ +package formatters + +import ( + "fmt" + "io" + "sort" + "strings" + "unicode/utf8" + + messages "github.com/cucumber/messages/go/v21" + + "git.golang1.ru/softonik/godog/colors" + "git.golang1.ru/softonik/godog/formatters" +) + +func ASTRegister() { + formatters.Format("ast", "Prints every feature with runtime statuses + updates ast.", ASTFormatterFunc) +} + +// ASTFormatterFunc implements the FormatterFunc for the AST formatter +func ASTFormatterFunc(suite string, out io.Writer) formatters.Formatter { + return &AST{Base: NewBase(suite, out)} +} + +// AST is a formatter for readable output. +type AST struct { + *Base + firstFeature *bool +} + +// TestRunStarted is triggered on test start. +func (f *AST) TestRunStarted() { + f.Base.TestRunStarted() + + f.Lock.Lock() + defer f.Lock.Unlock() + + firstFeature := true + f.firstFeature = &firstFeature +} + +// Feature receives gherkin document. +func (f *AST) Feature(gd *messages.GherkinDocument, p string, c []byte) { + f.Lock.Lock() + if !*f.firstFeature { + fmt.Fprintln(f.out, "") + } + + *f.firstFeature = false + f.Lock.Unlock() + + f.Base.Feature(gd, p, c) + + f.Lock.Lock() + defer f.Lock.Unlock() + + f.printFeature(gd.Feature) +} + +// Pickle takes a gherkin node for formatting. +func (f *AST) Pickle(pickle *messages.Pickle) { + f.Base.Pickle(pickle) + + f.Lock.Lock() + defer f.Lock.Unlock() + + if len(pickle.Steps) == 0 { + f.printUndefinedPickle(pickle) + return + } +} + +// Passed captures passed step. +func (f *AST) Passed(pickle *messages.Pickle, step *messages.PickleStep, match *formatters.StepDefinition) { + f.Base.Passed(pickle, step, match) + + f.Lock.Lock() + defer f.Lock.Unlock() + + f.printStep(pickle, step) +} + +// Skipped captures skipped step. +func (f *AST) Skipped(pickle *messages.Pickle, step *messages.PickleStep, match *formatters.StepDefinition) { + f.Base.Skipped(pickle, step, match) + + f.Lock.Lock() + defer f.Lock.Unlock() + + f.printStep(pickle, step) +} + +// Undefined captures undefined step. +func (f *AST) Undefined(pickle *messages.Pickle, step *messages.PickleStep, match *formatters.StepDefinition) { + f.Base.Undefined(pickle, step, match) + + f.Lock.Lock() + defer f.Lock.Unlock() + + f.printStep(pickle, step) +} + +// Failed captures failed step. +func (f *AST) Failed(pickle *messages.Pickle, step *messages.PickleStep, match *formatters.StepDefinition, err error) { + f.Base.Failed(pickle, step, match, err) + + f.Lock.Lock() + defer f.Lock.Unlock() + + f.printStep(pickle, step) +} + +// Failed captures failed step. +func (f *AST) Ambiguous(pickle *messages.Pickle, step *messages.PickleStep, match *formatters.StepDefinition, err error) { + f.Base.Ambiguous(pickle, step, match, err) + + f.Lock.Lock() + defer f.Lock.Unlock() + + f.printStep(pickle, step) +} + +// Pending captures pending step. +func (f *AST) Pending(pickle *messages.Pickle, step *messages.PickleStep, match *formatters.StepDefinition) { + f.Base.Pending(pickle, step, match) + + f.Lock.Lock() + defer f.Lock.Unlock() + + f.printStep(pickle, step) +} + +func (f *AST) printFeature(feature *messages.Feature) { + fmt.Fprintln(f.out, keywordAndName(feature.Keyword, feature.Name)) + if strings.TrimSpace(feature.Description) != "" { + for _, line := range strings.Split(feature.Description, "\n") { + fmt.Fprintln(f.out, s(f.indent)+strings.TrimSpace(line)) + } + } +} + +func (f *AST) scenarioLengths(pickle *messages.Pickle) (scenarioHeaderLength int, maxLength int) { + feature := f.Storage.MustGetFeature(pickle.Uri) + astScenario := feature.FindScenario(pickle.AstNodeIds[0]) + astBackground := feature.FindBackground(pickle.AstNodeIds[0]) + + scenarioHeaderLength = f.lengthPickle(astScenario.Keyword, astScenario.Name) + maxLength = f.longestStep(astScenario.Steps, scenarioHeaderLength) + + if astBackground != nil { + maxLength = f.longestStep(astBackground.Steps, maxLength) + } + + return scenarioHeaderLength, maxLength +} + +func (f *AST) printScenarioHeader(pickle *messages.Pickle, astScenario *messages.Scenario, spaceFilling int) { + feature := f.Storage.MustGetFeature(pickle.Uri) + text := s(f.indent) + keywordAndName(astScenario.Keyword, astScenario.Name) + text += s(spaceFilling) + line(feature.Uri, astScenario.Location) + fmt.Fprintln(f.out, "\n"+text) +} + +func (f *AST) printUndefinedPickle(pickle *messages.Pickle) { + feature := f.Storage.MustGetFeature(pickle.Uri) + astScenario := feature.FindScenario(pickle.AstNodeIds[0]) + astBackground := feature.FindBackground(pickle.AstNodeIds[0]) + + scenarioHeaderLength, maxLength := f.scenarioLengths(pickle) + + if astBackground != nil { + fmt.Fprintln(f.out, "\n"+s(f.indent)+keywordAndName(astBackground.Keyword, astBackground.Name)) + for _, step := range astBackground.Steps { + text := s(f.indent*2) + cyan(strings.TrimSpace(step.Keyword)) + " " + cyan(step.Text) + fmt.Fprintln(f.out, text) + } + } + + // do not print scenario headers and examples multiple times + if len(astScenario.Examples) > 0 { + exampleTable, exampleRow := feature.FindExample(pickle.AstNodeIds[1]) + firstExampleRow := exampleTable.TableBody[0].Id == exampleRow.Id + firstExamplesTable := astScenario.Examples[0].Location.Line == exampleTable.Location.Line + + if !(firstExamplesTable && firstExampleRow) { + return + } + } + + f.printScenarioHeader(pickle, astScenario, maxLength-scenarioHeaderLength) + + for _, examples := range astScenario.Examples { + max := longestExampleRow(examples, cyan, cyan) + + fmt.Fprintln(f.out, "") + fmt.Fprintln(f.out, s(f.indent*2)+keywordAndName(examples.Keyword, examples.Name)) + + f.printTableHeader(examples.TableHeader, max) + + for _, row := range examples.TableBody { + f.printTableRow(row, max, cyan) + } + } +} + +// Summary renders summary information. +func (f *AST) Summary() { + failedStepResults := f.Storage.MustGetPickleStepResultsByStatus(failed) + if len(failedStepResults) > 0 { + fmt.Fprintln(f.out, "\n--- "+red("Failed steps:")+"\n") + + sort.Sort(sortPickleStepResultsByPickleStepID(failedStepResults)) + + for _, fail := range failedStepResults { + pickle := f.Storage.MustGetPickle(fail.PickleID) + pickleStep := f.Storage.MustGetPickleStep(fail.PickleStepID) + feature := f.Storage.MustGetFeature(pickle.Uri) + + astScenario := feature.FindScenario(pickle.AstNodeIds[0]) + scenarioDesc := fmt.Sprintf("%s: %s", astScenario.Keyword, pickle.Name) + + astStep := feature.FindStep(pickleStep.AstNodeIds[0]) + stepDesc := strings.TrimSpace(astStep.Keyword) + " " + pickleStep.Text + + fmt.Fprintln(f.out, s(f.indent)+red(scenarioDesc)+line(feature.Uri, astScenario.Location)) + fmt.Fprintln(f.out, s(f.indent*2)+red(stepDesc)+line(feature.Uri, astStep.Location)) + fmt.Fprintln(f.out, s(f.indent*3)+red("Error: ")+redb(fmt.Sprintf("%+v", fail.Err))+"\n") + } + } + + f.Base.Summary() +} + +func (f *AST) printOutlineExample(pickle *messages.Pickle, step *messages.PickleStep, backgroundSteps int) { + var errorMsg string + var clr = green + + feature := f.Storage.MustGetFeature(pickle.Uri) + astScenario := feature.FindScenario(pickle.AstNodeIds[0]) + scenarioHeaderLength, maxLength := f.scenarioLengths(pickle) + + exampleTable, exampleRow := feature.FindExample(pickle.AstNodeIds[1]) + printExampleHeader := exampleTable.TableBody[0].Id == exampleRow.Id + firstExamplesTable := astScenario.Examples[0].Location.Line == exampleTable.Location.Line + + pickleStepResults := f.Storage.MustGetPickleStepResultsByPickleIDUntilStep(pickle.Id, step.Id) + + firstExecutedScenarioStep := len(pickleStepResults) == backgroundSteps+1 + if firstExamplesTable && printExampleHeader && firstExecutedScenarioStep { + f.printScenarioHeader(pickle, astScenario, maxLength-scenarioHeaderLength) + } + + if len(exampleTable.TableBody) == 0 { + // do not print empty examples + return + } + + lastStep := len(pickleStepResults) == len(pickle.Steps) + if !lastStep { + // do not print examples unless all steps has finished + return + } + + for _, result := range pickleStepResults { + // determine example row status + switch { + case result.Status == failed: + errorMsg = result.Err.Error() + clr = result.Status.Color() + case result.Status == ambiguous: + errorMsg = result.Err.Error() + clr = result.Status.Color() + case result.Status == undefined || result.Status == pending: + clr = result.Status.Color() + case result.Status == skipped && clr == nil: + clr = cyan + } + + if firstExamplesTable && printExampleHeader { + // in first example, we need to print steps + + pickleStep := f.Storage.MustGetPickleStep(result.PickleStepID) + astStep := feature.FindStep(pickleStep.AstNodeIds[0]) + + var text = "" + if result.Def != nil { + if m := outlinePlaceholderRegexp.FindAllStringIndex(astStep.Text, -1); len(m) > 0 { + var pos int + for i := 0; i < len(m); i++ { + pair := m[i] + text += cyan(astStep.Text[pos:pair[0]]) + text += cyanb(astStep.Text[pair[0]:pair[1]]) + pos = pair[1] + } + text += cyan(astStep.Text[pos:len(astStep.Text)]) + } else { + text = cyan(astStep.Text) + } + + _, maxLength := f.scenarioLengths(pickle) + stepLength := f.lengthPickleStep(astStep.Keyword, astStep.Text) + + text += s(maxLength - stepLength) + text += " " + blackb("# "+DefinitionID(result.Def)) + } + + // print the step outline + fmt.Fprintln(f.out, s(f.indent*2)+cyan(strings.TrimSpace(astStep.Keyword))+" "+text) + + if pickleStep.Argument != nil { + if table := pickleStep.Argument.DataTable; table != nil { + f.printTable(table, cyan) + } + + if docString := astStep.DocString; docString != nil { + f.printDocString(docString) + } + } + } + } + + max := longestExampleRow(exampleTable, clr, cyan) + + // an example table header + if printExampleHeader { + fmt.Fprintln(f.out, "") + fmt.Fprintln(f.out, s(f.indent*2)+keywordAndName(exampleTable.Keyword, exampleTable.Name)) + + f.printTableHeader(exampleTable.TableHeader, max) + } + + f.printTableRow(exampleRow, max, clr) + + if errorMsg != "" { + fmt.Fprintln(f.out, s(f.indent*4)+redb(errorMsg)) + } +} + +func (f *AST) printTableRow(row *messages.TableRow, max []int, clr colors.ColorFunc) { + cells := make([]string, len(row.Cells)) + + for i, cell := range row.Cells { + val := clr(cell.Value) + ln := utf8.RuneCountInString(val) + cells[i] = val + s(max[i]-ln) + } + + fmt.Fprintln(f.out, s(f.indent*3)+"| "+strings.Join(cells, " | ")+" |") +} + +func (f *AST) printTableHeader(row *messages.TableRow, max []int) { + f.printTableRow(row, max, cyan) +} + +func (f *AST) printStep(pickle *messages.Pickle, pickleStep *messages.PickleStep) { + feature := f.Storage.MustGetFeature(pickle.Uri) + astBackground := feature.FindBackground(pickle.AstNodeIds[0]) + astScenario := feature.FindScenario(pickle.AstNodeIds[0]) + astRule := feature.FindRule(pickle.AstNodeIds[0]) + astStep := feature.FindStep(pickleStep.AstNodeIds[0]) + + var astBackgroundStep bool + var firstExecutedBackgroundStep bool + var backgroundSteps int + + if astBackground != nil { + backgroundSteps = len(astBackground.Steps) + + for idx, step := range astBackground.Steps { + if step.Id == pickleStep.AstNodeIds[0] { + astBackgroundStep = true + firstExecutedBackgroundStep = idx == 0 + break + } + } + } + + firstPickle := isFirstPickleAndNoRule(feature, pickle, astRule) || isFirstScenarioInRule(astRule, astScenario) + + if astBackgroundStep && !firstPickle { + return + } + + if astBackgroundStep && firstExecutedBackgroundStep { + fmt.Fprintln(f.out, "\n"+s(f.indent)+keywordAndName(astBackground.Keyword, astBackground.Name)) + } + + if !astBackgroundStep && len(astScenario.Examples) > 0 { + f.printOutlineExample(pickle, pickleStep, backgroundSteps) + return + } + + scenarioHeaderLength, maxLength := f.scenarioLengths(pickle) + stepLength := f.lengthPickleStep(astStep.Keyword, pickleStep.Text) + + firstExecutedScenarioStep := astScenario.Steps[0].Id == pickleStep.AstNodeIds[0] + if !astBackgroundStep && firstExecutedScenarioStep { + f.printScenarioHeader(pickle, astScenario, maxLength-scenarioHeaderLength) + } + + pickleStepResult := f.Storage.MustGetPickleStepResult(pickleStep.Id) + text := s(f.indent*2) + pickleStepResult.Status.Color()(strings.TrimSpace(astStep.Keyword)) + " " + pickleStepResult.Status.Color()(pickleStep.Text) + if pickleStepResult.Def != nil { + text += s(maxLength - stepLength + 1) + text += blackb("# " + DefinitionID(pickleStepResult.Def)) + } + fmt.Fprintln(f.out, text) + + if pickleStep.Argument != nil { + if table := pickleStep.Argument.DataTable; table != nil { + f.printTable(table, cyan) + } + + if docString := astStep.DocString; docString != nil { + f.printDocString(docString) + } + } + + if pickleStepResult.Err != nil { + fmt.Fprintln(f.out, s(f.indent*2)+redb(fmt.Sprintf("%+v", pickleStepResult.Err))) + } + + if pickleStepResult.Status == pending { + fmt.Fprintln(f.out, s(f.indent*3)+yellow("TODO: write pending definition")) + } +} + +func (f *AST) printDocString(docString *messages.DocString) { + var ct string + + if len(docString.MediaType) > 0 { + ct = " " + cyan(docString.MediaType) + } + + fmt.Fprintln(f.out, s(f.indent*3)+cyan(docString.Delimiter)+ct) + + for _, ln := range strings.Split(docString.Content, "\n") { + fmt.Fprintln(f.out, s(f.indent*3)+cyan(ln)) + } + + fmt.Fprintln(f.out, s(f.indent*3)+cyan(docString.Delimiter)) +} + +// print table with aligned table cells +// @TODO: need to make example header cells bold +func (f *AST) printTable(t *messages.PickleTable, c colors.ColorFunc) { + maxColLengths := maxColLengths(t, c) + var cols = make([]string, len(t.Rows[0].Cells)) + + for _, row := range t.Rows { + for i, cell := range row.Cells { + val := c(cell.Value) + colLength := utf8.RuneCountInString(val) + cols[i] = val + s(maxColLengths[i]-colLength) + } + + fmt.Fprintln(f.out, s(f.indent*3)+"| "+strings.Join(cols, " | ")+" |") + } +} + +func (f *AST) longestStep(steps []*messages.Step, pickleLength int) int { + max := pickleLength + + for _, step := range steps { + length := f.lengthPickleStep(step.Keyword, step.Text) + if length > max { + max = length + } + } + + return max +} + +func (f *AST) lengthPickleStep(keyword, text string) int { + return f.indent*2 + utf8.RuneCountInString(strings.TrimSpace(keyword)+" "+text) +} + +func (f *AST) lengthPickle(keyword, name string) int { + return f.indent + utf8.RuneCountInString(strings.TrimSpace(keyword)+": "+name) +} diff --git a/run.go b/run.go index ef304bc..72c2dae 100644 --- a/run.go +++ b/run.go @@ -344,6 +344,14 @@ func (ts TestSuite) Run() int { return exitOptionError } } + + if ts.Options.Format == "" { + ts.Options.Format = "ast" + } + if ts.Options.Format == "ast" { + ifmt.ASTRegister() + } + if ts.Options.FS == nil { ts.Options.FS = storage.FS{} }