XtextTestBase.java

package com.itemis.xtext.testing;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.antlr.runtime.ANTLRStringStream;
import org.antlr.runtime.CharStream;
import org.antlr.runtime.Token;
import org.apache.log4j.Logger;
import org.eclipse.emf.common.util.TreeIterator;
import org.eclipse.emf.common.util.URI;
import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.InternalEObject;
import org.eclipse.emf.ecore.resource.Resource;
import org.eclipse.emf.ecore.resource.ResourceSet;
import org.eclipse.emf.ecore.util.EcoreUtil;
import org.eclipse.xtext.EcoreUtil2;
import org.eclipse.xtext.GrammarUtil;
import org.eclipse.xtext.IGrammarAccess;
import org.eclipse.xtext.ParserRule;
import org.eclipse.xtext.nodemodel.INode;
import org.eclipse.xtext.nodemodel.SyntaxErrorMessage;
import org.eclipse.xtext.parser.IParseResult;
import org.eclipse.xtext.parser.IParser;
import org.eclipse.xtext.parser.antlr.ITokenDefProvider;
import org.eclipse.xtext.parser.antlr.Lexer;
import org.eclipse.xtext.parser.antlr.XtextTokenStream;
import org.eclipse.xtext.resource.IResourceServiceProvider;
import org.eclipse.xtext.resource.SaveOptions;
import org.eclipse.xtext.resource.SaveOptions.Builder;
import org.eclipse.xtext.util.EmfFormatter;
import org.eclipse.xtext.util.Pair;
import org.eclipse.xtext.util.Tuples;
import org.eclipse.xtext.validation.CheckMode;
import org.eclipse.xtext.validation.Issue;
import org.junit.After;
import org.junit.Before;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;

import com.google.common.collect.Lists;
import com.google.inject.Inject;

/**
 * <p>
 * Base class for testing Xtext-based DSLs including validation, serialization,
 * formatting, e.g.
 * </p>
 *
 * <p>
 * <code>XtextTestBase</code> offers integration testing of model files (load, validate,
 * serialize, compare) as well as very specific unit-style testing for
 * terminals, keywords and parser rules.
 * </p>
 *
 * @author Karsten Thoms
 * @author Lars Corneliussen
 * @author Markus Voelter
 * @author Alexander Nittka
 * @author Vlad Dumitrescu
 * @author Marius Weth
 *
 */
public abstract class XtextTestBase {

	protected String resourceRoot;

	/* STATE for #testFile. TO BE initialized in #before */
	protected FluentIssueCollection issues;
	private Set<Issue> assertedIssues;
	private boolean compareSerializedModelToInputFile;
	private boolean invokeSerializer;
	private boolean formatOnSerialize;
	private boolean failOnParserWarnings;
	private boolean ignoreOsSpecificNewline;
	private EObject rootElement;
	/* END STATE for #testFile */

	private static Logger LOGGER = Logger.getLogger(XtextTestBase.class);

	@Inject
	protected ResourceSet resourceSet;

	@Inject
	private IResourceServiceProvider.Registry serviceProviderRegistry;

	@Inject
	private IGrammarAccess grammar;

	@Inject
	private IParser parser;

	@Inject
	private Lexer lexer;

	@Inject
	private ITokenDefProvider tokenDefProvider;

	public XtextTestBase() {
		this("/");
	}

	public XtextTestBase(final String resourceRoot) {
		/*
		 * Classpath resolution is weird
		 *
		 * For resources directly in the classpath, you need a starting slash
		 * after 'classpath:/': - classpath://bla.txt
		 *
		 * But if you wan't to point to something in a subfolder, the subfolder
		 * must occur directly after 'classpath:/': - classpath://subfolder
		 *
		 * A trailing slash is optional.
		 */
		if (!resourceRoot.contains(":/")) {
			this.resourceRoot = "classpath:/" + resourceRoot;
		} else {
			this.resourceRoot = resourceRoot;
		}
	}

	@Before
	@Deprecated
	public void before() {
	}

	@BeforeEach
	@Before
	public final void _before() {
		issues = null;
		assertedIssues = new HashSet<Issue>();
		invokeSerializer = true;
		compareSerializedModelToInputFile = true;
		formatOnSerialize = true;
		failOnParserWarnings = true;
	}

	private void ensureIsBeforeTestFile() {
		if (issues != null) {
			throw new RuntimeException("Method " + new Throwable().fillInStackTrace().getStackTrace()[1].getMethodName()
					+ " must be run BEFORE 'testFile' is executed!");
		}
	}

	private void ensureIsAfterTestFile() {
		if (issues == null) {
			throw new RuntimeException("Method " + new Throwable().fillInStackTrace().getStackTrace()[1].getMethodName()
					+ " must be run AFTER 'testFile' is executed!");
		}
	}

	@After
	@Deprecated
	public void after() {
	}

	@After
	@AfterEach
	public void _after() {
		if (issues != null) {
			dumpUnassertedIssues();
			if (issues.except(assertedIssues).getIssues().size() != 0) {
				Assertions.fail("\n\nfound unasserted issues " + issues.except(assertedIssues).getSummary() + "\n\n");
			}
		}
	}

	protected EObject getModelRoot() {
		return rootElement;
	}

	protected FluentIssueCollection testFile(final String fileToTest, final String... referencedResources) {

		LOGGER.info("testing " + fileToTest + " in test method " + this.getClass().getSimpleName() + "."
				+ new Throwable().fillInStackTrace().getStackTrace()[1].getMethodName());

		for (final String referencedResource : referencedResources) {
			final URI uri = URI.createURI(resourceRoot + "/" + referencedResource);
			loadModel(resourceSet, uri, getRootObjectType(uri));
		}

		final Pair<String, FluentIssueCollection> result = loadAndSaveModule(resourceRoot, fileToTest);

		String serialized = result.getFirst();

		if (compareSerializedModelToInputFile) {
			String expected = loadFileContents(resourceRoot, fileToTest);
			if (ignoreOsSpecificNewline) {
				expected = expected.replaceAll("(\r\n|\r)", "\n");
				serialized = serialized.replaceAll("(\r\n|\r)", "\n");
			}
			// Remove trailing whitespace, see Bug#320074
			// todo: Check if the trim really is still necessary!!
			assertEquals(expected.trim(), serialized.trim());
		}

		return issues = result.getSecond();
	}

	protected FluentIssueCollection testFileNoSerializer(final String fileToTest, final String... referencedResources) {
		suppressSerialization();
		return testFile(fileToTest, referencedResources);
	}

	protected void testParserRule(final String textToParse, final String ruleName) {
		testParserRule(textToParse, ruleName, false);
	}

	private List<SyntaxErrorMessage> testParserRule(final String textToParse, final String ruleName,
			final boolean errorsExpected) {

		final ParserRule parserRule = (ParserRule) GrammarUtil.findRuleForName(grammar.getGrammar(), ruleName);

		if (parserRule == null) {
			fail("\n\nCould not find ParserRule " + ruleName + "\n\n");
		}

		final IParseResult result = parser.parse(parserRule, new StringReader(textToParse));

		final ArrayList<SyntaxErrorMessage> errors = Lists.newArrayList();
		final ArrayList<String> errMsg = Lists.newArrayList();

		for (final INode err : result.getSyntaxErrors()) {
			errors.add(err.getSyntaxErrorMessage());
			errMsg.add(err.getSyntaxErrorMessage().getMessage());
		}

		if (!errorsExpected && !errors.isEmpty()) {
			fail("\n\nParsing of text '" + textToParse + "' for rule '" + ruleName + "' failed with errors: " + errMsg
					+ "\n\n");
		}
		if (errorsExpected && errors.isEmpty()) {
			fail("\n\nParsing of text '" + textToParse + "' for rule '" + ruleName
					+ "' was expected to have parse errors.\n\n");
		}

		return errors;
	}

	/**
	 * Test parsing input on a specific rule.
	 *
	 * @param textToParse
	 *            test to parse
	 * @param ruleName
	 *            name of rule to parse text with
	 * @param expectedErrorSubstrings
	 *            optional list of substrings expected to be found in errors. If
	 *            no expected errors are provided, the test passes as long as
	 *            some errors are encountered.
	 */
	protected void testParserRuleErrors(final String textToParse, final String ruleName,
			final String... expectedErrorSubstrings) {
		final List<SyntaxErrorMessage> errors = testParserRule(textToParse, ruleName, true);

		final Set<String> matchingSubstrings = new HashSet<String>();
		final Set<String> assertedErrors = new HashSet<String>();

		boolean hadError = false;
		for (final SyntaxErrorMessage err : errors) {
			for (final String substring : expectedErrorSubstrings) {
				final boolean contains = err.getMessage().contains(substring);
				if (contains) {
					matchingSubstrings.add(substring);
					assertedErrors.add(err.getMessage());
				}
			}
		}

		final StringBuilder error = new StringBuilder();
		if (expectedErrorSubstrings.length != matchingSubstrings.size()) {
			error.append("Unmatched assertions:");
			for (final String string : expectedErrorSubstrings) {
				if (!matchingSubstrings.contains(string)) {
					error.append("\n  - any error containing '" + string + "'");
				}
			}
			error.append("\n");
			hadError = true;
		}

		if (expectedErrorSubstrings.length > 0 && assertedErrors.size() != errors.size()) {
			error.append("Unasserted Errors:");
			for (final SyntaxErrorMessage err : errors) {
				if (!assertedErrors.contains(err.getMessage())) {
					error.append("\n  - " + err.getMessage());
				}
			}
		}

		final String failMessage = error.toString();
		if (hadError || !failMessage.equals("") && failOnParserWarnings) {
			fail("\n\n" + failMessage + "\n\n");
		}
	}

	/**
	 * return the list of tokens created by the lexer from the given input
	 */
	protected List<Token> getTokens(final String input) {
		final CharStream stream = new ANTLRStringStream(input);
		lexer.setCharStream(stream);
		final XtextTokenStream tokenStream = new XtextTokenStream(lexer, tokenDefProvider);
		@SuppressWarnings("unchecked")
		final List<Token> tokens = tokenStream.getTokens();
		return tokens;
	}

	/**
	 * return the name of the terminal rule for a given token
	 */
	protected String getTokenType(final Token token) {
		return tokenDefProvider.getTokenDefMap().get(token.getType());
	}

	/**
	 * check whether an input is chopped into a list of expected token types
	 */
	protected void testTerminal(final String input, final String... expectedTerminals) {
		final List<Token> tokens = getTokens(input);
		assertEquals(expectedTerminals.length, tokens.size(), input);
		for (int i = 0; i < tokens.size(); i++) {
			final Token token = tokens.get(i);
			String exp = expectedTerminals[i];
			if (!exp.startsWith("'")) {
				exp = "RULE_" + exp;
			}
			assertEquals(input, exp, getTokenType(token));
		}
	}

	/**
	 * check that an input is not tokenised using a particular terminal rule
	 */
	protected void testNotTerminal(final String input, final String unexpectedTerminal) {
		final List<Token> tokens = getTokens(input);

		final Token token = tokens.get(0);

		String tokenType = getTokenType(token);

		assertFalse(tokens.size() == 1 && tokenType != null && tokenType.equals("RULE_" + unexpectedTerminal), input);
	}

	/**
	 * check that input is treated as a keyword by the grammar
	 */
	protected void testKeyword(final String input) {
		// the rule name for a keyword is usually
		// the keyword enclosed in single quotes
		final String rule = new StringBuilder("'").append(input).append("'").toString();
		testTerminal(input, rule);
	}

	/**
	 * check that input is not treated as a keyword by the grammar
	 */
	protected void testNoKeyword(final String keyword) {
		final List<Token> tokens = getTokens(keyword);
		assertEquals(1, tokens.size(), keyword);
		final String type = getTokenType(tokens.get(0));
		assertFalse(type.charAt(0) == '\'', keyword);
	}

	protected String loadFileContents(final String rootPath, final String filename) {
		final URI uri = URI.createURI(resourceRoot + "/" + filename);
		try {
			final InputStream is = resourceSet.getURIConverter().createInputStream(uri);
			final ByteArrayOutputStream bos = new ByteArrayOutputStream();
			int i;
			while ((i = is.read()) >= 0) {
				bos.write(i);
			}
			is.close();
			bos.close();
			return bos.toString();
		} catch (final IOException e) {
			throw new RuntimeException(e);
		}
	}

	protected Pair<String, FluentIssueCollection> loadAndSaveModule(final String rootPath, final String filename) {
		final URI uri = URI.createURI(resourceRoot + "/" + filename);
		rootElement = loadModel(resourceSet, uri, getRootObjectType(uri));

		final Resource r = resourceSet.getResource(uri, false);
		final IResourceServiceProvider provider = serviceProviderRegistry.getResourceServiceProvider(r.getURI());
		final List<Issue> result = provider.getResourceValidator().validate(r, CheckMode.ALL, null);

		if (invokeSerializer) {
			final ByteArrayOutputStream bos = new ByteArrayOutputStream();
			try {
				final Builder builder = SaveOptions.newBuilder();
				if (formatOnSerialize) {
					builder.format();
				}
				final SaveOptions s = builder.getOptions();

				rootElement.eResource().save(bos, s.toOptionsMap());
			} catch (final IOException e) {
				throw new RuntimeException(e);
			}

			return Tuples.create(bos.toString(), new FluentIssueCollection(r, result, new ArrayList<String>()));
		} else {
			return Tuples.create("-not serialized-", new FluentIssueCollection(r, result, new ArrayList<String>()));
		}
	}

	/**
	 * Returns the expected type of the root element of the given resource.
	 */
	protected Class<? extends EObject> getRootObjectType(final URI uri) {
		return null;
	}

	public void setResourceRoot(final String resourceRoot) {
		this.resourceRoot = resourceRoot;
	}

	@SuppressWarnings("unchecked")
	protected <T extends EObject> T loadModel(final ResourceSet rs, final URI uri, final Class<T> clazz) {
		final Resource resource = rs.createResource(uri);
		try {
			resource.load(null);
		} catch (final IOException e) {
			throw new RuntimeException(e);
		}

		final StringBuilder errors = new StringBuilder();
		if (!resource.getWarnings().isEmpty()) {
			LOGGER.error("Resource " + uri.toString() + " has warnings:");
			for (final Resource.Diagnostic issue : resource.getWarnings()) {
				LOGGER.error(issue.getLine() + ": " + issue.getMessage());
			}
			if (failOnParserWarnings) {
				errors.append("Resource as warnings:");
				for (final Resource.Diagnostic issue : resource.getWarnings()) {
					errors.append("\n  - " + issue.getLine() + ": " + issue.getMessage());
				}
				errors.append("/n");
			}
		}

		if (!resource.getErrors().isEmpty()) {
			LOGGER.error("Resource " + uri.toString() + " has errors:");
			for (final Resource.Diagnostic issue : resource.getErrors()) {
				LOGGER.error("    " + issue.getLine() + ": " + issue.getMessage());
			}

			errors.append("Resource as errors:");
			for (final Resource.Diagnostic issue : resource.getErrors()) {
				errors.append("\n  - " + issue.getLine() + ": " + issue.getMessage());
			}
		}

		final String failMessage = errors.toString();
		if (!failMessage.equals("")) {
			fail("\n\n" + failMessage + "\n");
		}

		assertFalse(resource.getContents().isEmpty(), "Resource has no content");
		final EObject o = resource.getContents().get(0);
		// assure that the root element is of the expected type
		if (clazz != null) {
			assertTrue(clazz.isInstance(o));
		}
		EcoreUtil.resolveAll(resource);
		return (T) o;
	}

	protected void assertAllCrossReferencesResolvable(final EObject obj) {
		boolean allIsGood = true;
		final TreeIterator<EObject> it = EcoreUtil2.eAll(obj);
		while (it.hasNext()) {
			final EObject o = it.next();
			for (final EObject cr : o.eCrossReferences()) {
				if (cr.eIsProxy()) {
					allIsGood = false;
					System.err.println("CrossReference from " + EmfFormatter.objPath(o) + " to "
							+ ((InternalEObject) cr).eProxyURI() + " not resolved.");
				}
			}
		}
		if (!allIsGood) {
			fail("Unresolved cross references in " + EmfFormatter.objPath(obj));
		}
	}

	protected void resetAssertedIssues() {
		assertedIssues.clear();
	}

	/**
	 * If called prior to #testFile, serialization will be performed, but the
	 * result is not expected to exactly match the input file.
	 */
	protected void ignoreSerializationDifferences() {
		ensureIsBeforeTestFile();

		compareSerializedModelToInputFile = false;
	}

	/**
	 * If called prior to #testFile, serialization won't be performed.
	 */
	protected void suppressSerialization() {
		ensureIsBeforeTestFile();

		compareSerializedModelToInputFile = false;
		invokeSerializer = false;
	}

	/**
	 * If called prior to #testFile, parser warnings will be ignored. Errors
	 * will still be reported, though.
	 */
	protected void ignoreParserWarnings() {
		ensureIsBeforeTestFile();

		failOnParserWarnings = false;
	}

	/**
	 * Serialization will occur without formatting, hence the input model must
	 * not comply to formatting rules in order to succeed.
	 */
	protected void ignoreFormattingDifferences() {
		ensureIsBeforeTestFile();

		formatOnSerialize = false;
	}

	/**
	 * If called after to #testFile, the test wont fail for unasserted warnings.
	 */
	protected void ignoreUnassertedWarnings() {
		ensureIsAfterTestFile();

		// just treat the warnings left as asserted
		assertedIssues.addAll(issues.warningsOnly().except(assertedIssues).getIssues());
	}

	/**
	 * Text file comparison will ignore OS specific newlines by harmonizing
	 * expected and serialized text with Unix style newline.
	 */
	protected void ignoreOsSpecificNewline() {
		ignoreOsSpecificNewline = true;
	}

	protected void assertConstraints(final FluentIssueCollection coll, final String msg) {
		ensureIsAfterTestFile();

		assertedIssues.addAll(coll.getIssues());
		Assertions.assertTrue(coll.evaluate(), "failed " + msg + coll.getMessageString());
	}

	protected void assertConstraints(final FluentIssueCollection coll) {
		ensureIsAfterTestFile();

		assertedIssues.addAll(coll.getIssues());
		Assertions.assertTrue(coll.evaluate(), "<no id> failed" + coll.getMessageString());
	}

	protected void assertConstraints(final String constraintID, final FluentIssueCollection coll) {
		ensureIsAfterTestFile();

		assertedIssues.addAll(coll.getIssues());
		Assertions.assertTrue(coll.evaluate(), constraintID + " failed" + coll.getMessageString());
	}

	public EObject getEObject(final URI uri) {
		EObject eObject = issues.getResource().getEObject(uri.fragment());
		if (eObject.eIsProxy()) {
			eObject = EcoreUtil.resolve(eObject, issues.getResource());
		}
		return eObject;
	}

	private void dumpUnassertedIssues() {
		if (issues.except(assertedIssues).getIssues().size() > 0) {
			LOGGER.warn("---- Unasserted Issues ----");
			for (final Issue issue : issues.except(assertedIssues)) {
				FluentIssueCollection.dumpIssue(issues.getResource(), issue);
			}
		}
	}
}