Skip to content

Runtime regression in getkyo/kyo due to changes in Quotes API semantics #24596

@WojciechMazur

Description

@WojciechMazur

Based on the OpenCB failure in getkyo/kyo - build logs

Apparently some runtime semantics have changes since 3.7.4

Issue previously hidden by compiletime regressions: #24097 and #24547

Issue might require further minimization. Changing logic in def immediateParents can workaround this specific problem, but causes new problems elsewhere

Compiler version

Last good release: 3.8.0-RC1-bin-20250817-8c3f1a6-NIGHTLY
First bad release: 3.8.0-RC1-bin-20250818-aaa39c5-NIGHTLY
Bisect does not point to changes that can be an actual cause, more likely regression started to be present after switching to new Scala 3 artifacts (new stdlib)

Minimized code

// Uncomment if testing against original library
// //> using dep "io.getkyo::kyo-data:1.0-RC1"

import kyo.*

@main def Test = {
  val record = ("name" ~ "test")
      .asInstanceOf[Record["name" ~ String]]

  val name: String = record.name
  assert(name == "test")
}
package kyo

import scala.language.dynamics

opaque type Tag[A] = String | Tag.internal.Dynamic

object Tag:
    import internal.*
    import Type.*
    import Type.Entry.*

    inline given derive[A]: Tag[A] = ${ kyo.internal.TagMacro.deriveImpl[A] }

    private[kyo] object internal:
        import Type.*
        import Type.Entry.*

        final case class Type[A](staticDB: Map[Type.Entry.Id, Type.Entry], dynamicDB: Map[Type.Entry.Id, Tag[Any]])

        object Type:
            sealed abstract class Entry extends Serializable with Product derives CanEqual

            object Entry:
                type Id = String

                case object AnyEntry extends Entry
                case object NothingEntry extends Entry
                case object NullEntry extends Entry
                final case class ClassEntry(
                    className: String,
                    params: Seq[Id],
                    parents: Seq[Id]
                ) extends Entry
        end Type

        final case class Dynamic(tag: String, map: Map[Entry.Id, Any]):
            lazy val tpe = Type(decode(tag).staticDB, map.asInstanceOf[Map[Type.Entry.Id, Tag[Any]]])

        def decode(encoded: String): Type[Any] = 
            val lines = encoded.split("\n")
            val staticDB = scala.collection.mutable.Map.empty[Entry.Id, Entry]
            var i = 0
            while i < lines.length do
                val line = lines(i)
                if line.nonEmpty && line.contains(":") then
                    val colonIdx = line.indexOf(':')
                    val id = line.substring(0, colonIdx)
                    val rest = line.substring(colonIdx + 1)
                    staticDB(id) = parseEntry(rest, staticDB)
                i += 1
            Type(staticDB.toMap, Map.empty)

        private def parseEntry(encoded: String, db: scala.collection.mutable.Map[Entry.Id, Entry]): Entry =
            if encoded.startsWith("A") then Entry.AnyEntry
            else if encoded.startsWith("N") then Entry.NothingEntry
            else if encoded.startsWith("U") && encoded.length == 1 then Entry.NullEntry
            else if encoded.startsWith("C:") then
                val parts = encoded.substring(2).split(":")
                val className = parts(0).replace("_colon_", ":")
                val paramCount = if parts.length > 1 then parts(1).toInt else 0
                val paramStart = 2 + paramCount
                val params = (paramStart until paramStart + paramCount).map(i => if i < parts.length then parts(i) else "").filter(_.nonEmpty).toSeq
                val parents = (paramStart + paramCount until parts.length).map(i => parts(i)).filter(_.nonEmpty).toSeq
                Entry.ClassEntry(className, params, parents)
            else
                Entry.AnyEntry

        def encode[A](staticDB: Map[Type.Entry.Id, Type.Entry]): String =
            val concreteFlag = staticDB.get("0") match
                case Some(Entry.ClassEntry(_, params, _)) if params.size == 0 => "*"
                case _ => "."
            concreteFlag + staticDB.map { (id, entry) =>
                s"$id:${encodeEntry(entry)}"
            }.mkString("\n")

        private def encodeEntry(entry: Entry): String =
            entry match
                case Entry.AnyEntry => "A"
                case Entry.NothingEntry => "N"
                case Entry.NullEntry => "U"
                case Entry.ClassEntry(className, params, parents) =>
                    val sanitized = className.replaceAll(":", "_colon_")
                    s"C:$sanitized:${params.size}:${params.mkString(":")}:${parents.mkString(":")}"
                case _ => "A"
    end internal
end Tag

case class Field[Name <: String, Value](name: Name, tag: Tag[Value])

final class Record[+Fields] private (val toMap: Map[Field[?, ?], Any]) extends AnyVal with Dynamic:
    def selectDynamic[Name <: String & Singleton, Value](name: Name)(using
        ev: Fields <:< Name ~ Value,
        tag: Tag[Value]
    ): Value =
        val key = Field(name, tag)
        toMap.get(key).map(_.asInstanceOf[Value]).getOrElse {
            sys.error(s"Not found $key, got: ${toMap.keys.mkString(", ")}")
        }.asInstanceOf[Value]

object Record:
    final infix class ~[Name <: String, Value] private () extends Serializable   
    extension (self: String)
        def ~[Value](value: Value)(using tag: Tag[Value]): Record[self.type ~ Value] =
            Record(Map(Field(self, tag) -> value))
export Record.`~`
package kyo.internal

import kyo.*
import kyo.Tag.*
import kyo.Tag.internal.*
import kyo.Tag.internal.Type.*
import kyo.Tag.internal.Type.Entry.*
import scala.collection.immutable.HashMap
import scala.quoted.{Type as SType, *}

object TagMacro:
    def deriveImpl[A: SType](using Quotes): Expr[String | Tag.internal.Dynamic] =
        import quotes.reflect.*
        val (staticDB, dynamicDB) = deriveDB[A]
        Expr(Tag.internal.encode(staticDB))
        
    private def deriveDB[A: SType](using q: Quotes): (Map[Type.Entry.Id, Type.Entry], Map[Type.Entry.Id, (q.reflect.TypeRepr, Expr[Tag[Any]])]) =
        import quotes.reflect.*
        var nextId = 0
        var seen = Map.empty[TypeRepr | Symbol, (TypeRepr, String)]
        var static = HashMap.empty[Type.Entry.Id, Type.Entry]
        var dynamic = HashMap.empty[Type.Entry.Id, (TypeRepr, Expr[Tag[Any]])]

        def visit(t: TypeRepr): Type.Entry.Id =
            val tpe = t.dealiasKeepOpaques.simplified.dealiasKeepOpaques
            val key =
                tpe.typeSymbol.isNoSymbol match
                    case true => tpe
                    case false =>
                        seen.get(tpe.typeSymbol) match
                            case None => tpe.typeSymbol
                            case Some((t, _)) if t.equals(tpe) => tpe.typeSymbol
                            case _ => tpe
            if seen.contains(key) then
                seen(key)._2
            else
                val id = nextId.toString
                nextId += 1
                seen += key -> (tpe, id)

                def loop(tpe: TypeRepr): Entry =
                    tpe match
                        case tpe if tpe =:= TypeRepr.of[Any] => AnyEntry
                        case tpe if tpe =:= TypeRepr.of[Nothing] => NothingEntry
                        case tpe if tpe =:= TypeRepr.of[Null] => NullEntry
                        case tpe if tpe.typeSymbol.isClassDef =>
                            val symbol = tpe.typeSymbol
                            val name = symbol.fullName
                            val params = tpe.typeArgs.map(visit)
                            // Only parents differ between versions - this causes the bug
                            ClassEntry(
                                name,
                                params,    
                                immediateParents(tpe).map(visit) 
                            )
                        case tpe =>
                            tpe.asType match
                                case '[t] =>
                                    Expr.summon[Tag[t]] match
                                        case Some(tag) =>
                                            dynamic = dynamic.updated(id, tpe -> '{ $tag.asInstanceOf[Tag[Any]] })
                                            null
                                        case None =>
                                            report.errorAndAbort(s"Please provide an implicit kyo.Tag[${tpe.show}] parameter.")

                val entry = loop(tpe)
                if entry != null then
                    static = static.updated(id, loop(tpe))
                id
            end if
        end visit

        visit(TypeRepr.of[A])
        (static, dynamic)
    end deriveDB

    private def immediateParents(using Quotes)(tpe: quotes.reflect.TypeRepr): List[quotes.reflect.TypeRepr] =
        import quotes.reflect.*
        // removing `.tail` would fix the problem here, but would lead to other runtime failure in other places
        val all = tpe.baseClasses.tail.map(tpe.baseType)
        all.filter { parent =>
            !all.exists { otherAncestor =>
                !otherAncestor.equals(parent) && otherAncestor.baseClasses.contains(parent.typeSymbol)
            }
        }

Output

Exception in thread "main" java.lang.RuntimeException: Not found Field(name,*8:C:java.lang.String:0::1:5:6:7:9
4:A
9:C:java.io.Serializable:0::4
5:C:java.lang.constant.Constable:0::2
0:C:java.lang.String:0::1:5:6:7:9
2:C:java.lang.Object:0::3
7:C:java.lang.Comparable:1:8:2
3:C:scala.Matchable:0::4
6:C:java.lang.CharSequence:0::2
1:C:java.lang.constant.ConstantDesc:0::2), got: Field(name,*8:C:java.io.Serializable:0::4
4:A
5:C:java.lang.constant.Constable:0::2
0:C:java.lang.String:0::1:5:6:7:8
2:C:java.lang.Object:0::3
7:C:java.lang.Comparable:1:0:2
3:C:scala.Matchable:0::4
6:C:java.lang.CharSequence:0::2
1:C:java.lang.constant.ConstantDesc:0::2)
        at scala.sys.package$.error(package.scala:28)
        at kyo.Record$.selectDynamic$extension$$anonfun$2(repro.scala:97)
        at scala.Option.getOrElse(Option.scala:203)
        at kyo.Record$.selectDynamic$extension(repro.scala:98)
        at kyo.repro$u002Eusage$package$.Test(repro.usage.scala:11)
        at kyo.Test.main(repro.usage.scala:5)

Expectation

Metadata

Metadata

Assignees

Labels

area:metaprogramming:otherIssues tied to metaprogramming/macros not covered by the other labels.area:metaprogramming:quotesIssues related to quotes and splicesitype:bugregressionThis worked in a previous version but doesn't anymore

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions