Refactor ExpressionParser to improve error handling

This commit is contained in:
Zafar Khaja 2014-08-14 20:29:13 +03:00
parent 399df4d267
commit f3b43eee9c
8 changed files with 232 additions and 62 deletions

View File

@ -25,6 +25,8 @@ package com.github.zafarkhaja.semver;
import com.github.zafarkhaja.semver.expr.Expression;
import com.github.zafarkhaja.semver.expr.ExpressionParser;
import com.github.zafarkhaja.semver.expr.LexerException;
import com.github.zafarkhaja.semver.expr.UnexpectedTokenException;
import java.util.Comparator;
/**
@ -310,6 +312,9 @@ public class Version implements Comparable<Version> {
* @param expr the SemVer Expression
* @return {@code true} if this version satisfies the specified
* SemVer Expression or {@code false} otherwise
* @throws ParseException in case of a general parse error
* @throws LexerException when encounters an illegal character
* @throws UnexpectedTokenException when comes across an unexpected token
* @since 0.7.0
*/
public boolean satisfies(String expr) {

View File

@ -78,12 +78,14 @@ public class ExpressionParser implements Parser<Expression> {
* @param input a string representing the SemVer Expression
* @return the AST for the SemVer Expressions
* @throws LexerException when encounters an illegal character
* @throws UnexpectedElementException when consumes a token of an unexpected type
* @throws UnexpectedTokenException when consumes a token of an unexpected type
*/
@Override
public Expression parse(String input) {
tokens = lexer.tokenize(input);
return parseSemVerExpression();
Expression expr = parseSemVerExpression();
consumeNextToken(EOL);
return expr;
}
/**
@ -104,13 +106,13 @@ public class ExpressionParser implements Parser<Expression> {
Expression expr;
if (tokens.positiveLookahead(NOT)) {
tokens.consume();
tokens.consume(LEFT_PAREN);
consumeNextToken(LEFT_PAREN);
expr = new Not(parseSemVerExpression());
tokens.consume(RIGHT_PAREN);
consumeNextToken(RIGHT_PAREN);
} else if (tokens.positiveLookahead(LEFT_PAREN)) {
tokens.consume(LEFT_PAREN);
consumeNextToken(LEFT_PAREN);
expr = parseSemVerExpression();
tokens.consume(RIGHT_PAREN);
consumeNextToken(RIGHT_PAREN);
} else {
expr = parseExpression();
}
@ -224,21 +226,21 @@ public class ExpressionParser implements Parser<Expression> {
* @return the expression AST
*/
private Expression parseTildeExpression() {
tokens.consume(TILDE);
int major = intOf(tokens.consume(NUMERIC).lexeme);
consumeNextToken(TILDE);
int major = intOf(consumeNextToken(NUMERIC).lexeme);
if (!tokens.positiveLookahead(DOT)) {
return new GreaterOrEqual(versionOf(major, 0, 0));
}
tokens.consume(DOT);
int minor = intOf(tokens.consume(NUMERIC).lexeme);
consumeNextToken(DOT);
int minor = intOf(consumeNextToken(NUMERIC).lexeme);
if (!tokens.positiveLookahead(DOT)) {
return new And(
new GreaterOrEqual(versionOf(major, minor, 0)),
new Less(versionOf(major + 1, 0, 0))
);
}
tokens.consume(DOT);
int patch = intOf(tokens.consume(NUMERIC).lexeme);
consumeNextToken(DOT);
int patch = intOf(consumeNextToken(NUMERIC).lexeme);
return new And(
new GreaterOrEqual(versionOf(major, minor, patch)),
new Less(versionOf(major, minor + 1, 0))
@ -270,8 +272,8 @@ public class ExpressionParser implements Parser<Expression> {
* @return the expression AST
*/
private Expression parseVersionExpression() {
int major = intOf(tokens.consume(NUMERIC).lexeme);
tokens.consume(DOT);
int major = intOf(consumeNextToken(NUMERIC).lexeme);
consumeNextToken(DOT);
if (tokens.positiveLookahead(STAR)) {
tokens.consume();
return new And(
@ -279,9 +281,9 @@ public class ExpressionParser implements Parser<Expression> {
new Less(versionOf(major + 1, 0, 0))
);
}
int minor = intOf(tokens.consume(NUMERIC).lexeme);
tokens.consume(DOT);
tokens.consume(STAR);
int minor = intOf(consumeNextToken(NUMERIC).lexeme);
consumeNextToken(DOT);
consumeNextToken(STAR);
return new And(
new GreaterOrEqual(versionOf(major, minor, 0)),
new Less(versionOf(major, minor + 1, 0))
@ -313,7 +315,7 @@ public class ExpressionParser implements Parser<Expression> {
*/
private Expression parseRangeExpression() {
Expression ge = new GreaterOrEqual(parseVersion());
tokens.consume(HYPHEN);
consumeNextToken(HYPHEN);
Expression le = new LessOrEqual(parseVersion());
return new And(ge, le);
}
@ -332,16 +334,16 @@ public class ExpressionParser implements Parser<Expression> {
* @return the parsed version
*/
private Version parseVersion() {
int major = intOf(tokens.consume(NUMERIC).lexeme);
int major = intOf(consumeNextToken(NUMERIC).lexeme);
int minor = 0;
if (tokens.positiveLookahead(DOT)) {
tokens.consume();
minor = intOf(tokens.consume(NUMERIC).lexeme);
minor = intOf(consumeNextToken(NUMERIC).lexeme);
}
int patch = 0;
if (tokens.positiveLookahead(DOT)) {
tokens.consume();
patch = intOf(tokens.consume(NUMERIC).lexeme);
patch = intOf(consumeNextToken(NUMERIC).lexeme);
}
return versionOf(major, minor, patch);
}
@ -391,4 +393,19 @@ public class ExpressionParser implements Parser<Expression> {
private int intOf(String value) {
return Integer.parseInt(value);
}
/**
* Tries to consume the next token in the stream.
*
* @param expected the expected types of the next token
* @return the next token in the stream
* @throws UnexpectedTokenException when encounters an unexpected token type
*/
private Token consumeNextToken(Token.Type... expected) {
try {
return tokens.consume(expected);
} catch (UnexpectedElementException e) {
throw new UnexpectedTokenException(e);
}
}
}

View File

@ -64,15 +64,7 @@ class Lexer {
LEFT_PAREN("\\("),
RIGHT_PAREN("\\)"),
WHITESPACE("\\s+"),
EOL("?!") {
/**
* {@inheritDoc}
*/
@Override
public boolean isMatchedBy(Token token) {
return token == null;
}
};
EOL("?!");
/**
* A pattern matching this type.
@ -123,14 +115,22 @@ class Lexer {
final String lexeme;
/**
* Constructs a {@code Token} instance with the type and lexeme.
* The position of this token.
*/
final int position;
/**
* Constructs a {@code Token} instance
* with the type, lexeme and position.
*
* @param type the type of this token
* @param lexeme the lexeme of this token
* @param position the position of this token
*/
Token(Type type, String lexeme) {
Token(Type type, String lexeme, int position) {
this.type = type;
this.lexeme = (lexeme == null) ? "" : lexeme;
this.position = position;
}
/**
@ -145,7 +145,10 @@ class Lexer {
return false;
}
Token token = (Token) other;
return type.equals(token.type) && lexeme.equals(token.lexeme);
return
type.equals(token.type) &&
lexeme.equals(token.lexeme) &&
position == token.position;
}
/**
@ -156,6 +159,7 @@ class Lexer {
int hash = 5;
hash = 71 * hash + type.hashCode();
hash = 71 * hash + lexeme.hashCode();
hash = 71 * hash + position;
return hash;
}
@ -166,7 +170,11 @@ class Lexer {
*/
@Override
public String toString() {
return type.name() + "(" + lexeme + ")";
return String.format(
"%s(%s) at position %d",
type.name(),
lexeme, position
);
}
}
@ -186,6 +194,7 @@ class Lexer {
*/
Stream<Token> tokenize(String input) {
List<Token> tokens = new ArrayList<Token>();
int tokenPos = 0;
while (!input.isEmpty()) {
boolean matched = false;
for (Token.Type tokenType : Token.Type.values()) {
@ -194,8 +203,13 @@ class Lexer {
matched = true;
input = matcher.replaceFirst("");
if (tokenType != Token.Type.WHITESPACE) {
tokens.add(new Token(tokenType, matcher.group()));
tokens.add(new Token(
tokenType,
matcher.group(),
tokenPos
));
}
tokenPos += matcher.end();
break;
}
}
@ -203,6 +217,7 @@ class Lexer {
throw new LexerException(input);
}
}
tokens.add(new Token(Token.Type.EOL, null, tokenPos));
return new Stream<Token>(tokens.toArray(new Token[tokens.size()]));
}
}

View File

@ -24,7 +24,8 @@
package com.github.zafarkhaja.semver.expr;
import com.github.zafarkhaja.semver.ParseException;
import com.github.zafarkhaja.semver.expr.Lexer.*;
import com.github.zafarkhaja.semver.expr.Lexer.Token;
import com.github.zafarkhaja.semver.util.UnexpectedElementException;
import java.util.Arrays;
/**
@ -45,6 +46,17 @@ public class UnexpectedTokenException extends ParseException {
*/
private final Token.Type[] expected;
/**
* Constructs a {@code UnexpectedTokenException} instance with
* the wrapped {@code UnexpectedElementException} exception.
*
* @param cause the wrapped exception
*/
UnexpectedTokenException(UnexpectedElementException cause) {
unexpected = (Token) cause.getUnexpectedElement();
expected = (Token.Type[]) cause.getExpectedElementTypes();
}
/**
* Constructs a {@code UnexpectedTokenException} instance
* with the unexpected token and the expected types.
@ -57,6 +69,24 @@ public class UnexpectedTokenException extends ParseException {
this.expected = expected;
}
/**
* Gets the unexpected token.
*
* @return the unexpected token
*/
Token getUnexpectedToken() {
return unexpected;
}
/**
* Gets the expected token types.
*
* @return an array of expected token types
*/
Token.Type[] getExpectedTokenTypes() {
return expected;
}
/**
* Returns the string representation of this exception
* containing the information about the unexpected

View File

@ -24,7 +24,6 @@
package com.github.zafarkhaja.semver.expr;
import com.github.zafarkhaja.semver.Version;
import com.github.zafarkhaja.semver.util.UnexpectedElementException;
import org.junit.Test;
import static org.junit.Assert.*;
@ -197,7 +196,7 @@ public class ExpressionParserTest {
ExpressionParser parser = new ExpressionParser(new Lexer());
try {
parser.parse("((>=1.0.1 & < 2)");
} catch (UnexpectedElementException e) {
} catch (UnexpectedTokenException e) {
return;
}
fail("Should raise error if closing parenthesis is missing");

View File

@ -38,12 +38,13 @@ public class LexerTest {
@Test
public void shouldTokenizeVersionString() {
Token[] expected = {
new Token(GREATER, ">"),
new Token(NUMERIC, "1"),
new Token(DOT, "."),
new Token(NUMERIC, "0"),
new Token(DOT, "."),
new Token(NUMERIC, "0"),
new Token(GREATER, ">", 0),
new Token(NUMERIC, "1", 1),
new Token(DOT, ".", 2),
new Token(NUMERIC, "0", 3),
new Token(DOT, ".", 4),
new Token(NUMERIC, "0", 5),
new Token(EOL, null, 6),
};
Lexer lexer = new Lexer();
Stream<Token> stream = lexer.tokenize(">1.0.0");
@ -53,14 +54,30 @@ public class LexerTest {
@Test
public void shouldSkipWhitespaces() {
Token[] expected = {
new Token(GREATER, ">"),
new Token(NUMERIC, "1"),
new Token(GREATER, ">", 0),
new Token(NUMERIC, "1", 2),
new Token(EOL, null, 3),
};
Lexer lexer = new Lexer();
Stream<Token> stream = lexer.tokenize("> 1");
assertArrayEquals(expected, stream.toArray());
}
@Test
public void shouldEndWithEol() {
Token[] expected = {
new Token(NUMERIC, "1", 0),
new Token(DOT, ".", 1),
new Token(NUMERIC, "2", 2),
new Token(DOT, ".", 3),
new Token(NUMERIC, "3", 4),
new Token(EOL, null, 5),
};
Lexer lexer = new Lexer();
Stream<Token> stream = lexer.tokenize("1.2.3");
assertArrayEquals(expected, stream.toArray());
}
@Test
public void shouldRaiseErrorOnIllegalCharacter() {
Lexer lexer = new Lexer();

View File

@ -41,23 +41,23 @@ public class LexerTokenTest {
@Test
public void shouldBeReflexive() {
Token token = new Token(NUMERIC, "1");
Token token = new Token(NUMERIC, "1", 0);
assertTrue(token.equals(token));
}
@Test
public void shouldBeSymmetric() {
Token t1 = new Token(EQUAL, "=");
Token t2 = new Token(EQUAL, "=");
Token t1 = new Token(EQUAL, "=", 0);
Token t2 = new Token(EQUAL, "=", 0);
assertTrue(t1.equals(t2));
assertTrue(t2.equals(t1));
}
@Test
public void shouldBeTransitive() {
Token t1 = new Token(GREATER, ">");
Token t2 = new Token(GREATER, ">");
Token t3 = new Token(GREATER, ">");
Token t1 = new Token(GREATER, ">", 0);
Token t2 = new Token(GREATER, ">", 0);
Token t3 = new Token(GREATER, ">", 0);
assertTrue(t1.equals(t2));
assertTrue(t2.equals(t3));
assertTrue(t1.equals(t3));
@ -65,8 +65,8 @@ public class LexerTokenTest {
@Test
public void shouldBeConsistent() {
Token t1 = new Token(HYPHEN, "-");
Token t2 = new Token(HYPHEN, "-");
Token t1 = new Token(HYPHEN, "-", 0);
Token t2 = new Token(HYPHEN, "-", 0);
assertTrue(t1.equals(t2));
assertTrue(t1.equals(t2));
assertTrue(t1.equals(t2));
@ -74,28 +74,35 @@ public class LexerTokenTest {
@Test
public void shouldReturnFalseIfOtherVersionIsOfDifferentType() {
Token t1 = new Token(DOT, ".");
Token t1 = new Token(DOT, ".", 0);
assertFalse(t1.equals(new String(".")));
}
@Test
public void shouldReturnFalseIfOtherVersionIsNull() {
Token t1 = new Token(AND, "&");
Token t1 = new Token(AND, "&", 0);
Token t2 = null;
assertFalse(t1.equals(t2));
}
@Test
public void shouldReturnFalseIfTypesAreDifferent() {
Token t1 = new Token(EQUAL, "=");
Token t2 = new Token(NOT_EQUAL, "!=");
Token t1 = new Token(EQUAL, "=", 0);
Token t2 = new Token(NOT_EQUAL, "!=", 0);
assertFalse(t1.equals(t2));
}
@Test
public void shouldReturnFalseIfLexemesAreDifferent() {
Token t1 = new Token(NUMERIC, "1");
Token t2 = new Token(NUMERIC, "2");
Token t1 = new Token(NUMERIC, "1", 0);
Token t2 = new Token(NUMERIC, "2", 0);
assertFalse(t1.equals(t2));
}
@Test
public void shouldReturnFalseIfPositionsAreDifferent() {
Token t1 = new Token(NUMERIC, "1", 1);
Token t2 = new Token(NUMERIC, "1", 2);
assertFalse(t1.equals(t2));
}
}
@ -104,8 +111,8 @@ public class LexerTokenTest {
@Test
public void shouldReturnSameHashCodeIfTokensAreEqual() {
Token t1 = new Token(NUMERIC, "1");
Token t2 = new Token(NUMERIC, "1");
Token t1 = new Token(NUMERIC, "1", 0);
Token t2 = new Token(NUMERIC, "1", 0);
assertTrue(t1.equals(t2));
assertEquals(t1.hashCode(), t2.hashCode());
}

View File

@ -0,0 +1,80 @@
/*
* The MIT License
*
* Copyright 2014 Zafar Khaja <zafarkhaja@gmail.com>.
*
* 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 com.github.zafarkhaja.semver.expr;
import com.github.zafarkhaja.semver.expr.Lexer.Token;
import java.util.Arrays;
import java.util.Collection;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import static com.github.zafarkhaja.semver.expr.Lexer.Token.Type.*;
import static org.junit.Assert.*;
/**
*
* @author Zafar Khaja <zafarkhaja@gmail.com>
*/
@RunWith(Parameterized.class)
public class ParserErrorHandlingTest {
private final String invalidExpr;
private final Token unexpected;
private final Token.Type[] expected;
public ParserErrorHandlingTest(
String invalidExpr,
Token unexpected,
Token.Type[] expected
) {
this.invalidExpr = invalidExpr;
this.unexpected = unexpected;
this.expected = expected;
}
@Test
public void shouldCorrectlyHandleParseErrors() {
try {
ExpressionParser.newInstance().parse(invalidExpr);
} catch (UnexpectedTokenException e) {
assertEquals(unexpected, e.getUnexpectedToken());
assertArrayEquals(expected, e.getExpectedTokenTypes());
return;
}
fail("Uncaught exception");
}
@Parameters(name = "{0}")
public static Collection<Object[]> parameters() {
return Arrays.asList(new Object[][] {
{ "1)", new Token(RIGHT_PAREN, ")", 1), new Token.Type[] { EOL } },
{ "(>1.0.1", new Token(EOL, null, 7), new Token.Type[] { RIGHT_PAREN } },
{ "((>=1 & <2)", new Token(EOL, null, 11), new Token.Type[] { RIGHT_PAREN } },
{ ">=1.0.0 &", new Token(EOL, null, 9), new Token.Type[] { NUMERIC } },
{ "(>2.0 |)", new Token(RIGHT_PAREN, ")", 7), new Token.Type[] { NUMERIC } },
{ "& 1.2", new Token(AND, "&", 0), new Token.Type[] { NUMERIC } },
});
}
}