SemanticVersionParser.java
package xyz.apollosoftware.bibliothiki.versioning.schemes.semver;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import xyz.apollosoftware.bibliothiki.versioning.VersionParser;
import xyz.apollosoftware.bibliothiki.versioning.VersioningException;
import xyz.apollosoftware.bibliothiki.versioning.schemes.semver.SemanticVersion.BuildIdentifier;
import xyz.apollosoftware.bibliothiki.versioning.schemes.semver.SemanticVersion.PreReleaseIdentifier;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
/**
* A parser for a {@link SemanticVersion}.
*
* <p>
* This is implemented as a linear (LR) bottom-up parser, similarly to a regular
* expression however implemented manually for more flexibility in error
* handling.
*
* <p>
* This implementation delegates to other parsers defined for pre-release
* ({@link PreReleaseIdentifier}) and build identifiers
* ({@link BuildIdentifier}) in the constructors of those respective classes for
* consistency.
*/
public class SemanticVersionParser implements VersionParser<SemanticVersion> {
/**
* The version components in the core part of the version string.
*/
private static final List<String> CORE_PARTS = List.of("major", "minor", "patch");
/**
* Construct a {@link SemanticVersionParser}.
*/
public SemanticVersionParser() {
}
/**
* The parser state stores parsed semantic version components.
*/
private static class ParserState {
/**
* The major, minor and patch versions.
*/
private final int[] core;
private final List<PreReleaseIdentifier> preReleaseIdentifiers;
private final List<BuildIdentifier> buildIdentifiers;
public ParserState(int coreParts) {
this.core = new int[coreParts];
this.preReleaseIdentifiers = new ArrayList<>();
this.buildIdentifiers = new ArrayList<>();
}
}
/**
* Try to parse the given version string as a {@link SemanticVersion}.
*
* @param version The string to parse.
* @return The parsed {@link SemanticVersion}.
*/
@NonNull
@Override
public SemanticVersion parse(@NonNull String version) {
Objects.requireNonNull(version, "The version string must not be null");
if (version.isEmpty()) {
throw new VersioningException("The version string must not be empty");
}
final var state = new ParserState(CORE_PARTS.size());
int offset = 0;
// Parse the version core.
offset = parseVersionCore(state, version, offset, state.core.length);
while (offset < version.length()) {
final var nextSegment = version.charAt(offset);
switch (nextSegment) {
case '+' -> offset = parseBuildMetadata(state, version, offset + 1);
case '-' -> offset = parsePreReleases(state, version, offset + 1);
default ->
throw new VersioningException("Unexpected character following version core. Got '%s', expecting '+' or '-'".formatted(nextSegment));
}
}
return new SemanticVersion(state.core[0], state.core[1], state.core[2], state.preReleaseIdentifiers, state.buildIdentifiers);
}
/**
* Parse the core parts of the semantic version.
*
* @param state The parser state.
* @param version The version string to parse.
* @param offset The current offset in the version string.
* @param parts The number of core parts to parse.
* @return The new offset in the version string.
*/
private static int parseVersionCore(@NonNull ParserState state, @NonNull String version, int offset, int parts) {
final int versionLength = version.length();
// String[] is used instead of StringBuilder as in testing it seemed to
// be substantially faster (2x) to do string appending (perhaps due to
// reduced allocations as a result of the small string sizes).
final String[] coreParts = new String[parts];
// Loop over the version string to extract the version parts.
int part = 0;
while (offset < versionLength && part < coreParts.length) {
// Read the character at the current offset.
final char c = version.charAt(offset);
if (!Character.isDigit(c) && c != '.') {
// The end of the core is denoted by either + or -.
break;
}
if (c == '.') {
// The core parts are delimited by '.'.
part++;
} else {
// Append the character to the current part.
coreParts[part] = (coreParts[part] == null ? "" : coreParts[part]) + c;
}
offset++;
}
// Now parse each of the parts.
for (int i = 0; i < coreParts.length; i++) {
final var value = coreParts[i];
// Validate the part for a more friendly error message.
if (value == null) {
throw new VersioningException("Invalid version string, %s component missing".formatted(CORE_PARTS.get(i)));
}
try {
state.core[i] = Integer.parseInt(value);
} catch (NumberFormatException ex) {
throw new VersioningException("Invalid version string, %s component is not a valid number: %s".formatted(CORE_PARTS.get(i), value));
}
if (state.core[i] != 0 && value.startsWith("0")) {
throw new VersioningException("Invalid version string, %s component must not start with a leading zero: %s".formatted(CORE_PARTS.get(i), value));
}
}
return offset;
}
private static int parsePreReleases(@NonNull ParserState state, @NonNull String version, int offset) {
return parseComponent(
"pre-release",
'+',
identifier -> state.preReleaseIdentifiers.add(PreReleaseIdentifier.parse(identifier)),
version,
offset
);
}
private static int parseBuildMetadata(@NonNull ParserState state, @NonNull String version, int offset) {
return parseComponent(
"build metadata",
null,
identifier -> state.buildIdentifiers.add(new BuildIdentifier(identifier)),
version,
offset
);
}
private static int parseComponent(@NonNull String componentName, @Nullable Character nextSegmentDelimiter, @NonNull Consumer<String> onComponent, @NonNull String version, int offset) {
StringBuilder builder = new StringBuilder();
if (offset == version.length()) {
throw new VersioningException("Empty %s component".formatted(componentName));
}
while (offset < version.length()) {
final char c = version.charAt(offset);
boolean isValidIdentifierChar =
(c >= '0' && c <= '9') ||
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
c == '-';
boolean isEndOfVersionString = offset == version.length() - 1;
if (isEndOfVersionString) {
// If it's the end of the string, add the character immediately.
builder.append(c);
}
boolean isDelimiter = nextSegmentDelimiter != null && c == nextSegmentDelimiter;
if (isEndOfVersionString || isDelimiter || c == '.') {
onComponent.accept(builder.toString());
builder = new StringBuilder();
// If it's '+' or the end of the string, break out of the loop
// to continue processing other components.
if (isDelimiter) {
break;
}
// Otherwise, increment the counter to continue processing the
// pre-releases component.
offset++;
continue;
}
if (!isValidIdentifierChar) {
throw new VersioningException("Found unexpected character in %s identifier: '%s'".formatted(componentName, c));
}
builder.append(c);
offset++;
}
return offset;
}
}