From da9dbf3136cc7f63733482d08951e0790f6772b8 Mon Sep 17 00:00:00 2001 From: tarao Date: Mon, 27 Nov 2023 18:06:22 +0900 Subject: [PATCH] Restrict argument type of `concat` to be a concrete type for type safety. --- .../github/tarao/record4s/ArrayRecord.scala | 1 + .../com/github/tarao/record4s/Macros.scala | 22 ++++++++++ .../com/github/tarao/record4s/Record.scala | 1 + .../github/tarao/record4s/typing/Typing.scala | 8 ++++ .../github/tarao/record4s/TypeErrorSpec.scala | 42 ++++++++++++++++++- 5 files changed, 72 insertions(+), 2 deletions(-) diff --git a/modules/core/src/main/scala/com/github/tarao/record4s/ArrayRecord.scala b/modules/core/src/main/scala/com/github/tarao/record4s/ArrayRecord.scala index d686927..2354630 100644 --- a/modules/core/src/main/scala/com/github/tarao/record4s/ArrayRecord.scala +++ b/modules/core/src/main/scala/com/github/tarao/record4s/ArrayRecord.scala @@ -243,6 +243,7 @@ object ArrayRecord extends ArrayRecord.Extensible[EmptyTuple] { other: R2, )(using c: Concat[R, R2]): c.Out = withPotentialTypingError { + summon[typing.Concrete[R2]] val vec = record .__fields .toVector diff --git a/modules/core/src/main/scala/com/github/tarao/record4s/Macros.scala b/modules/core/src/main/scala/com/github/tarao/record4s/Macros.scala index a19fe56..5e6b0d4 100644 --- a/modules/core/src/main/scala/com/github/tarao/record4s/Macros.scala +++ b/modules/core/src/main/scala/com/github/tarao/record4s/Macros.scala @@ -19,6 +19,7 @@ package com.github.tarao.record4s import scala.annotation.nowarn import Record.newMapRecord +import typing.Concrete import typing.Record.{Concat, Lookup, Select, Unselect} @nowarn("msg=unused local") @@ -215,6 +216,27 @@ object Macros { } } + def derivedTypingConcreteImple[T: Type](using + Quotes, + ): Expr[Concrete[T]] = withInternal { + import quotes.reflect.* + import internal.* + + if (TypeRepr.of[T].dealias.typeSymbol.isTypeParam) + errorAndAbort( + Seq( + s"A concrete type expected but type variable ${Type.show[T]} is given.", + "Did you forget to make the method inline?", + ).mkString("\n"), + ) + else + '{ + Concrete + .instance + .asInstanceOf[Concrete[T]] + } + } + private def typeNameOfImpl[T: Type](using Quotes): Expr[String] = { import quotes.reflect.* diff --git a/modules/core/src/main/scala/com/github/tarao/record4s/Record.scala b/modules/core/src/main/scala/com/github/tarao/record4s/Record.scala index 456b0e2..2f8f907 100644 --- a/modules/core/src/main/scala/com/github/tarao/record4s/Record.scala +++ b/modules/core/src/main/scala/com/github/tarao/record4s/Record.scala @@ -129,6 +129,7 @@ object Record { inline def concat[R2: RecordLike, RR <: %]( other: R2, )(using Concat.Aux[R, R2, RR]): RR = withPotentialTypingError { + summon[typing.Concrete[R2]] newMapRecord[RR]( record .__iterable diff --git a/modules/core/src/main/scala/com/github/tarao/record4s/typing/Typing.scala b/modules/core/src/main/scala/com/github/tarao/record4s/typing/Typing.scala index 7edf4c1..5b5f381 100644 --- a/modules/core/src/main/scala/com/github/tarao/record4s/typing/Typing.scala +++ b/modules/core/src/main/scala/com/github/tarao/record4s/typing/Typing.scala @@ -31,6 +31,14 @@ trait MaybeError { type Msg <: String } +final class Concrete[T] private {} +object Concrete { + private[record4s] val instance = new Concrete[Nothing] + + transparent inline given [T]: Concrete[T] = + ${ Macros.derivedTypingConcreteImple } +} + private inline def showTypingError(using err: typing.MaybeError): Unit = { import scala.compiletime.{constValue, erasedValue, error} diff --git a/modules/core/src/test/scala/com/github/tarao/record4s/TypeErrorSpec.scala b/modules/core/src/test/scala/com/github/tarao/record4s/TypeErrorSpec.scala index 704abf5..f7ecb15 100644 --- a/modules/core/src/test/scala/com/github/tarao/record4s/TypeErrorSpec.scala +++ b/modules/core/src/test/scala/com/github/tarao/record4s/TypeErrorSpec.scala @@ -48,6 +48,22 @@ class TypeErrorSpec extends helper.UnitSpec { typing.Record.Concat.Aux[R, % { val name: String }, RR], ): RR = record ++ %(email = email) """ shouldNot typeCheck + + def checkErrors(errs: List[Error]): Unit = { + errs should not be empty + errs.head.kind shouldBe ErrorKind.Typer + val _ = errs.exists( + _.message.startsWith( + "A concrete type expected", + ), + ) shouldBe true + } + + checkErrors(typeCheckErrors(""" + def concat[R1 <: %, R2 <: %, RR <: %](r1: R1, r2: R2)(using + typing.Record.Concat.Aux[R1, R2, RR], + ): RR = r1 ++ r2 + """)) } } @@ -149,16 +165,38 @@ class TypeErrorSpec extends helper.UnitSpec { it("should detect wrong usage") { """ - def addEmail[R, RR <: %](record: ArrayRecord[R], email: String)(using + def addEmail[R, RR <: ProductRecord](record: ArrayRecord[R], email: String)(using typing.ArrayRecord.Concat.Aux[R, Nothing, RR], ): RR = record ++ ArrayRecord(email = email) """ shouldNot typeCheck """ - def addEmail[R, RR <: %](record: ArrayRecord[R], email: String)(using + def addEmail[R, RR <: ProductRecord](record: ArrayRecord[R], email: String)(using typing.ArrayRecord.Concat.Aux[R, ArrayRecord[("name", String) *: EmptyTuple], RR], ): RR = record ++ ArrayRecord(email = email) """ shouldNot typeCheck + + """ + def concat[R1, R2, RR <: ProductRecord](r1: ArrayRecord[R1], r2: ArrayRecord[R2])(using + typing.ArrayRecord.Concat.Aux[R1, R2, RR], + ): RR = r1 ++ r2 + """ shouldNot typeCheck + + def checkErrors(errs: List[Error]): Unit = { + errs should not be empty + errs.head.kind shouldBe ErrorKind.Typer + val _ = errs.exists( + _.message.startsWith( + "A concrete type expected", + ), + ) shouldBe true + } + + checkErrors(typeCheckErrors(""" + def concat[R1, R2 <: %, RR <: ProductRecord](r1: ArrayRecord[R1], r2: R2)(using + typing.ArrayRecord.Concat.Aux[R1, R2, RR], + ): RR = r1 ++ r2 + """)) } }