diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d3ce0e9..94c18724e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,11 +80,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/scala3') - run: mkdir -p spire/.js/target spire/.jvm/target testkit/.native/target units/.jvm/target testkit/.js/target unidocs/target core/.native/target spire/.native/target core/.js/target units/.native/target core/.jvm/target refined/.native/target refined/.js/target refined/.jvm/target units/.js/target testkit/.jvm/target project/target + run: mkdir -p runtime/.js/target spire/.js/target spire/.jvm/target testkit/.native/target units/.jvm/target runtime/.native/target testkit/.js/target parser/.jvm/target unidocs/target parser/.js/target core/.native/target pureconfig/.jvm/target spire/.native/target parser/.native/target core/.js/target units/.native/target runtime/.jvm/target core/.jvm/target refined/.native/target refined/.js/target refined/.jvm/target units/.js/target testkit/.jvm/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/scala3') - run: tar cf targets.tar spire/.js/target spire/.jvm/target testkit/.native/target units/.jvm/target testkit/.js/target unidocs/target core/.native/target spire/.native/target core/.js/target units/.native/target core/.jvm/target refined/.native/target refined/.js/target refined/.jvm/target units/.js/target testkit/.jvm/target project/target + run: tar cf targets.tar runtime/.js/target spire/.js/target spire/.jvm/target testkit/.native/target units/.jvm/target runtime/.native/target testkit/.js/target parser/.jvm/target unidocs/target parser/.js/target core/.native/target pureconfig/.jvm/target spire/.native/target parser/.native/target core/.js/target units/.native/target runtime/.jvm/target core/.jvm/target refined/.native/target refined/.js/target refined/.jvm/target units/.js/target testkit/.jvm/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/scala3') diff --git a/build.sbt b/build.sbt index 54e75f8f9..79841d9bd 100644 --- a/build.sbt +++ b/build.sbt @@ -6,6 +6,8 @@ ThisBuild / tlBaseVersion := "0.7" // publish settings +// artifacts now publish to s01.oss.sonatype.org, per: +// https://github.com/erikerlandson/coulomb/issues/500 ThisBuild / developers += tlGitHubDev("erikerlandson", "Erik Erlandson") ThisBuild / organization := "com.manyangled" ThisBuild / organizationName := "Erik Erlandson" @@ -38,7 +40,17 @@ def commonSettings = Seq( ) lazy val root = tlCrossRootProject - .aggregate(core, units, spire, refined, testkit, unidocs) + .aggregate( + core, + units, + runtime, + parser, + pureconfig, + spire, + refined, + testkit, + unidocs + ) lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) .crossType(CrossType.Pure) @@ -60,6 +72,69 @@ lazy val units = crossProject(JVMPlatform, JSPlatform, NativePlatform) libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % "2.5.0" % Test ) +// see also: https://github.com/lampepfl/dotty/issues/7647 +lazy val runtime = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("runtime")) + .settings(name := "coulomb-runtime") + .dependsOn( + core % "compile->compile;test->test", + units % Test + ) + .settings( + tlVersionIntroduced := Map("3" -> "0.8.0") + ) + .settings(commonSettings: _*) + .settings( + // staging compiler is only supported on JVM + // but is also used to satisfy builds on JS and Native + libraryDependencies += "org.scala-lang" %% "scala3-staging" % scalaVersion.value + ) + .platformsSettings(JSPlatform, NativePlatform)( + // any unit tests using staging must be excluded from JS and Native + Test / unmanagedSources / excludeFilter := HiddenFileFilter || "*stagingquantity.scala" + ) + +// cats-parse doesn't seem to build for JS or Native +lazy val parser = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("parser")) + .settings(name := "coulomb-parser") + .dependsOn( + core % "compile->compile;test->test", + runtime, + units % Test + ) + .settings( + tlVersionIntroduced := Map("3" -> "0.8.0") + ) + .settings(commonSettings: _*) + .settings( + libraryDependencies += "org.typelevel" %%% "cats-parse" % "0.3.10" + ) + +// pureconfig doesn't currently build for JS or Native +// https://github.com/pureconfig/pureconfig/issues/1307 +lazy val pureconfig = crossProject( + JVMPlatform /*, JSPlatform, NativePlatform */ +) + .crossType(CrossType.Pure) + .in(file("pureconfig")) + .settings(name := "coulomb-pureconfig") + .dependsOn( + core % "compile->compile;test->test", + runtime, + parser, + units % Test + ) + .settings( + tlVersionIntroduced := Map("3" -> "0.8.0") + ) + .settings(commonSettings: _*) + .settings( + libraryDependencies += "com.github.pureconfig" %%% "pureconfig-core" % "0.17.4" + ) + lazy val spire = crossProject(JVMPlatform, JSPlatform, NativePlatform) .crossType(CrossType.Pure) .in(file("spire")) @@ -102,6 +177,9 @@ lazy val all = project .dependsOn( core.jvm, units.jvm, + runtime.jvm, + parser.jvm, + pureconfig.jvm, spire.jvm, refined.jvm ) // scala repl only needs JVMPlatform subproj builds @@ -122,15 +200,110 @@ lazy val unidocs = project // https://typelevel.org/sbt-typelevel/site.html // sbt docs/tlSitePreview // http://localhost:4242 +import laika.ast.{ExternalTarget, InternalTarget, VirtualPath} +import laika.rewrite.link.{LinkConfig, ApiLinks, SourceLinks, TargetDefinition} lazy val docs = project .in(file("site")) - .dependsOn(core.jvm, units.jvm, spire.jvm, refined.jvm) + .dependsOn( + core.jvm, + units.jvm, + runtime.jvm, + parser.jvm, + pureconfig.jvm, + spire.jvm, + refined.jvm + ) .enablePlugins(TypelevelSitePlugin) .settings( // turn off the new -W warnings in mdoc scala compilations // at least until I can get a better handle on how to work with them Compile / scalacOptions ~= (_.filterNot { x => x.startsWith("-W") }) ) + .settings( + laikaConfig := LaikaConfig.defaults + .withConfigValue( + LinkConfig.empty + .addApiLinks( + ApiLinks( + baseUri = + "https://www.javadoc.io/doc/com.manyangled/coulomb-docs_3/latest/", + packagePrefix = "coulomb" + ), + ApiLinks( + baseUri = "https://scala-lang.org/api/3.x/", + packagePrefix = "scala" + ), + ApiLinks( + baseUri = + "https://javadoc.io/doc/com.github.pureconfig/pureconfig-core_3/latest/", + packagePrefix = "pureconfig" + ) + ) + .addTargets( + // Target names need to be all lowercase. + // Note, this does not align with Laika docs. + // In future laika releases the names will be case insensitive, see: + // https://github.com/typelevel/Laika/pull/541 + TargetDefinition( + // intended usage: [Quantity][quantitytypedef] + // Links to type defs do not work properly with laika '@:api(...)' constructs + // which is going to make a lot of coulomb references harder to do. + "quantitytypedef", + ExternalTarget( + "https://www.javadoc.io/doc/com.manyangled/coulomb-docs_3/latest/coulomb.html#Quantity[V,U]=V" + ) + ), + TargetDefinition( + "coulomb-introduction", + InternalTarget( + VirtualPath.parse("README.md") + ) + ), + TargetDefinition( + "coulomb-core", + InternalTarget( + VirtualPath.parse("coulomb-core.md") + ) + ), + TargetDefinition( + "coulomb-units", + InternalTarget( + VirtualPath.parse("coulomb-units.md") + ) + ), + TargetDefinition( + "coulomb-spire", + InternalTarget( + VirtualPath.parse("coulomb-spire.md") + ) + ), + TargetDefinition( + "coulomb-refined", + InternalTarget( + VirtualPath.parse("coulomb-refined.md") + ) + ), + TargetDefinition( + "coulomb-runtime", + InternalTarget( + VirtualPath.parse("coulomb-runtime.md") + ) + ), + TargetDefinition( + "coulomb-parser", + InternalTarget( + VirtualPath.parse("coulomb-parser.md") + ) + ), + TargetDefinition( + "coulomb-pureconfig", + InternalTarget( + VirtualPath.parse("coulomb-pureconfig.md") + ) + ) + ) + ) + ) // https://github.com/sbt/sbt-jmh // sbt "benchmarks/Jmh/run .*Benchmark" diff --git a/core/src/main/scala/coulomb/infra/meta.scala b/core/src/main/scala/coulomb/infra/meta.scala index f33ac84fa..8659f597b 100644 --- a/core/src/main/scala/coulomb/infra/meta.scala +++ b/core/src/main/scala/coulomb/infra/meta.scala @@ -263,19 +263,49 @@ object meta: object baseunit: def unapply(using Quotes)(u: quotes.reflect.TypeRepr): Boolean = + u match + case baseunitTR(_) => true + case _ => false + + object derivedunit: + def unapply(using qq: Quotes, mode: SigMode)( + u: quotes.reflect.TypeRepr + ): Option[(Rational, List[(quotes.reflect.TypeRepr, Rational)])] = + import quotes.reflect.* + u match + case derivedunitTR(dtr) => + mode match + case SigMode.Simplify => + // don't expand the signature definition in simplify mode + Some((Rational.const1, (u, Rational.const1) :: Nil)) + case _ => + val AppliedType(_, List(_, d, _, _)) = + dtr: @unchecked + Some(cansig(d)) + case _ => None + + object baseunitTR: + def unapply(using Quotes)( + u: quotes.reflect.TypeRepr + ): Option[quotes.reflect.TypeRepr] = import quotes.reflect.* Implicits.search( TypeRepr .of[BaseUnit] .appliedTo(List(u, TypeBounds.empty, TypeBounds.empty)) ) match - case iss: ImplicitSearchSuccess => true - case _ => false + case iss: ImplicitSearchSuccess => + Some( + iss.tree.tpe.baseType( + TypeRepr.of[BaseUnit].typeSymbol + ) + ) + case _ => None - object derivedunit: - def unapply(using qq: Quotes, mode: SigMode)( + object derivedunitTR: + def unapply(using Quotes)( u: quotes.reflect.TypeRepr - ): Option[(Rational, List[(quotes.reflect.TypeRepr, Rational)])] = + ): Option[quotes.reflect.TypeRepr] = import quotes.reflect.* Implicits.search( TypeRepr @@ -290,16 +320,11 @@ object meta: ) ) match case iss: ImplicitSearchSuccess => - mode match - case SigMode.Simplify => - // don't expand the signature definition in simplify mode - Some((Rational.const1, (u, Rational.const1) :: Nil)) - case _ => - val AppliedType(_, List(_, d, _, _)) = - iss.tree.tpe.baseType( - TypeRepr.of[DerivedUnit].typeSymbol - ): @unchecked - Some(cansig(d)) + Some( + iss.tree.tpe.baseType( + TypeRepr.of[DerivedUnit].typeSymbol + ) + ) case _ => None object deltaunit: @@ -360,6 +385,20 @@ object meta: case Nil => Nil case (u, e0) :: tail => (u, e0 * e) :: unifyPow(e, tail) + def typeReprList(using Quotes)( + tlist: quotes.reflect.TypeRepr + ): List[quotes.reflect.TypeRepr] = + import quotes.reflect.* + tlist match + case tnil if (tnil =:= TypeRepr.of[EmptyTuple]) => Nil + case AppliedType(t, List(head, tail)) if (t =:= TypeRepr.of[*:]) => + head :: typeReprList(tail) + case _ => + report.errorAndAbort( + s"typeReprList: bad type list ${tlist.show}" + ) + null.asInstanceOf[Nothing] + def typestr(using Quotes)(t: quotes.reflect.TypeRepr): String = // The policy goal here is that type aliases are never expanded. typestring(t, false) diff --git a/core/src/main/scala/coulomb/ops/ops.scala b/core/src/main/scala/coulomb/ops/ops.scala index 732bd7a7e..371a22932 100644 --- a/core/src/main/scala/coulomb/ops/ops.scala +++ b/core/src/main/scala/coulomb/ops/ops.scala @@ -168,10 +168,7 @@ object ValuePromotion: import scala.quoted.* import scala.language.implicitConversions - import coulomb.infra.meta.typestr - - final type &:[H, T] - final type TNil + import coulomb.infra.meta.* transparent inline given ctx_VP_Path[VF, VT]: ValuePromotion[VF, VT] = ${ vpPath[VF, VT] @@ -206,20 +203,19 @@ object ValuePromotion: iss.tree.tpe.baseType( TypeRepr.of[ValuePromotionPolicy].typeSymbol ): @unchecked - vpp2str(vppt) + vpp2str(typeReprList(vppt)) case _ => report.error("no ValuePromotionPolicy was found in scope") - VppSet.empty[(String, String)] + null.asInstanceOf[Nothing] private def vpp2str(using Quotes)( - vpp: quotes.reflect.TypeRepr + vppl: List[quotes.reflect.TypeRepr] ): VppSet[(String, String)] = import quotes.reflect.* - vpp match - case t if (t =:= TypeRepr.of[TNil]) => - VppSet.empty[(String, String)] - case AppliedType(v, List(AppliedType(t2, List(vf, vt)), tail)) - if ((v =:= TypeRepr.of[&:]) && (t2 =:= TypeRepr.of[Tuple2])) => + vppl match + case Nil => VppSet.empty[(String, String)] + case AppliedType(t2, List(vf, vt)) :: tail + if (t2 =:= TypeRepr.of[Tuple2]) => val vppset = vpp2str(tail) vppset.add( ( @@ -230,9 +226,9 @@ object ValuePromotion: vppset case _ => report.error( - s"type ${typestr(vpp)} is not a valid value promotion policy" + s"type ${typestr(vppl.head)} is not a valid promotion pair" ) - VppSet.empty[(String, String)] + null.asInstanceOf[Nothing] private def pathexists( vf: String, @@ -257,9 +253,10 @@ object ValuePromotion: done = true haspath -final class ValuePromotionPolicy[Pairs] +final class ValuePromotionPolicy[Pairs <: Tuple] object ValuePromotionPolicy: - def apply[P](): ValuePromotionPolicy[P] = new ValuePromotionPolicy[P] + def apply[P <: Tuple](): ValuePromotionPolicy[P] = + new ValuePromotionPolicy[P] final case class ShowUnit[U](value: String) object ShowUnit: diff --git a/core/src/main/scala/coulomb/ops/resolution/standard.scala b/core/src/main/scala/coulomb/ops/resolution/standard.scala index cbf89b619..6338e25a7 100644 --- a/core/src/main/scala/coulomb/ops/resolution/standard.scala +++ b/core/src/main/scala/coulomb/ops/resolution/standard.scala @@ -18,9 +18,8 @@ package coulomb.ops.resolution object standard: import coulomb.ops.ValuePromotionPolicy - import coulomb.ops.ValuePromotion.{&:, TNil} // ValuePromotion infers the transitive closure of all promotions given ctx_vpp_standard: ValuePromotionPolicy[ - (Int, Long) &: (Long, Float) &: (Float, Double) &: TNil + (Int, Long) *: (Long, Float) *: (Float, Double) *: EmptyTuple ] = ValuePromotionPolicy() diff --git a/core/src/test/scala/coulomb/testing/testing.scala b/core/src/test/scala/coulomb/testing/testing.scala index ce37bd660..7dfe7e52f 100644 --- a/core/src/test/scala/coulomb/testing/testing.scala +++ b/core/src/test/scala/coulomb/testing/testing.scala @@ -22,6 +22,11 @@ import coulomb.conversion.ValueConversion abstract class CoulombSuite extends munit.FunSuite: import coulomb.testing.types.* + extension [L, V, U](q: Either[L, Quantity[V, U]]) + inline def assertRQ[VT, UT](vt: VT): Unit = + assert(q.isRight) + q.toSeq.head.assertQ[VT, UT](vt) + extension [V, U](q: Quantity[V, U]) inline def assertQ[VT, UT](vt: VT): Unit = // checking types first @@ -58,6 +63,18 @@ abstract class CoulombSuite extends munit.FunSuite: val e = math.abs(vt) * eps.getOrElse(typeEps[V]) assertEqualsDouble(vc(q.value), vt, e) + extension [L, V](v: Either[L, V]) + inline def assertL: Unit = + assert(v.isLeft) + + inline def assertR(vt: V): Unit = + assert(v.isRight) + assertEquals(v.toSeq.head, vt) + + inline def assertRVT[VT](vt: VT): Unit = + assert(v.isRight) + v.toSeq.head.assertVT[VT](vt) + extension [V](v: V) inline def assertVT[VT](vt: VT): Unit = assertEquals(typeStr[V], typeStr[VT]) diff --git a/docs/README.md b/docs/README.md index 70c0f5867..7957f3b95 100644 --- a/docs/README.md +++ b/docs/README.md @@ -71,10 +71,13 @@ val fail = time + dist | name | description | | ---: | :--- | -| `coulomb-core` | Provides core `coulomb` logic. Defines policies for `Int`, `Long`, `Float`, `Double`. | -| `coulomb-units` | Defines common units, including SI, MKSA, Accepted, time, temperature, and US traditional | -| `coulomb-spire` | Defines policies for working with Spire and Scala numeric types | - +| [coulomb-core] | Provides core `coulomb` logic. Defines policies for `Int`, `Long`, `Float`, `Double`. | +| [coulomb-units] | Defines common units, including SI, MKSA, Accepted, time, temperature, and US traditional | +| [coulomb-spire] | Defines policies for working with Spire and Scala numeric types | +| [coulomb-refined] | Unit analysis with typelevel [refined](https://github.com/fthomas/refined#refined-simple-refinement-types-for-scala) awareness | +| [coulomb-pureconfig] | Configuration I/O with @:api(coulomb.Quantity$) values | +| [coulomb-runtime] | Runtime units and quantities | +| [coulomb-parser] | Parsing of expressions into runtime units | ## Resources diff --git a/docs/concepts.md b/docs/coulomb-core.md similarity index 99% rename from docs/concepts.md rename to docs/coulomb-core.md index 00b0ab52c..8079252c4 100644 --- a/docs/concepts.md +++ b/docs/coulomb-core.md @@ -1,6 +1,12 @@ -# coulomb Concepts +# coulomb-core -```scala mdoc:invisible +This page describes the fundamental `coulomb` concepts, implemented in `coulomb-core`. + +## Quick Start + +### import + +```scala mdoc // fundamental coulomb types and methods import coulomb.* import coulomb.syntax.* diff --git a/docs/coulomb-parser.md b/docs/coulomb-parser.md new file mode 100644 index 000000000..c4f757df5 --- /dev/null +++ b/docs/coulomb-parser.md @@ -0,0 +1,140 @@ +# coulomb-parser + +The `coulomb-parser` package defines a unit expression parsing API and a reference unit expression DSL, +which is used by runtime I/O integrations such as [coulomb-pureconfig] + +## Quick Start + +Before you begin, it is recommended to first familiarize yourself with the [coulomb-runtime] documentation. + +### packages + +```scala +libraryDependencies += "com.manyangled" %% "coulomb-parser" % "@VERSION@" + +// dependencies +libraryDependencies += "com.manyangled" %% "coulomb-core" % "@VERSION@" +libraryDependencies += "com.manyangled" %% "coulomb-runtime" % "@VERSION@" + +// coulomb predefined units +libraryDependencies += "com.manyangled" %% "coulomb-units" % "@VERSION@" +``` + +### import + +```scala mdoc +// fundamental coulomb types and methods +import coulomb.* +import coulomb.syntax.* + +// algebraic definitions +import algebra.instances.all.given +import coulomb.ops.algebra.all.given + +// unit and value type policies for operations +import coulomb.policy.standard.given +import scala.language.implicitConversions + +// unit definitions +import coulomb.units.si.{*, given} +import coulomb.units.si.prefixes.{*, given} +import coulomb.units.info.{*, given} +import coulomb.units.time.{*, given} + +// parsing definitions +import coulomb.parser.RuntimeUnitParser +import coulomb.parser.standard.RuntimeUnitDslParser +``` + +### examples + +The core API for `coulomb-parser` is +@:api(coulomb.parser.RuntimeUnitParser). +A programmer may define their own parsing implementations against this API. +This package defines a reference implementation named +@:api(coulomb.parser.standard.RuntimeUnitDslParser), +which implements a DSL for representing +@:api(coulomb.RuntimeUnit) +types. +The examples that follow illustrate the DSL syntax and semantics. + +A +@:api(coulomb.parser.standard.RuntimeUnitDslParser) +is defined by giving it a list of package or object names, +which contain unit type definitions. +The following declaration creates a DSL parser that can understand +unit definitions for SI units, SI prefixes and information units. + +```scala mdoc +val dslparser: RuntimeUnitParser = RuntimeUnitDslParser.of[ + "coulomb.units.si" *: + "coulomb.units.si.prefixes" *: + "coulomb.units.info" *: + EmptyTuple +] +``` + +Parsing can fail, and so the `parse` method returns an @:api(scala.util.Either) object. +In the following code, parsing a known unit `meter` results in a successful @:api(scala.util.Right) value. + +This example illustrates that unit names parse into a corresponding fully qualified +unit type name, as would be used in static unit type expressions. +@:api(coulomb.parser.standard.RuntimeUnitDslParser) +maintains a mapping between static unit types and their names, +as defined by the `showFull` method, +such as `"meter"` <-> `coulomb.units.si$.Meter` + +@:callout(info) +Scala 3 metaprogramming returns package objects with the `$` suffix, +for example `si$` instead of `si`. +This is mostly transparent to operations, unless you wish to refer +directly to fully qualified types using the `@` prefix in the DSL, +as illustrated in later examples. +@:@ + + +```scala mdoc +val u1 = dslparser.parse("meter") + +// Most of the following examples will display with `toString` to improve readability. +u1.toString +``` + +A parsing failure, such as a unit name that the parser does not know about, +results in a @:api(scala.util.Left) value containing a parsing error message. + +```scala mdoc +dslparser.parse("nope") +``` + +The reference DSL will parse any unit name that is composed of +a prefix followed by a unit, into its correct product: + +```scala mdoc +dslparser.parse("kilometer").toString + +dslparser.parse("megabyte").toString +``` + +As with static unit types and runtime units, +DSL units can be inductively combined with the operators `*`, `/` and `^`: + +```scala mdoc +dslparser.parse("kilometer/second^2").toString +``` + +Numeric literals can also appear, and are equivalent to literal +types in static unit type expressions: + +```scala mdoc +dslparser.parse("(1000 * meter) / (second ^ 2)").toString +``` + +You can also directly specify a fully qualified unit type name, +by prepending with an `@` symbol. +Note that these fully qualified names may require `name$` instead of `name`, +as with `si$` in the following: + +```scala mdoc +dslparser.parse("@coulomb.units.si$.Second").toString +``` diff --git a/docs/coulomb-pureconfig.md b/docs/coulomb-pureconfig.md new file mode 100644 index 000000000..760ad29d1 --- /dev/null +++ b/docs/coulomb-pureconfig.md @@ -0,0 +1,258 @@ +# coulomb-pureconfig + +The `coulomb-pureconfig` package defines `pureconfig` +@:api(pureconfig.ConfigReader) and @:api(pureconfig.ConfigWriter) +implicit context rules for +@:api(coulomb.Quantity$), @:api(coulomb.RuntimeQuantity), and @:api(coulomb.RuntimeUnit) objects. + +@:callout(info) +At this time pureconfig does not cross-compile to ScalaJS or ScalaNative, +and so `coulomb-pureconfig` also only builds for JVM. +This issue is tracked by pureconfig at +[#1307](https://github.com/pureconfig/pureconfig/issues/1307). +@:@ + +## Quick Start + +Before you begin, it is recommended to first familiarize yourself with the +[coulomb introduction][coulomb-introduction] +and +[coulomb-core]. + +### packages + +```scala +// coulomb pureconfig integrations +libraryDependencies += "com.manyangled" %% "coulomb-pureconfig" % "@VERSION@" + +// dependencies +libraryDependencies += "com.manyangled" %% "coulomb-core" % "@VERSION@" +libraryDependencies += "com.manyangled" %% "coulomb-runtime" % "@VERSION@" +libraryDependencies += "com.manyangled" %% "coulomb-parser" % "@VERSION@" + +// coulomb predefined units +libraryDependencies += "com.manyangled" %% "coulomb-units" % "@VERSION@" +``` + +### import + +The following example imports basic `coulomb` and `coulomb-pureconfig` definitions + +```scala mdoc +// fundamental coulomb types and methods +import coulomb.* +import coulomb.syntax.* + +// algebraic definitions +import algebra.instances.all.given +import coulomb.ops.algebra.all.given + +// unit and value type policies for operations +import coulomb.policy.standard.given +import scala.language.implicitConversions + +// unit definitions +import coulomb.units.si.prefixes.{*, given} +import coulomb.units.info.{*, given} +import coulomb.units.time.{*, given} + +// pureconfig defs +import _root_.pureconfig.{*, given} + +// import basic coulomb-pureconfig defs +import coulomb.pureconfig.* +``` + +### examples +Define a pureconfig runtime to enable io of coulomb objects. +You can list either package and object names, or type definitions. +When you provide a package or object name, as in the example below, +any unit type definitions inside that object will be included in the runtime. + +```scala mdoc +// define a pureconfig runtime with SI and SI prefix unit definitions +given given_pureconfig: PureconfigRuntime = PureconfigRuntime.of[ + "coulomb.units.si.prefixes" *: + "coulomb.units.info" *: + "coulomb.units.time" *: + EmptyTuple +] +``` + +For our example, we define a simple configuration class, +using values with units. + +```scala mdoc +case class Config( + duration: Quantity[Double, Second], + storage: Quantity[Double, Giga * Byte], + bandwidth: Quantity[Float, (Mega * Bit) / Second] +) +``` + +We will also define a ConfigReader for our config class, +because pureconfig in scala 3 does not currently support automatic +derivation of ConfigReader for case classes. + +```scala mdoc +// defined with 'using' context so that this function defers +// resolution and can operate with multiple pureconfig io policies +given given_ConfigLoader(using + ConfigReader[Quantity[Double, Second]], + ConfigReader[Quantity[Double, Giga * Byte]], + ConfigReader[Quantity[Float, (Mega * Bit) / Second]] +): ConfigReader[Config] = + ConfigReader.forProduct3("duration", "storage", "bandwidth") { + (d: Quantity[Double, Second], + s: Quantity[Double, Giga * Byte], + b: Quantity[Float, (Mega * Bit) / Second]) => + Config(d, s, b) + } +``` + +In this example, we will import the DSL-based derivations for +RuntimeUnit objects, and demonstrate that these rules will +automatically convert compatible units, and load successfully. + +```scala mdoc +// use the DSL-based io definitions for RuntimeUnit objects +import coulomb.pureconfig.policy.DSL.given + +// define a configuration source +// this source uses units that are different than the Config type +// definition, but they are convertable +val source = ConfigSource.string(""" +{ + duration: {value: 10, unit: minute}, + storage: {value: 100, unit: megabyte}, + bandwidth: {value: 200, unit: "gigabyte / second"} +} +""") + +// this load will succeed, with automatic unit conversions +val conf = source.load[Config] +``` + +If a configuration value has _incompatible_ units, +the load will fail with a corresponding error. + +```scala mdoc +// this config has the wrong unit for bandwidth +val bad = ConfigSource.string(""" +{ + duration: {value: 10, unit: minute}, + storage: {value: 100, unit: megabyte}, + bandwidth: {value: 200, unit: "gigabyte"} +} +""") + +// this load will fail because bandwidth units are incompatible +val fail = bad.load[Config] +``` + +## Integer Values + +In coulomb, conversion operations on integer values are considered to be +[truncating][truncating conversions]. +They may lose precision due to integer truncation. +Truncating conversions are generally explicit only, +because this loss of precision is numerically unsafe. + +In pureconfig I/O, however, there is no way to explicitly invoke a truncating conversion. +To mitigate this difficulty, the `coulomb-pureconfig` integrations will load without error +if the conversion factor is exactly 1. + +@:callout(info) +The safest way to ensure unit conversions will always succeed is to use fractional value types +such as Float or Double. +If desired, +[coulomb-spire](coulomb-spire.md) +provides integrations for fractional value types of higher precision. +@:@ + +```scala mdoc +// source for a quantity value +val qsrc = ConfigSource.string(""" +{ + value: 3 + unit: megabyte +} +""") + +// loading integer value types will succeed when type matches the config +qsrc.load[Quantity[Int, Mega * Byte]] + +// it will also succeed whenever the conversion coefficient is exactly 1. +qsrc.load[Quantity[Long, Byte * Mega]] + +// if the conversion is not exactly 1, load will fail +qsrc.load[Quantity[Int, Kilo * Byte]] +``` + +## IO Policies + +The `coulomb-pureconfig` integrations currently support two options for I/O "policies" +which differ primarily in how one represents unit information. +In the quick-start example above, the DSL-based policy was demonstrated. + +The second option is a JSON-based unit representation. +Here, the units are defined using a JSON structured unit expression. +This representation is more verbose, +but it is more amenable to explicitly structured expressions. + +```scala mdoc:reset:invisible +// if mdoc had push/pop, I would not have to copy all this +import coulomb.* +import coulomb.syntax.* +import algebra.instances.all.given +import coulomb.ops.algebra.all.given +import coulomb.policy.standard.given +import scala.language.implicitConversions +import coulomb.units.si.prefixes.{*, given} +import coulomb.units.info.{*, given} +import coulomb.units.time.{*, given} +import _root_.pureconfig.{*, given} +import coulomb.pureconfig.* + +given given_pureconfig: PureconfigRuntime = PureconfigRuntime.of[ + "coulomb.units.si.prefixes" *: + "coulomb.units.info" *: + "coulomb.units.time" *: + EmptyTuple +] + +case class Config( + duration: Quantity[Double, Second], + storage: Quantity[Double, Giga * Byte], + bandwidth: Quantity[Float, (Mega * Bit) / Second] +) + +given given_ConfigLoader(using + ConfigReader[Quantity[Double, Second]], + ConfigReader[Quantity[Double, Giga * Byte]], + ConfigReader[Quantity[Float, (Mega * Bit) / Second]] +): ConfigReader[Config] = + ConfigReader.forProduct3("duration", "storage", "bandwidth") { + (d: Quantity[Double, Second], + s: Quantity[Double, Giga * Byte], + b: Quantity[Float, (Mega * Bit) / Second]) => + Config(d, s, b) + } +``` + +```scala mdoc +// use the JSON-based io definitions for RuntimeUnit objects +import coulomb.pureconfig.policy.JSON.given + +// this configuration source represents units in structured JSON +val source = ConfigSource.string(""" +{ + duration: {value: 10, unit: minute}, + storage: {value: 100, unit: {lhs: mega, op: "*", rhs: byte}}, + bandwidth: {value: 200, unit: {lhs: {lhs: giga, op: "*", rhs: byte}, op: "/", rhs: second}} +} +""") + +// this load will succeed, with automatic unit conversions +val conf = source.load[Config] +``` diff --git a/docs/coulomb-refined.md b/docs/coulomb-refined.md index dcb6e59be..c301d95ea 100644 --- a/docs/coulomb-refined.md +++ b/docs/coulomb-refined.md @@ -139,7 +139,7 @@ plus(x: Refined[V, P], y: Refined[V, P]): Refined[V, P] = Because the refined algebraic policy is an overlay, you can use it with your choice of base policies, for example with -[core policies](concepts.md#coulomb-policies) +[core policies](coulomb-core.md#coulomb-policies) or [spire policies](coulomb-spire.md#policies). @:@ diff --git a/docs/coulomb-runtime.md b/docs/coulomb-runtime.md new file mode 100644 index 000000000..3c5a28e22 --- /dev/null +++ b/docs/coulomb-runtime.md @@ -0,0 +1,128 @@ +# coulomb-runtime + +The [coulomb-runtime] package implements +@:api(coulomb.RuntimeQuantity) and @:api(coulomb.RuntimeUnit). +Its primary use case at the time of this documentation is to support runtime I/O, +for example the [coulomb-pureconfig] package. + +## Quick Start + +### packages + +```scala +libraryDependencies += "com.manyangled" %% "coulomb-runtime" % "@VERSION@" + +// dependencies +libraryDependencies += "com.manyangled" %% "coulomb-core" % "@VERSION@" + +// coulomb predefined units +libraryDependencies += "com.manyangled" %% "coulomb-units" % "@VERSION@" +``` + +### import + +```scala mdoc +// fundamental coulomb types and methods +// these include RuntimeUnit and RuntimeQuantity +import coulomb.* +import coulomb.syntax.* + +// algebraic definitions +import algebra.instances.all.given +import coulomb.ops.algebra.all.given + +// unit and value type policies for operations +import coulomb.policy.standard.given +import scala.language.implicitConversions + +// unit definitions +import coulomb.units.si.{*, given} +import coulomb.units.si.prefixes.{*, given} +import coulomb.units.info.{*, given} +import coulomb.units.time.{*, given} + +// runtime definitions +import coulomb.conversion.runtimes.mapping.MappingCoefficientRuntime +``` + +### examples + +@:api(coulomb.RuntimeUnit) is the core data structure of the [coulomb-runtime] package. +It is a parallel runtime implementation of the standard static unit types and analysis +defined in [coulomb-core]. + +The `RuntimeUnit.of` method makes it easy to create RuntimeUnit values from static unit types. +Additionally, you can apply the standard unit type operators `*`, `/` and `^` to build up unit expressions. + +```scala mdoc +// create RuntimeUnit values from static unit types +val k = RuntimeUnit.of[Kilo] +val d = RuntimeUnit.of[Meter] +val t = RuntimeUnit.of[Second] + +// Build up unit expression from other expressions +val kps = (k * d) / t + +// values can be displayed with toString for readability +kps.toString +``` + +The `RuntimeUnit.of` method can be used with static unit types of arbitrary form. + +```scala mdoc +val kps2 = RuntimeUnit.of[Kilo * Meter / Second] +kps2.toString +``` + +A @:api(coulomb.RuntimeQuantity) is a value paired with a RuntimeUnit, +and is the runtime analog of @:api(coulomb.Quantity$). +The following example demonstrates some ways to create RuntimeQuantity objects. + +```scala mdoc +// a RuntimeQuantity is a value paired with a RuntimeUnit +val rq = RuntimeQuantity(1f, kps) + +// declare a RuntimeQuantity with a given RuntimeUnit +1f.withRuntimeUnit(kps).toString + +// an equivalent RuntimeQuantity based on a static unit type +1f.withRuntimeUnit[(Kilo * Meter) / Second].toString +``` + +It is also possible to convert from @:api(coulomb.RuntimeQuantity) to @:api(coulomb.Quantity). +This is accomplished using a @:api(coulomb.CoefficientRuntime) in context. + +As this example shows, you can list package names, which will import any unit types +into the CoefficientRuntime. + +@:callout(info) +The @:api(coulomb.CoefficientRuntime) bridges the gap between runtime unit expresions and +static unit types. +It is what allows loading unit aware configurations, for example in [coulomb-pureconfig]. +@:@ + +```scala mdoc +// declare a coefficient runtime that knows about SI units and prefixes +given given_CRT: CoefficientRuntime = MappingCoefficientRuntime.of[ + "coulomb.units.si" *: + "coulomb.units.si.prefixes" *: + EmptyTuple +] +``` + +With a @:api(coulomb.CoefficientRuntime), we can convert from runtime quantities to +static typed quantities. +We cannot know at compile time if these conversions will succeed, +so these operations return an @:api(scala.util.Either) value. + +```scala mdoc +// reconstruct the equivalent Quantity +rq.toQuantity[Float, Kilo * Meter / Second] + +// valid conversions of value types or unit types will also succeed +rq.toQuantity[Double, Meter / Second] + +// attempting to convert to incompatible units will fail +rq.toQuantity[Float, Second] +``` + diff --git a/docs/coulomb-spire.md b/docs/coulomb-spire.md index d23135d2a..2db23e890 100644 --- a/docs/coulomb-spire.md +++ b/docs/coulomb-spire.md @@ -130,5 +130,5 @@ and so `Int` promotes to `Real`, etc. The definition of promotions for `coulomb-spire` can be browsed [here](https://www.javadoc.io/doc/com.manyangled/coulomb-docs_3/latest/coulomb/ops/resolution/spire$.html) -[value-resolution-concepts]: concepts.md#value-promotion-and-resolution -[policy-concepts]: concepts.md#coulomb-policies +[value-resolution-concepts]: coulomb-core.md#value-promotion-and-resolution +[policy-concepts]: coulomb-core.md#coulomb-policies diff --git a/docs/develop.md b/docs/develop.md index 2f6d4e0f2..d12080f87 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -1,4 +1,4 @@ -# coulomb Development +# coulomb development ## sbt-typelevel diff --git a/docs/directory.conf b/docs/directory.conf index fd19860af..06c913a84 100644 --- a/docs/directory.conf +++ b/docs/directory.conf @@ -1,8 +1,10 @@ laika.navigationOrder = [ - README.md - concepts.md - develop.md + coulomb-core.md coulomb-units.md coulomb-spire.md coulomb-refined.md + coulomb-pureconfig.md + coulomb-runtime.md + coulomb-parser.md + develop.md ] diff --git a/parser/src/main/scala/coulomb/parser.scala b/parser/src/main/scala/coulomb/parser.scala new file mode 100644 index 000000000..b665b14c1 --- /dev/null +++ b/parser/src/main/scala/coulomb/parser.scala @@ -0,0 +1,71 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb.parser + +import scala.util.{Try, Success, Failure} + +import coulomb.RuntimeUnit +import coulomb.rational.Rational + +trait RuntimeUnitParser: + def parse(expr: String): Either[String, RuntimeUnit] + def render(u: RuntimeUnit): String + +object standard: + abstract class RuntimeUnitDslParser extends RuntimeUnitParser: + def unames: Map[String, String] + def pnames: Set[String] + + lazy val unamesinv: Map[String, String] = + unames.map { (k, v) => (v, k) } + + private lazy val parser: (String => Either[String, RuntimeUnit]) = + dsl.parser(unames, pnames, unamesinv) + + final def parse(expr: String): Either[String, RuntimeUnit] = + parser(expr) + + final def render(u: RuntimeUnit): String = + def paren(s: String, tl: Boolean): String = + if (tl) s else s"($s)" + def rparen(r: Rational, tl: Boolean): String = + if (r.d == 1) + s"${r.n}" + else + paren(s"${r.n}/${r.d}", tl) + def work(u: RuntimeUnit, tl: Boolean = false): String = + u match + case RuntimeUnit.UnitConst(value) => + rparen(value, tl) + case RuntimeUnit.UnitType(path) => + if (unamesinv.contains(path)) + // if it is in the inverse mapping write the name + unamesinv(path) + else + // otherwise write the fully qualified type name + s"@$path" + case RuntimeUnit.Mul(l, r) => + paren(s"${work(l)}*${work(r)}", tl) + case RuntimeUnit.Div(n, d) => + paren(s"${work(n)}/${work(d)}", tl) + case RuntimeUnit.Pow(b, e) => + paren(s"${work(b)}^${rparen(e, false)}", tl) + work(u, tl = true) + + object RuntimeUnitDslParser: + inline def of[UTL <: Tuple]: RuntimeUnitDslParser = + ${ infra.meta.ofUTL[UTL] } diff --git a/parser/src/main/scala/coulomb/parser/dsl.scala b/parser/src/main/scala/coulomb/parser/dsl.scala new file mode 100644 index 000000000..2ec0a0f7c --- /dev/null +++ b/parser/src/main/scala/coulomb/parser/dsl.scala @@ -0,0 +1,273 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb.parser + +import scala.util.{Try, Success, Failure} + +import coulomb.RuntimeUnit +import coulomb.rational.Rational + +object dsl: + def parser( + unames: Map[String, String], + pnames: Set[String], + unamesinv: Map[String, String] + ): (String => Either[String, RuntimeUnit]) = + val p = catsparse.unit( + catsparse.named(unames, pnames), + catsparse.typed(unamesinv) + ) + (expr: String) => + p.parse(expr) match + case Right((_, u)) => Right(u) + case Left(e) => Left(s"$e") + + // parsing library is implementation detail + // note this is private and so could be swapped out without breaking binary compat + private object catsparse: + import _root_.cats.parse.* + + // for consuming whitespace + val ws: Parser[Unit] = Parser.charIn(" \t").void + val ws0: Parser0[Unit] = ws.rep0.void + + // numeric literals parse into UnitConst objects + val numlit: Parser[RuntimeUnit] = + cats.parse.Numbers.jsonNumber.flatMap { lit => + lit match + case intlit(v) => + Parser.pure(RuntimeUnit.UnitConst(Rational(v, 1))) + case fplit(v) => + Parser.pure(RuntimeUnit.UnitConst(Rational(v))) + case _ => + Parser.failWith[RuntimeUnit]( + s"bad numeric literal '$lit'" + ) + } + + object intlit: + def unapply(lit: String): Option[BigInt] = + Try { BigInt(lit) }.toOption + + object fplit: + def unapply(lit: String): Option[Double] = + Try { lit.toDouble }.toOption + + // a token representing a unit name literal + // examples: "meter", "second", etc + // note that for any defined prefix and unit, is also valid + // for example if "kilo" and "meter" are defined units, "kilometer" will also + // parse correctly as RuntimeUnit.Mul(Kilo, Meter) + val unitlit: Parser[String] = + // this might be extended but not until I have a reason and a principle + // one possible extension would be "any printable char not in { '(', ')', '*', etc }" + // however I'm not sure if there is an efficient way to express that + // (starting char can also not be digit, + or -) + Rfc5234.alpha.rep.string + + // scala identifier + val idlit: Parser[String] = + (Rfc5234.alpha ~ (Rfc5234.alpha | Rfc5234.digit | Parser.char( + '$' + )).rep0).string + + // fully qualified scala module path for a UnitType + val typelit: Parser[String] = + // I expect at least one '.' in the type path + Parser.char('@') *> (idlit ~ (Parser.char('.') ~ idlit).rep).string + + // used for left-factoring the parsing for sequences of mul and div + val muldivop: Parser[(RuntimeUnit, RuntimeUnit) => RuntimeUnit] = + (Parser.char('*') <* ws0).as(RuntimeUnit.Mul(_, _)) | + (Parser.char('/') <* ws0).as(RuntimeUnit.Div(_, _)) + + // used for left-factoring the parsing of "^" (power) + val powop: Parser[(RuntimeUnit, RuntimeUnit) => RuntimeUnit] = + (Parser.char('^') <* ws0).as { (b: RuntimeUnit, e: RuntimeUnit) => + // we do not have to check for Left value here + // because it is verified during parsing + RuntimeUnit.Pow(b, e.toRational.toSeq.head) + } + + def unit( + named: Parser[RuntimeUnit], + typed: Parser[RuntimeUnit] + ): Parser[RuntimeUnit] = + lazy val unitexpr: Parser[RuntimeUnit] = Parser.defer { + // sequence of mul and div operators + // these have lowest precedence and form the top of the parse tree + // example: * / * ... + lazy val muldiv: Parser[RuntimeUnit] = + chainl1(pow, muldivop) + + // powers of form ^ , where: + // may be any unit expression and + // is an expression that must evaluate to a valid numeric constant + lazy val pow: Parser[RuntimeUnit] = + binaryl1(atom, numeric, powop) + + // numeric literal, named unit, or sub-expr in parens + lazy val atom: Parser[RuntimeUnit] = + paren | (numlit <* ws0) | (typed <* ws0) | (named <* ws0) + + // any unit subexpression inside of parens: () + lazy val paren: Parser[RuntimeUnit] = + unitexpr.between( + Parser.char('(') <* ws0, + Parser.char(')') <* ws0 + ) + + // parses a RuntimeUnit expression, but only succeeds + // if it evaluates to a constant numeric value + // note this is based on 'atom' so non-atomic expressions may only + // appear inside of () + // used to enforce that exponent of powers is a valid numeric value + lazy val numeric: Parser[RuntimeUnit] = + atom.flatMap { u => + u.toRational match + case Right(v) => + Parser.pure(RuntimeUnit.UnitConst(v)) + case Left(e) => + Parser.failWith[RuntimeUnit](e) + } + + // return the top of the parse tree + muldiv + } + // parse a unit expression, consuming any leading whitespace + // and requiring parsing reach end of input + // (trailing whitespace is consumed inside unitexpr) + ws0.with1 *> unitexpr <* Parser.end + + def typed(unamesinv: Map[String, String]): Parser[RuntimeUnit] = + typelit.flatMap { path => + if (unamesinv.contains(path)) + // type paths are ok if they are in the map + Parser.pure(RuntimeUnit.UnitType(path)) + else + Parser.failWith[RuntimeUnit]( + s"unrecognized unit type '$path'" + ) + } + + // parses "raw" unit literals - only succeeds if the literal is + // in the list of defined units (or unit prefixes) + // these lists are intended to be constructed at compile-time via scala metaprogramming + // to reduce errors + def named( + unames: Map[String, String], + pnames: Set[String] + ): Parser[RuntimeUnit] = + val prefixunit: Parser[(String, String)] = + if (pnames.isEmpty || unames.isEmpty) + Parser.fail + else + (strset(pnames) ~ strset( + unames.keySet `diff` pnames + )) <* Parser.end + unitlit.flatMap { name => + if (unames.contains(name)) + // name is a defined unit, return its type + Parser.pure(RuntimeUnit.UnitType(unames(name))) + else + // otherwise see if it can be parsed as + prefixunit.parse(name) match + case Right((_, (pn, un))) => + // => * + val p = RuntimeUnit.UnitType(unames(pn)) + val u = RuntimeUnit.UnitType(unames(un)) + Parser.pure(RuntimeUnit.Mul(p, u)) + case Left(_) => + Parser.failWith[RuntimeUnit]( + s"unrecognized unit '$name'" + ) + } + + def strset(ss: Set[String]): Parser[String] = + strsetvoid(ss).string + + // assumes ss is not empty and all members are length > 0 + // this is guaranteed by construction at compile time + private def strsetvoid(ss: Set[String]): Parser[Unit] = + // construct a parser "branch" for each starting character + val hp = ss.map(_.head).toList.map { h => + // set of string tails starting with char h + val tails = + ss.filter(_.head == h).map(_.drop(1)).filter(_.length > 0) + if (tails.isEmpty) + // no remaining string tails, just parse char h + Parser.char(h) + else + // parse h followed by parser for tails + (Parser.char(h) ~ strsetvoid(tails)).void + } + // final parser is just "or" of branches: hp(0) | hp(1) | hp(2) ... + // these are safe to "or" because by construction they share + // no common left factor + Parser.oneOf(hp) + + // the following are combinators for factoring left-recursive grammars + // they are taken from this paper: + // https://github.com/j-mie6/design-patterns-for-parser-combinators#readme + def chainl1[X](p: Parser[X], op: Parser[(X, X) => X]): Parser[X] = + lazy val rest: Parser0[X => X] = Parser.defer0 { + val some: Parser0[X => X] = (op, p, rest).mapN { + // found an , with possibly more + (f, y, next) => ((x: X) => next(f(x, y))) + } + // none consumes no input + val none: Parser0[X => X] = Parser.pure(identity[X]) + // "some" expected to be distinguished by leading char of "op" + // for example lhs + rhs distinguished by '+' + some | none + } + // this feels wrong but .with1 returns With1, not Parser + rapp(p, rest).asInstanceOf[Parser[X]] + + // like chainl1 but specifically a single left-factored binary expr + // [ ] + def binaryl1[X]( + pl: Parser[X], + pr: Parser[X], + op: Parser[(X, X) => X] + ): Parser[X] = + val some: Parser0[X => X] = (op, pr).mapN { + // found an + (f, y) => ((x: X) => f(x, y)) + } + val none: Parser0[X => X] = Parser.pure(identity[X]) + rapp(pl, some | none).asInstanceOf[Parser[X]] + + // parsley <*> + def app[X, Z](f: Parser0[X => Z], x: Parser0[X]): Parser0[Z] = + (f ~ x).map { case (f, x) => f(x) } + + // parsley <**> + def rapp[X, Z](x: Parser0[X], f: Parser0[X => Z]): Parser0[Z] = + (x ~ f).map { case (x, f) => f(x) } + + // parsley zipped + // can scala 3 '*:' clean this up? + extension [X1, X2](p: (Parser0[X1], Parser0[X2])) + def mapN[Z](f: (X1, X2) => Z): Parser0[Z] = + (p._1 ~ p._2).map { case (x1, x2) => f(x1, x2) } + + extension [X1, X2, X3](p: (Parser0[X1], Parser0[X2], Parser0[X3])) + def mapN[Z](f: (X1, X2, X3) => Z): Parser0[Z] = + ((p._1 ~ p._2) ~ p._3).map { case ((x1, x2), x3) => + f(x1, x2, x3) + } diff --git a/parser/src/main/scala/coulomb/parser/infra/meta.scala b/parser/src/main/scala/coulomb/parser/infra/meta.scala new file mode 100644 index 000000000..b803ef327 --- /dev/null +++ b/parser/src/main/scala/coulomb/parser/infra/meta.scala @@ -0,0 +1,80 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb.parser.infra + +import scala.util.{Try, Success, Failure} + +import coulomb.RuntimeUnit +import coulomb.rational.Rational + +object meta: + import scala.quoted.* + import scala.util.{Try, Success, Failure} + import scala.unchecked + import scala.language.implicitConversions + + import coulomb.infra.meta.{*, given} + import coulomb.infra.runtime.meta.{*, given} + import coulomb.conversion.runtimes.mapping.meta.moduleUnits + + import coulomb.parser.standard.RuntimeUnitDslParser + + def ofUTL[UTL](using Quotes, Type[UTL]): Expr[RuntimeUnitDslParser] = + import quotes.reflect.* + val (un, pn) = collect(typeReprList(TypeRepr.of[UTL])) + // remove any unit names that are empty strings + val pn1 = pn.filter(_.length > 0) + val un1 = un.filter { case (k, _) => + k.length > 0 + } + '{ + new RuntimeUnitDslParser: + val unames = ${ Expr(un1) } + val pnames = ${ Expr(pn1) } + } + + private def collect(using Quotes)( + tl: List[quotes.reflect.TypeRepr] + ): (Map[String, String], Set[String]) = + import quotes.reflect.* + tl match + case Nil => (Map.empty[String, String], Set.empty[String]) + case head :: tail => + val (un, pn) = collect(tail) + head match + case ConstantType(StringConstant(mname)) => + val (mu, mp) = collect(moduleUnits(mname)) + (un ++ mu, pn ++ mp) + case baseunitTR(tr) => + val AppliedType(_, List(_, n, _)) = tr: @unchecked + val ConstantType(StringConstant(name)) = n: @unchecked + // base units are never prefix units because prefix units are + // derived from '1' (unitless) + (un + (name -> head.typeSymbol.fullName), pn) + case derivedunitTR(tr) => + val AppliedType(_, List(_, _, n, _)) = tr: @unchecked + val ConstantType(StringConstant(name)) = n: @unchecked + // always add to unit types + val unr = un + (name -> head.typeSymbol.fullName) + // if it is derived from unitless, also add it to prefix unit set + val pnr = + if (convertible(head, TypeRepr.of[1])) pn + name + else pn + (unr, pnr) + case _ => + report.errorAndAbort(s"collect: bad type ${head.show}") + null.asInstanceOf[Nothing] diff --git a/parser/src/test/scala/coulomb/dsl.scala b/parser/src/test/scala/coulomb/dsl.scala new file mode 100644 index 000000000..54d96612a --- /dev/null +++ b/parser/src/test/scala/coulomb/dsl.scala @@ -0,0 +1,35 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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. + */ + +import coulomb.testing.CoulombSuite + +class ParserDSLSuite extends CoulombSuite: + import coulomb.* + import coulomb.syntax.* + import coulomb.parser.RuntimeUnitParser + import coulomb.parser.standard.RuntimeUnitDslParser + + import coulomb.RuntimeUnit + + val dslparser: RuntimeUnitParser = + RuntimeUnitDslParser.of[ + "coulomb.units.si" *: "coulomb.units.si.prefixes" *: EmptyTuple + ] + + test("smoke test") { + val t = dslparser.parse("kilometer") + println(t) + } diff --git a/pureconfig/src/main/scala/coulomb/io.scala b/pureconfig/src/main/scala/coulomb/io.scala new file mode 100644 index 000000000..e36636de8 --- /dev/null +++ b/pureconfig/src/main/scala/coulomb/io.scala @@ -0,0 +1,262 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb.pureconfig.io + +import scala.util.{Try, Success, Failure} +import scala.util.NotGiven + +import scala.jdk.CollectionConverters.* + +import com.typesafe.config.{ConfigValue, ConfigValueFactory} + +import _root_.pureconfig.* +import _root_.pureconfig.error.CannotConvert + +import algebra.ring.MultiplicativeSemigroup + +import coulomb.{infra => _, *} +import coulomb.syntax.* +import coulomb.rational.Rational +import coulomb.conversion.ValueConversion + +import coulomb.parser.RuntimeUnitParser + +object testing: + // probably useful for unit testing, will keep them here for now + extension [V, U](q: Quantity[V, U]) + inline def toCV(using ConfigWriter[Quantity[V, U]]): ConfigValue = + ConfigWriter[Quantity[V, U]].to(q) + extension (conf: ConfigValue) + inline def toQuantity[V, U](using + ConfigReader[Quantity[V, U]] + ): Quantity[V, U] = + ConfigReader[Quantity[V, U]].from(conf).toSeq.head + +object rational: + extension (v: BigInt) + def toCV: ConfigValue = + if (v.isValidInt) ConfigWriter[Int].to(v.toInt) + else ConfigWriter[BigInt].to(v) + + given ctx_RationalReader: ConfigReader[Rational] = + ConfigReader[BigInt].map(Rational(_, 1)) + `orElse` ConfigReader[Double].map(Rational(_)) + `orElse` ConfigReader.forProduct2("n", "d") { + (n: BigInt, d: BigInt) => + Rational(n, d) + } + + given ctx_RationalWriter: ConfigWriter[Rational] = + ConfigWriter.fromFunction[Rational] { r => + if (r.d == 1) + ConfigValueFactory.fromAnyRef(r.n.toCV) + else + ConfigValueFactory.fromAnyRef( + Map("n" -> r.n.toCV, "d" -> r.d.toCV).asJava + ) + } + +object ruDSL: + given ctx_RuntimeUnit_DSL_Reader(using + parser: RuntimeUnitParser + ): ConfigReader[RuntimeUnit] = + ConfigReader.fromCursor[RuntimeUnit] { cur => + cur.asString.flatMap { expr => + parser.parse(expr) match + case Right(u) => Right(u) + case Left(e) => + cur.failed(CannotConvert(expr, "RuntimeUnit", e)) + } + } + + given ctx_RuntimeUnit_DSL_Writer(using + parser: RuntimeUnitParser + ): ConfigWriter[RuntimeUnit] = + ConfigWriter[String].contramap[RuntimeUnit] { u => + parser.render(u) + } + +object ruJSON: + import coulomb.pureconfig.{UnitPathMapper, PathUnitMapper} + + given ctx_RuntimeUnit_JSON_Reader(using + rr: ConfigReader[Rational], + upm: UnitPathMapper + ): ConfigReader[RuntimeUnit] = + ConfigReader[Rational].map(RuntimeUnit.UnitConst(_)) + `orElse` ConfigReader[String].emap { id => + upm.path(id) match + case Right(path) => Right(RuntimeUnit.UnitType(path)) + case Left(_) => + Left( + CannotConvert( + s"$id", + "RuntimeUnit", + s"id has no mapping: '$id'" + ) + ) + } + `orElse` ConfigReader + .forProduct3("lhs", "op", "rhs") { + (lhs: RuntimeUnit, op: String, rhs: RuntimeUnit) => + (lhs, op, rhs) + } + .emap { (lhs, op, rhs) => + op match + case "*" => Right(RuntimeUnit.Mul(lhs, rhs)) + case "/" => Right(RuntimeUnit.Div(lhs, rhs)) + case "^" => + rhs.toRational match + case Right(e) => Right(RuntimeUnit.Pow(lhs, e)) + case Left(_) => + Left( + CannotConvert( + s"${(lhs, op, rhs)}", + "RuntimeUnit", + s"bad exponent '$rhs'" + ) + ) + case _ => + Left( + CannotConvert( + s"${(lhs, op, rhs)}", + "RuntimeUnit", + s"unrecognized operator: '$op'" + ) + ) + } + + given ctx_RuntimeUnit_JSON_Writer(using + cwr: ConfigWriter[Rational], + pum: PathUnitMapper + ): ConfigWriter[RuntimeUnit] = + def u2cv(u: RuntimeUnit): ConfigValue = + u match + case RuntimeUnit.UnitConst(v) => + ConfigValueFactory.fromAnyRef(ConfigWriter[Rational].to(v)) + case RuntimeUnit.UnitType(path) => + ConfigValueFactory.fromAnyRef( + ConfigWriter[String].to(pum.unit(path)) + ) + case RuntimeUnit.Mul(lhs, rhs) => + ConfigValueFactory.fromAnyRef( + Map( + "lhs" -> u2cv(lhs), + "rhs" -> u2cv(rhs), + "op" -> ConfigWriter[String].to("*") + ).asJava + ) + case RuntimeUnit.Div(num, den) => + ConfigValueFactory.fromAnyRef( + Map( + "lhs" -> u2cv(num), + "rhs" -> u2cv(den), + "op" -> ConfigWriter[String].to("/") + ).asJava + ) + case RuntimeUnit.Pow(b, e) => + ConfigValueFactory.fromAnyRef( + Map( + "lhs" -> u2cv(b), + "rhs" -> ConfigWriter[Rational].to(e), + "op" -> ConfigWriter[String].to("^") + ).asJava + ) + ConfigWriter.fromFunction[RuntimeUnit](u2cv) + +object runtimeq: + given ctx_RuntimeQuantity_Reader[V](using + ConfigReader[V], + ConfigReader[RuntimeUnit] + ): ConfigReader[RuntimeQuantity[V]] = + ConfigReader.forProduct2("value", "unit") { (v: V, u: RuntimeUnit) => + RuntimeQuantity(v, u) + } + + given ctx_RuntimeQuantity_Writer[V](using + ConfigWriter[V], + ConfigWriter[RuntimeUnit] + ): ConfigWriter[RuntimeQuantity[V]] = + ConfigWriter.forProduct2("value", "unit") { (q: RuntimeQuantity[V]) => + (q.value, q.unit) + } + +object quantity: + import givenall.{*, given} + + // if we have a conversion from Rational to V, that is happy path + // since we can safely convert units (basically, fractional values). + inline given ctx_Quantity_Reader_VC[V, U](using + vcr: ValueConversion[Rational, V], + mul: MultiplicativeSemigroup[V], + crq: ConfigReader[RuntimeQuantity[V]], + crt: CoefficientRuntime + ): ConfigReader[Quantity[V, U]] = + ConfigReader[RuntimeQuantity[V]].emap { rq => + crt.coefficient[V](rq.unit, RuntimeUnit.of[U]) match + case Right(coef) => Right(mul.times(coef, rq.value).withUnit[U]) + case Left(e) => Left(CannotConvert(s"$rq", "Quantity", e)) + } + + // if there is no conversion from Rational to V in context, then + // we can still try to safely load, as long as U is identical + // (or equivalent) to the unit we are loading from + inline given ctx_Quantity_Reader_NoVC[V, U](using + nocv: NotGiven[ + GivenAll[(ValueConversion[Rational, V], MultiplicativeSemigroup[V])] + ], + crq: ConfigReader[RuntimeQuantity[V]], + crt: CoefficientRuntime + ): ConfigReader[Quantity[V, U]] = + ConfigReader[RuntimeQuantity[V]].emap { rq => + val ufrom = rq.unit + val uto = RuntimeUnit.of[U] + crt.coefficientRational(ufrom, uto) match + case Right(coef) => + if (coef == Rational.const1) + // units are same or equivalent (conversion coefficient is 1) + // so it is valid to load directly without applying conversion coefficient + Right(rq.value.withUnit[U]) + else + Left( + CannotConvert( + s"$rq", + "Quantity", + s"no safe conversion from $ufrom to $uto" + ) + ) + case Left(e) => Left(CannotConvert(s"$rq", "Quantity", e)) + } + + inline given ctx_Quantity_Writer[V, U](using + ConfigWriter[RuntimeQuantity[V]] + ): ConfigWriter[Quantity[V, U]] = + ConfigWriter[RuntimeQuantity[V]].contramap[Quantity[V, U]] { q => + RuntimeQuantity(q.value, RuntimeUnit.of[U]) + } + + private object givenall: + // https://github.com/lampepfl/dotty/discussions/18415 + case class GivenAll[T <: Tuple](t: T) + + given given_GivenAll_Tuple[H, T <: Tuple](using + h: H, + t: GivenAll[T] + ): GivenAll[H *: T] = + GivenAll(h *: t.t) + + given given_GivenAll_Empty: GivenAll[EmptyTuple] = GivenAll(EmptyTuple) diff --git a/pureconfig/src/main/scala/coulomb/policy.scala b/pureconfig/src/main/scala/coulomb/policy.scala new file mode 100644 index 000000000..0d2006a02 --- /dev/null +++ b/pureconfig/src/main/scala/coulomb/policy.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb.pureconfig.policy + +object DSL: + export coulomb.pureconfig.io.rational.given + export coulomb.pureconfig.io.ruDSL.given + export coulomb.pureconfig.io.runtimeq.given + export coulomb.pureconfig.io.quantity.given + +object JSON: + export coulomb.pureconfig.io.rational.given + export coulomb.pureconfig.io.ruJSON.given + export coulomb.pureconfig.io.runtimeq.given + export coulomb.pureconfig.io.quantity.given diff --git a/pureconfig/src/main/scala/coulomb/pureconfig.scala b/pureconfig/src/main/scala/coulomb/pureconfig.scala new file mode 100644 index 000000000..3dcab4476 --- /dev/null +++ b/pureconfig/src/main/scala/coulomb/pureconfig.scala @@ -0,0 +1,76 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb.pureconfig + +import coulomb.{infra => _, *} +import coulomb.syntax.* + +import coulomb.rational.Rational + +import coulomb.parser.RuntimeUnitParser + +trait UnitPathMapper: + def path(unit: String): Either[String, String] + +trait PathUnitMapper: + def unit(path: String): String + +class PureconfigRuntime( + cr: CoefficientRuntime, + rup: RuntimeUnitParser, + upm: String => Either[String, String], + pum: String => String +) extends CoefficientRuntime + with RuntimeUnitParser + with UnitPathMapper + with PathUnitMapper: + + def parse(expr: String): Either[String, RuntimeUnit] = + rup.parse(expr) + def render(u: RuntimeUnit): String = + rup.render(u) + + def coefficientRational( + uf: RuntimeUnit, + ut: RuntimeUnit + ): Either[String, Rational] = + cr.coefficientRational(uf, ut) + + def path(unit: String): Either[String, String] = + upm(unit) + def unit(path: String): String = + pum(path) + +object PureconfigRuntime: + import coulomb.conversion.runtimes.mapping.MappingCoefficientRuntime + import coulomb.parser.standard.RuntimeUnitDslParser + + inline def of[UTL <: Tuple]: PureconfigRuntime = + val r = MappingCoefficientRuntime.of[UTL] + val p = RuntimeUnitDslParser.of[UTL] + val upm: (String => Either[String, String]) = (unit: String) => { + if (unit.isEmpty) Left(unit) + else if (unit.head == '@') Right(unit.tail) + else if (p.unames.contains(unit)) Right(p.unames(unit)) + else Left(unit) + } + val pum: (String => String) = (path: String) => { + if (path.isEmpty) throw new Exception("empty path string") + else if (p.unamesinv.contains(path)) p.unamesinv(path) + else s"@${path}" + } + new PureconfigRuntime(r, p, upm, pum) diff --git a/pureconfig/src/test/scala/coulomb/quantity.scala b/pureconfig/src/test/scala/coulomb/quantity.scala new file mode 100644 index 000000000..9a89236e5 --- /dev/null +++ b/pureconfig/src/test/scala/coulomb/quantity.scala @@ -0,0 +1,78 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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. + */ + +import coulomb.testing.CoulombSuite + +class QuantityDSLSuite extends CoulombSuite: + import pureconfig.{*, given} + + import coulomb.* + import coulomb.syntax.* + + import coulomb.policy.standard.given + import algebra.instances.all.given + import coulomb.ops.algebra.all.given + + import coulomb.pureconfig.* + import coulomb.pureconfig.policy.DSL.given + + import coulomb.units.si.{*, given} + import coulomb.units.si.prefixes.{*, given} + + given given_pureconfig: PureconfigRuntime = + PureconfigRuntime.of[ + "coulomb.units.si" *: "coulomb.units.si.prefixes" *: EmptyTuple + ] + + test("smoke test") { + ConfigSource + .string("""{value: 3, unit: kilometer}""") + .load[Quantity[Float, Meter]] + .assertRQ[Float, Meter](3000f) + + ConfigSource + .string("""{value: 3, unit: kilometer}""") + .load[Quantity[Float, Second]] + .assertL + } + +class QuantityJSONSuite extends CoulombSuite: + import pureconfig.{*, given} + + import coulomb.* + import coulomb.syntax.* + + import coulomb.policy.standard.given + import algebra.instances.all.given + import coulomb.ops.algebra.all.given + + import coulomb.pureconfig.* + import coulomb.pureconfig.policy.JSON.given + + import coulomb.units.si.{*, given} + import coulomb.units.si.prefixes.{*, given} + + given given_pureconfig: PureconfigRuntime = + PureconfigRuntime.of[ + "coulomb.units.si" *: "coulomb.units.si.prefixes" *: EmptyTuple + ] + + test("smoke test") { + ConfigSource + .string("""{value: 3, unit: {lhs: kilo, op: "*", rhs: meter}}""") + .load[Quantity[Float, Meter]] + .assertRQ[Float, Meter](3000f) + } diff --git a/runtime/src/main/scala/coulomb/runtime/conversion/runtimes/mapping.scala b/runtime/src/main/scala/coulomb/runtime/conversion/runtimes/mapping.scala new file mode 100644 index 000000000..2e6b32d37 --- /dev/null +++ b/runtime/src/main/scala/coulomb/runtime/conversion/runtimes/mapping.scala @@ -0,0 +1,195 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb.conversion.runtimes.mapping + +import scala.collection.immutable.HashMap + +import coulomb.* +import coulomb.rational.Rational + +sealed abstract class MappingCoefficientRuntime extends CoefficientRuntime: + // can protected members change and preserve binary compatibility? + protected def base: Set[RuntimeUnit.UnitType] + protected def derived: Map[RuntimeUnit.UnitType, RuntimeUnit] + + def coefficientRational( + uf: RuntimeUnit, + ut: RuntimeUnit + ): Either[String, Rational] = + canonical(uf / ut).flatMap { qcan => + val Canonical(coef, sig) = qcan + if (sig.isEmpty) Right(coef) + else Left(s"non-convertible units: $uf, $ut") + } + + def canonical(u: RuntimeUnit): Either[String, Canonical] = + u match + case RuntimeUnit.Mul(l, r) => canonical(l) * canonical(r) + case RuntimeUnit.Div(n, d) => canonical(n) / canonical(d) + case RuntimeUnit.Pow(b, e) => canonical(b).pow(e) + case RuntimeUnit.UnitConst(c) => + Right(Canonical(c, Canonical.one.sig)) + case u: RuntimeUnit.UnitType if (base.contains(u)) => + Right(Canonical(Rational.const1, HashMap(u -> Rational.const1))) + case u: RuntimeUnit.UnitType if (derived.contains(u)) => + canonical(derived(u)) + case _ => Left(s"canonical: unrecognized unit $u") + +extension (lhs: Either[String, Canonical]) + def *(rhs: Either[String, Canonical]): Either[String, Canonical] = + for { l <- lhs; r <- rhs } yield l * r + def /(rhs: Either[String, Canonical]): Either[String, Canonical] = + for { l <- lhs; r <- rhs } yield l / r + def pow(e: Rational): Either[String, Canonical] = + lhs.map { l => l.pow(e) } + +object MappingCoefficientRuntime: + inline def of[UTL <: Tuple]: MappingCoefficientRuntime = ${ + meta.ofUTL[UTL] + } + +case class Canonical(coef: Rational, sig: Map[RuntimeUnit.UnitType, Rational]): + def *(that: Canonical): Canonical = + val Canonical(rcoef, rsig) = that + val s = Canonical + .merge(sig, rsig)(_ + _) + .filter { case (_, e) => e != Rational.const0 } + Canonical(coef * rcoef, s) + + def /(that: Canonical): Canonical = + val Canonical(rcoef, rsig) = that + val rneg = rsig.map { case (u, e) => (u, -e) } + val s = Canonical + .merge(sig, rneg)(_ + _) + .filter { case (_, e) => e != Rational.const0 } + Canonical(coef / rcoef, s) + + def pow(e: Rational): Canonical = + if (e == Rational.const0) Canonical.one + else if (e == Rational.const1) this + else + val s = sig.map { case (u, ue) => (u, ue * e) } + Canonical(coef.pow(e), s) + +object Canonical: + def merge[K, V](m1: Map[K, V], m2: Map[K, V])(f: (V, V) => V): Map[K, V] = + val ki = m1.keySet & m2.keySet + val r1 = m1.filter { case (k, _) => !ki.contains(k) } + val r2 = m2.filter { case (k, _) => !ki.contains(k) } + val ri = ki.map { k => (k, f(m1(k), m2(k))) } + r1.concat(r2).concat(ri) + + val one: Canonical = Canonical( + Rational.const1, + HashMap.empty[RuntimeUnit.UnitType, Rational] + ) + +object meta: + import scala.quoted.* + import scala.util.{Try, Success, Failure} + import scala.unchecked + import scala.language.implicitConversions + + import coulomb.infra.meta.{*, given} + import coulomb.infra.runtime.meta.{*, given} + + def ofUTL[UTL](using Quotes, Type[UTL]): Expr[MappingCoefficientRuntime] = + import quotes.reflect.* + val (bu, du) = utlClosure(typeReprList(TypeRepr.of[UTL])) + '{ + new MappingCoefficientRuntime: + protected val base = ${ Expr(bu) } + protected val derived = ${ Expr(du) } + } + + private def utlClosure(using Quotes)( + utl: List[quotes.reflect.TypeRepr] + ): (Set[RuntimeUnit.UnitType], Map[RuntimeUnit.UnitType, RuntimeUnit]) = + import quotes.reflect.* + utl match + case Nil => emptyClosure + case head :: tail => + val (tbu, tdu) = utlClosure(tail) + val (hbu, hdu) = utClosure(head) + (hbu ++ tbu, hdu ++ tdu) + + private def utClosure(using Quotes)( + tr: quotes.reflect.TypeRepr + ): (Set[RuntimeUnit.UnitType], Map[RuntimeUnit.UnitType, RuntimeUnit]) = + import quotes.reflect.* + tr match + case ConstantType(StringConstant(mname)) => + utlClosure(moduleUnits(mname)) + case AppliedType(op, List(lu, ru)) + if (op =:= TypeRepr.of[coulomb.`*`]) => + val (lbu, ldu) = utClosure(lu) + val (rbu, rdu) = utClosure(ru) + (lbu ++ rbu, ldu ++ rdu) + case AppliedType(op, List(lu, ru)) + if (op =:= TypeRepr.of[coulomb.`/`]) => + val (lbu, ldu) = utClosure(lu) + val (rbu, rdu) = utClosure(ru) + (lbu ++ rbu, ldu ++ rdu) + case AppliedType(op, List(b, _)) + if (op =:= TypeRepr.of[coulomb.`^`]) => + utClosure(b) + case rationalTE(v) => + emptyClosure + case ut => + ut match + case baseunit() => + (Set(typeReprUT(ut)), emptyMap) + case derivedunitTR(dtr) => + val AppliedType(_, List(_, d, _, _)) = dtr: @unchecked + val (dbu, ddu) = utClosure(d) + ( + dbu, + ddu + (typeReprUT(ut) -> typeReprRTU(d)) + ) + case _ => + report.errorAndAbort( + s"closureUT: bad unit type ${ut.show}" + ) + null.asInstanceOf[Nothing] + + def moduleUnits(using Quotes)( + mname: String + ): List[quotes.reflect.TypeRepr] = + import quotes.reflect.* + val msym = fqFieldSymbol(mname) + if (!msym.flags.is(Flags.Module)) + report.errorAndAbort(s"$mname is not a module") + val usyms = msym.typeMembers.filter(isUnitSym) + // dealiasing here because otherwise types exposed via export + // can confuse the mapping. + // see also: typeReprUT function in runtime/infra/meta.scala + usyms.map(_.typeRef.dealias) + + def isUnitSym(using Quotes)(sym: quotes.reflect.Symbol): Boolean = + sym.typeRef match + case baseunit() => true + case derivedunitTR(_) => true + case _ => false + + private val emptyMap = + Map.empty[RuntimeUnit.UnitType, RuntimeUnit] + + private val emptyClosure = + ( + Set.empty[RuntimeUnit.UnitType], + emptyMap + ) diff --git a/runtime/src/main/scala/coulomb/runtime/conversion/runtimes/staging.scala b/runtime/src/main/scala/coulomb/runtime/conversion/runtimes/staging.scala new file mode 100644 index 000000000..f1b5d1bb0 --- /dev/null +++ b/runtime/src/main/scala/coulomb/runtime/conversion/runtimes/staging.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb.conversion.runtimes.staging + +import scala.quoted.staging + +import coulomb.* +import coulomb.infra.runtime.meta +import coulomb.rational.Rational + +// a CoefficientRuntime that leverages a staging compiler to do runtime magic +// it will be possible to define other flavors of CoefficientRuntime that +// do not require staging compiler and so can work with JS and Native +class StagingCoefficientRuntime(using + scmp: staging.Compiler +) extends CoefficientRuntime: + def coefficientRational( + uf: RuntimeUnit, + ut: RuntimeUnit + ): Either[String, Rational] = + meta.coefStaging(uf, ut)(using scmp) + +object StagingCoefficientRuntime: + def apply()(using + scmp: staging.Compiler + ): StagingCoefficientRuntime = + new StagingCoefficientRuntime(using scmp) diff --git a/runtime/src/main/scala/coulomb/runtime/infra/meta.scala b/runtime/src/main/scala/coulomb/runtime/infra/meta.scala new file mode 100644 index 000000000..5b7269b99 --- /dev/null +++ b/runtime/src/main/scala/coulomb/runtime/infra/meta.scala @@ -0,0 +1,199 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb.infra.runtime + +import scala.quoted.* +import scala.util.{Try, Success, Failure} + +import coulomb.* +import coulomb.rational.Rational + +object meta: + import scala.unchecked + import scala.language.implicitConversions + + import coulomb.infra.meta.{*, given} + import coulomb.conversion.coefficients.coefficientRational + + given ctx_RuntimeUnitConstToExpr: ToExpr[RuntimeUnit.UnitConst] with + def apply(uc: RuntimeUnit.UnitConst)(using + Quotes + ): Expr[RuntimeUnit.UnitConst] = + '{ RuntimeUnit.UnitConst(${ Expr(uc.value) }) } + + given ctx_RuntimeUnitTypeToExpr: ToExpr[RuntimeUnit.UnitType] with + def apply(ut: RuntimeUnit.UnitType)(using + Quotes + ): Expr[RuntimeUnit.UnitType] = + '{ RuntimeUnit.UnitType(${ Expr(ut.path) }) } + + given ctx_RuntimeUnitToExpr: ToExpr[RuntimeUnit] with + def apply(rtu: RuntimeUnit)(using Quotes): Expr[RuntimeUnit] = + rtu match + case uc: RuntimeUnit.UnitConst => Expr(uc) + case ut: RuntimeUnit.UnitType => Expr(ut) + case RuntimeUnit.Mul(l, r) => + '{ RuntimeUnit.Mul(${ Expr(l) }, ${ Expr(r) }) } + case RuntimeUnit.Div(n, d) => + '{ RuntimeUnit.Div(${ Expr(n) }, ${ Expr(d) }) } + case RuntimeUnit.Pow(b, e) => + '{ RuntimeUnit.Pow(${ Expr(b) }, ${ Expr(e) }) } + + def coefStaging(uf: RuntimeUnit, ut: RuntimeUnit)(using + staging.Compiler + ): Either[String, Rational] = + Try { + staging.run { + import quotes.reflect.* + (rtuTypeRepr(uf).asType, rtuTypeRepr(ut).asType) match + case ('[f], '[t]) => '{ coefficientRational[f, t] } + } + } match + case Success(coef) => Right(coef) + case Failure(e) => Left(e.getMessage) + + inline def crExpr[UT]( + cr: CoefficientRuntime, + uf: RuntimeUnit + ): Either[String, Rational] = + ${ crExprMeta[UT]('cr, 'uf) } + + def crExprMeta[UT](cr: Expr[CoefficientRuntime], uf: Expr[RuntimeUnit])( + using + Quotes, + Type[UT] + ): Expr[Either[String, Rational]] = + import quotes.reflect.* + val ut = typeReprRTU(TypeRepr.of[UT]) + '{ ${ cr }.coefficientRational($uf, ${ Expr(ut) }) } + + def unitRTU[U](using Quotes, Type[U]): Expr[RuntimeUnit] = + import quotes.reflect.* + Expr(typeReprRTU(TypeRepr.of[U])) + + def rtuTypeRepr(using Quotes)( + rtu: RuntimeUnit + ): quotes.reflect.TypeRepr = + import quotes.reflect.* + rtu match + case RuntimeUnit.UnitConst(value) => rationalTE(value) + case RuntimeUnit.UnitType(path) => fqTypeRepr(path) + case RuntimeUnit.Mul(l, r) => + val ltr = rtuTypeRepr(l) + val rtr = rtuTypeRepr(r) + TypeRepr.of[coulomb.`*`].appliedTo(List(ltr, rtr)) + case RuntimeUnit.Div(n, d) => + val ntr = rtuTypeRepr(n) + val dtr = rtuTypeRepr(d) + TypeRepr.of[coulomb.`/`].appliedTo(List(ntr, dtr)) + case RuntimeUnit.Pow(b, e) => + val btr = rtuTypeRepr(b) + val etr = rationalTE(e) + TypeRepr.of[coulomb.`^`].appliedTo(List(btr, etr)) + + def typeReprRTU(using Quotes)( + tr: quotes.reflect.TypeRepr + ): RuntimeUnit = + import quotes.reflect.* + tr match + case AppliedType(op, List(lu, ru)) + if (op =:= TypeRepr.of[coulomb.`*`]) => + RuntimeUnit.Mul(typeReprRTU(lu), typeReprRTU(ru)) + case AppliedType(op, List(lu, ru)) + if (op =:= TypeRepr.of[coulomb.`/`]) => + RuntimeUnit.Div(typeReprRTU(lu), typeReprRTU(ru)) + case AppliedType(op, List(b, e)) + if (op =:= TypeRepr.of[coulomb.`^`]) => + val rationalTE(ev) = e: @unchecked + RuntimeUnit.Pow(typeReprRTU(b), ev) + case rationalTE(v) => + RuntimeUnit.UnitConst(v) + case ut => typeReprUT(ut) + + def typeReprUT(using Quotes)( + tr: quotes.reflect.TypeRepr + ): RuntimeUnit.UnitType = + import quotes.reflect.* + // should I add checking for types with type-args here? + // de-aliasing here because otherwise type exposed via export + // can confuse the lookup. There might be a use for more complicated + // logic that handles cases where dealiasing is not what I want but + // until I see one I'm going to keep it simple. + // This pairs with dealias in moduleUnits function in mapping.scala + RuntimeUnit.UnitType(tr.dealias.typeSymbol.fullName) + + def fqTypeRepr(using Quotes)(path: String): quotes.reflect.TypeRepr = + fqTypeRepr(path.split('.').toIndexedSeq) + + def fqFieldSymbol(using Quotes)(path: String): quotes.reflect.Symbol = + fqFieldSymbol(path.split('.').toIndexedSeq) + + def fqTypeRepr(using Quotes)( + path: Seq[String] + ): quotes.reflect.TypeRepr = + import quotes.reflect.* + if (path.isEmpty) + report.errorAndAbort("fqTypeRepr: empty path") + TypeRepr.of[Unit] + else + val q = fqFieldSymbol(path.dropRight(1)) + val qt = q.typeMembers.filter(_.name == path.last) + if (qt.length == 1) qt.head.typeRef + else + report.errorAndAbort( + s"""fqTypeRepr: bad path ${path.mkString( + "." + )} at ${path.last}""" + ) + TypeRepr.of[Unit] + + def fqFieldSymbol(using Quotes)( + path: Seq[String] + ): quotes.reflect.Symbol = + import quotes.reflect.* + def work(q: Symbol, tail: Seq[String]): Symbol = + if (tail.isEmpty) q + else + // look for modules first + // this includes packages and objects + val qt = q.declarations.filter { x => + (x.name == tail.head) && x.flags.is(Flags.Module) + } + // there are sometimes two symbols representing a module + // is the difference important to this function? + if (qt.length > 0) work(qt.head, tail.tail) + else + // if no module exists, look for declared symbol + val f = q.declarations.filter { x => + x.name == tail.head + } + // expect a unique field declaration in this case + if ((f.length == 1) && (tail.length == 1)) f.head + else + // if we cannot find a field here, it is a failure + report.errorAndAbort( + s"""fqFieldSymbol: bad path ${path.mkString( + "." + )} at ${tail.head}""" + ) + defn.RootPackage + if (path.isEmpty) + defn.RootPackage + else if (path.head == "_root_") + work(defn.RootPackage, path.tail) + else + work(defn.RootPackage, path) diff --git a/runtime/src/main/scala/coulomb/runtime/runtime.scala b/runtime/src/main/scala/coulomb/runtime/runtime.scala new file mode 100644 index 000000000..9ed0e253b --- /dev/null +++ b/runtime/src/main/scala/coulomb/runtime/runtime.scala @@ -0,0 +1,138 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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 coulomb + +import coulomb.{infra => _, *} +import coulomb.syntax.* +import coulomb.rational.Rational +import coulomb.conversion.* + +sealed abstract class RuntimeUnit: + def *(rhs: RuntimeUnit): RuntimeUnit.Mul = RuntimeUnit.Mul(this, rhs) + def /(den: RuntimeUnit): RuntimeUnit.Div = RuntimeUnit.Div(this, den) + def ^(e: Rational): RuntimeUnit.Pow = RuntimeUnit.Pow(this, e) + + override def toString: String = + def paren(s: String, tl: Boolean): String = + if (tl) s else s"($s)" + def work(u: RuntimeUnit, tl: Boolean = false): String = + u match + case RuntimeUnit.UnitConst(value) => + s"$value" + case RuntimeUnit.UnitType(path) => + path.split('.').last + case RuntimeUnit.Mul(l, r) => + paren(s"${work(l)}*${work(r)}", tl) + case RuntimeUnit.Div(n, d) => + paren(s"${work(n)}/${work(d)}", tl) + case RuntimeUnit.Pow(b, e) => + paren(s"${work(b)}^$e", tl) + work(this, tl = true) + + // evaluate a RuntimeUnit expression whose leaves are + // all UnitConst into a Rational value + def toRational: Either[String, Rational] = + this match + case RuntimeUnit.UnitConst(v) => Right(v) + case RuntimeUnit.Mul(lhs, rhs) => + for { + lv <- lhs.toRational + rv <- rhs.toRational + } yield (lv * rv) + case RuntimeUnit.Div(num, den) => + den.toRational match + case Left(e) => Left(e) + case Right(dv) => + if (dv == Rational.const0) + Left("toRational: div by zero") + else + for { + nv <- num.toRational + } yield (nv / dv) + case RuntimeUnit.Pow(b, e) => + for { + bv <- b.toRational + } yield bv.pow(e) + case _ => + Left(s"toRational: bad rational expression: $this") + +object RuntimeUnit: + case class UnitConst(value: Rational) extends RuntimeUnit + case class UnitType(path: String) extends RuntimeUnit + case class Mul(lhs: RuntimeUnit, rhs: RuntimeUnit) extends RuntimeUnit + case class Div(num: RuntimeUnit, den: RuntimeUnit) extends RuntimeUnit + case class Pow(b: RuntimeUnit, e: Rational) extends RuntimeUnit + inline def of[U]: RuntimeUnit = ${ infra.runtime.meta.unitRTU[U] } + +def runtimeCoefficient[V](uf: RuntimeUnit, ut: RuntimeUnit)(using + crt: CoefficientRuntime, + vc: ValueConversion[Rational, V] +): Either[String, V] = + crt.coefficient[V](uf, ut) + +package syntax { + extension [V](v: V) + inline def withRuntimeUnit(u: RuntimeUnit): RuntimeQuantity[V] = + RuntimeQuantity(v, u) + + inline def withRuntimeUnit[U]: RuntimeQuantity[V] = + RuntimeQuantity(v, RuntimeUnit.of[U]) +} + +case class RuntimeQuantity[V](value: V, unit: RuntimeUnit) + +object RuntimeQuantity: + import algebra.ring.MultiplicativeSemigroup + import coulomb.ops.* + + inline def apply[V, U](q: Quantity[V, U]): RuntimeQuantity[V] = + RuntimeQuantity(q.value, RuntimeUnit.of[U]) + + inline def apply[U](using a: Applier[U]) = a + + class Applier[U]: + inline def apply[V](v: V): RuntimeQuantity[V] = + RuntimeQuantity(v, RuntimeUnit.of[U]) + object Applier: + given ctx_Applier[U]: Applier[U] = new Applier[U] + + extension [VL](ql: RuntimeQuantity[VL]) + inline def toQuantity[VR, UR](using + crt: CoefficientRuntime, + vc: ValueConversion[VL, VR], + vcr: ValueConversion[Rational, VR], + mul: MultiplicativeSemigroup[VR] + ): Either[String, Quantity[VR, UR]] = + crt.coefficient[VR](ql.unit, RuntimeUnit.of[UR]).map { coef => + mul.times(coef, vc(ql.value)).withUnit[UR] + } + +trait CoefficientRuntime: + def coefficientRational( + uf: RuntimeUnit, + ut: RuntimeUnit + ): Either[String, Rational] + + final inline def coefficientRational[UT]( + uf: RuntimeUnit + ): Either[String, Rational] = + infra.runtime.meta.crExpr[UT](this, uf) + + final def coefficient[V](uf: RuntimeUnit, ut: RuntimeUnit)(using + vc: ValueConversion[Rational, V] + ): Either[String, V] = + this.coefficientRational(uf, ut).map(vc) diff --git a/runtime/src/test/scala/coulomb/mappingquantity.scala b/runtime/src/test/scala/coulomb/mappingquantity.scala new file mode 100644 index 000000000..255da5cb2 --- /dev/null +++ b/runtime/src/test/scala/coulomb/mappingquantity.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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. + */ + +import coulomb.testing.CoulombSuite + +import coulomb.units.si.{*, given} +import coulomb.units.si.prefixes.{*, given} +import coulomb.units.us.{*, given} + +import coulomb.CoefficientRuntime +import coulomb.conversion.runtimes.mapping.MappingCoefficientRuntime + +val mappingRT: CoefficientRuntime = + MappingCoefficientRuntime + .of["coulomb.units.si" *: "coulomb.units.si.prefixes" *: EmptyTuple] + +class MappingRuntimeQuantitySuite extends RuntimeQuantitySuite(using mappingRT) diff --git a/runtime/src/test/scala/coulomb/runtimequantity.scala b/runtime/src/test/scala/coulomb/runtimequantity.scala new file mode 100644 index 000000000..d1b6dadd1 --- /dev/null +++ b/runtime/src/test/scala/coulomb/runtimequantity.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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. + */ + +import coulomb.testing.CoulombSuite +import coulomb.CoefficientRuntime + +abstract class RuntimeQuantitySuite(using CoefficientRuntime) + extends CoulombSuite: + import coulomb.* + import coulomb.syntax.* + + import algebra.instances.all.given + import coulomb.ops.algebra.all.{*, given} + + import coulomb.units.si.{*, given} + import coulomb.units.si.prefixes.{*, given} + import coulomb.units.us.{*, given} + + test("runtimeCoefficient") { + import coulomb.policy.strict.given + runtimeCoefficient[Double]( + RuntimeUnit.of[Kilo * Meter], + RuntimeUnit.of[Meter] + ).assertRVT[Double](1000d) + + runtimeCoefficient[Double]( + RuntimeUnit.of[Kilo * Meter], + RuntimeUnit.of[Second] + ).assertL + } + + test("toQuantity") { + import coulomb.policy.strict.given + RuntimeQuantity(1d, RuntimeUnit.of[Kilo * Meter]) + .toQuantity[Float, Meter] + .assertRQ[Float, Meter](1000f) + + RuntimeQuantity(1d, RuntimeUnit.of[Kilo * Meter]) + .toQuantity[Float, Second] + .assertL + } diff --git a/runtime/src/test/scala/coulomb/stagingquantity.scala b/runtime/src/test/scala/coulomb/stagingquantity.scala new file mode 100644 index 000000000..2ce5d9413 --- /dev/null +++ b/runtime/src/test/scala/coulomb/stagingquantity.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Erik Erlandson + * + * 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. + */ + +import coulomb.testing.CoulombSuite + +import scala.quoted.staging +import coulomb.conversion.runtimes.staging.StagingCoefficientRuntime + +import coulomb.CoefficientRuntime + +val compiler: staging.Compiler = + staging.Compiler.make(classOf[staging.Compiler].getClassLoader) + +val stagingRT: CoefficientRuntime = StagingCoefficientRuntime()(using compiler) + +class StagingRuntimeQuantitySuite extends RuntimeQuantitySuite(using stagingRT) diff --git a/spire/src/main/scala/coulomb/ops/resolution/spire.scala b/spire/src/main/scala/coulomb/ops/resolution/spire.scala index 184a9cd68..672d9683e 100644 --- a/spire/src/main/scala/coulomb/ops/resolution/spire.scala +++ b/spire/src/main/scala/coulomb/ops/resolution/spire.scala @@ -20,12 +20,11 @@ object spire: import _root_.spire.math.* import coulomb.ops.ValuePromotionPolicy - import coulomb.ops.ValuePromotion.{&:, TNil} // ValuePromotion infers the transitive closure of all promotions given ctx_vpp_spire: ValuePromotionPolicy[ - (Int, Long) &: (Long, Float) &: (Float, Double) &: - (Double, BigDecimal) &: (BigDecimal, Rational) &: (Long, BigInt) &: - (BigInt, Float) &: (Rational, Algebraic) &: (Algebraic, Real) &: - TNil + (Int, Long) *: (Long, Float) *: (Float, Double) *: + (Double, BigDecimal) *: (BigDecimal, Rational) *: (Long, BigInt) *: + (BigInt, Float) *: (Rational, Algebraic) *: (Algebraic, Real) *: + EmptyTuple ] = ValuePromotionPolicy()