/*
* The MIT License
*
* Copyright (c) 2010 tap4j team (see AUTHORS)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.tap4j.parser;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.UnsupportedCharsetException;
import java.util.Map;
import java.util.Scanner;
import java.util.Stack;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.tap4j.model.BailOut;
import org.tap4j.model.Comment;
import org.tap4j.model.Footer;
import org.tap4j.model.Header;
import org.tap4j.model.Plan;
import org.tap4j.model.TapElement;
import org.tap4j.model.TapElementFactory;
import org.tap4j.model.TestResult;
import org.tap4j.model.TestSet;
import org.tap4j.model.Text;
import org.yaml.snakeyaml.Yaml;
/**
* TAP 13 parser.
*
* @since 1.0
*/
public class Tap13Parser implements Parser {
/**
* Logger.
*/
private static final Logger LOGGER = Logger.getLogger(Tap13Parser.class
.getCanonicalName());
/**
* UTF-8 encoding constant.
*/
private static final String UTF8_ENCODING = "UTF-8";
/**
* Stack of stream status information bags. Every bag stores state of the parser
* related to certain indentation level. This is to support subtest feature.
*/
private Stack<StreamStatus> states = new Stack<StreamStatus>();
/**
* The current state.
*/
private StreamStatus state = null;
private int baseIndentation;
/**
* Decoder used. This is only used when trying to parse something that is
* encoded (like a raw file or byte stream) and the encoding isn't otherwise
* known.
*/
private CharsetDecoder decoder;
/**
* Require a TAP plan.
*/
private boolean planRequired = true;
/**
* Enable subtests.
*/
private boolean enableSubtests = true;
/**
* Parser Constructor.
*
* A parser constructed this way will enforce that any input should include
* a plan.
*
* @param encoding Encoding. This will not matter when parsing sources that
* are already decoded (e.g. {@link String} or {@link Readable}), but it
* will be used in the {@link #parseFile} method (whether or not it is the
* right encoding for the File being parsed).
* @param enableSubtests Whether subtests are enabled or not
*/
public Tap13Parser(String encoding, boolean enableSubtests) {
this(encoding, enableSubtests, true);
}
/**
* Parser Constructor.
*
* @param encoding Encoding. This will not matter when parsing sources that
* are already decoded (e.g. {@link String} or {@link Readable}), but it
* will be used in the {@link #parseFile} method (whether or not it is the
* right encoding for the File being parsed).
* @param enableSubtests Whether subtests are enabled or not
* @param planRequired flag that defines whether a plan is required or not
*/
public Tap13Parser(String encoding, boolean enableSubtests, boolean planRequired) {
super();
/*
* Resolving the encoding name to a CharsetDecoder here has two
* benefits. First, if it isn't known or supported, the caller finds out
* as early as possible. Second, a decoder obtained this way will check
* the validity of its input and throw exceptions if it doesn't match
* the encoding. All the other ways to specify an encoding result in a
* default behavior to silently drop or change data ... not great in a
* testing tool.
*/
try {
if (null != encoding) {
this.decoder = Charset.forName(encoding).newDecoder();
}
} catch (UnsupportedCharsetException uce) {
throw new ParserException(String.format("Invalid encoding: %s", encoding), uce);
}
this.enableSubtests = enableSubtests;
this.planRequired = planRequired;
}
/**
* Parser Constructor.
*
* A parser created with this constructor will assume that any input to the
* {@link #parseFile} method is encoded in {@code UTF-8}.
*
* @param enableSubtests Whether subtests are enabled or not
*/
public Tap13Parser(boolean enableSubtests) {
this(UTF8_ENCODING, enableSubtests);
}
/**
* Parser Constructor.
*
* A parser created with this constructor will assume that any input to the
* {@link #parseFile} method is encoded in {@code UTF-8}, and will not
* recognize subtests.
*/
public Tap13Parser() {
this(UTF8_ENCODING, false);
}
/**
* Saves the current state in the stack.
* @param indentation state indentation
*/
private void pushState(int indentation) {
states.push(state);
state = new StreamStatus();
state.setIndentationLevel(indentation);
}
/**
* {@inheritDoc}
*/
@Override
public TestSet parseTapStream(String tapStream) {
return parseTapStream(CharBuffer.wrap(tapStream));
}
/**
* {@inheritDoc}
*/
@Override
public TestSet parseFile(File tapFile) {
if (null == decoder) {
throw new ParserException(
"Must have encoding specified if using parseFile");
}
try (
FileInputStream fis = new FileInputStream(tapFile);
InputStreamReader isr = new InputStreamReader(fis, decoder);
) {
return parseTapStream(isr);
} catch (FileNotFoundException e) {
throw new ParserException("TAP file not found: " + tapFile, e);
} catch (IOException e) {
LOGGER.log(Level.SEVERE, String.format("Failed to close file stream: %s", e.getMessage()), e);
throw new ParserException(String.format("Failed to close file stream for file %s: %s: ",
tapFile, e.getMessage()), e);
}
}
/**
* {@inheritDoc}
*/
@Override
public TestSet parseTapStream(Readable tapStream) {
state = new StreamStatus();
baseIndentation = Integer.MAX_VALUE;
try (Scanner scanner = new Scanner(tapStream)) {
while (scanner.hasNextLine()) {
String line = scanner.nextLine();
if (line != null && line.length() > 0) {
parseLine(line);
}
}
onFinish();
} catch (Exception e) {
throw new ParserException(String.format("Error parsing TAP Stream: %s", e.getMessage()), e);
}
return state.getTestSet();
}
/**
* Parse a TAP line.
*
* @param tapLine TAP line
*/
public void parseLine(String tapLine) {
TapElement tapElement = TapElementFactory.createTapElement(tapLine);
if (tapElement == null || state.isInYaml()) {
String trimmedLine = tapLine.trim();
Text text = TapElementFactory.createTextElement(tapLine);
if (state.isInYaml()) {
boolean yamlEndMarkReached = trimmedLine.equals("...") && (
tapLine.equals(state.getYamlIndentation() + "...")
|| text.getIndentation() < state.getYamlIndentation().length());
if (yamlEndMarkReached) {
state.setInYaml(false);
parseDiagnostics();
} else {
state.getDiagnosticBuffer().append(tapLine);
state.getDiagnosticBuffer().append('\n');
}
} else {
if (trimmedLine.equals("---")) {
if (text.getIndentation() < baseIndentation) {
throw new ParserException(String.format("Invalid indentation. Check your TAP Stream. Line: %s",
tapLine));
}
state.setInYaml(true);
state.setYamlIndentation(text.getIndentationString());
} else {
state.getTestSet().getTapLines().add(text);
state.setLastParsedElement(text);
}
}
return;
}
int indentation = tapElement.getIndentation();
if (indentation < baseIndentation) {
baseIndentation = indentation;
}
if (indentation != state.getIndentationLevel()) { // indentation changed
if (indentation > state.getIndentationLevel()) {
StreamStatus parentState = state;
pushState(indentation); // make room for children
TapElement lastParentElement = parentState.getLastParsedElement();
if (lastParentElement instanceof TestResult) {
final TestResult lastTestResult = (TestResult) lastParentElement;
// whatever test set comes should be attached to parent
if (lastTestResult.getSubtest() == null && this.enableSubtests) {
lastTestResult.setSubtest(state.getTestSet());
state.attachedToParent = true;
}
}
} else {
// going down
do {
StreamStatus prevState = state;
state = states.pop();
if (!prevState.attachedToParent && this.enableSubtests) {
state.looseSubtests = prevState.getTestSet();
}
// there could be more than one level diff
} while (indentation < state.getIndentationLevel());
}
}
if (tapElement instanceof Header) {
if (state.getTestSet().getHeader() != null) {
throw new ParserException("Duplicated TAP Header found.");
}
if (!state.isFirstLine()) {
throw new ParserException(
"Invalid position of TAP Header. It must be the first "
+ "element (apart of Comments) in the TAP Stream.");
}
state.getTestSet().setHeader((Header) tapElement);
} else if (tapElement instanceof Plan) {
Plan currentPlan = (Plan) tapElement;
if (state.getTestSet().getPlan() != null) {
if (currentPlan.getInitialTestNumber() != 1 || currentPlan.getLastTestNumber() != 0) {
throw new ParserException("Duplicated TAP Plan found.");
}
} else {
state.getTestSet().setPlan(currentPlan);
}
if (state.getTestSet().getTestResults().size() <= 0
&& state.getTestSet().getBailOuts().size() <= 0) {
state.setPlanBeforeTestResult(true);
}
} else if (tapElement instanceof TestResult) {
parseDiagnostics();
final TestResult testResult = (TestResult) tapElement;
if (testResult.getTestNumber() == 0) {
if (state.getTestSet().getPlan() != null && state.isPlanBeforeTestResult() == false) {
return; // done testing mark
}
if (state.getTestSet().getPlan() != null &&
state.getTestSet().getPlan().getLastTestNumber() == state.getTestSet().getTestResults().size()) {
return; // done testing mark but plan before test result
}
testResult.setTestNumber(state.getTestSet().getNextTestNumber());
}
state.getTestSet().addTestResult(testResult);
if (state.looseSubtests != null && this.enableSubtests) {
testResult.setSubtest(state.looseSubtests);
state.looseSubtests = null;
}
} else if (tapElement instanceof Footer) {
state.getTestSet().setFooter((Footer) tapElement);
} else if (tapElement instanceof BailOut) {
state.getTestSet().addBailOut((BailOut) tapElement);
} else if (tapElement instanceof Comment) {
final Comment comment = (Comment) tapElement;
state.getTestSet().addComment(comment);
if (state.getLastParsedElement() instanceof TestResult) {
((TestResult) state.getLastParsedElement()).addComment(comment);
}
}
state.setFirstLine(false);
if (!(tapElement instanceof Comment)) {
state.setLastParsedElement(tapElement);
}
}
/**
* Called after the rest of the stream has been processed.
*/
private void onFinish() {
if (planRequired && state.getTestSet().getPlan() == null) {
throw new ParserException("Missing TAP Plan.");
}
parseDiagnostics();
while (!states.isEmpty() && state.getIndentationLevel() > baseIndentation) {
state = states.pop();
}
}
/* -- Utility methods --*/
/**
* <p>
* Checks if there is any diagnostic information on the diagnostic buffer.
* </p>
* <p>
* If so, tries to parse it using snakeyaml.
* </p>
*/
private void parseDiagnostics() {
// If we found any meta, then process it with SnakeYAML
if (state.getDiagnosticBuffer().length() > 0) {
if (state.getLastParsedElement() == null) {
throw new ParserException("Found diagnostic information without a previous TAP element.");
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> metaIterable = (Map<String, Object>) new Yaml()
.load(state.getDiagnosticBuffer().toString());
state.getLastParsedElement().setDiagnostic(metaIterable);
} catch (Exception ex) {
throw new ParserException(String.format("Error parsing YAML [%s]: %s",
state.getDiagnosticBuffer().toString(), ex.getMessage()), ex);
}
this.state.getDiagnosticBuffer().setLength(0);
}
}
}