diff --git a/build.mill b/build.mill index 8dae275..007b89a 100644 --- a/build.mill +++ b/build.mill @@ -15,15 +15,17 @@ import java.io.File object DepVersions { def mdoc = "2.3.6" def scala213 = "2.13.18" + def scala3 = "3.7.4" def scalaJs = "1.20.2" - def scala = Seq(scala213, "2.12.21") + def scala = Seq(scala213, "2.12.21", scala3) } object Deps { def macroParadise = mvn"org.scalamacros:::paradise:2.1.1" def pprint = mvn"com.lihaoyi::pprint::0.9.6" def utest = mvn"com.lihaoyi::utest::0.8.9" + def unrollAnnotation = mvn"com.lihaoyi:::unroll-annotation:0.3.0" } trait VersionsMima extends Mima with PublishModule { @@ -105,19 +107,32 @@ trait Versions extends Cross.Module[String] with ScalaModule with VersionsPublis val scala213Opts = if (sv.startsWith("2.13.")) Seq("-Ymacro-annotations") else Nil - super.scalacOptions() ++ scala213Opts ++ Seq( - "-deprecation", - "--release", - "8" - ) + val releaseOpts = + if (sv.startsWith("3.")) Seq("-release", "8") + else Seq("--release", "8") + super.scalacOptions() ++ scala213Opts ++ Seq("-deprecation") ++ releaseOpts } def sources = Task { - super.sources() ++ Seq(versions.shared.sources()) + val sv = scalaVersion() + val versionSpecific = + if (sv.startsWith("3.")) versions.shared.sources3() + else versions.shared.sources2() + super.sources() ++ Seq(versions.shared.sources(), versionSpecific) } - def compileMvnDeps = Seq( - mvn"io.github.alexarchambault::data-class:0.2.7" - ) + def compileMvnDeps = Task { + val sv = scalaVersion() + if (sv.startsWith("3.")) Seq.empty + else Seq(mvn"io.github.alexarchambault::data-class:0.2.7") + } + + def mvnDeps = Task { + val sv = scalaVersion() + val unrollDeps = + if (sv.startsWith("3.")) Seq(Deps.unrollAnnotation) + else Seq.empty + super.mvnDeps() ++ unrollDeps + } def mimaBinaryIssueFilters = super.mimaBinaryIssueFilters() ++ Seq( // Additional abstract method on *sealed* trait @@ -157,6 +172,8 @@ trait VersionsJs extends Versions with ScalaJSModule { object versions extends Module { object shared extends Module { def sources = Task.Source("src") + def sources2 = Task.Source("src-2") + def sources3 = Task.Source("src-3") def testSources = Task.Source("test/src") } object jvm extends Cross[VersionsJvm](DepVersions.scala) diff --git a/versions/shared/src/coursier/version/ModuleMatcher.scala b/versions/shared/src-2/coursier/version/ModuleMatcher.scala similarity index 100% rename from versions/shared/src/coursier/version/ModuleMatcher.scala rename to versions/shared/src-2/coursier/version/ModuleMatcher.scala diff --git a/versions/shared/src/coursier/version/ModuleMatchers.scala b/versions/shared/src-2/coursier/version/ModuleMatchers.scala similarity index 100% rename from versions/shared/src/coursier/version/ModuleMatchers.scala rename to versions/shared/src-2/coursier/version/ModuleMatchers.scala diff --git a/versions/shared/src/coursier/version/Version.scala b/versions/shared/src-2/coursier/version/Version.scala similarity index 100% rename from versions/shared/src/coursier/version/Version.scala rename to versions/shared/src-2/coursier/version/Version.scala diff --git a/versions/shared/src/coursier/version/VersionConstraint.scala b/versions/shared/src-2/coursier/version/VersionConstraint.scala similarity index 100% rename from versions/shared/src/coursier/version/VersionConstraint.scala rename to versions/shared/src-2/coursier/version/VersionConstraint.scala diff --git a/versions/shared/src/coursier/version/VersionInterval.scala b/versions/shared/src-2/coursier/version/VersionInterval.scala similarity index 100% rename from versions/shared/src/coursier/version/VersionInterval.scala rename to versions/shared/src-2/coursier/version/VersionInterval.scala diff --git a/versions/shared/src-3/coursier/version/ModuleMatcher.scala b/versions/shared/src-3/coursier/version/ModuleMatcher.scala new file mode 100644 index 0000000..6b1ac97 --- /dev/null +++ b/versions/shared/src-3/coursier/version/ModuleMatcher.scala @@ -0,0 +1,65 @@ +package coursier.version + +import java.util.regex.Pattern + +import com.lihaoyi.unroll +import scala.annotation.tailrec +import scala.util.matching.Regex + +// Adapted from https://github.com/coursier/coursier/blob/876a6604d0cd0c3783ed729f5399549f52a3a385/modules/coursier/shared/src/main/scala/coursier/util/ModuleMatcher.scala + +case class ModuleMatcher( + organizationMatcher: String, + nameMatcher: String, + @unroll attributeMatchers: Map[String, String] = Map.empty +) { + + import ModuleMatcher.blobToPattern + + lazy val orgPattern = blobToPattern(organizationMatcher) + lazy val namePattern = blobToPattern(nameMatcher) + lazy val attributesPattern = attributeMatchers + .iterator + .map { + case (k, v) => + (k, blobToPattern(v)) + } + .toMap + + def matches(organization: String, name: String): Boolean = + matches(organization, name, Map.empty) + + def matches(organization: String, name: String, attributes: Map[String, String]): Boolean = + orgPattern.pattern.matcher(organization).matches() && + namePattern.pattern.matcher(name).matches() && + attributes.keySet == attributesPattern.keySet && + attributesPattern.forall { + case (k, p) => + attributes.get(k).exists(p.pattern.matcher(_).matches()) + } + +} + +object ModuleMatcher { + + def all: ModuleMatcher = + ModuleMatcher("*", "*") + + @tailrec + private def blobToPattern(s: String, b: StringBuilder = new StringBuilder): Regex = + if (s.isEmpty) + b.result().r + else { + val idx = s.indexOf('*') + if (idx < 0) { + b ++= Pattern.quote(s) + b.result().r + } else { + if (idx > 0) + b ++= Pattern.quote(s.substring(0, idx)) + b ++= ".*" + blobToPattern(s.substring(idx + 1), b) + } + } + +} diff --git a/versions/shared/src-3/coursier/version/ModuleMatchers.scala b/versions/shared/src-3/coursier/version/ModuleMatchers.scala new file mode 100644 index 0000000..8bd1c1e --- /dev/null +++ b/versions/shared/src-3/coursier/version/ModuleMatchers.scala @@ -0,0 +1,38 @@ +package coursier.version + +import com.lihaoyi.unroll + +// Adapted from https://github.com/coursier/coursier/blob/f0b10fb1744e5bdf94bf17857dfb3cb19fda2e5b/modules/coursier/shared/src/main/scala/coursier/util/ModuleMatchers.scala + +case class ModuleMatchers( + exclude: Set[ModuleMatcher], + include: Set[ModuleMatcher] = Set(), + @unroll includeByDefault: Boolean = true +) { + + // If modules are included by default: + // Those matched by anything in exclude are excluded, but for those also matched by something in include. + // If modules are excluded by default: + // Those matched by anything in include are included, but for those also matched by something in exclude. + + def matches(organization: String, name: String): Boolean = + matches(organization, name, Map.empty) + + def matches(organization: String, name: String, attributes: Map[String, String]): Boolean = + if (includeByDefault) + !exclude.exists(_.matches(organization, name, attributes)) || + include.exists(_.matches(organization, name, attributes)) + else + include.exists(_.matches(organization, name, attributes)) && + !exclude.exists(_.matches(organization, name, attributes)) + +} + +object ModuleMatchers { + def all: ModuleMatchers = + ModuleMatchers(Set.empty, Set.empty) + def only(organization: String, name: String): ModuleMatchers = + only(organization, name, Map.empty) + def only(organization: String, name: String, attributes: Map[String, String]): ModuleMatchers = + ModuleMatchers(Set.empty, Set(ModuleMatcher(organization, name, attributes)), includeByDefault = false) +} diff --git a/versions/shared/src-3/coursier/version/Version.scala b/versions/shared/src-3/coursier/version/Version.scala new file mode 100644 index 0000000..cdf7c7b --- /dev/null +++ b/versions/shared/src-3/coursier/version/Version.scala @@ -0,0 +1,273 @@ +package coursier.version + +import coursier.version.internal.Compatibility._ + +import scala.annotation.tailrec + +/** + * Used internally by Resolver. + * + * Same kind of ordering as aether-util/src/main/java/org/eclipse/aether/util/version/GenericVersion.java + */ +case class Version(repr: String) extends Ordered[Version] { + def asString: String = repr + private var items0: Vector[Version.Item] = null + def items: Vector[Version.Item] = { + // no need to guard against concurrent computations, this is not too expensive to compute + if (items0 == null) + items0 = Version.items(repr) + items0 + } + def compare(other: Version) = { + if (repr == other.repr) 0 // fast path + else Version.listCompare(items, other.items) + } + def isEmpty = items.forall(_.isEmpty) + + lazy val isStable: Boolean = + !repr.endsWith("SNAPSHOT") && + !repr.exists(_.isLetter) && + repr + .split(Array('.', '-')) + .forall(_.lengthCompare(5) <= 0) + + override lazy val hashCode: Int = repr.hashCode() +} + +object Version { + + private val zero0 = Version("") + + def zero: Version = zero0 + + sealed abstract class Item extends Ordered[Item] { + def compare(other: Item): Int = + (this, other) match { + case (a: Number, b: Number) => a.value.compare(b.value) + case (a: BigNumber, b: BigNumber) => a.value.compare(b.value) + case (a: Number, b: BigNumber) => -b.value.compare(a.value) + case (a: BigNumber, b: Number) => a.value.compare(b.value) + case (a: Tag, b: Tag) => a.compareTag(b) + case _ => + val rel0 = compareToEmpty + val rel1 = other.compareToEmpty + + if (rel0 == rel1) order.compare(other.order) + else rel0.compare(rel1) + } + + final def isNumber: Boolean = + this match { + case _: Numeric => true + case _ => false + } + + def order: Int + def isEmpty: Boolean = compareToEmpty == 0 + def compareToEmpty: Int = 1 + } + + sealed abstract class Numeric extends Item { + def repr: String + def next: Numeric + } + case class Number(value: Int) extends Numeric { + val order = 0 + def next: Number = Number(value + 1) + def repr: String = value.toString + override def compareToEmpty = value.compare(0) + } + case class BigNumber(value: BigInt) extends Numeric { + val order = 0 + def next: BigNumber = BigNumber(value + 1) + def repr: String = value.toString + override def compareToEmpty = value.compare(0) + } + + /** + * Tags represent prerelease tags, typically appearing after - for SemVer compatible versions. + */ + case class Tag(value: String) extends Item { + val order = -1 + private val otherLevel = -5 + lazy val level: Int = + value match { + case "ga" | "final" | "" => 0 // 1.0.0 equivalent + case "snapshot" => -1 + case "rc" | "cr" => -2 + case "beta" | "b" => -3 + case "alpha" | "a" => -4 + case "dev" => -6 + case "sp" | "bin" => 1 + case _ => otherLevel + } + + override def compareToEmpty = level.compare(0) + def isPreRelease: Boolean = level < 0 + def compareTag(other: Tag): Int = { + val levelComp = level.compare(other.level) + if (levelComp == 0 && level == otherLevel) value.compareToIgnoreCase(other.value) + else levelComp + } + } + case class BuildMetadata(value: String) extends Item { + val order = 1 + override def compareToEmpty = 0 + } + + case object Min extends Item { + val order = -8 + override def compareToEmpty = -1 + } + case object Max extends Item { + val order = 8 + } + + val empty = Number(0) + + object Tokenizer { + sealed abstract class Separator + case object Dot extends Separator + case object Hyphen extends Separator + case object Underscore extends Separator + case object Plus extends Separator + case object None extends Separator + + def apply(str: String): (Item, LazyList[(Separator, Item)]) = { + def parseItem(s: LazyList[Char], prev: Option[Separator]): (Item, LazyList[Char]) = { + if (s.isEmpty) (empty, s) + else if (s.head.isDigit) { + def digits(b: StringBuilder, s: LazyList[Char]): (String, LazyList[Char]) = + if (s.isEmpty || !s.head.isDigit) (b.result(), s) + else digits(b += s.head, s.tail) + + val (digits0, rem) = digits(new StringBuilder, s) + val item = + if (digits0.length >= 10) BigNumber(BigInt(digits0)) + else Number(digits0.toInt) + + (item, rem) + } else if (s.head.letter) { + def letters(b: StringBuilder, s: LazyList[Char]): (String, LazyList[Char]) = + if (s.isEmpty || !s.head.letter) + (b.result().toLowerCase, s) // not specifying a Locale (error with scala js) + else + letters(b += s.head, s.tail) + + val (letters0, rem) = letters(new StringBuilder, s) + val item = letters0 match { + case "x" if prev == Some(Dot) => Max + case "min" => Min + case "max" => Max + case _ => Tag(letters0) + } + (item, rem) + } else { + val (sep, _) = parseSeparator(s) + (prev, sep) match { + case (_, None) => + def other(b: StringBuilder, s: LazyList[Char]): (String, LazyList[Char]) = + if (s.isEmpty || s.head.isLetterOrDigit || parseSeparator(s)._1 != None) + (b.result().toLowerCase, s) // not specifying a Locale (error with scala js) + else + other(b += s.head, s.tail) + + val (item, rem0) = other(new StringBuilder, s) + // treat .* as .max + if (prev == Some(Dot) && item == "*") (Max, rem0) + else (Tag(item), rem0) + // treat .+ as .max + case (Some(Dot), Plus) => (Max, s) + case _ => (empty, s) + } + } + } + + def parseSeparator(s: LazyList[Char]): (Separator, LazyList[Char]) = { + assert(s.nonEmpty) + + s.head match { + case '.' => (Dot, s.tail) + case '-' => (Hyphen, s.tail) + case '_' => (Underscore, s.tail) + case '+' => (Plus, s.tail) + case _ => (None, s) + } + } + + def helper(s: LazyList[Char]): LazyList[(Separator, Item)] = { + if (s.isEmpty) LazyList() + else { + val (sep, rem0) = parseSeparator(s) + sep match { + case Plus => + LazyList((sep, BuildMetadata(rem0.mkString))) + case _ => + val (item, rem) = parseItem(rem0, Some(sep)) + (sep, item) #:: helper(rem) + } + } + } + + val (first, rem) = parseItem(LazyList.from(str), scala.None) + (first, helper(rem)) + } + } + + def isNumeric(item: Item) = item match { case _: Numeric => true; case _ => false } + private def isNumericOrMinMax(item: Item): Boolean = + item match { + case _: Numeric | Min | Max => true + case _ => false + } + def isBuildMetadata(item: Item) = item match { case _: BuildMetadata => true; case _ => false } + + def items(repr: String): Vector[Item] = { + val (first, tokens) = Tokenizer(repr) + first +: tokens.toVector.map(_._2) + } + + // before comparing two versions pad the number parts to the equal number of digits + // for example, 1-ga, and 1.0.0 comparison will be adjusted first to 1.0.0-ga and 1.0.0. + def listCompare(first0: Vector[Item], second0: Vector[Item]): Int = { + // Semver ยง 10: two versions that differ only in the build metadata, have the same precedence. + val first = first0.filterNot(isBuildMetadata) + val second = second0.filterNot(isBuildMetadata) + + def padNum(xs: Vector[Item], original: Int, next: Int): Vector[Item] = { + val (before, after) = xs.splitAt(original) + before ++ Vector.fill(next - original)(empty) ++ after + } + val num1 = first.prefixLength(isNumericOrMinMax) + val num2 = second.prefixLength(isNumericOrMinMax) + (num1, num2) match { + case (x, y) if x == y => + listCompare0(first, second) + case (x, y) if x > y => + listCompare0(first, padNum(second, y, x)) + case (x, y) if x < y => + listCompare0(padNum(first, x, y), second) + } + } + + @tailrec + private def listCompare0(first: Vector[Item], second: Vector[Item], idx: Int = 0): Int = { + val firstDone = idx >= first.length + val secondDone = idx >= second.length + if (firstDone && secondDone) 0 + else if (firstDone) { + var i = idx + while (i < second.length && second(i).isEmpty) i += 1 + if (i < second.length) -second(i).compareToEmpty else 0 + } else if (secondDone) { + var i = idx + while (i < first.length && first(i).isEmpty) i += 1 + if (i < first.length) first(i).compareToEmpty else 0 + } else { + val rel = first(idx).compare(second(idx)) + if (rel == 0) listCompare0(first, second, idx + 1) + else rel + } + } + +} diff --git a/versions/shared/src-3/coursier/version/VersionConstraint.scala b/versions/shared/src-3/coursier/version/VersionConstraint.scala new file mode 100644 index 0000000..cede666 --- /dev/null +++ b/versions/shared/src-3/coursier/version/VersionConstraint.scala @@ -0,0 +1,202 @@ +package coursier.version + +import scala.annotation.tailrec + +sealed abstract class VersionConstraint extends Product with Serializable with Ordered[VersionConstraint] { + def asString: String + def interval: VersionInterval + + /** + * Preferred version, if any + */ + def preferred: Option[Version] + + def latest: Option[Latest] + + def generateString: String = + VersionConstraint.generateString(interval, preferred, latest) + + private lazy val compareKey = preferred.headOption.orElse(interval.from).getOrElse(Version.zero) + def compare(other: VersionConstraint): Int = + compareKey.compare(other.compareKey) + + def isValid: Boolean = + interval.isValid && preferred.forall { v => + interval.contains(v) || + interval.to.forall { to => + val cmp = v.compare(to) + cmp < 0 || (cmp == 0 && interval.toIncluded) + } + } + + def withLatest(latestOpt: Option[Latest]): VersionConstraint +} + +object VersionConstraint { + def apply(version: String): VersionConstraint = + Lazy(version) + def from(interval: VersionInterval, preferred: Option[Version], latest: Option[Latest]): VersionConstraint = + Eager( + generateString(interval, preferred, latest), + interval, + preferred, + latest + ) + + def empty: VersionConstraint = + empty0 + + private def generateString(interval: VersionInterval, preferred: Option[Version], latest: Option[Latest]): String = + if (preferred.isEmpty && latest.isEmpty) + if (interval == VersionInterval.empty) "" + else interval.repr + else { + val nonIntervalPart = (preferred.iterator.map(_.asString) ++ latest.iterator.map(_.asString)).mkString(";") + if (interval == VersionInterval.zero) nonIntervalPart + else s"${interval.repr}&$nonIntervalPart" + } + + def fromVersion(version: Version): VersionConstraint = + if (version.repr.isEmpty) + empty + else + Eager( + version.repr, + VersionInterval.zero, + Some(version), + None + ) + + def merge(constraints0: VersionConstraint*): Option[VersionConstraint] = { + // Initial pass to filter exact duplicates + val constraints = constraints0.distinct + if (constraints.isEmpty) Some(empty) + else if (constraints.lengthCompare(1) == 0) Some(constraints.head).filter(_.isValid) + else { + val intervals = constraints.map(_.interval) + + val intervalOpt = + intervals.foldLeft(Option(VersionInterval.zero)) { + case (acc, itv) => + acc.flatMap(_.merge(itv)) + } + + val constraintOpt = intervalOpt.map { interval => + val preferreds = { + val allPreferred = constraints.flatMap(_.preferred) + interval.from match { + case Some(from) => + allPreferred.filter { v => + val cmp = from.compare(v) + cmp < 0 || (cmp == 0 && interval.fromIncluded) + } + case None => + allPreferred + } + } + val latests = constraints.flatMap(_.latest) + from( + interval, + Some(preferreds).filter(_.nonEmpty).map(_.max), + Some(latests).filter(_.nonEmpty).map(_.max) + ) + } + + constraintOpt.filter(_.isValid) + } + } + + // 1. sort constraints in ascending order. + // 2. from the right, merge them two-by-two with the merge method above + // 3. return the last successful merge + def relaxedMerge(constraints: VersionConstraint*): VersionConstraint = + constraints.toList.sorted.reverse match { + case Nil => VersionConstraint.empty + case h :: Nil => h + case h :: t => + + @tailrec + def mergeByTwo(head: VersionConstraint, rest: List[VersionConstraint]): VersionConstraint = + rest match { + case next :: xs => + merge(head, next) match { + case Some(success) => mergeByTwo(success, xs) + case _ => head + } + case Nil => head + } + + val latestOpt = { + val it = constraints.iterator.flatMap(_.latest.iterator) + if (it.hasNext) Some(it.max) else None + } + + val merged = mergeByTwo(h, t) + + if (merged.latest == latestOpt) merged + else merged.withLatest(latestOpt) + } + + + private[version] def fromPreferred(input: String, version: Version): Eager = + if (input.isEmpty) empty0 + else Eager(input, VersionInterval.zero, Some(version), None) + private[version] def fromInterval(input: String, interval: VersionInterval): Eager = + Eager(input, interval, None, None) + private[version] def fromLatest(input: String, latest: Latest): Eager = + Eager(input, VersionInterval.zero, None, Some(latest)) + + private[version] val empty0 = Eager("", VersionInterval.empty, None, None) + + private[coursier] val parsedValueAsToString: ThreadLocal[Boolean] = new ThreadLocal[Boolean] { + override protected def initialValue(): Boolean = + false + } + + case class Lazy(asString: String) extends VersionConstraint { + private var parsed0: Eager = null + private def parsed = { + if (parsed0 == null) + parsed0 = VersionParse.eagerVersionConstraint(asString) + parsed0 + } + def interval: VersionInterval = parsed.interval + def preferred: Option[Version] = parsed.preferred + def latest: Option[Latest] = parsed.latest + + override def toString: String = + if (parsedValueAsToString.get()) asString + else + s"VersionConstraint.Lazy($asString, ${if (parsed0 == null) "[unparsed]" else s"$interval, $preferred, $latest"})" + override def hashCode(): Int = + (VersionConstraint, asString).hashCode() + override def equals(obj: Any): Boolean = + obj.isInstanceOf[VersionConstraint] && { + val other = obj.asInstanceOf[VersionConstraint] + asString == other.asString + } + + def withLatest(latestOpt: Option[Latest]): VersionConstraint = + parsed.withLatest(latestOpt) + } + case class Eager( + asString: String, + interval: VersionInterval, + preferred: Option[Version], + latest: Option[Latest] + ) extends VersionConstraint { + override def toString: String = + if (parsedValueAsToString.get()) asString + else + s"VersionConstraint.Eager($asString, $interval, $preferred, $latest)" + override def hashCode(): Int = + (VersionConstraint, asString).hashCode() + override def equals(obj: Any): Boolean = + obj.isInstanceOf[VersionConstraint] && { + val other = obj.asInstanceOf[VersionConstraint] + asString == other.asString + } + def withLatest(latestOpt: Option[Latest]): VersionConstraint = + copy(latest = latestOpt) + } +} diff --git a/versions/shared/src-3/coursier/version/VersionInterval.scala b/versions/shared/src-3/coursier/version/VersionInterval.scala new file mode 100644 index 0000000..f41de93 --- /dev/null +++ b/versions/shared/src-3/coursier/version/VersionInterval.scala @@ -0,0 +1,91 @@ +package coursier.version + +case class VersionInterval( + from: Option[Version], + to: Option[Version], + fromIncluded: Boolean, + toIncluded: Boolean +) { + + def isValid: Boolean = { + val fromToOrder = + for { + f <- from + t <- to + cmd = f.compare(t) + } yield cmd < 0 || (cmd == 0 && fromIncluded && toIncluded) + + fromToOrder.forall(x => x) && (from.nonEmpty || !fromIncluded) && (to.nonEmpty || !toIncluded) + } + + def contains(version: Version): Boolean = { + val fromCond = + from.forall { from0 => + val cmp = from0.compare(version) + cmp < 0 || cmp == 0 && fromIncluded + } + lazy val toCond = + to.forall { to0 => + val cmp = version.compare(to0) + cmp < 0 || cmp == 0 && toIncluded + } + + fromCond && toCond + } + + def merge(other: VersionInterval): Option[VersionInterval] = { + val (newFrom, newFromIncluded) = + (from, other.from) match { + case (Some(a), Some(b)) => + val cmp = a.compare(b) + if (cmp < 0) (Some(b), other.fromIncluded) + else if (cmp > 0) (Some(a), fromIncluded) + else (Some(a), fromIncluded && other.fromIncluded) + + case (Some(a), None) => (Some(a), fromIncluded) + case (None, Some(b)) => (Some(b), other.fromIncluded) + case (None, None) => (None, false) + } + + val (newTo, newToIncluded) = + (to, other.to) match { + case (Some(a), Some(b)) => + val cmp = a.compare(b) + if (cmp < 0) (Some(a), toIncluded) + else if (cmp > 0) (Some(b), other.toIncluded) + else (Some(a), toIncluded && other.toIncluded) + + case (Some(a), None) => (Some(a), toIncluded) + case (None, Some(b)) => (Some(b), other.toIncluded) + case (None, None) => (None, false) + } + + Some(VersionInterval(newFrom, newTo, newFromIncluded, newToIncluded)) + .filter(_.isValid) + } + + def constraint: VersionConstraint = + this match { + case VersionInterval.zero => VersionConstraint.empty + case itv => + (itv.from, itv.to, itv.fromIncluded, itv.toIncluded) match { + case (Some(version), None, true, false) => + VersionConstraint.fromPreferred(version.repr, version) + case _ => + VersionConstraint.fromInterval(repr, itv) + } + } + + def repr: String = Seq( + if (fromIncluded) "[" else "(", + from.map(_.repr).mkString, + ",", + to.map(_.repr).mkString, + if (toIncluded) "]" else ")" + ).mkString +} + +object VersionInterval { + val zero = VersionInterval(None, None, fromIncluded = false, toIncluded = false) + val empty = VersionInterval(None, Some(Version.zero), fromIncluded = false, toIncluded = false) +}