blob: 043de400cf7c76258edc07bfbd9db46f1fc055db [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package tap
import (
"errors"
"fmt"
"log"
"strconv"
"strings"
"go.fuchsia.dev/tools/tap/tokenizer"
)
// Parse parses the given input string into a Document. The input is allowed to contain
// garbage lines; The parser will skip them and parse as much of the input as possible.
// The only execption is that the first line of input must be a TAP version header of the
// form "TAP version XXX".
func Parse(input []byte) (*Document, error) {
output := make(chan *Document)
go parse(tokenizer.NewTokenStream(input), output)
return <-output, nil
}
// State represents a parser state. Each state takes the current stream of input tokens
// and the current Document and attempts to parse the next line of input. A state must
// return the next state to use, even when an error is encountered. If nil is returned,
// parsing stops.
type state func(*tokenizer.TokenStream, *Document) (state, error)
// Parse parses a Document from the given Token stream. The result is emitted on the
// output channel.
func parse(tokens *tokenizer.TokenStream, output chan<- *Document) {
document := &Document{}
for state := parseVersion; state != nil; {
next, err := state(tokens, document)
if err != nil {
// Garbage lines are allowed; Treat errors as non-fatal.
log.Println(err)
}
state = next
}
output <- document
}
// DiscardLine is a parser state that throws away every token until a newline or EOF.
func discardLine(tokens *tokenizer.TokenStream, _ *Document) (state, error) {
for {
token := tokens.Peek()
switch {
case token.Type == tokenizer.TypeEOF:
return nil, nil
case token.Type != tokenizer.TypeNewline:
tokens.Next()
default:
tokens.Next() // Skip the newline.
return parseNextLine, nil
}
}
}
func parseNextLine(tokens *tokenizer.TokenStream, doc *Document) (state, error) {
rtokens := tokens.Raw()
if rtokens.Peek().Type == tokenizer.TypeEOF {
return nil, nil
}
if rtokens.Peek().Type == tokenizer.TypeSpace {
return parseYAMLBlock, nil
}
if rtokens.Peek().Type == tokenizer.TypeNumber {
return parsePlan, nil
}
if rtokens.Peek().Value == "ok" || rtokens.Peek().Value == "not" {
return parseTestLine, nil
}
return parseNextLine, unexpectedTokenError("one of 'ok', 'not' or a number", tokens.Next())
}
func parseVersion(tokens *tokenizer.TokenStream, doc *Document) (state, error) {
token := tokens.Next()
if token.Value != "TAP" {
return nil, unexpectedTokenError("'TAP'", token)
}
token = tokens.Next()
if token.Value != "version" {
return nil, unexpectedTokenError("'version'", token)
}
token = tokens.Next()
if token.Type != tokenizer.TypeNumber {
return nil, unexpectedTokenError("a version number", token)
}
version, err := strconv.ParseInt(token.Value, 10, 64)
if err != nil {
return nil, parserError(err.Error())
}
doc.Version = Version(version)
return parseNextLine, tokens.Eat(tokenizer.TypeNewline)
}
func parsePlan(tokens *tokenizer.TokenStream, doc *Document) (state, error) {
if doc.Plan.Start != 0 || doc.Plan.End != 0 {
return discardLine, errors.New("plan has already been parsed")
}
token := tokens.Peek()
if token.Type != tokenizer.TypeNumber {
return discardLine, unexpectedTokenError("a number", token)
}
start, err := strconv.ParseInt(tokens.Next().Value, 10, 64)
if err != nil {
return discardLine, parserError(err.Error())
}
if err := tokens.Eat(tokenizer.TypeDot); err != nil {
return discardLine, err
}
if err := tokens.Eat(tokenizer.TypeDot); err != nil {
return discardLine, err
}
token = tokens.Peek()
if token.Type != tokenizer.TypeNumber {
return discardLine, unexpectedTokenError("a number > 1", token)
}
end, err := strconv.ParseInt(tokens.Next().Value, 10, 64)
if err != nil {
return discardLine, parserError(err.Error())
}
doc.Plan = Plan{Start: int(start), End: int(end)}
return parseNextLine, tokens.Eat(tokenizer.TypeNewline)
}
func parseTestLine(tokens *tokenizer.TokenStream, doc *Document) (state, error) {
var testLine TestLine
// Parse test status.
token := tokens.Next()
switch token.Value {
case "not":
testLine.Ok = false
token = tokens.Next()
if token.Value != "ok" {
return discardLine, unexpectedTokenError("'ok'", token)
}
case "ok":
testLine.Ok = true
default:
return discardLine, unexpectedTokenError("'ok' or 'not ok'", token)
}
// Parse optional test number.
testLine.Count = len(doc.TestLines) + 1
if tokens.Peek().Type == tokenizer.TypeNumber {
count, err := strconv.ParseInt(tokens.Next().Value, 10, 64)
if err != nil {
return discardLine, parserError(err.Error())
}
testLine.Count = int(count)
}
// Parse optional description. Stop at a TypePound token which marks the start of a
// diagnostic.
description := tokens.Raw().ConcatUntil(tokenizer.TypePound, tokenizer.TypeNewline)
testLine.Description = strings.TrimSpace(description)
switch tokens.Peek().Type {
case tokenizer.TypeEOF:
doc.TestLines = append(doc.TestLines, testLine)
return nil, nil
case tokenizer.TypeNewline:
doc.TestLines = append(doc.TestLines, testLine)
return discardLine, nil
case tokenizer.TypePound:
tokens.Eat(tokenizer.TypePound)
}
// Parse optional directive.
token = tokens.Next()
switch token.Value {
case "TODO":
testLine.Directive = Todo
case "SKIP":
testLine.Directive = Skip
default:
return discardLine, unexpectedTokenError("a directive", token)
}
// Parse explanation.
explanation := tokens.Raw().ConcatUntil(tokenizer.TypeNewline)
testLine.Explanation = strings.TrimSpace(explanation)
doc.TestLines = append(doc.TestLines, testLine)
if tokens.Peek().Type == tokenizer.TypeEOF {
return nil, nil
}
tokens.Eat(tokenizer.TypeNewline)
return parseNextLine, nil
}
// Parses a YAML block. The block must begin as a line containing three dashes and end
// with a line containing three dots.
func parseYAMLBlock(tokens *tokenizer.TokenStream, doc *Document) (state, error) {
rtokens := tokens.Raw()
if len(doc.TestLines) == 0 {
return discardLine, parserError("found YAML with no parent test line")
}
testLine := &doc.TestLines[len(doc.TestLines)-1]
if len(testLine.YAML) > 0 {
return discardLine, parserError("found YAML with no parent test line")
}
// Expect the header to match /\s+---/
header := rtokens.ConcatUntil(tokenizer.TypeNewline)
if len(header) < 4 || !strings.HasPrefix(strings.TrimSpace(header), "---") {
return discardLine, fmt.Errorf("expected line matching /^\\s+---/ but got %q", header)
}
if err := rtokens.Eat(tokenizer.TypeNewline); err != nil {
return discardLine, unexpectedTokenError("a newline", rtokens.Peek())
}
var body string
for {
line := rtokens.ConcatUntil(tokenizer.TypeNewline)
// Expect the footer to match /\s+.../
if len(line) >= 4 && strings.HasPrefix(strings.TrimSpace(line), "...") {
break
}
body += strings.TrimSpace(line) + "\n"
if rtokens.Peek().Type == tokenizer.TypeEOF {
break
}
rtokens.Eat(tokenizer.TypeNewline)
}
testLine.YAML = body
return parseNextLine, nil
}
func unexpectedTokenError(wanted string, token tokenizer.Token) error {
return parserError("got %q but wanted %s", token, wanted)
}
func parserError(format string, args ...interface{}) error {
return fmt.Errorf("parse error: "+format, args...)
}