Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 6 additions & 13 deletions modules/api/js/src/main/scala/tausi/api/EventTypes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,8 @@ object EventName:

given CanEqual[EventName, EventName] = CanEqual.derived

// Codec instances for EventName
given Encoder[EventName] = (name: EventName) => name.value.asInstanceOf[js.Any] // scalafix:ok
given Decoder[EventName] = (value: js.Any) =>
val str = value.asInstanceOf[String] // scalafix:ok
EventName(str).left.map(_ => s"Invalid event name: $str")
given Codec[EventName] = new Codec[EventName]:
def encode(value: EventName): js.Any = summon[Encoder[EventName]].encode(value)
def decode(value: js.Any): Either[String, EventName] = summon[Decoder[EventName]].decode(value)
/** Codec instance for EventName using iemap for validation. */
given Codec[EventName] = Codec[String].iemap(EventName.apply)(_.value)
end EventName

/** Data describing a Tauri event emitted from the backend. */
Expand All @@ -82,14 +76,13 @@ opaque type EventId = Int

object EventId:
def unsafe(value: Int): EventId = value

extension (id: EventId) def toInt: Int = id

given CanEqual[EventId, EventId] = CanEqual.derived

given Encoder[EventId] = (id: EventId) => id.toInt.asInstanceOf[js.Any] // scalafix:ok
given Decoder[EventId] = (value: js.Any) => Right(EventId.unsafe(value.asInstanceOf[Int])) // scalafix:ok
given Codec[EventId] = new Codec[EventId]:
def encode(value: EventId): js.Any = summon[Encoder[EventId]].encode(value)
def decode(value: js.Any): Either[String, EventId] = summon[Decoder[EventId]].decode(value)
/** Codec instance for EventId using imap (no validation needed). */
given Codec[EventId] = Codec[Int].imap(EventId.unsafe)(_.toInt)

/** Event targets recognised by Tauri. */
enum EventTarget:
Expand Down
18 changes: 7 additions & 11 deletions modules/api/js/src/main/scala/tausi/api/Types.scala
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,13 @@ object ResourceId:
/** Enable equality comparison for ResourceId */
given CanEqual[ResourceId, ResourceId] = CanEqual.derived

/** Codec instance for ResourceId */
given codec.Codec[ResourceId] = codec.Codec.from(using
new codec.Encoder[ResourceId]:
def encode(id: ResourceId): scalajs.js.Any = id.toInt.asInstanceOf[scalajs.js.Any]
,
new codec.Decoder[ResourceId]:
def decode(value: scalajs.js.Any): Either[String, ResourceId] =
value.asInstanceOf[Int] match
case n if n >= 0 => Right(unsafe(n))
case n => Left(s"ResourceId must be non-negative, got: $n")
) // scalafix:ok
/** Codec instance for ResourceId using iemap for validation. */
given codec.Codec[ResourceId] = codec
.Codec[Int]
.iemap { n =>
if n >= 0 then Right(unsafe(n))
else Left(s"ResourceId must be non-negative, got: $n")
}(_.toInt)

extension (id: ResourceId)
/** Convert ResourceId to its underlying integer value. */
Expand Down
204 changes: 156 additions & 48 deletions modules/api/js/src/main/scala/tausi/api/codec/Codec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,24 @@ import scala.compiletime.summonInline
import scala.deriving.*
import scala.scalajs.js
import scala.scalajs.js.JSConverters.*
import scala.util.boundary
import scala.util.boundary.break

// scalafix:off DisableSyntax.asInstanceOf, DisableSyntax.null, DisableSyntax.while, DisableSyntax.throw, DisableSyntax.var

// ===========================
// Shared Derivation Helpers
// ===========================

// Get field labels as IArray for O(1) indexed access (computed once at derivation)
private inline def getFieldLabelsArray[T <: Tuple]: IArray[String] =
IArray.from(getFieldLabelsList[T])

private inline def getFieldLabelsList[T <: Tuple]: List[String] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (t *: ts) => constValue[t].asInstanceOf[String] :: getFieldLabelsList[ts]

// Check if ADT children have fields (are case classes, not singletons)
private transparent inline def hasFieldsInChildren[T <: Tuple]: Boolean =
inline erasedValue[T] match
Expand Down Expand Up @@ -80,17 +91,31 @@ private inline def ordinalToValue[A, T <: Tuple](ordinal: Int): A =
if ordinal == 0 then summonInline[Mirror.ProductOf[t]].fromProduct(EmptyTuple).asInstanceOf[A]
else ordinalToValue[A, ts](ordinal - 1)

/** Encodes Scala values to JavaScript values for Tauri interoperability. */
/** Encodes Scala values to JavaScript values for Tauri interoperability.
*
* Encoder is a contravariant functor - the type parameter appears in input position.
* Use `contramap` to adapt an existing encoder to work with a different input type.
*
* @example
* {{{
* // Create encoder for opaque type via contramap
* opaque type UserId = String
* object UserId:
* def apply(id: String): UserId = id
* extension (id: UserId) def value: String = id
* given Encoder[UserId] = Encoder[String].contramap(_.value)
* }}}
*/
trait Encoder[A]:
self =>

/** Encode a Scala value to a JavaScript value. */
def encode(value: A): js.Any

/** Create a new encoder that transforms input before encoding.
/** Create a new encoder that transforms input before encoding (contravariant functor).
*
* @param f Function to transform the input type
* @return A new Encoder for type B
* @param f Function to extract the underlying value from B
* @return A new Encoder for type B that delegates to this encoder
*/
def contramap[B](f: B => A): Encoder[B] =
(value: B) => self.encode(f(value))
Expand Down Expand Up @@ -158,20 +183,24 @@ object Encoder:
}
dict.asInstanceOf[js.Any]

// Derivation support
private inline def summonAll[T <: Tuple]: List[Encoder[?]] =
// Derivation support - returns IArray for O(1) indexed access
private inline def summonAllArray[T <: Tuple]: IArray[Encoder[?]] =
IArray.from(summonAllList[T])

private inline def summonAllList[T <: Tuple]: List[Encoder[?]] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (t *: ts) => summonInline[Encoder[t]] :: summonAll[ts]
case _: (t *: ts) => summonInline[Encoder[t]] :: summonAllList[ts]

@nowarn inline def derived[A](using m: Mirror.Of[A]): Encoder[A] =
inline m match
case s: Mirror.SumOf[A] =>
// Hoist labels to derivation time (outside encode method)
val labels = getFieldLabelsArray[m.MirroredElemLabels]
val isSimpleEnum = !hasFieldsInChildren[m.MirroredElemTypes]
new Encoder[A]: // Intentional inline instantiation for compile-time specialization
private val isSimpleEnum = !hasFieldsInChildren[m.MirroredElemTypes]
def encode(value: A): js.Any =
val ordinal = s.ordinal(value)
val labels = getFieldLabels[m.MirroredElemLabels]
if isSimpleEnum then
// Simple enum with no fields - encode as string
labels(ordinal).asInstanceOf[js.Any]
Expand All @@ -183,28 +212,45 @@ object Encoder:
obj("$value") = encoded
obj.asInstanceOf[js.Any]
end encode
end new
case p: Mirror.ProductOf[A] =>
val encoders = summonAll[m.MirroredElemTypes]
// Hoist labels and encoders to derivation time (outside encode method)
val labels = getFieldLabelsArray[m.MirroredElemLabels]
val encoders = summonAllArray[m.MirroredElemTypes]
new Encoder[A]: // Intentional inline instantiation for compile-time specialization
def encode(value: A): js.Any =
val product = value.asInstanceOf[Product]
val labels = getFieldLabels[m.MirroredElemLabels]
val obj = js.Dictionary.empty[js.Any]
var i = 0
while i < encoders.length do
obj(labels(i)) = encoders(i).asInstanceOf[Encoder[Any]].encode(product.productElement(i))
i += 1
obj.asInstanceOf[js.Any]

private inline def getFieldLabels[T <: Tuple]: List[String] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (t *: ts) => constValue[t].asInstanceOf[String] :: getFieldLabels[ts]

extension [A](value: A) def toJS(using enc: Encoder[A]): js.Any = enc.encode(value)
end Encoder

/** Decodes JavaScript values to Scala values for Tauri interoperability. */
/** Decodes JavaScript values to Scala values for Tauri interoperability.
*
* Decoder is a covariant functor - the type parameter appears in output position.
* Use `map` for infallible transformations and `emap` for fallible ones.
*
* @example
* {{{
* // Create decoder for opaque type via map
* opaque type UserId = String
* object UserId:
* def apply(id: String): UserId = id
* given Decoder[UserId] = Decoder[String].map(UserId.apply)
*
* // Create decoder with validation via emap
* opaque type PositiveInt = Int
* object PositiveInt:
* def from(n: Int): Either[String, PositiveInt] =
* if n > 0 then Right(n) else Left(s"Expected positive, got $n")
* given Decoder[PositiveInt] = Decoder[Int].emap(PositiveInt.from)
* }}}
*/
trait Decoder[A]:
self =>

Expand All @@ -214,13 +260,26 @@ trait Decoder[A]:
*/
def decode(value: js.Any): Either[String, A]

/** Create a new decoder that transforms output after decoding.
/** Create a new decoder that transforms output after decoding (covariant functor).
*
* Use this for infallible transformations where the conversion always succeeds.
*
* @param f Function to transform the decoded value
* @return A new Decoder for type B
*/
def map[B](f: A => B): Decoder[B] =
(value: js.Any) => self.decode(value).map(f)

/** Create a new decoder with fallible transformation (effectful map).
*
* Use this when the transformation may fail with a validation error.
* The error message from `f` will be used as the decode error.
*
* @param f Function to validate and transform the decoded value
* @return A new Decoder for type B
*/
def emap[B](f: A => Either[String, B]): Decoder[B] =
(value: js.Any) => self.decode(value).flatMap(f)
end Decoder

object Decoder:
Expand Down Expand Up @@ -328,19 +387,23 @@ object Decoder:
else Left(s"Expected object, got ${js.typeOf(value)}")
end given

// Derivation support
private inline def summonAll[T <: Tuple]: List[Decoder[?]] =
// Derivation support - returns IArray for O(1) indexed access
private inline def summonAllArray[T <: Tuple]: IArray[Decoder[?]] =
IArray.from(summonAllList[T])

private inline def summonAllList[T <: Tuple]: List[Decoder[?]] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (t *: ts) => summonInline[Decoder[t]] :: summonAll[ts]
case _: (t *: ts) => summonInline[Decoder[t]] :: summonAllList[ts]

@nowarn inline def derived[A](using m: Mirror.Of[A]): Decoder[A] =
inline m match
case s: Mirror.SumOf[A] =>
// Hoist labels to derivation time (outside decode method)
val labels = getFieldLabelsArray[m.MirroredElemLabels]
val isSimpleEnum = !hasFieldsInChildren[m.MirroredElemTypes]
new Decoder[A]: // Intentional inline instantiation for compile-time specialization
private val isSimpleEnum = !hasFieldsInChildren[m.MirroredElemTypes]
def decode(value: js.Any): Either[String, A] =
val labels = getFieldLabels[m.MirroredElemLabels]
if isSimpleEnum then
// Simple enum with singleton cases - decode from string
if js.typeOf(value) == "string" then
Expand All @@ -366,44 +429,89 @@ object Decoder:
else Left(s"Expected object for ADT, got ${js.typeOf(value)}")
end if
end decode
end new
case p: Mirror.ProductOf[A] =>
val decoders = summonAll[m.MirroredElemTypes]
// Hoist labels and decoders to derivation time (outside decode method)
val labels = getFieldLabelsArray[m.MirroredElemLabels]
val decoders = summonAllArray[m.MirroredElemTypes]
val fieldCount = labels.length
new Decoder[A]: // Intentional inline instantiation for compile-time specialization
def decode(value: js.Any): Either[String, A] =
if js.typeOf(value) != "object" || value == null then Left(s"Expected object, got ${js.typeOf(value)}")
else
val obj = value.asInstanceOf[js.Dictionary[js.Any]]
val labels = getFieldLabels[m.MirroredElemLabels]

labels
.zip(decoders)
.foldLeft[Either[String, List[Any]]](Right(Nil)) { case (acc, (label, decoder)) =>
acc.flatMap { list =>
val fieldValue = obj.get(label).getOrElse(js.undefined)
decoder
.asInstanceOf[Decoder[Any]]
.decode(fieldValue)
.map(a => list :+ a)
.left
.map(err => s"Field '$label': $err")
}
}
.map(fields => p.fromProduct(Tuple.fromArray(fields.toArray)))
// Use boundary/break for structured early exit with indexed iteration
boundary:
val arr = new Array[Any](fieldCount)
var i = 0
while i < fieldCount do
val label = labels(i)
val fieldValue = obj.get(label).getOrElse(js.undefined)
decoders(i).asInstanceOf[Decoder[Any]].decode(fieldValue) match
case Right(v) => arr(i) = v
case Left(e) => break(Left(s"Field '$label': $e"))
i += 1
Right(p.fromProduct(Tuple.fromArray(arr)))
end new

private inline def getFieldLabels[T <: Tuple]: List[String] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (t *: ts) => constValue[t].asInstanceOf[String] :: getFieldLabels[ts]

extension (value: js.Any) def fromJS[A](using dec: Decoder[A]): Either[String, A] = dec.decode(value)
end Decoder

/** Combined encoder and decoder.
/** Combined encoder and decoder for bidirectional JavaScript/Scala conversions.
*
* Codec is an invariant functor - the type parameter appears in both input and output positions.
* Use `imap` for bidirectional transformations and `iemap` when decoding may fail.
*
* Useful for bidirectional conversions.
* @example
* {{{
* // Simple opaque type wrapping
* opaque type UserId = String
* object UserId:
* def apply(id: String): UserId = id
* extension (id: UserId) def value: String = id
* given Codec[UserId] = Codec[String].imap(UserId.apply)(_.value)
*
* // Validated opaque type with decode-time validation
* opaque type PositiveInt = Int
* object PositiveInt:
* def from(n: Int): Either[String, PositiveInt] =
* if n > 0 then Right(n) else Left(s"Expected positive, got $n")
* def unsafe(n: Int): PositiveInt = n
* extension (n: PositiveInt) def value: Int = n
* given Codec[PositiveInt] = Codec[Int].iemap(PositiveInt.from)(_.value)
* }}}
*/
trait Codec[A] extends Encoder[A], Decoder[A]
trait Codec[A] extends Encoder[A], Decoder[A]:
self =>

/** Bidirectional transformation for invariant mapping.
*
* Use this for opaque types and other simple wrappers where both
* conversion directions are infallible.
*
* @param f Function to construct B from A (used in decoding)
* @param g Function to extract A from B (used in encoding)
* @return A new Codec for type B
*/
def imap[B](f: A => B)(g: B => A): Codec[B] =
new Codec[B]:
def encode(value: B): js.Any = self.encode(g(value))
def decode(value: js.Any): Either[String, B] = self.decode(value).map(f)

/** Bidirectional transformation with fallible decoding.
*
* Use this for validated opaque types where the conversion from the
* underlying type may fail validation.
*
* @param f Function to validate and construct B from A (used in decoding)
* @param g Function to extract A from B (used in encoding)
* @return A new Codec for type B
*/
def iemap[B](f: A => Either[String, B])(g: B => A): Codec[B] =
new Codec[B]:
def encode(value: B): js.Any = self.encode(g(value))
def decode(value: js.Any): Either[String, B] = self.decode(value).flatMap(f)
end Codec

object Codec:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ object image extends ImageCommandsGenerated:
(0 until data.length).foreach(i => arr(i) = data(i).toByte)
arr

given Codec[RgbaData] = Codec[Uint8Array].asInstanceOf[Codec[RgbaData]] // scalafix:ok
given CanEqual[RgbaData, RgbaData] = CanEqual.derived
given Codec[RgbaData] = Codec[Uint8Array].imap(RgbaData.apply)(_.bytes)
end RgbaData

/** Image dimensions */
Expand Down
1 change: 0 additions & 1 deletion modules/api/js/src/test/scala/tausi/api/CoreSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ package tausi.api
import munit.FunSuite

class CoreApiSuite extends FunSuite:
// scalafix:off
test("core.isTauri should return false in non-browser environment"):
// In Node.js test environment, window doesn't exist so isTauri returns false
// In actual Tauri environment, this would return true
Expand Down
Loading