diff --git a/src/main/java/org/eolang/lints/SemVer.java b/src/main/java/org/eolang/lints/SemVer.java new file mode 100644 index 000000000..30b199929 --- /dev/null +++ b/src/main/java/org/eolang/lints/SemVer.java @@ -0,0 +1,444 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com + * SPDX-License-Identifier: MIT + */ +package org.eolang.lints; + +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Semantic Versioning 2.0.0. + *
+ * Parses, validates, and compares version strings that follow the + * SemVer 2.0.0 specification. + * A valid SemVer string has the format {@code MAJOR.MINOR.PATCH}, + * optionally followed by a hyphen and pre-release identifiers, and/or + * a plus sign and build metadata. + *
+ * + * @see Semantic Versioning 2.0.0 + * @since 1.0 + */ +@SuppressWarnings({"PMD.TooManyMethods", "PMD.GodClass"}) +public final class SemVer implements Comparable+ * Parses a version string in the format {@code MAJOR.MINOR.PATCH} + * with optional pre-release and build metadata components. + *
+ * + * @param version Version string to parse + * @throws IllegalArgumentException If the string is not valid SemVer + */ + public SemVer(final String version) { + this(SemVer.parsed(version)); + } + + /** + * Ctor. + * @param major Major version + * @param minor Minor version + * @param patch Patch version + * @checkstyle ParameterNumberCheck (5 lines) + */ + public SemVer(final int major, final int minor, final int patch) { + this(major, minor, patch, "", ""); + } + + /** + * Ctor. + * @param major Major version + * @param minor Minor version + * @param patch Patch version + * @param prerelease Pre-release identifiers + * @checkstyle ParameterNumberCheck (5 lines) + */ + public SemVer( + final int major, final int minor, final int patch, + final String prerelease + ) { + this(major, minor, patch, prerelease, ""); + } + + /** + * Ctor. + * @param major Major version + * @param minor Minor version + * @param patch Patch version + * @param prerelease Pre-release identifiers (may be null, normalized to empty string) + * @param meta Build metadata (may be null, normalized to empty string) + * @checkstyle ParameterNumberCheck (5 lines) + */ + public SemVer( + final int major, final int minor, final int patch, + final String prerelease, final String meta + ) { + this.mjr = SemVer.validated(major, "Major"); + this.mnr = SemVer.validated(minor, "Minor"); + this.ptch = SemVer.validated(patch, "Patch"); + this.pre = SemVer.validatedIdentifier( + prerelease, "pre-release", SemVer.PRE_PATTERN + ); + this.build = SemVer.validatedIdentifier( + meta, "build metadata", SemVer.BUILD_PATTERN + ); + } + + /** + * Private delegating ctor from a parsed SemVer. + * @param origin Parsed instance + */ + private SemVer(final SemVer origin) { + this(origin.mjr, origin.mnr, origin.ptch, origin.pre, origin.build); + } + + /** + * Major version number. + * @return Major version + */ + public int major() { + return this.mjr; + } + + /** + * Minor version number. + * @return Minor version + */ + public int minor() { + return this.mnr; + } + + /** + * Patch version number. + * @return Patch version + */ + public int patch() { + return this.ptch; + } + + /** + * Pre-release identifiers. + * @return Pre-release string, empty if absent + */ + public String prerelease() { + return this.pre; + } + + /** + * Build metadata. + * @return Build metadata string, empty if absent + */ + public String metadata() { + return this.build; + } + + /** + * Whether this version has a pre-release component. + * @return True if pre-release identifiers are present + */ + public boolean isPrerelease() { + return !this.pre.isEmpty(); + } + + @Override + public String toString() { + final StringBuilder result = new StringBuilder() + .append(this.mjr) + .append('.') + .append(this.mnr) + .append('.') + .append(this.ptch); + if (!this.pre.isEmpty()) { + result.append('-').append(this.pre); + } + if (!this.build.isEmpty()) { + result.append('+').append(this.build); + } + return result.toString(); + } + + @Override + @SuppressWarnings("PMD.CognitiveComplexity") + public int compareTo(final SemVer other) { + int result = Integer.compare(this.mjr, other.mjr); + if (result == 0) { + result = Integer.compare(this.mnr, other.mnr); + } + if (result == 0) { + result = Integer.compare(this.ptch, other.ptch); + } + if (result == 0) { + result = SemVer.comparePre(this.pre, other.pre); + } + return result; + } + + @Override + public boolean equals(final Object obj) { + final boolean result; + if (this == obj) { + result = true; + } else if (obj instanceof SemVer) { + result = this.compareTo((SemVer) obj) == 0; + } else { + result = false; + } + return result; + } + + @Override + public int hashCode() { + return Objects.hash(this.mjr, this.mnr, this.ptch, this.pre); + } + + /** + * Validates that a version component is non-negative. + * @param value Version component value + * @param name Component name for error message + * @return The value if valid + * @throws IllegalArgumentException If value is negative + */ + private static int validated(final int value, final String name) { + if (value < 0) { + throw new IllegalArgumentException( + String.format("%s version must be non-negative", name) + ); + } + return value; + } + + /** + * Returns empty string when null. + * @param value String value + * @return Value or empty string if null + */ + private static String defaulted(final String value) { + final String result; + if (value == null) { + result = ""; + } else { + result = value; + } + return result; + } + + /** + * Validates optional identifier (prerelease or build) against SemVer pattern. + * @param value Identifier string, null or empty allowed + * @param name Component name for error message + * @param pattern Pattern to match non-empty values + * @return Empty string if null/empty, otherwise validated value + * @throws IllegalArgumentException If non-empty value does not match pattern + */ + private static String validatedIdentifier( + final String value, final String name, final Pattern pattern + ) { + final String result = SemVer.defaulted(value); + if (!result.isEmpty() && !pattern.matcher(result).matches()) { + throw new IllegalArgumentException( + String.format("Invalid SemVer %s: '%s'", name, value) + ); + } + return result; + } + + /** + * Parse version string into a SemVer instance. + * @param version Version string + * @return Parsed SemVer + * @throws IllegalArgumentException If the string is not valid SemVer + */ + private static SemVer parsed(final String version) { + final Matcher matcher = SemVer.PATTERN.matcher(version); + if (!matcher.matches()) { + throw new IllegalArgumentException( + String.format("Invalid SemVer: '%s'", version) + ); + } + final int major; + final int minor; + final int patch; + try { + major = Integer.parseInt(matcher.group("major")); + minor = Integer.parseInt(matcher.group("minor")); + patch = Integer.parseInt(matcher.group("patch")); + } catch (final NumberFormatException ex) { + throw new IllegalArgumentException( + String.format("Invalid SemVer: '%s'", version), + ex + ); + } + final String pre = SemVer.defaulted(matcher.group("prerelease")); + final String bld = SemVer.defaulted(matcher.group("buildmetadata")); + return new SemVer(major, minor, patch, pre, bld); + } + + /** + * Compare pre-release identifiers according to the SemVer specification. + *+ * When major, minor, and patch are equal, a version with pre-release + * identifiers has lower precedence than a normal version. Pre-release + * identifiers are compared in order: numeric are compared as integers, + * non-numeric lexically. Numeric always have lower precedence than + * non-numeric. + *
+ * + * @param left Left pre-release string + * @param right Right pre-release string + * @return Comparison result + */ + @SuppressWarnings("PMD.CognitiveComplexity") + private static int comparePre(final String left, final String right) { + final int result; + if (left.isEmpty() && right.isEmpty()) { + result = 0; + } else if (left.isEmpty()) { + result = 1; + } else if (right.isEmpty()) { + result = -1; + } else { + result = SemVer.comparePreIdentifiers( + left.split("\\."), + right.split("\\.") + ); + } + return result; + } + + /** + * Compare arrays of pre-release identifiers according to SemVer rules. + * + * @param left Left identifier array + * @param right Right identifier array + * @return Comparison result + * @checkstyle LocalVariableNameCheck (20 lines) + */ + @SuppressWarnings({"PMD.UseVarargs", "PMD.CognitiveComplexity"}) + private static int comparePreIdentifiers( + final String[] left, final String[] right + ) { + int result = 0; + final int length = Math.min(left.length, right.length); + for (int idx = 0; idx < length; idx += 1) { + result = SemVer.compareIdentifier(left[idx], right[idx]); + if (result != 0) { + break; + } + } + if (result == 0) { + result = Integer.compare(left.length, right.length); + } + return result; + } + + /** + * Compare two individual pre-release identifiers. + * + * @param left Left identifier + * @param right Right identifier + * @return Comparison result + */ + private static int compareIdentifier( + final String left, final String right + ) { + final boolean lnum = SemVer.isNumericIdentifier(left); + final boolean rnum = SemVer.isNumericIdentifier(right); + final int result; + if (lnum && rnum) { + result = SemVer.compareNumericStrings(left, right); + } else if (lnum) { + result = -1; + } else if (rnum) { + result = 1; + } else { + result = left.compareTo(right); + } + return result; + } + + /** + * Compare two numeric strings by length first, then lexically. + * @param left Left numeric string + * @param right Right numeric string + * @return Comparison result + */ + private static int compareNumericStrings( + final String left, final String right + ) { + final int result; + if (left.length() == right.length()) { + result = left.compareTo(right); + } else { + result = Integer.compare(left.length(), right.length()); + } + return result; + } + + /** + * Determines if the identifier consists only of digits. + * + * @param identifier The identifier string + * @return True if numeric, false otherwise + * @checkstyle LocalVariableNameCheck (10 lines) + */ + private static boolean isNumericIdentifier(final String identifier) { + boolean digits = !identifier.isEmpty(); + for (int idx = 0; idx < identifier.length(); idx += 1) { + if (!Character.isDigit(identifier.charAt(idx))) { + digits = false; + break; + } + } + return digits; + } +} diff --git a/src/test/java/org/eolang/lints/SemVerTest.java b/src/test/java/org/eolang/lints/SemVerTest.java new file mode 100644 index 000000000..a809e929f --- /dev/null +++ b/src/test/java/org/eolang/lints/SemVerTest.java @@ -0,0 +1,275 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com + * SPDX-License-Identifier: MIT + */ +package org.eolang.lints; + +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests for {@link SemVer}. + * + * @since 1.0 + */ +@SuppressWarnings("PMD.TooManyMethods") +final class SemVerTest { + + @Test + void parsesSimpleVersion() { + final SemVer ver = new SemVer("1.2.3"); + MatcherAssert.assertThat( + "Major version doesn't match", + ver.major(), + Matchers.equalTo(1) + ); + MatcherAssert.assertThat( + "Minor version doesn't match", + ver.minor(), + Matchers.equalTo(2) + ); + MatcherAssert.assertThat( + "Patch version doesn't match", + ver.patch(), + Matchers.equalTo(3) + ); + } + + @Test + void parsesVersionWithPrerelease() { + final SemVer ver = new SemVer("1.0.0-alpha.1"); + MatcherAssert.assertThat( + "Pre-release doesn't match", + ver.prerelease(), + Matchers.equalTo("alpha.1") + ); + MatcherAssert.assertThat( + "Should be pre-release", + ver.isPrerelease(), + Matchers.equalTo(true) + ); + } + + @Test + void parsesVersionWithBuildMetadata() { + final SemVer ver = new SemVer("1.0.0+20130313144700"); + MatcherAssert.assertThat( + "Build metadata doesn't match", + ver.metadata(), + Matchers.equalTo("20130313144700") + ); + } + + @Test + void parsesVersionWithPrereleaseAndBuild() { + final SemVer ver = new SemVer("1.0.0-beta+exp.sha.5114f85"); + MatcherAssert.assertThat( + "Pre-release doesn't match", + ver.prerelease(), + Matchers.equalTo("beta") + ); + MatcherAssert.assertThat( + "Build metadata doesn't match", + ver.metadata(), + Matchers.equalTo("exp.sha.5114f85") + ); + } + + @ParameterizedTest + @ValueSource(strings = { + "2.3.4", + "0.0.0", + "10.20.30", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-0.3.7", + "1.0.0-x.7.z.92", + "1.0.0+20130313144700", + "1.0.0-beta+exp.sha.5114f85", + "1.0.0+21AF26D3----117B344092BD" + }) + void parsesCorrectVersions(final String version) { + MatcherAssert.assertThat( + String.format("'%s' should parse without error", version), + new SemVer(version).toString(), + Matchers.equalTo(version) + ); + } + + @ParameterizedTest + @ValueSource(strings = { + "1.2", + "1", + "1.2.3.4", + "01.2.3", + "1.02.3", + "1.2.03", + "alpha.beta.gamma", + "v1.2.3", + "1.2.3-", + "1.2.3+" + }) + @SuppressWarnings("PMD.AvoidUsingHardCodedIP") + void rejectsInvalidVersions(final String version) { + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new SemVer(version), + String.format("'%s' should be rejected", version) + ); + } + + @Test + void throwsOnInvalidParse() { + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new SemVer("not-a-version"), + "Should throw on invalid version string" + ); + } + + @Test + void convertsToString() { + MatcherAssert.assertThat( + "toString doesn't produce correct output", + new SemVer("1.2.3-alpha+build").toString(), + Matchers.equalTo("1.2.3-alpha+build") + ); + } + + @Test + void convertsSimpleToString() { + MatcherAssert.assertThat( + "toString doesn't produce correct simple output", + new SemVer(3, 4, 5).toString(), + Matchers.equalTo("3.4.5") + ); + } + + @Test + void comparesMajorVersions() { + MatcherAssert.assertThat( + "1.0.0 should be less than 2.0.0", + new SemVer("1.0.0").compareTo(new SemVer("2.0.0")), + Matchers.equalTo(-1) + ); + } + + @Test + void comparesMinorVersions() { + MatcherAssert.assertThat( + "1.0.1 should be less than 1.1.0", + new SemVer("1.0.1").compareTo(new SemVer("1.1.0")), + Matchers.equalTo(-1) + ); + } + + @Test + void comparesPatchVersions() { + MatcherAssert.assertThat( + "1.0.4 should be less than 1.0.5", + new SemVer("1.0.4").compareTo(new SemVer("1.0.5")), + Matchers.equalTo(-1) + ); + } + + @Test + void treatsPrereleaseAsLessThanRelease() { + MatcherAssert.assertThat( + "Pre-release should be less than release", + new SemVer("1.0.0-alpha").compareTo(new SemVer("1.0.0")), + Matchers.equalTo(-1) + ); + } + + @Test + void comparesPrereleaseIdentifiers() { + MatcherAssert.assertThat( + "alpha should be less than beta", + new SemVer("1.0.0-alpha"), + Matchers.lessThan(new SemVer("1.0.0-beta")) + ); + } + + @Test + void comparesNumericPrereleaseIdentifiers() { + MatcherAssert.assertThat( + "beta.2 should be less than beta.11", + new SemVer("1.0.0-beta.2"), + Matchers.lessThan(new SemVer("1.0.0-beta.11")) + ); + } + + @Test + void followsSemverPrecedence() { + MatcherAssert.assertThat( + "alpha < alpha.1", + new SemVer("1.0.0-alpha"), + Matchers.lessThan(new SemVer("1.0.0-alpha.1")) + ); + MatcherAssert.assertThat( + "alpha.1 < alpha.beta", + new SemVer("1.0.0-alpha.1"), + Matchers.lessThan(new SemVer("1.0.0-alpha.beta")) + ); + MatcherAssert.assertThat( + "alpha.beta < beta", + new SemVer("1.0.0-alpha.beta"), + Matchers.lessThan(new SemVer("1.0.0-beta")) + ); + MatcherAssert.assertThat( + "beta < beta.2", + new SemVer("1.0.0-beta"), + Matchers.lessThan(new SemVer("1.0.0-beta.2")) + ); + MatcherAssert.assertThat( + "beta.2 < beta.11", + new SemVer("1.0.0-beta.2"), + Matchers.lessThan(new SemVer("1.0.0-beta.11")) + ); + MatcherAssert.assertThat( + "beta.11 < rc.1", + new SemVer("1.0.0-beta.11"), + Matchers.lessThan(new SemVer("1.0.0-rc.1")) + ); + } + + @Test + void treatsEqualVersionsAsEqual() { + MatcherAssert.assertThat( + "Same versions should be equal", + new SemVer("1.6.1"), + Matchers.equalTo(new SemVer("1.6.1")) + ); + } + + @Test + void ignoresBuildMetadataInComparison() { + MatcherAssert.assertThat( + "Build metadata should be ignored in comparison", + new SemVer("1.0.0+build1"), + Matchers.equalTo(new SemVer("1.0.0+build2")) + ); + } + + @Test + void identifiesNotPrereleaseWhenAbsent() { + MatcherAssert.assertThat( + "Should not be pre-release", + new SemVer("1.0.0").isPrerelease(), + Matchers.equalTo(false) + ); + } + + @Test + void producesConsistentHashCodes() { + MatcherAssert.assertThat( + "Equal versions should have equal hash codes", + new SemVer("1.6.2").hashCode(), + Matchers.equalTo(new SemVer("1.6.2").hashCode()) + ); + } +}