BaseVersion.groovy

/*
 * Copyright 2014-2015 David Fallah
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.github.tagc.semver.version

import groovy.transform.Immutable
import groovy.transform.PackageScope

import java.util.regex.Matcher
import java.util.regex.Pattern

/**
 * A concrete, base implementation of {@link com.github.tagc.semver.version.Version Version}.
 *
 * @author davidfallah
 * @since v0.1.0
 */
@Immutable
@PackageScope
final class BaseVersion implements Version {

    /**
     * Version parser - parses strings and constructs {@link com.github.tagc.semver.version.BaseVersion BaseVersion}
     * instances from them.
     *
     * @author davidfallah
     * @since 0.2.1
     */
    @Singleton
    static class Parser {
        private static final Pattern WHITESPACE = ~/\s*/
        private static final Pattern VERSION_PATTERN = ~/(\d+)\.(\d+).(\d+)/
        private static final Pattern SHORT_VERSION_PATTERN = ~/(\d+)\.(\d+)/

        private static final Pattern RELEASE_VERSION_PATTERN =
        ~/$WHITESPACE$VERSION_PATTERN$WHITESPACE/

        private static final Pattern RELEASE_SHORT_VERSION_PATTERN =
        ~/$WHITESPACE$SHORT_VERSION_PATTERN$WHITESPACE/

        private static final Pattern SNAPSHOT_VERSION_PATTERN =
        ~/$WHITESPACE$VERSION_PATTERN$SNAPSHOT_IDENTIFIER$WHITESPACE/

        private static final Pattern SNAPSHOT_SHORT_VERSION_PATTERN =
        ~/$WHITESPACE$SHORT_VERSION_PATTERN$SNAPSHOT_IDENTIFIER$WHITESPACE/

        private static final int MATCHER_MAJOR = 1
        private static final int MATCHER_MINOR = 2
        private static final int MATCHER_PATCH = 3

        /**
         * Parses the text within the file and replaces all occurrences of version data
         * with new version data based on {@code newVersion}.
         *
         * This method will not modify the given file.
         *
         * @param inputFile a file containing text that represents a version specifier
         * @param newVersion the version to replace the existing version data in the input with
         * @return a copy of the file's text with the replacements made, if any
         */
        String parseAndReplace(File inputFile, boolean strict=false, Version newVersion) {
            parseAndReplace(inputFile.text, strict, newVersion)
        }

        /**
         * Parses the specified input string and replaces all occurrences of version data
         * with new version data based on {@code newVersion}.
         *
         * @param input a string representing a version specifier
         * @param newVersion the version to replace the existing version data in the input with
         * @return a copy of the input string with the replacements made, if any
         */
        String parseAndReplace(String input, boolean strict=false, Version newVersion) {
            tryParseAndReplaceVersion(input, strict, newVersion)
        }

        /**
         * Parses the text within the given file and tries to construct an instance of
         * {@code Version} from it.
         *
         * @param inputFile a file containing text that represents a version specifier
         * @param strict set {@code true} if the parse attempt should succeed only if the entire string can be parsed
         * @return an instance of {@code BaseVersion} if the input could be parsed or {@code null} if it could not
         */
        BaseVersion parse(File inputFile, boolean strict=false) {
            parse(inputFile.text, strict)
        }

        /**
         * Parses the specified input string and tries to construct an instance of
         * {@code Version} from it.
         *
         * @param input a string representing a version specifier
         * @param strict set {@code true} if the parse attempt should succeed only if the entire string can be parsed
         * @return an instance of {@code BaseVersion} if the input could be parsed or {@code null} if it could not
         */
        BaseVersion parse(String input, boolean strict=false) {
            tryParseVersion(input, strict)
        }

        private BaseVersion tryParseVersion(String input, boolean strict) {
            BaseVersion version

            if ((version = tryParseFullSnapshotVersion(input, strict)) != null) {
                return version
            } else if ((version = tryParseShortSnapshotVersion(input, strict)) != null) {
                return version
            } else if ((version = tryParseFullReleaseVersion(input, strict)) != null) {
                return version
            } else if ((version = tryParseShortReleaseVersion(input, strict)) != null) {
                return version
            }

            return null
        }

        private String tryParseAndReplaceVersion(String input, boolean strict, Version replacement) {
            String updatedText
            String originalText = updatedText

            if ((updatedText = tryParseAndReplaceFullSnapshotVersion(input, strict, replacement)) != null) {
                return updatedText
            } else if ((updatedText = tryParseAndReplaceShortSnapshotVersion(input, strict, replacement)) != null) {
                return updatedText
            } else if ((updatedText = tryParseAndReplaceFullReleaseVersion(input, strict, replacement)) != null) {
                return updatedText
            } else if ((updatedText = tryParseAndReplaceShortReleaseVersion(input, strict, replacement)) != null) {
                return updatedText
            }

            return originalText
        }

        private Version tryParseFullSnapshotVersion(String input, boolean strict) {
            Matcher m = checkInputAgainstPattern(input, SNAPSHOT_VERSION_PATTERN, strict)
            if (!m) {
                return null
            }

            def builder = new BaseVersion.Builder()
            builder.setMajor(m[0][MATCHER_MAJOR].toInteger())
                    .setMinor(m[0][MATCHER_MINOR].toInteger())
                    .setPatch(m[0][MATCHER_PATCH].toInteger())
                    .setRelease(false)
                    .build()
        }

        private BaseVersion tryParseShortSnapshotVersion(String input, boolean strict) {
            Matcher m = checkInputAgainstPattern(input, SNAPSHOT_SHORT_VERSION_PATTERN, strict)
            if (!m) {
                return null
            }

            def builder = new BaseVersion.Builder()
            builder.setMajor(m[0][MATCHER_MAJOR].toInteger())
                    .setMinor(m[0][MATCHER_MINOR].toInteger())
                    .setRelease(false)
                    .build()
        }

        private BaseVersion tryParseFullReleaseVersion(String input, boolean strict) {
            Matcher m = checkInputAgainstPattern(input, RELEASE_VERSION_PATTERN, strict)
            if (!m) {
                return null
            }

            def builder = new BaseVersion.Builder()
            builder.setMajor(m[0][MATCHER_MAJOR].toInteger())
                    .setMinor(m[0][MATCHER_MINOR].toInteger())
                    .setPatch(m[0][MATCHER_PATCH].toInteger())
                    .setRelease(true)
                    .build()
        }

        private BaseVersion tryParseShortReleaseVersion(String input, boolean strict) {
            Matcher m = checkInputAgainstPattern(input, RELEASE_SHORT_VERSION_PATTERN, strict)
            if (!m) {
                return null
            }

            def builder = new BaseVersion.Builder()
            builder.setMajor(m[0][MATCHER_MAJOR].toInteger())
                    .setMinor(m[0][MATCHER_MINOR].toInteger())
                    .setRelease(true)
                    .build()
        }

        private String tryParseAndReplaceFullSnapshotVersion(String input, boolean strict, Version replacement) {
            Matcher m = checkInputAgainstPattern(input, SNAPSHOT_VERSION_PATTERN, strict)
            if (m) {
                return m.replaceAll(replacement.toString())
            }

            return null
        }

        private String tryParseAndReplaceShortSnapshotVersion(String input, boolean strict, Version replacement) {
            Matcher m = checkInputAgainstPattern(input, SNAPSHOT_SHORT_VERSION_PATTERN, strict)
            if (m) {
                return m.replaceAll(replacement.toString())
            }

            return null
        }

        private String tryParseAndReplaceFullReleaseVersion(String input, boolean strict, Version replacement) {
            Matcher m = checkInputAgainstPattern(input, RELEASE_VERSION_PATTERN, strict)
            if (m) {
                return m.replaceAll(replacement.toString())
            }

            return null
        }

        private String tryParseAndReplaceShortReleaseVersion(String input, boolean strict, Version replacement) {
            Matcher m = checkInputAgainstPattern(input, RELEASE_SHORT_VERSION_PATTERN, strict)
            if (m) {
                return m.replaceAll(replacement.toString())
            }

            return null
        }

        private Matcher checkInputAgainstPattern(String input, Pattern pattern, boolean strict) {
            if (strict && !(input ==~ pattern)) {
                return null
            }

            input =~ pattern
        }
    }

    /**
     * Version builder - allows for {@link com.github.tagc.semver.Version Version} construction parameters to be
     * selected incrementally.
     *
     * @author davidfallah
     * @since 0.1.0
     */
    static class Builder {
        int major = 0
        int minor = 0
        int patch = 0
        boolean release = false

        BaseVersion.Builder setMajor(int major) {
            this.major = major
            return this
        }

        BaseVersion.Builder setMinor(int minor) {
            this.minor = minor
            return this
        }

        BaseVersion.Builder setPatch(int patch) {
            this.patch = patch
            return this
        }

        BaseVersion.Builder setRelease(boolean release) {
            this.release = release
            return this
        }

        /**
         * Constructs and returns a {@code Version} object based on this builder's configuration.
         *
         * @return a new instance of {@code Version}
         */
        BaseVersion build() {
            new BaseVersion(major, minor, patch, release)
        }
    }

    private static final String SNAPSHOT_IDENTIFIER = '-SNAPSHOT'

    /**
     * The major category of this version.
     */
    int major = 0

    /**
     * The minor category of this version.
     */
    int minor = 0

    /**
     * The patch category of this version.
     */
    int patch = 0

    /**
     * Whether this version is a release or snapshot version.
     */
    boolean release = false

    @Override
    Version incrementByCategory(Version.Category category) {
        switch (category) {
            case Version.Category.MAJOR:
                return incrementMajor()
            case Version.Category.MINOR:
                return incrementMinor()
            case Version.Category.PATCH:
                return incrementPatch()
            default:
                throw new IllegalArgumentException("Invalid increment category: $category")
        }
    }

    @Override
    Version incrementMajor() {
        new BaseVersion(major + 1, minor, patch, release)
    }

    @Override
    Version incrementMinor() {
        new BaseVersion(major, minor + 1, patch, release)
    }

    @Override
    Version incrementPatch() {
        new BaseVersion(major, minor, patch + 1, release)
    }

    @Override
    Version bumpByCategory(Version.Category category) {
        switch (category) {
            case Version.Category.MAJOR:
                return bumpMajor()
            case Version.Category.MINOR:
                return bumpMinor()
            case Version.Category.PATCH:
                return bumpPatch()
            default:
                throw new IllegalArgumentException("Invalid bump category: $category")
        }
    }

    @Override
    Version bumpMajor() {
        new BaseVersion(major + 1, 0, 0, release)
    }

    @Override
    Version bumpMinor() {
        new BaseVersion(major, minor + 1, 0, release)
    }

    @Override
    Version bumpPatch() {
        new BaseVersion(major, minor, patch + 1, release)
    }

    @Override
    Version toRelease() {
        new BaseVersion(major, minor, patch, true)
    }

    @Override
    Version toDevelop() {
        new BaseVersion(major, minor, patch, false)
    }

    @Override
    Version.Category distanceFrom(Version newerVersion) {
        if (newerVersion.toRelease() == this.bumpMajor().toRelease()) {
            return Version.Category.MAJOR
        } else if (newerVersion.toRelease() == this.bumpMinor().toRelease()) {
            return Version.Category.MINOR
        } else if (newerVersion.toRelease() == this.bumpPatch().toRelease()) {
            return Version.Category.PATCH
        }

        return null
    }

    @Override
    BaseVersion unwrap() {
        return this
    }

    @Override
    boolean equals(Object o) {
        if (o == null) {
            return false
        } else if (! (o instanceof Version)) {
            return false
        } else if (this.major != o.major) {
            return false
        } else if (this.minor != o.minor) {
            return false
        } else if (this.patch != o.patch) {
            return false
        }
        this.release == o.release
    }

    @Override
    int hashCode() {
        final int factor = 31
        def result = 17
        result = factor * result + major
        result = factor * result + minor
        result = factor * result + patch
        result = factor * result + (release ? 1 : 0)
        return result
    }

    @Override
    int compareTo(Version that) {
        if (this.major == that.major) {
            if (this.minor == that.minor) {
                if (this.patch == that.patch) {
                    return -(this.release <=> that.release)
                }

                return this.patch <=> that.patch
            }

            return this.minor <=> that.minor
        }

        return this.major <=> that.major
    }

    @Override
    String toString() {
        "$major.$minor.$patch${release ? '' : SNAPSHOT_IDENTIFIER}"
    }
}