package org.rascalmpl.library.util; import java.util.Scanner; import java.util.regex.Pattern; /** * Concise implementation of Semantic Versioning as described at http://semver.org * Many examples and tests inspired by https://github.com/npm/node-semver * */ public class SemVer { private int major = 0; // >-0 when used as (major/minor/patch) version number, or -1 when used as wildcard private int minor = 0; private int patch = 0; private String prerelease = ""; private String build = ""; static final Pattern wildCard = Pattern.compile("[xX\\*]"); static final Pattern number = Pattern.compile("0|([1-9][0-9]*)"); static final Pattern part = Pattern.compile("(0|([1-9][0-9]*))|[-0-9A-Za-z]+"); static final Pattern or = Pattern.compile("\\s*\\|\\|\\s*"); static final Pattern spaces = Pattern.compile("\\s+"); static final Pattern hyphen = Pattern.compile(" - "); static final Pattern startVersion = Pattern.compile("[xX\\*]|([0-9]+)"); static final String rangeTokens = "((\\s+\\-\\s+)|\\+|\\-|\\~|\\^|<|>|=|(\\s+\\|\\|\\s+)|\\s)"; static final String versionTokens = "(\\+|\\-)"; static final String digits = "[0-9]+"; static final String delimiter = "\\."; SemVer(int major, int minor, int patch, String prerelease, String build){ this.major = major; this.minor = minor; this.patch = patch; this.prerelease = prerelease; this.build = build; } /** * @param version Semantic Version string * @throws RuntimeException when version is invalid */ public SemVer(String version){ this(new Scanner(version.replaceAll(versionTokens, ".$1.")), false); } private SemVer(Scanner s){ this(s, true); } private SemVer(Scanner s, boolean isPattern){ if(!isPattern && s.hasNext("[=v]")){ s.next("[=v]"); } s.useDelimiter(delimiter); if(!s.hasNext(startVersion)){ throw new RuntimeException("Invalid version string"); } major = hasNextXorN(s) ? nextXorN(s) : -1; minor = hasNextXorN(s) ? nextXorN(s) : -1; patch = hasNextXorN(s) ? nextXorN(s) : -1; prerelease = s.hasNext("\\-") ? nextParts(s) : ""; build = s.hasNext("\\+") ? nextParts(s) : ""; if(isPattern){ if(s.hasNext() && !(s.hasNext(spaces) || s.hasNext(hyphen) || s.hasNext(or))){ throw new RuntimeException("Invalid version pattern"); } } else { if(s.hasNext()){ throw new RuntimeException("Invalid version string"); } s.close(); } } /** * @return SemVer with incremented major version number and cleared prerelease and build strings */ public SemVer incMajor(){ return new SemVer(major+1, 0, 0, "", ""); } /** * @return SemVer with incremented minor version number and cleared prerelease and build strings */ public SemVer incMinor(){ return new SemVer(major, minor+1, 0, "", ""); } /** * @return SemVer with incremented patch version number and cleared prerelease and build strings */ public SemVer incPatch(){ return new SemVer(major, minor, patch+1, "", ""); } /****************************************************************************/ /* Utilities for parsing version strings */ /****************************************************************************/ private boolean hasNextXorN(Scanner s){ return s.hasNext(wildCard) || s.hasNext(number); } private int nextXorN(Scanner s){ if(s.hasNext(wildCard)){ s.next(); return -1; } if(s.hasNext(number)){ return Integer.valueOf(s.next()); } throw new RuntimeException("Expected nextXorN"); } private boolean hasNextPart(Scanner s){ return s.hasNext(part); } private String nextPart(Scanner s){ return s.next(part); } private String nextParts(Scanner s){ s.next(); // skip + or -; String parts = nextPart(s); while(hasNextPart(s)){ parts += "." + nextPart(s); } return parts; } /****************************************************************************/ /* Comparisons */ /****************************************************************************/ /** * @param other SemVer * @return true, when both SemVers are equivalent (excludes build info) */ public boolean equivalentVersion(SemVer other){ return equal(major, other.major) && equal(minor, other.minor) && equal(patch, other.patch) && prerelease.equals(other.prerelease); } /** * @param other SemVer * @return true, when both SemVers are equals (includes build info) */ public boolean equalVersion(SemVer other){ return this.equivalentVersion(other) && build.equals(other.build); } private boolean matchEqualTo(SemVer other){ return matchEqual(major, other.major) && matchEqual(minor, other.minor) && matchEqual(patch, other.patch); } private boolean matchEqual(int n, int m){ return n >= 0 ? (m < 0 || n == m) : true; } private boolean equal(int n, int m){ return n >= 0 && m >= 0 && n == m; } private boolean less(int n, int m) { return n >= 0 ? (m >= 0 && n < m) : false; } private boolean greater(int n, int m) { return n >= 0 ? (m >= 0 && n > m) : false; } /** * @param other SemVer * @return true if this SemVer is less than other */ public boolean lessVersion(SemVer other){ return less(major, other.major) || less(minor, other.minor) || less(patch, other.patch) || (!prerelease.equals(other.prerelease) && lessPrerelease(other)); } /** * @param other SemVer * @return true if this SemVer is greater than other */ public boolean greaterVersion(SemVer other){ return greater(major, other.major) || greater(minor, other.minor) || greater(patch, other.patch) || (!prerelease.equals(other.prerelease) && !lessPrerelease(other)); } /** * @param other SemVer * @return true if this SemVer is less than or equal to other */ public boolean lessEqualVersion(SemVer other){ return lessVersion(other) || equalVersion(other); } /** * @param other SemVer * @return true if this SemVer is greater than or equal to other */ public boolean greaterEqualVersion(SemVer other){ return greaterVersion(other) || equalVersion(other); } private boolean lessPrerelease(SemVer other){ String[] pre1 = prerelease.split(delimiter); String[] pre2 = other.prerelease.split(delimiter); int n = Integer.min(pre1.length, pre2.length); for(int i = 0; i < n; i++){ String id1 = pre1[i]; String id2 = pre2[i]; if(!id1.isEmpty() && id2.isEmpty()){ return true; } if(id1.matches(digits)){ if(id2.matches(digits)){ if(Integer.valueOf(id1) < Integer.valueOf(id2)){ return true; } } else { return true; } } else if(id2.matches(digits)){ return true; } if(id1.compareTo(id2) < 0){ return true; } } return pre1.length < pre2.length; } /****************************************************************************/ /* Satisfies */ /****************************************************************************/ /** * Check that this SemVer instance satisfies a range-set as defined by: * (See https://github.com/npm/node-semver): * * <verbatim> * range-set ::= range ( logical-or range ) * * logical-or ::= ( ' ' ) * '||' ( ' ' ) * * range ::= hyphen | simple ( ' ' simple ) * | '' * hyphen ::= partial ' - ' partial * simple ::= primitive | partial | tilde | caret * primitive ::= ( '<' | '>' | '>=' | '<=' | '=' | ) partial * partial ::= xr ( '.' xr ( '.' xr qualifier ? )? )? * xr ::= 'x' | 'X' | '*' | nr * nr ::= '0' | ['1'-'9'] ( ['0'-'9'] ) * * tilde ::= '~' partial * caret ::= '^' partial * qualifier ::= ( '-' pre )? ( '+' build )? * pre ::= parts * build ::= parts * parts ::= part ( '.' part ) * * part ::= nr | [-0-9A-Za-z]+ * * @param rangeSet to be checked * @return true if this SemVer instance satisfies rangeSet */ public boolean satisfiesVersion(String rangeSet){ try(Scanner s = new Scanner(insertDelimiters(rangeSet))){ s.useDelimiter(delimiter); if(satisfiesRange(s)){ if(!s.hasNext() || s.hasNext(or)){ return true; } else { throw new RuntimeException("Invalid range string"); } } while(s.hasNext(or)){ s.next(or); if(satisfiesRange(s)){ return true; } } return false; } } private String insertDelimiters(String input){ return input.replaceAll(rangeTokens, ".$1.").replaceAll(delimiter + delimiter, delimiter); } private SemVer setWildCardsToZero(){ if(major < 0) major = 0; if(minor < 0) minor = 0; if(patch < 0) patch = 0; return this; } private boolean satisfiesCaret(SemVer pat){ if(this.matchEqualTo(pat)){ return true; } if(pat.major > 0){ return this.greaterEqualVersion(pat) && this.lessVersion(pat.incMajor()); } if(pat.minor > 0){ return this.greaterEqualVersion(pat) && this.lessVersion(pat.incMinor()); } if(pat.patch > 0){ return this.greaterEqualVersion(pat) && this.lessVersion(pat.incPatch()); } return false; } private boolean satisfiesSimple(Scanner s){ s.skip("\\s*"); if(s.hasNext("~")){ s.next(); SemVer pat = new SemVer(s); if(pat.minor >= 0){ // Allow patch level changes pat.setWildCardsToZero(); return this.greaterEqualVersion(pat) && this.lessVersion(pat.incMinor()); } pat.setWildCardsToZero(); return this.greaterEqualVersion(pat) && this.lessVersion(pat.incMajor()); } if(s.hasNext("\\^")){ s.next(); return satisfiesCaret(new SemVer(s)); } if(s.hasNext(">")){ s.next(); if(s.hasNext("=")){ s.next(); return this.greaterEqualVersion(new SemVer(s)); } return this.greaterVersion(new SemVer(s)); } if(s.hasNext("<")){ s.next(); if(s.hasNext("=")){ s.next(); return this.lessEqualVersion(new SemVer(s)); } return this.lessVersion(new SemVer(s)); } if(s.hasNext("=")){ s.next(); return this.equalVersion(new SemVer(s)); } if(s.hasNext("[xX\\*]|[0-9]")){ return this.equalVersion(new SemVer(s)); } throw new RuntimeException("Invalid range string"); } private boolean satisfiesRange(Scanner s){ if(s.hasNext("\\s*[\\<\\=\\>\\^\\~]")){ while(s.hasNext("\\s*[\\<\\=\\>\\^\\~]")){ if(!satisfiesSimple(s)){ if(s.hasNext() && !s.hasNext(spaces)){ throw new RuntimeException("Invalid range string"); } return false; } if(s.hasNext(spaces)){ s.next(spaces); } } return true; } else { SemVer from = new SemVer(s); if(s.hasNext(hyphen)){ s.next(hyphen); from.setWildCardsToZero(); SemVer to = new SemVer(s).setWildCardsToZero(); return this.greaterEqualVersion(from) && this.lessEqualVersion(to); } else { if(!this.matchEqualTo(from)){ return false; } } } return true; } public String toString(){ return vnum(major) + "." + vnum(minor) + "." + vnum(patch) + (prerelease.isEmpty() ? "" : "-" + prerelease) + (build.isEmpty() ? "" : "+" + build); } private String vnum(int n){ return n < 0 ? "*" : String.valueOf(n); } }