diff --git a/src/main/java/com/github/zafarkhaja/semver/Version.java b/src/main/java/com/github/zafarkhaja/semver/Version.java index 9d9a3a2..3e33603 100644 --- a/src/main/java/com/github/zafarkhaja/semver/Version.java +++ b/src/main/java/com/github/zafarkhaja/semver/Version.java @@ -139,6 +139,8 @@ public class Version implements Comparable { * Builds a {@code Version} object. * * @return a newly built {@code Version} instance + * @throws ParseException when invalid version string is provided + * @throws UnexpectedCharacterException is a special case of {@code ParseException} */ public Version build() { StringBuilder sb = new StringBuilder(); @@ -254,6 +256,8 @@ public class Version implements Comparable { * @param version the version string to parse * @return a new instance of the {@code Version} class * @throws IllegalArgumentException if the input string is {@code NULL} or empty + * @throws ParseException when invalid version string is provided + * @throws UnexpectedCharacterException is a special case of {@code ParseException} */ public static Version valueOf(String version) { return VersionParser.parseValidSemVer(version); @@ -328,6 +332,8 @@ public class Version implements Comparable { * @param preRelease the pre-release version to append * @return a new instance of the {@code Version} class * @throws IllegalArgumentException if the input string is {@code NULL} or empty + * @throws ParseException when invalid version string is provided + * @throws UnexpectedCharacterException is a special case of {@code ParseException} */ public Version incrementMajorVersion(String preRelease) { return new Version( @@ -351,6 +357,8 @@ public class Version implements Comparable { * @param preRelease the pre-release version to append * @return a new instance of the {@code Version} class * @throws IllegalArgumentException if the input string is {@code NULL} or empty + * @throws ParseException when invalid version string is provided + * @throws UnexpectedCharacterException is a special case of {@code ParseException} */ public Version incrementMinorVersion(String preRelease) { return new Version( @@ -374,6 +382,8 @@ public class Version implements Comparable { * @param preRelease the pre-release version to append * @return a new instance of the {@code Version} class * @throws IllegalArgumentException if the input string is {@code NULL} or empty + * @throws ParseException when invalid version string is provided + * @throws UnexpectedCharacterException is a special case of {@code ParseException} */ public Version incrementPatchVersion(String preRelease) { return new Version( @@ -406,6 +416,8 @@ public class Version implements Comparable { * @param preRelease the pre-release version to set * @return a new instance of the {@code Version} class * @throws IllegalArgumentException if the input string is {@code NULL} or empty + * @throws ParseException when invalid version string is provided + * @throws UnexpectedCharacterException is a special case of {@code ParseException} */ public Version setPreReleaseVersion(String preRelease) { return new Version(normal, VersionParser.parsePreRelease(preRelease)); @@ -417,6 +429,8 @@ public class Version implements Comparable { * @param build the build metadata to set * @return a new instance of the {@code Version} class * @throws IllegalArgumentException if the input string is {@code NULL} or empty + * @throws ParseException when invalid version string is provided + * @throws UnexpectedCharacterException is a special case of {@code ParseException} */ public Version setBuildMetadata(String build) { return new Version(normal, preRelease, VersionParser.parseBuild(build)); diff --git a/src/main/java/com/github/zafarkhaja/semver/VersionParser.java b/src/main/java/com/github/zafarkhaja/semver/VersionParser.java index 5347a19..31dca2c 100644 --- a/src/main/java/com/github/zafarkhaja/semver/VersionParser.java +++ b/src/main/java/com/github/zafarkhaja/semver/VersionParser.java @@ -257,15 +257,19 @@ class VersionParser implements Parser { private Version parseValidSemVer() { NormalVersion normal = parseVersionCore(); MetadataVersion preRelease = MetadataVersion.NULL; - if (chars.positiveLookahead(HYPHEN)) { - chars.consume(); - preRelease = parsePreRelease(); - } MetadataVersion build = MetadataVersion.NULL; - if (chars.positiveLookahead(PLUS)) { - chars.consume(); + + Character next = consumeNextCharacter(HYPHEN, PLUS, EOL); + if (HYPHEN.isMatchedBy(next)) { + preRelease = parsePreRelease(); + next = consumeNextCharacter(PLUS, EOL); + if (PLUS.isMatchedBy(next)) { + build = parseBuild(); + } + } else if (PLUS.isMatchedBy(next)) { build = parseBuild(); } + consumeNextCharacter(EOL); return new Version(normal, preRelease, build); } @@ -282,9 +286,9 @@ class VersionParser implements Parser { */ private NormalVersion parseVersionCore() { int major = Integer.parseInt(numericIdentifier()); - chars.consume(DOT); + consumeNextCharacter(DOT); int minor = Integer.parseInt(numericIdentifier()); - chars.consume(DOT); + consumeNextCharacter(DOT); int patch = Integer.parseInt(numericIdentifier()); return new NormalVersion(major, minor, patch); } @@ -305,14 +309,16 @@ class VersionParser implements Parser { * @throws ParseException if the pre-release version has empty identifier(s) */ private MetadataVersion parsePreRelease() { + ensureValidLookahead(DIGIT, LETTER, HYPHEN); List idents = new ArrayList(); do { - checkForEmptyIdentifier(); idents.add(preReleaseIdentifier()); if (chars.positiveLookahead(DOT)) { - chars.consume(DOT); + consumeNextCharacter(DOT); + continue; } - } while (!chars.positiveLookahead(PLUS, EOL)); + break; + } while (true); return new MetadataVersion(idents.toArray(new String[idents.size()])); } @@ -329,6 +335,7 @@ class VersionParser implements Parser { * @return a single pre-release identifier */ private String preReleaseIdentifier() { + checkForEmptyIdentifier(); CharType boundary = nearestCharType(DOT, PLUS, EOL); if (chars.positiveLookaheadBefore(boundary, LETTER, HYPHEN)) { return alphanumericIdentifier(); @@ -353,14 +360,16 @@ class VersionParser implements Parser { * @throws ParseException if the build metadata has empty identifier(s) */ private MetadataVersion parseBuild() { + ensureValidLookahead(DIGIT, LETTER, HYPHEN); List idents = new ArrayList(); do { - checkForEmptyIdentifier(); idents.add(buildIdentifier()); if (chars.positiveLookahead(DOT)) { - chars.consume(DOT); + consumeNextCharacter(DOT); + continue; } - } while (!chars.positiveLookahead(EOL)); + break; + } while (true); return new MetadataVersion(idents.toArray(new String[idents.size()])); } @@ -377,6 +386,7 @@ class VersionParser implements Parser { * @return a single build identifier */ private String buildIdentifier() { + checkForEmptyIdentifier(); CharType boundary = nearestCharType(DOT, EOL); if (chars.positiveLookaheadBefore(boundary, LETTER, HYPHEN)) { return alphanumericIdentifier(); @@ -421,7 +431,7 @@ class VersionParser implements Parser { private String alphanumericIdentifier() { StringBuilder sb = new StringBuilder(); do { - sb.append(chars.consume(DIGIT, LETTER, HYPHEN)); + sb.append(consumeNextCharacter(DIGIT, LETTER, HYPHEN)); } while (chars.positiveLookahead(DIGIT, LETTER, HYPHEN)); return sb.toString(); } @@ -441,7 +451,7 @@ class VersionParser implements Parser { private String digits() { StringBuilder sb = new StringBuilder(); do { - sb.append(chars.consume(DIGIT)); + sb.append(consumeNextCharacter(DIGIT)); } while (chars.positiveLookahead(DIGIT)); return sb.toString(); } @@ -471,7 +481,7 @@ class VersionParser implements Parser { private void checkForLeadingZeroes() { Character la1 = chars.lookahead(1); Character la2 = chars.lookahead(2); - if (la1 == '0' && DIGIT.isMatchedBy(la2)) { + if (la1 != null && la1 == '0' && DIGIT.isMatchedBy(la2)) { throw new ParseException( "Numeric identifier MUST NOT contain leading zeroes" ); @@ -485,8 +495,39 @@ class VersionParser implements Parser { * metadata have empty identifier(s) */ private void checkForEmptyIdentifier() { - if (DOT.isMatchedBy(chars.lookahead(1))) { - throw new ParseException("Identifiers MUST NOT be empty"); + Character la = chars.lookahead(1); + if (DOT.isMatchedBy(la) || PLUS.isMatchedBy(la) || EOL.isMatchedBy(la)) { + throw new ParseException( + "Identifiers MUST NOT be empty", + new UnexpectedCharacterException(la, DIGIT, LETTER, HYPHEN) + ); + } + } + + /** + * Tries to consume the next character in the stream. + * + * @param expected the expected types of the next character + * @return the next character in the stream + * @throws UnexpectedCharacterException if the next element is of an unexpected type + */ + private Character consumeNextCharacter(CharType... expected) { + try { + return chars.consume(expected); + } catch (UnexpectedElementException e) { + throw new UnexpectedCharacterException(e); + } + } + + /** + * Checks if the next character in the stream is valid. + * + * @param expected the expected types of the next character + * @throws UnexpectedCharacterException if the next element is not valid + */ + private void ensureValidLookahead(CharType... expected) { + if (!chars.positiveLookahead(expected)) { + throw new UnexpectedCharacterException(chars.lookahead(1), expected); } } } diff --git a/src/test/java/com/github/zafarkhaja/semver/ParserErrorHandlingTest.java b/src/test/java/com/github/zafarkhaja/semver/ParserErrorHandlingTest.java new file mode 100644 index 0000000..d3e0738 --- /dev/null +++ b/src/test/java/com/github/zafarkhaja/semver/ParserErrorHandlingTest.java @@ -0,0 +1,108 @@ +/* + * The MIT License + * + * Copyright 2014 Zafar Khaja . + * + * 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; + +import com.github.zafarkhaja.semver.VersionParser.CharType; +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.VersionParser.CharType.*; +import static org.junit.Assert.*; + +/** + * + * @author Zafar Khaja + */ +@RunWith(Parameterized.class) +public class ParserErrorHandlingTest { + + private final String invalidVersion; + private final Character unexpected; + private final CharType[] expected; + + public ParserErrorHandlingTest( + String invalidVersion, + Character unexpected, + CharType[] expected + ) { + this.invalidVersion = invalidVersion; + this.unexpected = unexpected; + this.expected = expected; + } + + @Test + public void shouldCorrectlyHandleParseErrors() { + try { + VersionParser.parseValidSemVer(invalidVersion); + } catch (UnexpectedCharacterException e) { + assertEquals(unexpected, e.getUnexpectedCharacter()); + assertArrayEquals(expected, e.getExpectedCharTypes()); + return; + } catch (ParseException e) { + if (e.getCause() != null) { + UnexpectedCharacterException cause = (UnexpectedCharacterException) e.getCause(); + assertEquals(unexpected, cause.getUnexpectedCharacter()); + assertArrayEquals(expected, cause.getExpectedCharTypes()); + } + return; + } + fail("Uncaught exception"); + } + + @Parameters(name = "{0}") + public static Collection parameters() { + return Arrays.asList(new Object[][] { + { "1", null, new CharType[] { DOT } }, + { "1 ", ' ', new CharType[] { DOT } }, + { "1.", null, new CharType[] { DIGIT } }, + { "1.2", null, new CharType[] { DOT } }, + { "1.2.", null, new CharType[] { DIGIT } }, + { "a.b.c", 'a', new CharType[] { DIGIT } }, + { "1.b.c", 'b', new CharType[] { DIGIT } }, + { "1.2.c", 'c', new CharType[] { DIGIT } }, + { "!.2.3", '!', new CharType[] { DIGIT } }, + { "1.!.3", '!', new CharType[] { DIGIT } }, + { "1.2.!", '!', new CharType[] { DIGIT } }, + { "v1.2.3", 'v', new CharType[] { DIGIT } }, + { "1.2.3-", null, new CharType[] { DIGIT, LETTER, HYPHEN } }, + { "1.2. 3", ' ', new CharType[] { DIGIT } }, + { "1.2.3=alpha", '=', new CharType[] { HYPHEN, PLUS, EOL } }, + { "1.2.3~beta", '~', new CharType[] { HYPHEN, PLUS, EOL } }, + { "1.2.3-be$ta", '$', new CharType[] { PLUS, EOL } }, + { "1.2.3+b1+b2", '+', new CharType[] { EOL } }, + { "1.2.3-rc!", '!', new CharType[] { PLUS, EOL } }, + { "1.2.3-+", '+', new CharType[] { DIGIT, LETTER, HYPHEN } }, + { "1.2.3-@", '@', new CharType[] { DIGIT, LETTER, HYPHEN } }, + { "1.2.3+@", '@', new CharType[] { DIGIT, LETTER, HYPHEN } }, + { "1.2.3-rc1.", null, new CharType[] { DIGIT, LETTER, HYPHEN } }, + { "1.2.3+20140620.", null, new CharType[] { DIGIT, LETTER, HYPHEN } }, + { "1.2.3-b.+b", '+', new CharType[] { DIGIT, LETTER, HYPHEN } }, + { "1.2.3-rc..", '.', new CharType[] { DIGIT, LETTER, HYPHEN } }, + { "1.2.3-rc+bld..", '.', new CharType[] { DIGIT, LETTER, HYPHEN } }, + }); + } +}