diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c39a291..b53d5cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,20 @@ permissions: contents: read jobs: + format: + timeout-minutes: 15 + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + submodules: true + - uses: coursier/cache-action@v8 + with: + ignoreJob: true + - uses: VirtusLab/scala-cli-setup@v1 + - run: scala-cli fmt . --check + build: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 95b6996..4064ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,10 @@ target/ project/.boot/ project/.ivy/ project/.sbtboot/ + +.idea/ +.metals/ +.vscode/ +.bsp/ +.bloop/ +.cursor/ diff --git a/agent/src/main/scala/sloth/agent/AgentConfig.scala b/agent/src/main/scala/sloth/agent/AgentConfig.scala index c93cb3c..bc240f8 100644 --- a/agent/src/main/scala/sloth/agent/AgentConfig.scala +++ b/agent/src/main/scala/sloth/agent/AgentConfig.scala @@ -34,13 +34,11 @@ object AgentConfig { /** Parse agent config from the agentArgs string. * - * Format: comma-separated options, e.g.: - * "verbose,include=com.example.,exclude=com.example.internal." + * Format: comma-separated options, e.g.: "verbose,include=com.example.,exclude=com.example.internal." * * Log level options (each sets the scribe log level): * - verbose: Debug level - * - trace: Trace level - * (default is Warn if none specified) + * - trace: Trace level (default is Warn if none specified) */ def parse(agentArgs: String): AgentConfig = { if (agentArgs == null || agentArgs.trim.isEmpty) return AgentConfig() diff --git a/agent/src/main/scala/sloth/agent/SlothAgent.scala b/agent/src/main/scala/sloth/agent/SlothAgent.scala index c0d587d..fdaa76a 100644 --- a/agent/src/main/scala/sloth/agent/SlothAgent.scala +++ b/agent/src/main/scala/sloth/agent/SlothAgent.scala @@ -4,8 +4,8 @@ import java.lang.instrument.Instrumentation /** Java agent entry point for Sloth. * - * Patches Scala 3.0-3.7 lazy val bytecode at class-load time to use - * VarHandle-based implementation (3.8+ format), avoiding sun.misc.Unsafe. + * Patches Scala 3.0-3.7 lazy val bytecode at class-load time to use VarHandle-based implementation (3.8+ format), + * avoiding sun.misc.Unsafe. * * Usage: java -javaagent:sloth-agent.jar[=options] -jar app.jar * diff --git a/agent/src/main/scala/sloth/agent/SlothTransformer.scala b/agent/src/main/scala/sloth/agent/SlothTransformer.scala index 3da5cbd..5fff852 100644 --- a/agent/src/main/scala/sloth/agent/SlothTransformer.scala +++ b/agent/src/main/scala/sloth/agent/SlothTransformer.scala @@ -9,27 +9,29 @@ import sloth.patching.BytecodePatcher /** ClassFileTransformer that patches Scala 3.0-3.7 lazy val bytecode at load time. * - * Uses the group-based patching API to correctly handle companion pairs where - * lazy val implementation is split across object and class files. When one side - * of a companion pair is loaded, the other side is read via getResourceAsStream - * (pure I/O, no class loading) and both are patched together. The companion's - * patched bytes are buffered for when it actually loads. + * Uses the group-based patching API to correctly handle companion pairs where lazy val implementation is split across + * object and class files. When one side of a companion pair is loaded, the other side is read via getResourceAsStream + * (pure I/O, no class loading) and both are patched together. The companion's patched bytes are buffered for when it + * actually loads. */ class SlothTransformer(config: AgentConfig) extends ClassFileTransformer { - /** Packages to always skip (JDK internals, our own code, Scala runtime). - * NOTE: String literals for our own package and scala/runtime/ are constructed - * via StringBuilder/Array to prevent sbt-assembly shade rules from rewriting them. - * The agent receives UNSHADED class names from the JVM. + /** Packages to always skip (JDK internals, our own code, Scala runtime). NOTE: String literals for our own package + * and scala/runtime/ are constructed via StringBuilder/Array to prevent sbt-assembly shade rules from rewriting + * them. The agent receives UNSHADED class names from the JVM. */ private val skipPrefixes = Array( - "java/", "javax/", "jdk/", "sun/", "com/sun/", + "java/", + "javax/", + "jdk/", + "sun/", + "com/sun/", new StringBuilder("sl").append("oth/").toString, new StringBuilder("sca").append("la/runtime/").toString ) - /** Buffer for patched companion bytes. When one side of a companion pair is patched, - * the other side's bytes are stored here and consumed (via remove) when that class loads. + /** Buffer for patched companion bytes. When one side of a companion pair is patched, the other side's bytes are + * stored here and consumed (via remove) when that class loads. */ private val patchedCompanionBuffer = new ConcurrentHashMap[String, Array[Byte]]() @@ -115,7 +117,9 @@ class SlothTransformer(config: AgentConfig) extends ClassFileTransformer { null case Right(groups) => - scribe.debug(s" group() returned ${groups.size} group(s): ${groups.map(g => s"${g.getClass.getSimpleName}(${g.primaryName})").mkString(", ")}") + scribe.debug( + s" group() returned ${groups.size} group(s): ${groups.map(g => s"${g.getClass.getSimpleName}(${g.primaryName})").mkString(", ")}" + ) // There should be exactly one group (single or companion pair) groups.headOption.map(BytecodePatcher.patch(_, classLoader = Some(loader))) match { diff --git a/build.sbt b/build.sbt index 0ca7e3d..f02519d 100644 --- a/build.sbt +++ b/build.sbt @@ -1,16 +1,18 @@ -inThisBuild(List( - organization := "org.virtuslab", - homepage := Some(url("https://github.com/VirtusLab/sloth")), - licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")), - developers := List( - Developer( - "lbialy", - "Łukasz Biały", - "lbialy@virtuslab.com", - url("https://github.com/VirtusLab") +inThisBuild( + List( + organization := "org.virtuslab", + homepage := Some(url("https://github.com/VirtusLab/sloth")), + licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")), + developers := List( + Developer( + "lbialy", + "Łukasz Biały", + "lbialy@virtuslab.com", + url("https://github.com/VirtusLab") + ) ) ) -)) +) lazy val core = project .in(file("core")) @@ -52,7 +54,7 @@ lazy val testops = project assembly / assemblyJarName := "sloth-testops.jar", assembly / assemblyMergeStrategy := { case PathList("META-INF", xs @ _*) => MergeStrategy.discard - case x => MergeStrategy.first + case x => MergeStrategy.first } ) .dependsOn(core) @@ -107,7 +109,7 @@ lazy val cli = project assembly / assemblyJarName := "sloth.jar", assembly / assemblyMergeStrategy := { case PathList("META-INF", xs @ _*) => MergeStrategy.discard - case x => MergeStrategy.first + case x => MergeStrategy.first } ) .dependsOn(core) @@ -133,7 +135,7 @@ lazy val agent = project new RuleTransformer(new RewriteRule { override def transform(node: Node): Seq[Node] = node match { case e: Elem if e.label == "dependencies" => NodeSeq.Empty - case n => n + case n => n } }).transform(node).head }, @@ -152,17 +154,20 @@ lazy val agent = project val fullCp = (cliJar +: depJars).map(_.getAbsolutePath).mkString(java.io.File.pathSeparator) val debugAssembly = sys.env.contains("DEBUG_AGENT_ASSEMBLY") - val processLogger: scala.sys.process.ProcessLogger = if (debugAssembly) - scala.sys.process.ProcessLogger(s => log.info(s), s => log.error(s)) - else scala.sys.process.ProcessLogger(_ => (), _ => ()) + val processLogger: scala.sys.process.ProcessLogger = + if (debugAssembly) + scala.sys.process.ProcessLogger(s => log.info(s), s => log.error(s)) + else scala.sys.process.ProcessLogger(_ => (), _ => ()) val processedFiles = depJars.map { depJar => val dest = processedDir / depJar.getName IO.copyFile(depJar, dest) if (debugAssembly) log.info(s"Processing ${depJar.getName}...") - val exitCode = scala.sys.process.Process( - Seq("java", "-cp", fullCp, "sloth.cli.Main", dest.getAbsolutePath) - ).!(processLogger) + val exitCode = scala.sys.process + .Process( + Seq("java", "-cp", fullCp, "sloth.cli.Main", dest.getAbsolutePath) + ) + .!(processLogger) if (exitCode != 0) { throw new MessageOnlyException(s"Failed to process ${depJar.getName} (exit code $exitCode)") } @@ -182,8 +187,8 @@ lazy val agent = project assembly / assemblyJarName := "sloth-agent.jar", assembly / assemblyMergeStrategy := { case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard - case PathList("META-INF", xs @ _*) => MergeStrategy.discard - case x => MergeStrategy.first + case PathList("META-INF", xs @ _*) => MergeStrategy.discard + case x => MergeStrategy.first }, assembly / assemblyShadeRules := Seq( ShadeRule.rename("sloth.**" -> "sloth.shaded.agent.@0").inAll, diff --git a/cli/src/main/scala/sloth/cli/Main.scala b/cli/src/main/scala/sloth/cli/Main.scala index 623f102..21f495e 100644 --- a/cli/src/main/scala/sloth/cli/Main.scala +++ b/cli/src/main/scala/sloth/cli/Main.scala @@ -160,7 +160,11 @@ object Main { } /** Processes a classfile group */ - private def processGroup(group: ClassfileGroup, targetDir: os.Path, classLoader: ClassLoader): (String, PatchGroupResult) = { + private def processGroup( + group: ClassfileGroup, + targetDir: os.Path, + classLoader: ClassLoader + ): (String, PatchGroupResult) = { val groupName = group.primaryName print(fansi.Color.Cyan(s"Processing: $groupName ... ")) diff --git a/core/src/main/scala/sloth/classfile/ClassfileComparator.scala b/core/src/main/scala/sloth/classfile/ClassfileComparator.scala index a6db445..dd4be73 100644 --- a/core/src/main/scala/sloth/classfile/ClassfileComparator.scala +++ b/core/src/main/scala/sloth/classfile/ClassfileComparator.scala @@ -249,7 +249,7 @@ object ClassfileComparator { bytes1: Option[Array[Byte]] = None, bytes2: Option[Array[Byte]] = None ): Seq[Difference] = compare(class1, class2, bytes1, bytes2) match { - case FullyIdentical => Seq.empty - case Different(diffs) => diffs + case FullyIdentical => Seq.empty + case Different(diffs) => diffs } } diff --git a/core/src/main/scala/sloth/classfile/ClassfileModels.scala b/core/src/main/scala/sloth/classfile/ClassfileModels.scala index 251c49c..fd424f7 100644 --- a/core/src/main/scala/sloth/classfile/ClassfileModels.scala +++ b/core/src/main/scala/sloth/classfile/ClassfileModels.scala @@ -96,12 +96,10 @@ sealed trait ClassfileError { object ClassfileError { /** Error during ASM class reading. */ - final case class ReadError(message: String, cause: Option[Throwable] = None) - extends ClassfileError + final case class ReadError(message: String, cause: Option[Throwable] = None) extends ClassfileError /** Error during bytecode dump generation. */ - final case class DumpError(message: String, cause: Option[Throwable] = None) - extends ClassfileError + final case class DumpError(message: String, cause: Option[Throwable] = None) extends ClassfileError /** Invalid or corrupted classfile. */ final case class InvalidClassfile( diff --git a/core/src/main/scala/sloth/classfile/ClassfileParser.scala b/core/src/main/scala/sloth/classfile/ClassfileParser.scala index 9e7d188..f924d2b 100644 --- a/core/src/main/scala/sloth/classfile/ClassfileParser.scala +++ b/core/src/main/scala/sloth/classfile/ClassfileParser.scala @@ -9,8 +9,8 @@ import java.io.{StringWriter, PrintWriter} /** Parser for Java classfiles using ASM library. * - * Provides composable, testable parsing of classfiles into immutable domain - * models. All methods return Try or Either to avoid throwing exceptions. + * Provides composable, testable parsing of classfiles into immutable domain models. All methods return Try or Either + * to avoid throwing exceptions. */ final class ClassfileParser: import scribe.{info, debug, warn, error} @@ -22,10 +22,10 @@ final class ClassfileParser: * @return * Right with ClassInfo on success, Left with error on failure */ - def parseClassfile(bytes: Array[Byte]): Either[ClassfileError, ClassInfo] = + def parseClassfile(bytes: Array[Byte]): Either[ClassfileError, ClassInfo] = info(s"Starting classfile parse (${bytes.length} bytes)") - for + for classNode <- readClassNode(bytes) fullDump <- generateFullDump(bytes) classInfo <- buildClassInfo(classNode, fullDump) @@ -66,8 +66,6 @@ final class ClassfileParser: .toEither.left.map: ex => error(s"Failed to read classfile", ex) ClassfileError.ReadError(s"Failed to read classfile: ${ex.getMessage}", Some(ex)) - - /** Generates a full textual dump of the classfile. * @@ -132,14 +130,15 @@ final class ClassfileParser: private def parseFields(classNode: ClassNode): Seq[FieldInfo] = val fields = Option(classNode.fields) .map: - _.asScala.map: field => - FieldInfo( - name = field.name, - descriptor = field.desc, - access = field.access, - signature = Option(field.signature) - ) - .toSeq + _.asScala + .map: field => + FieldInfo( + name = field.name, + descriptor = field.desc, + access = field.access, + signature = Option(field.signature) + ) + .toSeq .getOrElse(Seq.empty) debug(s"Parsed ${fields.size} fields") @@ -157,28 +156,28 @@ final class ClassfileParser: private def parseMethods(classNode: ClassNode, fullDump: String): Seq[MethodInfo] = val methods = Option(classNode.methods) .map: - _.asScala.map: method => - val instructions = parseInstructions(method) - val localVars = parseLocalVariables(method) - val exceptions = parseExceptions(method) - val bytecodeText = extractMethodBytecode(fullDump, method.name, method.desc) + _.asScala + .map: method => + val instructions = parseInstructions(method) + val localVars = parseLocalVariables(method) + val exceptions = parseExceptions(method) + val bytecodeText = extractMethodBytecode(fullDump, method.name, method.desc) - MethodInfo( - name = method.name, - descriptor = method.desc, - access = method.access, - signature = Option(method.signature), - instructions = instructions, - localVariables = localVars, - exceptions = exceptions, - bytecodeText = bytecodeText - ) - .toSeq + MethodInfo( + name = method.name, + descriptor = method.desc, + access = method.access, + signature = Option(method.signature), + instructions = instructions, + localVariables = localVars, + exceptions = exceptions, + bytecodeText = bytecodeText + ) + .toSeq .getOrElse(Seq.empty) debug(s"Parsed ${methods.size} methods") methods - /** Parses instruction information from a method. * @@ -191,26 +190,28 @@ final class ClassfileParser: Option(method.instructions) .map: insnList => val insns = insnList.iterator().asScala.toSeq - insns.map: insn => - val opcodeStr = if insn.getOpcode >= 0 then - Try: - val printer = new Textifier() - val methodVisitor = new TraceMethodVisitor(printer) - insn.accept(methodVisitor) - printer.getText.asScala.headOption - .map(_.toString.trim) + insns + .map: insn => + val opcodeStr = + if insn.getOpcode >= 0 then + Try: + val printer = new Textifier() + val methodVisitor = new TraceMethodVisitor(printer) + insn.accept(methodVisitor) + printer.getText.asScala.headOption + .map(_.toString.trim) + .getOrElse("???") .getOrElse("???") - .getOrElse("???") - else "LABEL/FRAME/LINE" + else "LABEL/FRAME/LINE" - InstructionInfo( - opcode = insn.getOpcode, - opcodeString = if insn.getOpcode >= 0 then - OpcodeUtils.opcodeToString(insn.getOpcode) - else "META", - details = opcodeStr - ) - .toSeq + InstructionInfo( + opcode = insn.getOpcode, + opcodeString = + if insn.getOpcode >= 0 then OpcodeUtils.opcodeToString(insn.getOpcode) + else "META", + details = opcodeStr + ) + .toSeq .getOrElse(Seq.empty) /** Parses local variable information from a method. @@ -223,9 +224,10 @@ final class ClassfileParser: private def parseLocalVariables(method: MethodNode): Seq[String] = Option(method.localVariables) .map: lvs => - lvs.asScala.map: lv => - s"${lv.name}: ${lv.desc}" - .toSeq + lvs.asScala + .map: lv => + s"${lv.name}: ${lv.desc}" + .toSeq .getOrElse(Seq.empty) /** Parses exception information from a method. @@ -271,14 +273,12 @@ final class ClassfileParser: }, methodStart + 1 ) - val endIdx = if methodEnd > methodStart then methodEnd else lines.length lines.slice(methodStart, endIdx).mkString("\n") else warn(s"Method $methodName$methodDesc not found in dump") s"Method $methodName$methodDesc not found" - object ClassfileParser: @@ -304,4 +304,3 @@ object ClassfileParser: */ def parseTry(bytes: Array[Byte]): Try[ClassInfo] = ClassfileParser().parseClassfileTry(bytes) - diff --git a/core/src/main/scala/sloth/classfile/OpcodeUtils.scala b/core/src/main/scala/sloth/classfile/OpcodeUtils.scala index 8cf5be4..12b5216 100644 --- a/core/src/main/scala/sloth/classfile/OpcodeUtils.scala +++ b/core/src/main/scala/sloth/classfile/OpcodeUtils.scala @@ -13,163 +13,163 @@ object OpcodeUtils: * The opcode name (e.g., "ALOAD", "INVOKEVIRTUAL") */ def opcodeToString(opcode: Int): String = opcode match - case Opcodes.ALOAD => "ALOAD" - case Opcodes.ASTORE => "ASTORE" - case Opcodes.GETFIELD => "GETFIELD" - case Opcodes.PUTFIELD => "PUTFIELD" - case Opcodes.GETSTATIC => "GETSTATIC" - case Opcodes.PUTSTATIC => "PUTSTATIC" - case Opcodes.INVOKEVIRTUAL => "INVOKEVIRTUAL" - case Opcodes.INVOKESPECIAL => "INVOKESPECIAL" - case Opcodes.INVOKESTATIC => "INVOKESTATIC" - case Opcodes.INVOKEINTERFACE => "INVOKEINTERFACE" - case Opcodes.INVOKEDYNAMIC => "INVOKEDYNAMIC" - case Opcodes.RETURN => "RETURN" - case Opcodes.ARETURN => "ARETURN" - case Opcodes.IRETURN => "IRETURN" - case Opcodes.LRETURN => "LRETURN" - case Opcodes.FRETURN => "FRETURN" - case Opcodes.DRETURN => "DRETURN" - case Opcodes.MONITORENTER => "MONITORENTER" - case Opcodes.MONITOREXIT => "MONITOREXIT" - case Opcodes.NEW => "NEW" - case Opcodes.DUP => "DUP" - case Opcodes.DUP_X1 => "DUP_X1" - case Opcodes.DUP_X2 => "DUP_X2" - case Opcodes.DUP2 => "DUP2" - case Opcodes.DUP2_X1 => "DUP2_X1" - case Opcodes.DUP2_X2 => "DUP2_X2" - case Opcodes.POP => "POP" - case Opcodes.POP2 => "POP2" - case Opcodes.SWAP => "SWAP" - case Opcodes.ICONST_0 => "ICONST_0" - case Opcodes.ICONST_1 => "ICONST_1" - case Opcodes.ICONST_2 => "ICONST_2" - case Opcodes.ICONST_3 => "ICONST_3" - case Opcodes.ICONST_4 => "ICONST_4" - case Opcodes.ICONST_5 => "ICONST_5" - case Opcodes.ICONST_M1 => "ICONST_M1" - case Opcodes.LCONST_0 => "LCONST_0" - case Opcodes.LCONST_1 => "LCONST_1" - case Opcodes.FCONST_0 => "FCONST_0" - case Opcodes.FCONST_1 => "FCONST_1" - case Opcodes.FCONST_2 => "FCONST_2" - case Opcodes.DCONST_0 => "DCONST_0" - case Opcodes.DCONST_1 => "DCONST_1" - case Opcodes.ACONST_NULL => "ACONST_NULL" - case Opcodes.BIPUSH => "BIPUSH" - case Opcodes.SIPUSH => "SIPUSH" - case Opcodes.LDC => "LDC" - case Opcodes.IF_ICMPNE => "IF_ICMPNE" - case Opcodes.IF_ICMPEQ => "IF_ICMPEQ" - case Opcodes.IF_ICMPLT => "IF_ICMPLT" - case Opcodes.IF_ICMPGE => "IF_ICMPGE" - case Opcodes.IF_ICMPGT => "IF_ICMPGT" - case Opcodes.IF_ICMPLE => "IF_ICMPLE" - case Opcodes.IF_ACMPEQ => "IF_ACMPEQ" - case Opcodes.IF_ACMPNE => "IF_ACMPNE" - case Opcodes.IFEQ => "IFEQ" - case Opcodes.IFNE => "IFNE" - case Opcodes.IFLT => "IFLT" - case Opcodes.IFGE => "IFGE" - case Opcodes.IFGT => "IFGT" - case Opcodes.IFLE => "IFLE" - case Opcodes.IFNULL => "IFNULL" - case Opcodes.IFNONNULL => "IFNONNULL" - case Opcodes.GOTO => "GOTO" - case Opcodes.JSR => "JSR" - case Opcodes.RET => "RET" - case Opcodes.TABLESWITCH => "TABLESWITCH" - case Opcodes.LOOKUPSWITCH => "LOOKUPSWITCH" - case Opcodes.ILOAD => "ILOAD" - case Opcodes.LLOAD => "LLOAD" - case Opcodes.FLOAD => "FLOAD" - case Opcodes.DLOAD => "DLOAD" - case Opcodes.ISTORE => "ISTORE" - case Opcodes.LSTORE => "LSTORE" - case Opcodes.FSTORE => "FSTORE" - case Opcodes.DSTORE => "DSTORE" - case Opcodes.IALOAD => "IALOAD" - case Opcodes.LALOAD => "LALOAD" - case Opcodes.FALOAD => "FALOAD" - case Opcodes.DALOAD => "DALOAD" - case Opcodes.AALOAD => "AALOAD" - case Opcodes.BALOAD => "BALOAD" - case Opcodes.CALOAD => "CALOAD" - case Opcodes.SALOAD => "SALOAD" - case Opcodes.IASTORE => "IASTORE" - case Opcodes.LASTORE => "LASTORE" - case Opcodes.FASTORE => "FASTORE" - case Opcodes.DASTORE => "DASTORE" - case Opcodes.AASTORE => "AASTORE" - case Opcodes.BASTORE => "BASTORE" - case Opcodes.CASTORE => "CASTORE" - case Opcodes.SASTORE => "SASTORE" - case Opcodes.IADD => "IADD" - case Opcodes.LADD => "LADD" - case Opcodes.FADD => "FADD" - case Opcodes.DADD => "DADD" - case Opcodes.ISUB => "ISUB" - case Opcodes.LSUB => "LSUB" - case Opcodes.FSUB => "FSUB" - case Opcodes.DSUB => "DSUB" - case Opcodes.IMUL => "IMUL" - case Opcodes.LMUL => "LMUL" - case Opcodes.FMUL => "FMUL" - case Opcodes.DMUL => "DMUL" - case Opcodes.IDIV => "IDIV" - case Opcodes.LDIV => "LDIV" - case Opcodes.FDIV => "FDIV" - case Opcodes.DDIV => "DDIV" - case Opcodes.IREM => "IREM" - case Opcodes.LREM => "LREM" - case Opcodes.FREM => "FREM" - case Opcodes.DREM => "DREM" - case Opcodes.INEG => "INEG" - case Opcodes.LNEG => "LNEG" - case Opcodes.FNEG => "FNEG" - case Opcodes.DNEG => "DNEG" - case Opcodes.ISHL => "ISHL" - case Opcodes.LSHL => "LSHL" - case Opcodes.ISHR => "ISHR" - case Opcodes.LSHR => "LSHR" - case Opcodes.IUSHR => "IUSHR" - case Opcodes.LUSHR => "LUSHR" - case Opcodes.IAND => "IAND" - case Opcodes.LAND => "LAND" - case Opcodes.IOR => "IOR" - case Opcodes.LOR => "LOR" - case Opcodes.IXOR => "IXOR" - case Opcodes.LXOR => "LXOR" - case Opcodes.IINC => "IINC" - case Opcodes.I2L => "I2L" - case Opcodes.I2F => "I2F" - case Opcodes.I2D => "I2D" - case Opcodes.L2I => "L2I" - case Opcodes.L2F => "L2F" - case Opcodes.L2D => "L2D" - case Opcodes.F2I => "F2I" - case Opcodes.F2L => "F2L" - case Opcodes.F2D => "F2D" - case Opcodes.D2I => "D2I" - case Opcodes.D2L => "D2L" - case Opcodes.D2F => "D2F" - case Opcodes.I2B => "I2B" - case Opcodes.I2C => "I2C" - case Opcodes.I2S => "I2S" - case Opcodes.LCMP => "LCMP" - case Opcodes.FCMPL => "FCMPL" - case Opcodes.FCMPG => "FCMPG" - case Opcodes.DCMPL => "DCMPL" - case Opcodes.DCMPG => "DCMPG" - case Opcodes.CHECKCAST => "CHECKCAST" - case Opcodes.INSTANCEOF => "INSTANCEOF" - case Opcodes.ARRAYLENGTH => "ARRAYLENGTH" - case Opcodes.ATHROW => "ATHROW" - case Opcodes.NEWARRAY => "NEWARRAY" - case Opcodes.ANEWARRAY => "ANEWARRAY" - case Opcodes.MULTIANEWARRAY => "MULTIANEWARRAY" - case _ => s"OPCODE_$opcode" + case Opcodes.ALOAD => "ALOAD" + case Opcodes.ASTORE => "ASTORE" + case Opcodes.GETFIELD => "GETFIELD" + case Opcodes.PUTFIELD => "PUTFIELD" + case Opcodes.GETSTATIC => "GETSTATIC" + case Opcodes.PUTSTATIC => "PUTSTATIC" + case Opcodes.INVOKEVIRTUAL => "INVOKEVIRTUAL" + case Opcodes.INVOKESPECIAL => "INVOKESPECIAL" + case Opcodes.INVOKESTATIC => "INVOKESTATIC" + case Opcodes.INVOKEINTERFACE => "INVOKEINTERFACE" + case Opcodes.INVOKEDYNAMIC => "INVOKEDYNAMIC" + case Opcodes.RETURN => "RETURN" + case Opcodes.ARETURN => "ARETURN" + case Opcodes.IRETURN => "IRETURN" + case Opcodes.LRETURN => "LRETURN" + case Opcodes.FRETURN => "FRETURN" + case Opcodes.DRETURN => "DRETURN" + case Opcodes.MONITORENTER => "MONITORENTER" + case Opcodes.MONITOREXIT => "MONITOREXIT" + case Opcodes.NEW => "NEW" + case Opcodes.DUP => "DUP" + case Opcodes.DUP_X1 => "DUP_X1" + case Opcodes.DUP_X2 => "DUP_X2" + case Opcodes.DUP2 => "DUP2" + case Opcodes.DUP2_X1 => "DUP2_X1" + case Opcodes.DUP2_X2 => "DUP2_X2" + case Opcodes.POP => "POP" + case Opcodes.POP2 => "POP2" + case Opcodes.SWAP => "SWAP" + case Opcodes.ICONST_0 => "ICONST_0" + case Opcodes.ICONST_1 => "ICONST_1" + case Opcodes.ICONST_2 => "ICONST_2" + case Opcodes.ICONST_3 => "ICONST_3" + case Opcodes.ICONST_4 => "ICONST_4" + case Opcodes.ICONST_5 => "ICONST_5" + case Opcodes.ICONST_M1 => "ICONST_M1" + case Opcodes.LCONST_0 => "LCONST_0" + case Opcodes.LCONST_1 => "LCONST_1" + case Opcodes.FCONST_0 => "FCONST_0" + case Opcodes.FCONST_1 => "FCONST_1" + case Opcodes.FCONST_2 => "FCONST_2" + case Opcodes.DCONST_0 => "DCONST_0" + case Opcodes.DCONST_1 => "DCONST_1" + case Opcodes.ACONST_NULL => "ACONST_NULL" + case Opcodes.BIPUSH => "BIPUSH" + case Opcodes.SIPUSH => "SIPUSH" + case Opcodes.LDC => "LDC" + case Opcodes.IF_ICMPNE => "IF_ICMPNE" + case Opcodes.IF_ICMPEQ => "IF_ICMPEQ" + case Opcodes.IF_ICMPLT => "IF_ICMPLT" + case Opcodes.IF_ICMPGE => "IF_ICMPGE" + case Opcodes.IF_ICMPGT => "IF_ICMPGT" + case Opcodes.IF_ICMPLE => "IF_ICMPLE" + case Opcodes.IF_ACMPEQ => "IF_ACMPEQ" + case Opcodes.IF_ACMPNE => "IF_ACMPNE" + case Opcodes.IFEQ => "IFEQ" + case Opcodes.IFNE => "IFNE" + case Opcodes.IFLT => "IFLT" + case Opcodes.IFGE => "IFGE" + case Opcodes.IFGT => "IFGT" + case Opcodes.IFLE => "IFLE" + case Opcodes.IFNULL => "IFNULL" + case Opcodes.IFNONNULL => "IFNONNULL" + case Opcodes.GOTO => "GOTO" + case Opcodes.JSR => "JSR" + case Opcodes.RET => "RET" + case Opcodes.TABLESWITCH => "TABLESWITCH" + case Opcodes.LOOKUPSWITCH => "LOOKUPSWITCH" + case Opcodes.ILOAD => "ILOAD" + case Opcodes.LLOAD => "LLOAD" + case Opcodes.FLOAD => "FLOAD" + case Opcodes.DLOAD => "DLOAD" + case Opcodes.ISTORE => "ISTORE" + case Opcodes.LSTORE => "LSTORE" + case Opcodes.FSTORE => "FSTORE" + case Opcodes.DSTORE => "DSTORE" + case Opcodes.IALOAD => "IALOAD" + case Opcodes.LALOAD => "LALOAD" + case Opcodes.FALOAD => "FALOAD" + case Opcodes.DALOAD => "DALOAD" + case Opcodes.AALOAD => "AALOAD" + case Opcodes.BALOAD => "BALOAD" + case Opcodes.CALOAD => "CALOAD" + case Opcodes.SALOAD => "SALOAD" + case Opcodes.IASTORE => "IASTORE" + case Opcodes.LASTORE => "LASTORE" + case Opcodes.FASTORE => "FASTORE" + case Opcodes.DASTORE => "DASTORE" + case Opcodes.AASTORE => "AASTORE" + case Opcodes.BASTORE => "BASTORE" + case Opcodes.CASTORE => "CASTORE" + case Opcodes.SASTORE => "SASTORE" + case Opcodes.IADD => "IADD" + case Opcodes.LADD => "LADD" + case Opcodes.FADD => "FADD" + case Opcodes.DADD => "DADD" + case Opcodes.ISUB => "ISUB" + case Opcodes.LSUB => "LSUB" + case Opcodes.FSUB => "FSUB" + case Opcodes.DSUB => "DSUB" + case Opcodes.IMUL => "IMUL" + case Opcodes.LMUL => "LMUL" + case Opcodes.FMUL => "FMUL" + case Opcodes.DMUL => "DMUL" + case Opcodes.IDIV => "IDIV" + case Opcodes.LDIV => "LDIV" + case Opcodes.FDIV => "FDIV" + case Opcodes.DDIV => "DDIV" + case Opcodes.IREM => "IREM" + case Opcodes.LREM => "LREM" + case Opcodes.FREM => "FREM" + case Opcodes.DREM => "DREM" + case Opcodes.INEG => "INEG" + case Opcodes.LNEG => "LNEG" + case Opcodes.FNEG => "FNEG" + case Opcodes.DNEG => "DNEG" + case Opcodes.ISHL => "ISHL" + case Opcodes.LSHL => "LSHL" + case Opcodes.ISHR => "ISHR" + case Opcodes.LSHR => "LSHR" + case Opcodes.IUSHR => "IUSHR" + case Opcodes.LUSHR => "LUSHR" + case Opcodes.IAND => "IAND" + case Opcodes.LAND => "LAND" + case Opcodes.IOR => "IOR" + case Opcodes.LOR => "LOR" + case Opcodes.IXOR => "IXOR" + case Opcodes.LXOR => "LXOR" + case Opcodes.IINC => "IINC" + case Opcodes.I2L => "I2L" + case Opcodes.I2F => "I2F" + case Opcodes.I2D => "I2D" + case Opcodes.L2I => "L2I" + case Opcodes.L2F => "L2F" + case Opcodes.L2D => "L2D" + case Opcodes.F2I => "F2I" + case Opcodes.F2L => "F2L" + case Opcodes.F2D => "F2D" + case Opcodes.D2I => "D2I" + case Opcodes.D2L => "D2L" + case Opcodes.D2F => "D2F" + case Opcodes.I2B => "I2B" + case Opcodes.I2C => "I2C" + case Opcodes.I2S => "I2S" + case Opcodes.LCMP => "LCMP" + case Opcodes.FCMPL => "FCMPL" + case Opcodes.FCMPG => "FCMPG" + case Opcodes.DCMPL => "DCMPL" + case Opcodes.DCMPG => "DCMPG" + case Opcodes.CHECKCAST => "CHECKCAST" + case Opcodes.INSTANCEOF => "INSTANCEOF" + case Opcodes.ARRAYLENGTH => "ARRAYLENGTH" + case Opcodes.ATHROW => "ATHROW" + case Opcodes.NEWARRAY => "NEWARRAY" + case Opcodes.ANEWARRAY => "ANEWARRAY" + case Opcodes.MULTIANEWARRAY => "MULTIANEWARRAY" + case _ => s"OPCODE_$opcode" /** Converts access flags to a human-readable string. * @@ -178,7 +178,7 @@ object OpcodeUtils: * @return * Space-separated access modifiers (e.g., "public static final") */ - def accessFlagsToString(access: Int): String = + def accessFlagsToString(access: Int): String = val flags = Seq( (Opcodes.ACC_PUBLIC, "public"), (Opcodes.ACC_PRIVATE, "private"), diff --git a/core/src/main/scala/sloth/jar/JarProcessor.scala b/core/src/main/scala/sloth/jar/JarProcessor.scala index b08b81a..351fe8c 100644 --- a/core/src/main/scala/sloth/jar/JarProcessor.scala +++ b/core/src/main/scala/sloth/jar/JarProcessor.scala @@ -75,7 +75,6 @@ object JarProcessor: nameToEntryPath.get(clsName).foreach(ep => patchedBytes(ep) = clsBytes) case BytecodePatcher.PatchResult.NotApplicable => // nothing to do - case BytecodePatcher.PatchResult.Failed(error) => errors += s"Failed to patch group ${group.primaryName}: $error" catch diff --git a/core/src/main/scala/sloth/lazyval/LazyValComparator.scala b/core/src/main/scala/sloth/lazyval/LazyValComparator.scala index d67f03a..a86dd44 100644 --- a/core/src/main/scala/sloth/lazyval/LazyValComparator.scala +++ b/core/src/main/scala/sloth/lazyval/LazyValComparator.scala @@ -75,17 +75,19 @@ object LazyValDifference: /** Compares lazy val implementations between two classes. * - * Focuses specifically on lazy val implementation patterns, - * ignoring other bytecode differences. + * Focuses specifically on lazy val implementation patterns, ignoring other bytecode differences. */ final class LazyValComparator: import scribe.{info, debug} /** Compares lazy val implementations in two classes. * - * @param class1 First class - * @param class2 Second class - * @return Comparison result focusing on lazy vals + * @param class1 + * First class + * @param class2 + * Second class + * @return + * Comparison result focusing on lazy vals */ def compare( class1: ClassInfo, @@ -136,16 +138,16 @@ final class LazyValComparator: /** Extracts lazy vals from detection result. */ private def extractLazyVals(result: LazyValDetectionResult): Seq[LazyValInfo] = result match - case LazyValDetectionResult.NoLazyVals => Seq.empty + case LazyValDetectionResult.NoLazyVals => Seq.empty case LazyValDetectionResult.LazyValsFound(lazyVals, _) => lazyVals - case LazyValDetectionResult.MixedVersions(lazyVals) => lazyVals + case LazyValDetectionResult.MixedVersions(lazyVals) => lazyVals /** Gets overall version from detection result. */ private def getOverallVersion(result: LazyValDetectionResult): ScalaVersion = result match - case LazyValDetectionResult.NoLazyVals => ScalaVersion.Unknown("no lazy vals detected") + case LazyValDetectionResult.NoLazyVals => ScalaVersion.Unknown("no lazy vals detected") case LazyValDetectionResult.LazyValsFound(_, version) => version - case LazyValDetectionResult.MixedVersions(lazyVals) => + case LazyValDetectionResult.MixedVersions(lazyVals) => // Return most common version lazyVals.groupBy(_.version).maxBy(_._2.size)._1 @@ -221,19 +223,19 @@ final class LazyValComparator: (lv1.offsetField, lv2.offsetField) match case (Some(_), None) => diffs += "offset field only in first" case (None, Some(_)) => diffs += "offset field only in second" - case _ => () + case _ => () // Check bitmap field (lv1.bitmapField, lv2.bitmapField) match case (Some(_), None) => diffs += "bitmap field only in first" case (None, Some(_)) => diffs += "bitmap field only in second" - case _ => () + case _ => () // Check VarHandle field (lv1.varHandleField, lv2.varHandleField) match case (Some(_), None) => diffs += "VarHandle field only in first" case (None, Some(_)) => diffs += "VarHandle field only in second" - case _ => () + case _ => () // Check storage field descriptor if lv1.storageField.descriptor != lv2.storageField.descriptor then diff --git a/core/src/main/scala/sloth/lazyval/LazyValDetector.scala b/core/src/main/scala/sloth/lazyval/LazyValDetector.scala index 86276e7..c5f5ec0 100644 --- a/core/src/main/scala/sloth/lazyval/LazyValDetector.scala +++ b/core/src/main/scala/sloth/lazyval/LazyValDetector.scala @@ -193,9 +193,12 @@ final class LazyValDetector: ) version match - case ScalaVersion.Unknown(_) if offsetField.isEmpty && bitmapField.isEmpty && - varHandleField.isEmpty && initMethod.isEmpty && !isVolatile(storageField.access) => - debug(s"Skipping field ${storageField.name}: matches $$lzy pattern but has no lazy val infrastructure (likely eager companion object reference)") + case ScalaVersion.Unknown(_) + if offsetField.isEmpty && bitmapField.isEmpty && + varHandleField.isEmpty && initMethod.isEmpty && !isVolatile(storageField.access) => + debug( + s"Skipping field ${storageField.name}: matches $$lzy pattern but has no lazy val infrastructure (likely eager companion object reference)" + ) None case _ => Some( @@ -230,8 +233,8 @@ final class LazyValDetector: * Uses the offset mapping built from bytecode to find the correct OFFSET field. Searches in both current * class and companion class fields. * - * For bitmap-based lazy vals (3.0-3.2), the OFFSET maps to bitmap field (e.g., "0bitmap$1"). - * For unsafe/varhandle-based lazy vals (3.3+), the OFFSET maps to storage field (e.g., "a$lzy1"). + * For bitmap-based lazy vals (3.0-3.2), the OFFSET maps to bitmap field (e.g., "0bitmap$1"). For + * unsafe/varhandle-based lazy vals (3.3+), the OFFSET maps to storage field (e.g., "a$lzy1"). * * @return * Tuple of (offsetField, location) where location indicates which class contains the field @@ -286,8 +289,8 @@ final class LazyValDetector: /** Finds the bitmap field that corresponds to a storage field. * - * For 3.0.x-3.2.x, the bitmap field is referenced in instead of the lzy field. - * The bitmap field has the same index as the storage field: "a$lzy1" -> "0bitmap$1" + * For 3.0.x-3.2.x, the bitmap field is referenced in instead of the lzy field. The bitmap field has the + * same index as the storage field: "a$lzy1" -> "0bitmap$1" */ private def findBitmapFieldForStorage( storageFieldName: String, @@ -365,7 +368,7 @@ final class LazyValDetector: // The is in the class that contains the OFFSET field val clinitClassInfo = offsetFieldLocation match case OffsetFieldLocation.InCompanionClass => companionClassInfo.getOrElse(classInfo) - case _ => classInfo + case _ => classInfo debug(s"Looking for in ${clinitClassInfo.name} (offsetLocation=$offsetFieldLocation)") val clinit = clinitClassInfo.methods.find(_.name == "") @@ -376,7 +379,9 @@ final class LazyValDetector: val hasGetOffsetStatic = bytecode.contains("getOffsetStatic") val hasLazyValsGetOffset = bytecode.contains("LazyVals$.getOffset (") - debug(s" checks: getDeclaredField=$hasDeclaredField, getOffsetStatic=$hasGetOffsetStatic, LazyVals.getOffset=$hasLazyValsGetOffset") + debug( + s" checks: getDeclaredField=$hasDeclaredField, getOffsetStatic=$hasGetOffsetStatic, LazyVals.getOffset=$hasLazyValsGetOffset" + ) if hasDeclaredField && hasGetOffsetStatic then debug("Uses getDeclaredField + getOffsetStatic -> Scala 3.2.x") @@ -407,7 +412,8 @@ final class LazyValDetector: debug("Uses objCAS with Object field -> Scala 3.3.x-3.7.x") ScalaVersion.Scala33x_37x else - val reason = s"Object field with OFFSET but no objCAS in initMethod ${method.name}. Bytecode: ${method.bytecodeText.take(500)}" + val reason = + s"Object field with OFFSET but no objCAS in initMethod ${method.name}. Bytecode: ${method.bytecodeText.take(500)}" warn(reason) ScalaVersion.Unknown(reason) case None => @@ -419,7 +425,8 @@ final class LazyValDetector: debug(s" offsetField=${offsetField.isDefined} initMethod=${initMethod.isDefined}") debug(s" descriptor=${storageField.descriptor} (expected: Ljava/lang/Object;)") debug(s" isVolatile=${isVolatile(storageField.access)}") - val reason = s"No pattern matched. offsetField=${offsetField.isDefined} initMethod=${initMethod.isDefined} descriptor=${storageField.descriptor} isVolatile=${isVolatile(storageField.access)}" + val reason = + s"No pattern matched. offsetField=${offsetField.isDefined} initMethod=${initMethod.isDefined} descriptor=${storageField.descriptor} isVolatile=${isVolatile(storageField.access)}" ScalaVersion.Unknown(reason) /** Checks if access flags indicate a static field. */ diff --git a/core/src/main/scala/sloth/lazyval/LazyValModels.scala b/core/src/main/scala/sloth/lazyval/LazyValModels.scala index ceed258..e1b243d 100644 --- a/core/src/main/scala/sloth/lazyval/LazyValModels.scala +++ b/core/src/main/scala/sloth/lazyval/LazyValModels.scala @@ -32,15 +32,15 @@ enum ScalaVersion: def isLegacy: Boolean = this match case Scala30x_31x | Scala32x | Scala33x_37x => true - case _ => false + case _ => false def isBitmapBased: Boolean = this match case Scala30x_31x | Scala32x => true - case _ => false + case _ => false def needsTransformation: Boolean = this match case Scala30x_31x | Scala32x | Scala33x_37x => true - case _ => false + case _ => false /** Information about a single lazy val found in a class. */ final case class LazyValInfo( diff --git a/core/src/main/scala/sloth/lazyval/SemanticLazyValComparator.scala b/core/src/main/scala/sloth/lazyval/SemanticLazyValComparator.scala index b3d108c..2f777e1 100644 --- a/core/src/main/scala/sloth/lazyval/SemanticLazyValComparator.scala +++ b/core/src/main/scala/sloth/lazyval/SemanticLazyValComparator.scala @@ -23,29 +23,30 @@ object SemanticLazyValComparisonResult: def areIdentical: Boolean = true /** Only one class has lazy vals (different) */ - final case class OnlyOneHasLazyVals(firstHas: Boolean, count: Int) - extends SemanticLazyValComparisonResult: + final case class OnlyOneHasLazyVals(firstHas: Boolean, count: Int) extends SemanticLazyValComparisonResult: def areIdentical: Boolean = false /** Semantic comparator for lazy val implementations. * - * Compares lazy vals based on their implementation patterns, - * ignoring all other bytecode differences. + * Compares lazy vals based on their implementation patterns, ignoring all other bytecode differences. * * Version-aware comparison: - * - 3.0.x/3.1.x use same pattern (bitmap-based with getOffset) - * - 3.2.x uses similar bitmap pattern (with getDeclaredField) - * - 3.3-3.7.x use same pattern (object-based with Unsafe) - * - 3.8+ uses new pattern (object-based with VarHandle) + * - 3.0.x/3.1.x use same pattern (bitmap-based with getOffset) + * - 3.2.x uses similar bitmap pattern (with getDeclaredField) + * - 3.3-3.7.x use same pattern (object-based with Unsafe) + * - 3.8+ uses new pattern (object-based with VarHandle) */ final class SemanticLazyValComparator: import scribe.{debug, trace} /** Compares lazy val implementations semantically. * - * @param class1 First class - * @param class2 Second class - * @return Whether lazy val implementations are identical + * @param class1 + * First class + * @param class2 + * Second class + * @return + * Whether lazy val implementations are identical */ def compare( class1: ClassInfo, @@ -85,9 +86,9 @@ final class SemanticLazyValComparator: /** Extracts lazy vals from detection result. */ private def extractLazyVals(result: LazyValDetectionResult): Seq[LazyValInfo] = result match - case LazyValDetectionResult.NoLazyVals => Seq.empty - case LazyValDetectionResult.LazyValsFound(lazyVals, _) => lazyVals - case LazyValDetectionResult.MixedVersions(lazyVals) => lazyVals + case LazyValDetectionResult.NoLazyVals => Seq.empty + case LazyValDetectionResult.LazyValsFound(lazyVals, _) => lazyVals + case LazyValDetectionResult.MixedVersions(lazyVals) => lazyVals /** Compares two sequences of lazy vals semantically. */ private def compareImplementations( @@ -108,11 +109,9 @@ final class SemanticLazyValComparator: val onlyIn1 = map1.keySet -- map2.keySet val onlyIn2 = map2.keySet -- map1.keySet - if onlyIn1.nonEmpty then - reasons += s"Lazy vals only in first: ${onlyIn1.mkString(", ")}" + if onlyIn1.nonEmpty then reasons += s"Lazy vals only in first: ${onlyIn1.mkString(", ")}" - if onlyIn2.nonEmpty then - reasons += s"Lazy vals only in second: ${onlyIn2.mkString(", ")}" + if onlyIn2.nonEmpty then reasons += s"Lazy vals only in second: ${onlyIn2.mkString(", ")}" // Compare lazy vals present in both val common = map1.keySet.intersect(map2.keySet) @@ -127,15 +126,13 @@ final class SemanticLazyValComparator: val containingClass2 = determineContainingClass(lv2, class2, companion2) // For OFFSET initialization, check companion class if OFFSET is located there - val offsetClass1 = if lv1.offsetFieldLocation == OffsetFieldLocation.InCompanionClass then - companion1.getOrElse(containingClass1) - else - containingClass1 + val offsetClass1 = + if lv1.offsetFieldLocation == OffsetFieldLocation.InCompanionClass then companion1.getOrElse(containingClass1) + else containingClass1 - val offsetClass2 = if lv2.offsetFieldLocation == OffsetFieldLocation.InCompanionClass then - companion2.getOrElse(containingClass2) - else - containingClass2 + val offsetClass2 = + if lv2.offsetFieldLocation == OffsetFieldLocation.InCompanionClass then companion2.getOrElse(containingClass2) + else containingClass2 // Extract canonical pattern for each val pattern1 = extractCanonicalPattern(lv1, containingClass1, offsetClass1) @@ -145,8 +142,7 @@ final class SemanticLazyValComparator: val diff = describePatternDifference(name, pattern1, pattern2) reasons += diff trace(s"Lazy val $name differs: $diff") - else - trace(s"Lazy val $name: identical pattern") + else trace(s"Lazy val $name: identical pattern") if reasons.isEmpty then debug("All lazy val implementations are identical") @@ -170,12 +166,15 @@ final class SemanticLazyValComparator: /** Extracts a canonical pattern signature for a lazy val. * - * This captures the essential implementation characteristics - * for the detected version by extracting synchronization skeleton. + * This captures the essential implementation characteristics for the detected version by extracting synchronization + * skeleton. * - * @param lv The lazy val info - * @param containingClass The class containing the lazy val implementation - * @param offsetClass The class containing the OFFSET field initialization (may be companion class) + * @param lv + * The lazy val info + * @param containingClass + * The class containing the lazy val implementation + * @param offsetClass + * The class containing the OFFSET field initialization (may be companion class) */ private def extractCanonicalPattern( lv: LazyValInfo, @@ -227,9 +226,9 @@ final class SemanticLazyValComparator: /** Extracts OFFSET field initialization pattern from static initializer. * * This captures how the OFFSET field is computed, which differs between versions: - * - 3.0/3.1: LazyVals$.getOffset(Class, String) - * - 3.2: Class.getDeclaredField(String) + LazyVals$.getOffsetStatic(Field) - * - 3.3+: Uses OFFSET in object-based pattern differently + * - 3.0/3.1: LazyVals$.getOffset(Class, String) + * - 3.2: Class.getDeclaredField(String) + LazyVals$.getOffsetStatic(Field) + * - 3.3+: Uses OFFSET in object-based pattern differently */ private def extractOffsetInitPattern(classInfo: ClassInfo): Option[String] = // Find method @@ -242,31 +241,25 @@ final class SemanticLazyValComparator: val hasGetOffset = instructions.exists(insn => insn.details.contains("LazyVals$.getOffset") || insn.details.contains("LazyVals.getOffset") ) - val hasGetDeclaredField = instructions.exists(insn => - insn.details.contains("getDeclaredField") - ) - val hasGetOffsetStatic = instructions.exists(insn => - insn.details.contains("getOffsetStatic") - ) + val hasGetDeclaredField = instructions.exists(insn => insn.details.contains("getDeclaredField")) + val hasGetOffsetStatic = instructions.exists(insn => insn.details.contains("getOffsetStatic")) - if hasGetOffset && !hasGetDeclaredField then - Some("LazyVals.getOffset") // 3.0/3.1 pattern - else if hasGetDeclaredField && hasGetOffsetStatic then - Some("getDeclaredField+getOffsetStatic") // 3.2 pattern - else - None + if hasGetOffset && !hasGetDeclaredField then Some("LazyVals.getOffset") // 3.0/3.1 pattern + else if hasGetDeclaredField && hasGetOffsetStatic then Some("getDeclaredField+getOffsetStatic") // 3.2 pattern + else None } /** Extracts synchronization skeleton from a method. * - * This extracts only the lazy val synchronization operations, - * ignoring the actual computation (body) of the lazy val. + * This extracts only the lazy val synchronization operations, ignoring the actual computation (body) of the lazy + * val. * - * The skeleton preserves order of operations, which is critical - * for thread safety semantics. + * The skeleton preserves order of operations, which is critical for thread safety semantics. * - * @param method The method to extract from (accessor or init method) - * @return Sequence of synchronization instruction patterns + * @param method + * The method to extract from (accessor or init method) + * @return + * Sequence of synchronization instruction patterns */ private def extractSynchronizationSkeleton(method: MethodInfo): Seq[String] = import sloth.classfile.InstructionInfo @@ -283,8 +276,8 @@ final class SemanticLazyValComparator: /** Normalizes a bytecode instruction to synchronization pattern form. * - * Returns Some(pattern) if this instruction is part of synchronization logic, - * None if it should be ignored (part of lazy val body). + * Returns Some(pattern) if this instruction is part of synchronization logic, None if it should be ignored (part of + * lazy val body). */ private def normalizeSyncInstruction( insn: sloth.classfile.InstructionInfo, @@ -294,41 +287,31 @@ final class SemanticLazyValComparator: val opcode = insn.opcodeString // Synchronization field patterns - if details.contains("bitmap$") then - Some(s"GETFIELD bitmap") - else if details.contains("OFFSET$") then - Some(s"GETFIELD OFFSET") + if details.contains("bitmap$") then Some(s"GETFIELD bitmap") + else if details.contains("OFFSET$") then Some(s"GETFIELD OFFSET") else if details.contains("$lzy") && details.contains("GETFIELD") && !details.contains("lzyHandle") then Some(s"GETFIELD storage") else if details.contains("$lzy") && details.contains("PUTFIELD") && !details.contains("lzyHandle") then Some(s"PUTFIELD storage") - else if details.contains("lzyHandle") && details.contains("GETFIELD") then - Some(s"GETFIELD varhandle") + else if details.contains("lzyHandle") && details.contains("GETFIELD") then Some(s"GETFIELD varhandle") // Synchronization operations - else if opcode == "MONITORENTER" then - Some("MONITORENTER") - else if opcode == "MONITOREXIT" then - Some("MONITOREXIT") + else if opcode == "MONITORENTER" then Some("MONITORENTER") + else if opcode == "MONITOREXIT" then Some("MONITOREXIT") // Unsafe CAS operations - else if details.contains("objCAS") || details.contains("compareAndSet") then - Some("CAS") + else if details.contains("objCAS") || details.contains("compareAndSet") then Some("CAS") // VarHandle operations - else if details.contains("VarHandle") && details.contains("invoke") then - Some("VARHANDLE_OP") + else if details.contains("VarHandle") && details.contains("invoke") then Some("VARHANDLE_OP") // Bitwise operations on bitmaps/flags - else if (opcode == "IAND" || opcode == "IOR") && lastOpcode.exists(_ == "GETFIELD") then - Some(s"BITOP $opcode") + else if (opcode == "IAND" || opcode == "IOR") && lastOpcode.exists(_ == "GETFIELD") then Some(s"BITOP $opcode") // Conditional jumps for flag checks (thread safety) else if opcode.startsWith("IF") && lastOpcode.exists(op => op == "IAND" || op == "IOR") then Some(s"CONDITIONAL $opcode") // Stack operations immediately following sync field access (needed for CAS setup) else if (opcode == "ALOAD" || opcode == "ASTORE") && lastOpcode.exists(_.contains("FIELD")) then Some(s"STACK_SYNC $opcode") - else if opcode == "DUP" && lastOpcode.exists(_.contains("FIELD")) then - Some(s"STACK_SYNC DUP") + else if opcode == "DUP" && lastOpcode.exists(_.contains("FIELD")) then Some(s"STACK_SYNC DUP") // Ignore everything else (lazy val body computation) - else - None + else None /** Describes the difference between two patterns. */ private def describePatternDifference( @@ -337,38 +320,32 @@ final class SemanticLazyValComparator: p2: LazyValCanonicalPattern ): String = (p1, p2) match - case (LazyValCanonicalPattern.BitmapBased(_, _, desc1, _, acc1, offset1), - LazyValCanonicalPattern.BitmapBased(_, _, desc2, _, acc2, offset2)) => - if desc1 != desc2 then - s"Lazy val '$name': storage type differs ($desc1 vs $desc2)" - else if offset1 != offset2 then - s"Lazy val '$name': OFFSET initialization differs ($offset1 vs $offset2)" - else if acc1 != acc2 then - s"Lazy val '$name': accessor synchronization skeleton differs" - else - s"Lazy val '$name': bitmap-based patterns differ in structure" - - case (LazyValCanonicalPattern.ObjectBasedUnsafe(_, desc1, _, init1, acc1), - LazyValCanonicalPattern.ObjectBasedUnsafe(_, desc2, _, init2, acc2)) => - if desc1 != desc2 then - s"Lazy val '$name': storage type differs ($desc1 vs $desc2)" - else if init1 != init2 then - s"Lazy val '$name': init method synchronization skeleton differs" - else if acc1 != acc2 then - s"Lazy val '$name': accessor synchronization skeleton differs" - else - s"Lazy val '$name': Unsafe-based patterns differ in structure" - - case (LazyValCanonicalPattern.ObjectBasedVarHandle(_, desc1, _, init1, acc1), - LazyValCanonicalPattern.ObjectBasedVarHandle(_, desc2, _, init2, acc2)) => - if desc1 != desc2 then - s"Lazy val '$name': storage type differs ($desc1 vs $desc2)" - else if init1 != init2 then - s"Lazy val '$name': init method synchronization skeleton differs" - else if acc1 != acc2 then - s"Lazy val '$name': accessor synchronization skeleton differs" - else - s"Lazy val '$name': VarHandle-based patterns differ in structure" + case ( + LazyValCanonicalPattern.BitmapBased(_, _, desc1, _, acc1, offset1), + LazyValCanonicalPattern.BitmapBased(_, _, desc2, _, acc2, offset2) + ) => + if desc1 != desc2 then s"Lazy val '$name': storage type differs ($desc1 vs $desc2)" + else if offset1 != offset2 then s"Lazy val '$name': OFFSET initialization differs ($offset1 vs $offset2)" + else if acc1 != acc2 then s"Lazy val '$name': accessor synchronization skeleton differs" + else s"Lazy val '$name': bitmap-based patterns differ in structure" + + case ( + LazyValCanonicalPattern.ObjectBasedUnsafe(_, desc1, _, init1, acc1), + LazyValCanonicalPattern.ObjectBasedUnsafe(_, desc2, _, init2, acc2) + ) => + if desc1 != desc2 then s"Lazy val '$name': storage type differs ($desc1 vs $desc2)" + else if init1 != init2 then s"Lazy val '$name': init method synchronization skeleton differs" + else if acc1 != acc2 then s"Lazy val '$name': accessor synchronization skeleton differs" + else s"Lazy val '$name': Unsafe-based patterns differ in structure" + + case ( + LazyValCanonicalPattern.ObjectBasedVarHandle(_, desc1, _, init1, acc1), + LazyValCanonicalPattern.ObjectBasedVarHandle(_, desc2, _, init2, acc2) + ) => + if desc1 != desc2 then s"Lazy val '$name': storage type differs ($desc1 vs $desc2)" + else if init1 != init2 then s"Lazy val '$name': init method synchronization skeleton differs" + else if acc1 != acc2 then s"Lazy val '$name': accessor synchronization skeleton differs" + else s"Lazy val '$name': VarHandle-based patterns differ in structure" case _ => s"Lazy val '$name': different implementation versions ($p1 vs $p2)" diff --git a/core/src/main/scala/sloth/patching/BytecodePatcher.scala b/core/src/main/scala/sloth/patching/BytecodePatcher.scala index 511cded..7699c90 100644 --- a/core/src/main/scala/sloth/patching/BytecodePatcher.scala +++ b/core/src/main/scala/sloth/patching/BytecodePatcher.scala @@ -29,6 +29,7 @@ object BytecodePatcher { /** Result of a patching operation */ sealed trait PatchResult object PatchResult { + /** Single classfile was patched */ case class PatchedSingle(name: String, bytes: Array[Byte]) extends PatchResult @@ -89,7 +90,7 @@ object BytecodePatcher { val detectionResult = LazyValDetector.detect(classInfo, None) val (lazyVals, version) = detectionResult match { - case LazyValDetectionResult.NoLazyVals => return PatchResult.NotApplicable + case LazyValDetectionResult.NoLazyVals => return PatchResult.NotApplicable case LazyValDetectionResult.LazyValsFound(lvs, ver) => (lvs, ver) case LazyValDetectionResult.MixedVersions(lvs) => return PatchResult.Failed(buildDiagnostic("Mixed Scala versions detected", name, classInfo, lvs)) @@ -98,8 +99,9 @@ object BytecodePatcher { // Dispatch to version-specific patching version match { case ScalaVersion.Scala30x_31x => patchScala30x_31x(bytes, classInfo, lazyVals, name, classLoader = classLoader) - case ScalaVersion.Scala32x => patchScala32x(bytes, classInfo, lazyVals, name, classLoader = classLoader) - case ScalaVersion.Scala33x_37x => patchScala33x_37x(bytes, classInfo, lazyVals, name, None, None, classLoader = classLoader) + case ScalaVersion.Scala32x => patchScala32x(bytes, classInfo, lazyVals, name, classLoader = classLoader) + case ScalaVersion.Scala33x_37x => + patchScala33x_37x(bytes, classInfo, lazyVals, name, None, None, classLoader = classLoader) case ScalaVersion.Scala38Plus => PatchResult.NotApplicable case ScalaVersion.Unknown(reason) => PatchResult.Failed(buildDiagnostic(s"Unknown Scala version detected: $reason", name, classInfo, lazyVals)) @@ -134,7 +136,10 @@ object BytecodePatcher { (objLvs, Seq.empty, objVer) case (LazyValDetectionResult.NoLazyVals, LazyValDetectionResult.LazyValsFound(clsLvs, clsVer)) => (Seq.empty, clsLvs, clsVer) - case (LazyValDetectionResult.LazyValsFound(objLvs, objVer), LazyValDetectionResult.LazyValsFound(clsLvs, clsVer)) => + case ( + LazyValDetectionResult.LazyValsFound(objLvs, objVer), + LazyValDetectionResult.LazyValsFound(clsLvs, clsVer) + ) => // Both have lazy vals — versions MUST match. A mismatch is always a bug. if (objVer != clsVer) { val diag = new StringBuilder @@ -146,7 +151,10 @@ object BytecodePatcher { } diag.append(s" Lazy vals (${objLvs.size}):\n") objLvs.foreach { lv => - diag.append(s" ${lv.name} (index=${lv.index}, version=${lv.version}, offset=${lv.offsetField.map(_.name)}, varHandle=${lv.varHandleField.map(_.name)}, bitmap=${lv.bitmapField.map(_.name)}, init=${lv.initMethod.map(_.name)})\n") + diag.append( + s" ${lv.name} (index=${lv.index}, version=${lv.version}, offset=${lv.offsetField.map(_.name)}, varHandle=${lv.varHandleField + .map(_.name)}, bitmap=${lv.bitmapField.map(_.name)}, init=${lv.initMethod.map(_.name)})\n" + ) } diag.append(s"\n--- Companion class: $className (detected as $clsVer) ---\n") diag.append(s" Fields:\n") @@ -155,17 +163,33 @@ object BytecodePatcher { } diag.append(s" Lazy vals (${clsLvs.size}):\n") clsLvs.foreach { lv => - diag.append(s" ${lv.name} (index=${lv.index}, version=${lv.version}, offset=${lv.offsetField.map(_.name)}, varHandle=${lv.varHandleField.map(_.name)}, bitmap=${lv.bitmapField.map(_.name)}, init=${lv.initMethod.map(_.name)})\n") + diag.append( + s" ${lv.name} (index=${lv.index}, version=${lv.version}, offset=${lv.offsetField.map(_.name)}, varHandle=${lv.varHandleField + .map(_.name)}, bitmap=${lv.bitmapField.map(_.name)}, init=${lv.initMethod.map(_.name)})\n" + ) } return PatchResult.Failed(diag.toString()) } (objLvs, clsLvs, objVer) case (LazyValDetectionResult.MixedVersions(lvs), _) => - val allLvs = lvs ++ (classDetectionResult match { case LazyValDetectionResult.LazyValsFound(l, _) => l; case _ => Seq.empty }) - return PatchResult.Failed(buildDiagnostic("Mixed Scala versions detected in companion object", companionObjectName, companionObjectInfo, allLvs)) + val allLvs = lvs ++ (classDetectionResult match { + case LazyValDetectionResult.LazyValsFound(l, _) => l; case _ => Seq.empty + }) + return PatchResult.Failed( + buildDiagnostic( + "Mixed Scala versions detected in companion object", + companionObjectName, + companionObjectInfo, + allLvs + ) + ) case (_, LazyValDetectionResult.MixedVersions(lvs)) => - val allLvs = (objectDetectionResult match { case LazyValDetectionResult.LazyValsFound(l, _) => l; case _ => Seq.empty }) ++ lvs - return PatchResult.Failed(buildDiagnostic("Mixed Scala versions detected in companion class", className, classInfo, allLvs)) + val allLvs = (objectDetectionResult match { + case LazyValDetectionResult.LazyValsFound(l, _) => l; case _ => Seq.empty + }) ++ lvs + return PatchResult.Failed( + buildDiagnostic("Mixed Scala versions detected in companion class", className, classInfo, allLvs) + ) } // Already in target format — nothing to patch @@ -194,10 +218,14 @@ object BytecodePatcher { case (true, true) => // BOTH have lazy vals - need to patch both independently patchCompanionPairBothHaveLazyVals30x_31x( - companionObjectName, className, - companionObjectInfo, classInfo, - companionObjectBytes, classBytes, - objectLazyVals, classLazyVals, + companionObjectName, + className, + companionObjectInfo, + classInfo, + companionObjectBytes, + classBytes, + objectLazyVals, + classLazyVals, classLoader = classLoader ) case (false, false) => @@ -223,10 +251,14 @@ object BytecodePatcher { case (true, true) => // BOTH have lazy vals - need to patch both independently patchCompanionPairBothHaveLazyVals30x_31x( - companionObjectName, className, - companionObjectInfo, classInfo, - companionObjectBytes, classBytes, - objectLazyVals, classLazyVals, + companionObjectName, + className, + companionObjectInfo, + classInfo, + companionObjectBytes, + classBytes, + objectLazyVals, + classLazyVals, classLoader = classLoader ) case (false, false) => @@ -238,7 +270,8 @@ object BytecodePatcher { (objectLazyVals.nonEmpty, classLazyVals.nonEmpty) match { case (true, false) => // Only object has lazy vals - val hasCompanionOffset = objectLazyVals.exists(_.offsetFieldLocation == OffsetFieldLocation.InCompanionClass) + val hasCompanionOffset = + objectLazyVals.exists(_.offsetFieldLocation == OffsetFieldLocation.InCompanionClass) if (hasCompanionOffset) { patchScala33x_37x( companionObjectBytes, @@ -249,7 +282,15 @@ object BytecodePatcher { classLoader = classLoader ) } else { - patchScala33x_37x(companionObjectBytes, companionObjectInfo, objectLazyVals, companionObjectName, None, None, classLoader = classLoader) + patchScala33x_37x( + companionObjectBytes, + companionObjectInfo, + objectLazyVals, + companionObjectName, + None, + None, + classLoader = classLoader + ) } case (false, true) => // Only class has lazy vals - patch as standalone @@ -257,10 +298,14 @@ object BytecodePatcher { case (true, true) => // BOTH have lazy vals - need to patch both independently patchCompanionPairBothHaveLazyVals33x_37x( - companionObjectName, className, - companionObjectInfo, classInfo, - companionObjectBytes, classBytes, - objectLazyVals, classLazyVals, + companionObjectName, + className, + companionObjectInfo, + classInfo, + companionObjectBytes, + classBytes, + objectLazyVals, + classLazyVals, classLoader = classLoader ) case (false, false) => @@ -270,7 +315,14 @@ object BytecodePatcher { case ScalaVersion.Scala38Plus => PatchResult.NotApplicable case ScalaVersion.Unknown(reason) => val allLvs = objectLazyVals ++ classLazyVals - PatchResult.Failed(buildDiagnostic(s"Unknown Scala version detected: $reason", companionObjectName, companionObjectInfo, allLvs)) + PatchResult.Failed( + buildDiagnostic( + s"Unknown Scala version detected: $reason", + companionObjectName, + companionObjectInfo, + allLvs + ) + ) } } @@ -296,7 +348,7 @@ object BytecodePatcher { lazyVals.foreach { lv => val versionStr = lv.version match { case ScalaVersion.Unknown(reason) => s"Unknown($reason)" - case other => other.toString + case other => other.toString } sb.append(s" ${lv.name} (index=${lv.index}, version=$versionStr)\n") } @@ -309,8 +361,8 @@ object BytecodePatcher { /** Patches Scala 3.0-3.1 lazy vals to 3.8+ format. * - * Transforms bitmap-based lazy vals with inline initialization to VarHandle-based - * implementation with separate lzyINIT methods, matching the 3.8+ pattern. + * Transforms bitmap-based lazy vals with inline initialization to VarHandle-based implementation with separate + * lzyINIT methods, matching the 3.8+ pattern. * * @param companionInfo * Optional tuple of (className, classInfo, classBytes) for companion class that contains OFFSET fields @@ -440,16 +492,17 @@ object BytecodePatcher { while (current != null) { current match { case fieldInsn: FieldInsnNode - if fieldInsn.getOpcode == PUTSTATIC && - fieldInsn.desc == "J" && - fieldInsn.name.matches("OFFSET\\$_m_\\d+") => + if fieldInsn.getOpcode == PUTSTATIC && + fieldInsn.desc == "J" && + fieldInsn.name.matches("OFFSET\\$_m_\\d+") => // Found PUTSTATIC for OFFSET$_m_N. Walk backwards to find GETSTATIC LazyVals$.MODULE$ var prev = current.getPrevious var removeCount = 0 while (prev != null && removeCount < 10) { prev match { - case gs: FieldInsnNode if gs.getOpcode == GETSTATIC && - gs.owner == LazyValsObj && gs.name == "MODULE$" => + case gs: FieldInsnNode + if gs.getOpcode == GETSTATIC && + gs.owner == LazyValsObj && gs.name == "MODULE$" => var temp = prev while (temp != null && temp != current.getNext) { toRemove += temp @@ -511,14 +564,16 @@ object BytecodePatcher { storageFieldDescriptors.foreach { case (lv, storageFieldName, _) => val varHandleName = s"${storageFieldName}$$lzyHandle" if (!classNode.fields.asScala.exists(_.name == varHandleName)) { - classNode.fields.add(new FieldNode( - ASM9, - ACC_PRIVATE | ACC_STATIC | ACC_FINAL, - varHandleName, - "Ljava/lang/invoke/VarHandle;", - null, - null - )) + classNode.fields.add( + new FieldNode( + ASM9, + ACC_PRIVATE | ACC_STATIC | ACC_FINAL, + varHandleName, + "Ljava/lang/invoke/VarHandle;", + null, + null + ) + ) } } @@ -547,12 +602,17 @@ object BytecodePatcher { val computation = extractComputation30x_31x(accessor) // Replace accessor body with 3.8+ pattern - replaceAccessor30x_31x(accessor, classInternalName, storageFieldName, varHandleName, - lzyInitName, originalDesc) + replaceAccessor30x_31x(accessor, classInternalName, storageFieldName, varHandleName, lzyInitName, originalDesc) // Generate lzyINIT method - val lzyInit = generateLzyInit30x_31x(classInternalName, storageFieldName, varHandleName, - lzyInitName, originalDesc, computation) + val lzyInit = generateLzyInit30x_31x( + classInternalName, + storageFieldName, + varHandleName, + lzyInitName, + originalDesc, + computation + ) classNode.methods.add(lzyInit) } } @@ -564,8 +624,8 @@ object BytecodePatcher { /** Extracts the computation instructions from a 3.0-3.1 accessor method. * * In 3.0-3.1 bytecode, the computation is between the CAS IFEQ and either: - * - The first store to slot 5 (xSTORE 5) for normal computations - * - The end of the try-catch block (for computations that always throw) + * - The first store to slot 5 (xSTORE 5) for normal computations + * - The end of the try-catch block (for computations that always throw) * * Returns cloned instructions with a proper label map so that JumpInsnNodes work correctly. */ @@ -594,21 +654,22 @@ object BytecodePatcher { // This gives us the boundary for always-throwing computations val tryCatchEndLabels: Set[LabelNode] = accessor.tryCatchBlocks.asScala.collect { case tcb if { - // Check if handler code calls setFlag (indicating it's the lazy val exception handler) - var n = tcb.handler.getNext - var isSetFlagHandler = false - var checked = 0 - while (n != null && checked < 10 && !isSetFlagHandler) { - n match { - case m: MethodInsnNode if m.owner == LazyValsObj && m.name == "setFlag" => - isSetFlagHandler = true - case _ => - } - n = n.getNext - checked += 1 - } - isSetFlagHandler - } => tcb.end + // Check if handler code calls setFlag (indicating it's the lazy val exception handler) + var n = tcb.handler.getNext + var isSetFlagHandler = false + var checked = 0 + while (n != null && checked < 10 && !isSetFlagHandler) { + n match { + case m: MethodInsnNode if m.owner == LazyValsObj && m.name == "setFlag" => + isSetFlagHandler = true + case _ => + } + n = n.getNext + checked += 1 + } + isSetFlagHandler + } => + tcb.end }.toSet // First pass: collect raw instructions (including labels) up to xSTORE 5 or try-catch end @@ -632,13 +693,13 @@ object BytecodePatcher { val labelMap = new java.util.HashMap[LabelNode, LabelNode]() rawInsns.foreach { case l: LabelNode => labelMap.put(l, new LabelNode()) - case _ => + case _ => } // Second pass: clone instructions (skip FrameNodes which are recomputed by COMPUTE_FRAMES) rawInsns.flatMap { case _: FrameNode => None - case insn => Some(insn.clone(labelMap)) + case insn => Some(insn.clone(labelMap)) }.toSeq } @@ -689,8 +750,7 @@ object BytecodePatcher { // ALOAD 1 insns.add(new VarInsnNode(ALOAD, 1)) // GETSTATIC LazyVals$NullValue$.MODULE$ - insns.add(new FieldInsnNode(GETSTATIC, NullValue, "MODULE$", - NullValueDesc)) + insns.add(new FieldInsnNode(GETSTATIC, NullValue, "MODULE$", NullValueDesc)) // IF_ACMPNE lInit insns.add(new JumpInsnNode(IF_ACMPNE, lInit)) @@ -710,8 +770,7 @@ object BytecodePatcher { // ALOAD 0 insns.add(new VarInsnNode(ALOAD, 0)) // INVOKESPECIAL lzyINIT - insns.add(new MethodInsnNode(INVOKESPECIAL, classInternalName, lzyInitName, - "()Ljava/lang/Object;", false)) + insns.add(new MethodInsnNode(INVOKESPECIAL, classInternalName, lzyInitName, "()Ljava/lang/Object;", false)) // unbox/checkcast + return typeInfo.addUnboxOrCast(insns) insns.add(new InsnNode(typeInfo.returnOpcode)) @@ -734,19 +793,19 @@ object BytecodePatcher { val insns = method.instructions // Labels - val lNonNull = new LabelNode() // jump target when value is non-null - val lCasFailed = new LabelNode() // CAS null→Evaluating failed, goto 0 - val lNullMapping = new LabelNode() // null→NullValue$ mapping - val lAfterCompute = new LabelNode() // after computation block - val lExHandler = new LabelNode() // exception handler - val lExCasOk = new LabelNode() // in exception handler, CAS succeeded - val lSuccessCasOk = new LabelNode() // in success path, CAS succeeded - val lReturnResult = new LabelNode() // return aload_3 - val lLoopBack = new LabelNode() // goto 0 (loop back) + val lNonNull = new LabelNode() // jump target when value is non-null + val lCasFailed = new LabelNode() // CAS null→Evaluating failed, goto 0 + val lNullMapping = new LabelNode() // null→NullValue$ mapping + val lAfterCompute = new LabelNode() // after computation block + val lExHandler = new LabelNode() // exception handler + val lExCasOk = new LabelNode() // in exception handler, CAS succeeded + val lSuccessCasOk = new LabelNode() // in success path, CAS succeeded + val lReturnResult = new LabelNode() // return aload_3 + val lLoopBack = new LabelNode() // goto 0 (loop back) val lCheckEvaluating = new LabelNode() // check if Evaluating$ - val lCheckWaiting = new LabelNode() // check if Waiting - val lReturnNull = new LabelNode() // return null (unknown LazyValControlState) - val lReturnVal = new LabelNode() // return non-control-state value + val lCheckWaiting = new LabelNode() // check if Waiting + val lReturnNull = new LabelNode() // return null (unknown LazyValControlState) + val lReturnVal = new LabelNode() // return non-control-state value // Start of method (offset 0) — the loop target val lStart = new LabelNode() @@ -765,10 +824,16 @@ object BytecodePatcher { insns.add(new FieldInsnNode(GETSTATIC, classInternalName, varHandleName, "Ljava/lang/invoke/VarHandle;")) insns.add(new VarInsnNode(ALOAD, 0)) insns.add(new InsnNode(ACONST_NULL)) - insns.add(new FieldInsnNode(GETSTATIC, Evaluating, "MODULE$", - EvaluatingDesc)) - insns.add(new MethodInsnNode(INVOKEVIRTUAL, "java/lang/invoke/VarHandle", "compareAndSet", - "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", false)) + insns.add(new FieldInsnNode(GETSTATIC, Evaluating, "MODULE$", EvaluatingDesc)) + insns.add( + new MethodInsnNode( + INVOKEVIRTUAL, + "java/lang/invoke/VarHandle", + "compareAndSet", + "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", + false + ) + ) insns.add(new JumpInsnNode(IFEQ, lCasFailed)) // CAS succeeded: compute value @@ -794,8 +859,7 @@ object BytecodePatcher { // null → NullValue$ mapping: ALOAD 3; IFNONNULL lNullMapping insns.add(new VarInsnNode(ALOAD, 3)) insns.add(new JumpInsnNode(IFNONNULL, lNullMapping)) - insns.add(new FieldInsnNode(GETSTATIC, NullValue, "MODULE$", - NullValueDesc)) + insns.add(new FieldInsnNode(GETSTATIC, NullValue, "MODULE$", NullValueDesc)) insns.add(new VarInsnNode(ASTORE, 2)) insns.add(new JumpInsnNode(GOTO, lAfterCompute)) @@ -815,11 +879,17 @@ object BytecodePatcher { // CAS Evaluating$ → result (in exception handler, result is still in slot 2) insns.add(new FieldInsnNode(GETSTATIC, classInternalName, varHandleName, "Ljava/lang/invoke/VarHandle;")) insns.add(new VarInsnNode(ALOAD, 0)) - insns.add(new FieldInsnNode(GETSTATIC, Evaluating, "MODULE$", - EvaluatingDesc)) + insns.add(new FieldInsnNode(GETSTATIC, Evaluating, "MODULE$", EvaluatingDesc)) insns.add(new VarInsnNode(ALOAD, 2)) - insns.add(new MethodInsnNode(INVOKEVIRTUAL, "java/lang/invoke/VarHandle", "compareAndSet", - "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", false)) + insns.add( + new MethodInsnNode( + INVOKEVIRTUAL, + "java/lang/invoke/VarHandle", + "compareAndSet", + "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", + false + ) + ) insns.add(new JumpInsnNode(IFNE, lExCasOk)) // Exception handler: CAS failed, need to countDown Waiting @@ -831,8 +901,15 @@ object BytecodePatcher { insns.add(new VarInsnNode(ALOAD, 0)) insns.add(new VarInsnNode(ALOAD, 5)) insns.add(new VarInsnNode(ALOAD, 2)) - insns.add(new MethodInsnNode(INVOKEVIRTUAL, "java/lang/invoke/VarHandle", "compareAndSet", - "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", false)) + insns.add( + new MethodInsnNode( + INVOKEVIRTUAL, + "java/lang/invoke/VarHandle", + "compareAndSet", + "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", + false + ) + ) insns.add(new InsnNode(POP)) insns.add(new VarInsnNode(ALOAD, 5)) insns.add(new MethodInsnNode(INVOKEVIRTUAL, Waiting, "countDown", "()V", false)) @@ -848,11 +925,17 @@ object BytecodePatcher { insns.add(lSuccessCasOk) insns.add(new FieldInsnNode(GETSTATIC, classInternalName, varHandleName, "Ljava/lang/invoke/VarHandle;")) insns.add(new VarInsnNode(ALOAD, 0)) - insns.add(new FieldInsnNode(GETSTATIC, Evaluating, "MODULE$", - EvaluatingDesc)) + insns.add(new FieldInsnNode(GETSTATIC, Evaluating, "MODULE$", EvaluatingDesc)) insns.add(new VarInsnNode(ALOAD, 2)) - insns.add(new MethodInsnNode(INVOKEVIRTUAL, "java/lang/invoke/VarHandle", "compareAndSet", - "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", false)) + insns.add( + new MethodInsnNode( + INVOKEVIRTUAL, + "java/lang/invoke/VarHandle", + "compareAndSet", + "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", + false + ) + ) insns.add(new JumpInsnNode(IFNE, lReturnResult)) // Success CAS failed: countDown Waiting and return @@ -864,8 +947,15 @@ object BytecodePatcher { insns.add(new VarInsnNode(ALOAD, 0)) insns.add(new VarInsnNode(ALOAD, 5)) insns.add(new VarInsnNode(ALOAD, 2)) - insns.add(new MethodInsnNode(INVOKEVIRTUAL, "java/lang/invoke/VarHandle", "compareAndSet", - "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", false)) + insns.add( + new MethodInsnNode( + INVOKEVIRTUAL, + "java/lang/invoke/VarHandle", + "compareAndSet", + "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", + false + ) + ) insns.add(new InsnNode(POP)) insns.add(new VarInsnNode(ALOAD, 5)) insns.add(new MethodInsnNode(INVOKEVIRTUAL, Waiting, "countDown", "()V", false)) @@ -887,8 +977,7 @@ object BytecodePatcher { // Check Evaluating$ insns.add(new VarInsnNode(ALOAD, 1)) - insns.add(new FieldInsnNode(GETSTATIC, Evaluating, "MODULE$", - EvaluatingDesc)) + insns.add(new FieldInsnNode(GETSTATIC, Evaluating, "MODULE$", EvaluatingDesc)) insns.add(new JumpInsnNode(IF_ACMPNE, lCheckWaiting)) // Is Evaluating$: CAS Evaluating$ → new Waiting, loop back @@ -898,8 +987,15 @@ object BytecodePatcher { insns.add(new TypeInsnNode(NEW, Waiting)) insns.add(new InsnNode(DUP)) insns.add(new MethodInsnNode(INVOKESPECIAL, Waiting, "", "()V", false)) - insns.add(new MethodInsnNode(INVOKEVIRTUAL, "java/lang/invoke/VarHandle", "compareAndSet", - "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", false)) + insns.add( + new MethodInsnNode( + INVOKEVIRTUAL, + "java/lang/invoke/VarHandle", + "compareAndSet", + "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z", + false + ) + ) insns.add(new InsnNode(POP)) insns.add(new JumpInsnNode(GOTO, lStart)) @@ -934,8 +1030,8 @@ object BytecodePatcher { method } - /** Patches for 3.0-3.1: removes OFFSET initialization, adds VarHandle initialization. - * Preserves MODULE$ initialization for objects. + /** Patches for 3.0-3.1: removes OFFSET initialization, adds VarHandle initialization. Preserves MODULE$ + * initialization for objects. */ private def patchClinit30x_31x( clinit: MethodNode, @@ -951,16 +1047,17 @@ object BytecodePatcher { while (current != null) { current match { case putStatic: FieldInsnNode - if putStatic.getOpcode == PUTSTATIC && - putStatic.desc == "J" && - (putStatic.name.matches("OFFSET\\$_m_\\d+") || putStatic.name.matches("OFFSET\\$\\d+")) => + if putStatic.getOpcode == PUTSTATIC && + putStatic.desc == "J" && + (putStatic.name.matches("OFFSET\\$_m_\\d+") || putStatic.name.matches("OFFSET\\$\\d+")) => // Walk backwards to find GETSTATIC LazyVals$.MODULE$ var prev = current.getPrevious var count = 0 while (prev != null && count < 10) { prev match { - case gs: FieldInsnNode if gs.getOpcode == GETSTATIC && - gs.owner == LazyValsObj && gs.name == "MODULE$" => + case gs: FieldInsnNode + if gs.getOpcode == GETSTATIC && + gs.owner == LazyValsObj && gs.name == "MODULE$" => // Found start, collect everything from here to putStatic var temp = prev while (temp != null && temp != current.getNext) { @@ -1002,8 +1099,15 @@ object BytecodePatcher { val varHandleName = s"${storageFieldName}$$lzyHandle" // MethodHandles.lookup() - insns.add(new MethodInsnNode(INVOKESTATIC, "java/lang/invoke/MethodHandles", "lookup", - "()Ljava/lang/invoke/MethodHandles$Lookup;", false)) + insns.add( + new MethodInsnNode( + INVOKESTATIC, + "java/lang/invoke/MethodHandles", + "lookup", + "()Ljava/lang/invoke/MethodHandles$Lookup;", + false + ) + ) // LDC insns.add(new LdcInsnNode(org.objectweb.asm.Type.getObjectType(classInternalName))) // LDC @@ -1011,28 +1115,33 @@ object BytecodePatcher { // LDC Object.class insns.add(new LdcInsnNode(org.objectweb.asm.Type.getType("Ljava/lang/Object;"))) // findVarHandle - insns.add(new MethodInsnNode(INVOKEVIRTUAL, "java/lang/invoke/MethodHandles$Lookup", - "findVarHandle", - "(Ljava/lang/Class;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/invoke/VarHandle;", false)) + insns.add( + new MethodInsnNode( + INVOKEVIRTUAL, + "java/lang/invoke/MethodHandles$Lookup", + "findVarHandle", + "(Ljava/lang/Class;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/invoke/VarHandle;", + false + ) + ) // PUTSTATIC - insns.add(new FieldInsnNode(PUTSTATIC, classInternalName, varHandleName, - "Ljava/lang/invoke/VarHandle;")) + insns.add(new FieldInsnNode(PUTSTATIC, classInternalName, varHandleName, "Ljava/lang/invoke/VarHandle;")) } } /** Adds inner class references required for 3.8+ lazy val implementation. */ private def addLazyValInnerClasses(classNode: ClassNode): Unit = { val innerClassesToAdd = Seq( - ("java/lang/invoke/MethodHandles$Lookup", "java/lang/invoke/MethodHandles", "Lookup", - ACC_PUBLIC | ACC_FINAL | ACC_STATIC), - (Evaluating, LazyVals, "Evaluating$", - ACC_PUBLIC | ACC_FINAL | ACC_STATIC), - (ControlState, LazyVals, "LazyValControlState", - ACC_PUBLIC | ACC_STATIC), - (NullValue, LazyVals, "NullValue$", - ACC_PUBLIC | ACC_FINAL | ACC_STATIC), - (Waiting, LazyVals, "Waiting", - ACC_PUBLIC | ACC_FINAL | ACC_STATIC) + ( + "java/lang/invoke/MethodHandles$Lookup", + "java/lang/invoke/MethodHandles", + "Lookup", + ACC_PUBLIC | ACC_FINAL | ACC_STATIC + ), + (Evaluating, LazyVals, "Evaluating$", ACC_PUBLIC | ACC_FINAL | ACC_STATIC), + (ControlState, LazyVals, "LazyValControlState", ACC_PUBLIC | ACC_STATIC), + (NullValue, LazyVals, "NullValue$", ACC_PUBLIC | ACC_FINAL | ACC_STATIC), + (Waiting, LazyVals, "Waiting", ACC_PUBLIC | ACC_FINAL | ACC_STATIC) ) innerClassesToAdd.foreach { case (name, outerName, innerName, access) => @@ -1054,41 +1163,136 @@ object BytecodePatcher { /** Gets type information for boxing/unboxing based on original field descriptor. */ private def getTypeInfo(descriptor: String): TypeInfo = descriptor match { - case "I" => TypeInfo("java/lang/Integer", true, IRETURN, - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "unboxToInt", "(Ljava/lang/Object;)I", false)), - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "boxToInteger", "(I)Ljava/lang/Integer;", false))) - case "J" => TypeInfo("java/lang/Long", true, LRETURN, - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "unboxToLong", "(Ljava/lang/Object;)J", false)), - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "boxToLong", "(J)Ljava/lang/Long;", false))) - case "D" => TypeInfo("java/lang/Double", true, DRETURN, - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "unboxToDouble", "(Ljava/lang/Object;)D", false)), - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "boxToDouble", "(D)Ljava/lang/Double;", false))) - case "Z" => TypeInfo("java/lang/Boolean", true, IRETURN, - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "unboxToBoolean", "(Ljava/lang/Object;)Z", false)), - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "boxToBoolean", "(Z)Ljava/lang/Boolean;", false))) - case "F" => TypeInfo("java/lang/Float", true, FRETURN, - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "unboxToFloat", "(Ljava/lang/Object;)F", false)), - insns => insns.add(new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", - "boxToFloat", "(F)Ljava/lang/Float;", false))) + case "I" => + TypeInfo( + "java/lang/Integer", + true, + IRETURN, + insns => + insns.add( + new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", "unboxToInt", "(Ljava/lang/Object;)I", false) + ), + insns => + insns.add( + new MethodInsnNode( + INVOKESTATIC, + "scala/runtime/BoxesRunTime", + "boxToInteger", + "(I)Ljava/lang/Integer;", + false + ) + ) + ) + case "J" => + TypeInfo( + "java/lang/Long", + true, + LRETURN, + insns => + insns.add( + new MethodInsnNode( + INVOKESTATIC, + "scala/runtime/BoxesRunTime", + "unboxToLong", + "(Ljava/lang/Object;)J", + false + ) + ), + insns => + insns.add( + new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", "boxToLong", "(J)Ljava/lang/Long;", false) + ) + ) + case "D" => + TypeInfo( + "java/lang/Double", + true, + DRETURN, + insns => + insns.add( + new MethodInsnNode( + INVOKESTATIC, + "scala/runtime/BoxesRunTime", + "unboxToDouble", + "(Ljava/lang/Object;)D", + false + ) + ), + insns => + insns.add( + new MethodInsnNode( + INVOKESTATIC, + "scala/runtime/BoxesRunTime", + "boxToDouble", + "(D)Ljava/lang/Double;", + false + ) + ) + ) + case "Z" => + TypeInfo( + "java/lang/Boolean", + true, + IRETURN, + insns => + insns.add( + new MethodInsnNode( + INVOKESTATIC, + "scala/runtime/BoxesRunTime", + "unboxToBoolean", + "(Ljava/lang/Object;)Z", + false + ) + ), + insns => + insns.add( + new MethodInsnNode( + INVOKESTATIC, + "scala/runtime/BoxesRunTime", + "boxToBoolean", + "(Z)Ljava/lang/Boolean;", + false + ) + ) + ) + case "F" => + TypeInfo( + "java/lang/Float", + true, + FRETURN, + insns => + insns.add( + new MethodInsnNode( + INVOKESTATIC, + "scala/runtime/BoxesRunTime", + "unboxToFloat", + "(Ljava/lang/Object;)F", + false + ) + ), + insns => + insns.add( + new MethodInsnNode(INVOKESTATIC, "scala/runtime/BoxesRunTime", "boxToFloat", "(F)Ljava/lang/Float;", false) + ) + ) case desc if desc.startsWith("L") && desc.endsWith(";") => val internalName = desc.substring(1, desc.length - 1) - TypeInfo(internalName, false, ARETURN, + TypeInfo( + internalName, + false, + ARETURN, insns => insns.add(new TypeInsnNode(CHECKCAST, internalName)), - insns => () /* no boxing needed for reference types */) + insns => () /* no boxing needed for reference types */ + ) case desc => // Fallback for Object or unknown — treat as reference type - TypeInfo("java/lang/Object", false, ARETURN, + TypeInfo( + "java/lang/Object", + false, + ARETURN, insns => (), /* no cast needed for Object */ - insns => () /* no boxing needed */) + insns => () /* no boxing needed */ + ) } // ============================================================================ @@ -1098,8 +1302,8 @@ object BytecodePatcher { /** Patches Scala 3.2 lazy vals to 3.8+ format. * * Scala 3.2 bytecode is nearly identical to 3.0-3.1 — the only difference is in clinit - * (getDeclaredField+getOffsetStatic vs getOffset), which the backward-walking removal - * code handles transparently. Delegates to the 3.0-3.1 patching logic. + * (getDeclaredField+getOffsetStatic vs getOffset), which the backward-walking removal code handles transparently. + * Delegates to the 3.0-3.1 patching logic. */ private def patchScala32x( bytes: Array[Byte], @@ -1132,7 +1336,8 @@ object BytecodePatcher { ): PatchResult = { try { // Check if object lazy vals have OFFSET in companion class - val objectHasCompanionOffset = objectLazyVals.exists(_.offsetFieldLocation == OffsetFieldLocation.InCompanionClass) + val objectHasCompanionOffset = + objectLazyVals.exists(_.offsetFieldLocation == OffsetFieldLocation.InCompanionClass) if (objectHasCompanionOffset) { // Complex case: object has OFFSET in companion class, AND class has its own lazy vals @@ -1181,7 +1386,8 @@ object BytecodePatcher { None, classLoader = classLoader ) - val classPatchResult = patchScala33x_37x(classBytes, classInfo, classLazyVals, className, None, None, classLoader = classLoader) + val classPatchResult = + patchScala33x_37x(classBytes, classInfo, classLazyVals, className, None, None, classLoader = classLoader) (objectPatchResult, classPatchResult) match { case (PatchResult.PatchedSingle(_, objBytes), PatchResult.PatchedSingle(_, clsBytes)) => @@ -1315,9 +1521,9 @@ object BytecodePatcher { /** Patches companion class to remove OFFSET fields belonging to the companion object. * - * IMPORTANT: Only removes OFFSET$_m_N fields (companion object's lazy vals). - * Preserves OFFSET$N fields (class's own lazy vals) for subsequent patching. - * Does NOT remove since it may contain initialization for the class's own OFFSET fields. + * IMPORTANT: Only removes OFFSET$_m_N fields (companion object's lazy vals). Preserves OFFSET$N fields (class's own + * lazy vals) for subsequent patching. Does NOT remove since it may contain initialization for the class's + * own OFFSET fields. */ private def patchCompanionClass33x_37x(classNode: ClassNode, lazyVals: Seq[LazyValInfo]): Unit = { // Only remove OFFSET$_m_N fields (companion object's lazy vals) @@ -1340,9 +1546,9 @@ object BytecodePatcher { while (current != null) { current match { case fieldInsn: FieldInsnNode - if fieldInsn.getOpcode == PUTSTATIC && - fieldInsn.desc == "J" && - fieldInsn.name.matches("OFFSET\\$_m_\\d+") => + if fieldInsn.getOpcode == PUTSTATIC && + fieldInsn.desc == "J" && + fieldInsn.name.matches("OFFSET\\$_m_\\d+") => // Found PUTSTATIC for OFFSET$_m_N field // Remove the entire initialization sequence: ALOAD/LDC -> INVOKEVIRTUAL -> PUTSTATIC var prev = current.getPrevious @@ -1517,7 +1723,7 @@ object BytecodePatcher { classNode.methods.add(clinit) case None => - // No clinit and no VarHandles to initialize, do nothing + // No clinit and no VarHandles to initialize, do nothing } // Step 4: Patch lzyINIT methods diff --git a/core/src/main/scala/sloth/patching/ClassLoaderClassWriter.scala b/core/src/main/scala/sloth/patching/ClassLoaderClassWriter.scala index 6d3077c..28e0647 100644 --- a/core/src/main/scala/sloth/patching/ClassLoaderClassWriter.scala +++ b/core/src/main/scala/sloth/patching/ClassLoaderClassWriter.scala @@ -4,17 +4,16 @@ import org.objectweb.asm.{ClassReader, ClassWriter, Opcodes} /** ClassWriter subclass that resolves class hierarchies WITHOUT triggering class loading. * - * ASM's COMPUTE_FRAMES needs to find common superclasses. The default implementation uses - * Class.forName() which triggers JVM class loading — problematic in agent mode because it - * can load classes with unpatched bytes before the transformer has a chance to patch them. + * ASM's COMPUTE_FRAMES needs to find common superclasses. The default implementation uses Class.forName() which + * triggers JVM class loading — problematic in agent mode because it can load classes with unpatched bytes before the + * transformer has a chance to patch them. * - * This implementation reads class bytecode via getResourceAsStream and parses superclass - * names with ASM's ClassReader, walking the hierarchy without ever calling loadClass(). + * This implementation reads class bytecode via getResourceAsStream and parses superclass names with ASM's ClassReader, + * walking the hierarchy without ever calling loadClass(). * - * On Java 9+, JDK classes in platform modules (java.base, etc.) may not be accessible via - * getResourceAsStream from arbitrary classloaders. For JDK types (java/, javax/, jdk/, sun/), - * we fall back to Class.forName() which is safe — these classes are already loaded or are - * guaranteed not to contain Scala lazy vals. + * On Java 9+, JDK classes in platform modules (java.base, etc.) may not be accessible via getResourceAsStream from + * arbitrary classloaders. For JDK types (java/, javax/, jdk/, sun/), we fall back to Class.forName() which is safe — + * these classes are already loaded or are guaranteed not to contain Scala lazy vals. */ class ClassLoaderClassWriter(classLoader: ClassLoader) extends ClassWriter(ClassWriter.COMPUTE_FRAMES): @@ -24,8 +23,7 @@ class ClassLoaderClassWriter(classLoader: ClassLoader) extends ClassWriter(Class private val jdkPrefixes = Array("java/", "javax/", "jdk/", "sun/", "com/sun/") private def readClassInfo(internalName: String): Option[ClassInfo] = - if internalName == "java/lang/Object" then - Some(ClassInfo(null, Array.empty, isInterface = false)) + if internalName == "java/lang/Object" then Some(ClassInfo(null, Array.empty, isInterface = false)) else readClassInfoFromResource(classLoader, internalName) .orElse { @@ -47,9 +45,8 @@ class ClassLoaderClassWriter(classLoader: ClassLoader) extends ClassWriter(Class catch case _: Throwable => None finally resource.close() - /** Fallback: use Class.forName for JDK types that can't be found via getResourceAsStream - * on Java 9+ (module system). This is safe because JDK classes never contain Scala lazy vals, - * so loading them won't trigger unwanted patching. + /** Fallback: use Class.forName for JDK types that can't be found via getResourceAsStream on Java 9+ (module system). + * This is safe because JDK classes never contain Scala lazy vals, so loading them won't trigger unwanted patching. */ private def readClassInfoViaReflection(internalName: String): Option[ClassInfo] = if !jdkPrefixes.exists(internalName.startsWith) then None @@ -94,8 +91,7 @@ class ClassLoaderClassWriter(classLoader: ClassLoader) extends ClassWriter(Class false override def getCommonSuperClass(type1: String, type2: String): String = - if type1 == "java/lang/Object" || type2 == "java/lang/Object" then - return "java/lang/Object" + if type1 == "java/lang/Object" || type2 == "java/lang/Object" then return "java/lang/Object" val info1 = readClassInfo(type1) val info2 = readClassInfo(type2) diff --git a/core/src/main/scala/sloth/patching/LazyValPatchingException.scala b/core/src/main/scala/sloth/patching/LazyValPatchingException.scala index d3b3108..ab796dc 100644 --- a/core/src/main/scala/sloth/patching/LazyValPatchingException.scala +++ b/core/src/main/scala/sloth/patching/LazyValPatchingException.scala @@ -2,8 +2,7 @@ package sloth.patching /** Thrown when lazy val bytecode patching fails due to an unrecognized pattern. * - * Contains a detailed diagnostic message with class fields, methods, and - * per-lazy-val version breakdown to aid in debugging and creating test fixtures. + * Contains a detailed diagnostic message with class fields, methods, and per-lazy-val version breakdown to aid in + * debugging and creating test fixtures. */ -class LazyValPatchingException(val diagnostic: String) - extends RuntimeException(diagnostic) +class LazyValPatchingException(val diagnostic: String) extends RuntimeException(diagnostic) diff --git a/testops/src/main/scala/sloth/CompilationResult.scala b/testops/src/main/scala/sloth/CompilationResult.scala index b2ad64d..5a0e738 100644 --- a/testops/src/main/scala/sloth/CompilationResult.scala +++ b/testops/src/main/scala/sloth/CompilationResult.scala @@ -21,6 +21,7 @@ case class ExampleCompilationResult( exampleName: String, results: Map[String, VersionCompilationResult] ) { + /** Get all unique class file relative paths across all versions */ def allClassPaths: Set[String] = results.values.flatMap(_.classFiles.map(_.relativePath)).toSet diff --git a/testops/src/main/scala/sloth/CompileExamplesMain.scala b/testops/src/main/scala/sloth/CompileExamplesMain.scala index 67709f0..8e8fa58 100644 --- a/testops/src/main/scala/sloth/CompileExamplesMain.scala +++ b/testops/src/main/scala/sloth/CompileExamplesMain.scala @@ -5,9 +5,9 @@ import scala.collection.immutable.TreeSet /** Compiles experimental examples across multiple Scala versions for debugging purposes. * * This tool: - * - Takes examples from experimental/examples/ - * - Compiles them with all interesting Scala versions (from LazyValDetectionTests) - * - Outputs compiled classfiles to .out/ directory for inspection + * - Takes examples from experimental/examples/ + * - Compiles them with all interesting Scala versions (from LazyValDetectionTests) + * - Outputs compiled classfiles to .out/ directory for inspection */ object CompileExamplesMain { import scribe._ @@ -147,7 +147,8 @@ object CompileExamplesMain { // Generate javap output for each classfile classFiles.foreach { classFile => val className = classFile.last.stripSuffix(".class") - val javapOutput = os.proc("javap", "-v", "-p", classFile.toString) + val javapOutput = os + .proc("javap", "-v", "-p", classFile.toString) .call(cwd = versionDir, check = false, stderr = os.Pipe, stdout = os.Pipe) if (javapOutput.exitCode == 0) { @@ -189,7 +190,8 @@ object CompileExamplesMain { classFiles.foreach { classFile => val className = classFile.last.stripSuffix(".class") - val javapOutput = os.proc("javap", "-v", "-p", classFile.toString) + val javapOutput = os + .proc("javap", "-v", "-p", classFile.toString) .call(cwd = patchedDir, check = false, stderr = os.Pipe, stdout = os.Pipe) if (javapOutput.exitCode == 0) { @@ -239,7 +241,8 @@ object CompileExamplesMain { .filter(p => !p.toString.contains(".bloop")) .filter(p => !p.last.contains("$package")) .size - val javapFiles = os.list(versionDir) + val javapFiles = os + .list(versionDir) .filter(p => os.isFile(p) && p.last.endsWith(".javap.txt")) .size info(s" ${versionDir.last}/ ($classFiles .class files, $javapFiles .javap.txt files)") diff --git a/testops/src/main/scala/sloth/ExampleLoader.scala b/testops/src/main/scala/sloth/ExampleLoader.scala index 9619d74..2166737 100644 --- a/testops/src/main/scala/sloth/ExampleLoader.scala +++ b/testops/src/main/scala/sloth/ExampleLoader.scala @@ -108,7 +108,7 @@ trait ExampleLoader { sys.env.get("INSPECT_BYTECODE").exists { value => value.toLowerCase match { case "true" | "1" | "yes" => true - case _ => false + case _ => false } } } @@ -261,7 +261,8 @@ trait ExampleLoader { import scala.sys.process._ try { - val result = os.proc("javap", "-v", "-p", classFile.absolutePath.toString) + val result = os + .proc("javap", "-v", "-p", classFile.absolutePath.toString) .call(check = false) if (result.exitCode == 0) { diff --git a/testops/src/main/scala/sloth/ExampleMetadata.scala b/testops/src/main/scala/sloth/ExampleMetadata.scala index 9e1a937..d18f1df 100644 --- a/testops/src/main/scala/sloth/ExampleMetadata.scala +++ b/testops/src/main/scala/sloth/ExampleMetadata.scala @@ -4,20 +4,20 @@ import com.github.plokhotnyuk.jsoniter_scala.core.* import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker final case class ExpectedLazyVal( - name: String, - index: Int + name: String, + index: Int ) final case class ExpectedClass( - className: String, - lazyVals: List[ExpectedLazyVal] + className: String, + lazyVals: List[ExpectedLazyVal] ) final case class ExampleMetadata( - description: String, - expectedClasses: List[ExpectedClass], - expectedOutput: Option[String] = None, - mainClassName: String + description: String, + expectedClasses: List[ExpectedClass], + expectedOutput: Option[String] = None, + mainClassName: String ) object ExampleMetadata { diff --git a/testops/src/main/scala/sloth/ExampleRunner.scala b/testops/src/main/scala/sloth/ExampleRunner.scala index 8a5b232..ee31157 100644 --- a/testops/src/main/scala/sloth/ExampleRunner.scala +++ b/testops/src/main/scala/sloth/ExampleRunner.scala @@ -12,13 +12,12 @@ import sloth.patching.BytecodePatcher /** Single chokepoint that boots the scala-cli Bloop compilation server exactly once per JVM. * - * The fixtures compile each Scala version in parallel. Starting the Bloop daemon is the only step - * that races: many scala-cli clients hitting `ensureBloopRunning` at once on a cold CI runner all - * try to spawn the server and time out (`TimeoutException: Future timed out after [30 seconds]`). - * Once the daemon is up, every client just connects to it — so we warm it once, behind a `lazy val` - * (which the JVM initializes under a lock, blocking concurrent callers until it completes), and only - * then let the parallel compilations proceed. Keeping Bloop (vs `--server=false`) preserves its - * large incremental-compile speedup. + * The fixtures compile each Scala version in parallel. Starting the Bloop daemon is the only step that races: many + * scala-cli clients hitting `ensureBloopRunning` at once on a cold CI runner all try to spawn the server and time out + * (`TimeoutException: Future timed out after [30 seconds]`). Once the daemon is up, every client just connects to it — + * so we warm it once, behind a `lazy val` (which the JVM initializes under a lock, blocking concurrent callers until + * it completes), and only then let the parallel compilations proceed. Keeping Bloop (vs `--server=false`) preserves + * its large incremental-compile speedup. */ object BloopWarmup { // First access boots the daemon; concurrent accessors block on lazy-val init until it's ready. @@ -125,7 +124,18 @@ class ExampleRunner( // default target and is therefore only run on the JDK-25 leg (used as a static reference here). val releaseArgs = if (scalaVersion.startsWith("3.8")) Seq.empty else Seq("--release", "9") val result = os - .proc("scala-cli", "compile", "--jvm", "17", "--bloop-startup-timeout", "180s", releaseArgs, "-S", scalaVersion, targetDir.toString) + .proc( + "scala-cli", + "compile", + "--jvm", + "17", + "--bloop-startup-timeout", + "180s", + releaseArgs, + "-S", + scalaVersion, + targetDir.toString + ) .call(cwd = targetDir, stderr = os.Pipe, stdout = os.Pipe, check = false) // Only log output if compilation failed or not in quiet mode @@ -357,11 +367,24 @@ class ExampleRunner( detectionResult match { case LazyValDetectionResult.NoLazyVals => // OK case LazyValDetectionResult.LazyValsFound(lazyVals, ScalaVersion.Unknown(reason)) => - break(Left(s"Detected Unknown version for ${group.primaryName} compiled with Scala $scalaVersion: $reason. LazyVals: ${lazyVals.map(lv => s"${lv.name} (version=${lv.version})").mkString(", ")}")) + break( + Left( + s"Detected Unknown version for ${group.primaryName} compiled with Scala $scalaVersion: $reason. LazyVals: ${lazyVals + .map(lv => s"${lv.name} (version=${lv.version})") + .mkString(", ")}" + ) + ) case LazyValDetectionResult.LazyValsFound(_, _) => // OK case LazyValDetectionResult.MixedVersions(lazyVals) => - val versionBreakdown = lazyVals.groupBy(_.version).map { case (v, lvs) => s"$v: ${lvs.map(_.name).mkString(", ")}" }.mkString("; ") - break(Left(s"Detected mixed versions for ${group.primaryName} compiled with Scala $scalaVersion. Breakdown: $versionBreakdown")) + val versionBreakdown = lazyVals + .groupBy(_.version) + .map { case (v, lvs) => s"$v: ${lvs.map(_.name).mkString(", ")}" } + .mkString("; ") + break( + Left( + s"Detected mixed versions for ${group.primaryName} compiled with Scala $scalaVersion. Breakdown: $versionBreakdown" + ) + ) } } @@ -481,7 +504,9 @@ class ExampleRunner( } if (failCount > 0) { - Left(s"Patching completed with errors: $successCount succeeded, $failCount failed. Errors:\n${errors.mkString("\n")}") + Left( + s"Patching completed with errors: $successCount succeeded, $failCount failed. Errors:\n${errors.mkString("\n")}" + ) } else if (successCount > 0) { Right(s"Patched $successCount versions successfully") } else { diff --git a/testops/src/main/scala/sloth/LazyValDetectorExample.scala b/testops/src/main/scala/sloth/LazyValDetectorExample.scala index 2585f0b..039655b 100644 --- a/testops/src/main/scala/sloth/LazyValDetectorExample.scala +++ b/testops/src/main/scala/sloth/LazyValDetectorExample.scala @@ -73,7 +73,8 @@ import scala.util.{Try, Success, Failure} import scala.jdk.StreamConverters._ - val classfiles = Files.walk(dir) + val classfiles = Files + .walk(dir) .toScala(LazyList) .filter(p => Files.isRegularFile(p) && p.toString.endsWith(".class")) .toSeq @@ -96,11 +97,10 @@ import scala.util.{Try, Success, Failure} val withLazyVals = results.filter { case (_, _, result) => result match case sloth.lazyval.LazyValDetectionResult.NoLazyVals => false - case _ => true + case _ => true } - if withLazyVals.isEmpty then - println("No lazy vals found in any classfile") + if withLazyVals.isEmpty then println("No lazy vals found in any classfile") else println(s"\nFound lazy vals in ${withLazyVals.size} classes:\n") withLazyVals.foreach { case (filename, className, result) => diff --git a/testops/src/main/scala/sloth/TestPaths.scala b/testops/src/main/scala/sloth/TestPaths.scala index 0ac5876..53a1bb6 100644 --- a/testops/src/main/scala/sloth/TestPaths.scala +++ b/testops/src/main/scala/sloth/TestPaths.scala @@ -2,22 +2,21 @@ package sloth /** Small filesystem/bytecode helpers shared by the test modules. * - * Kept here (testops/src/main) so both tests-jdk11 and tests-jdk25 can use it via dependsOn(testops), - * and so paths stay version-agnostic instead of hard-coding the current Scala target directory. + * Kept here (testops/src/main) so both tests-jdk11 and tests-jdk25 can use it via dependsOn(testops), and so paths + * stay version-agnostic instead of hard-coding the current Scala target directory. */ object TestPaths { val isCI: Boolean = sys.env.contains("CI") - /** Locate the assembled agent jar under agent/target/ without hard-coding the Scala version in - * the path. The agent sets crossPaths := false so the jar lands directly in agent/target/, but - * we search recursively to stay robust against build layout changes. + /** Locate the assembled agent jar under agent/target/ without hard-coding the Scala version in the path. The agent + * sets crossPaths := false so the jar lands directly in agent/target/, but we search recursively to stay robust + * against build layout changes. */ def findAgentJar(): os.Path = { val agentTarget = os.pwd / "agent" / "target" val candidates = - if os.exists(agentTarget) then - os.walk(agentTarget).filter(p => os.isFile(p) && p.last == "sloth-agent.jar") + if os.exists(agentTarget) then os.walk(agentTarget).filter(p => os.isFile(p) && p.last == "sloth-agent.jar") else Seq.empty candidates.headOption.getOrElse( throw new RuntimeException( @@ -26,8 +25,8 @@ object TestPaths { ) } - /** The classfile major version of a single .class byte array (bytes 6-7, big-endian). - * 52 = Java 8, 53 = Java 9, 61 = Java 17. + /** The classfile major version of a single .class byte array (bytes 6-7, big-endian). 52 = Java 8, 53 = Java 9, 61 = + * Java 17. */ def classMajorVersion(classBytes: Array[Byte]): Int = ((classBytes(6) & 0xff) << 8) | (classBytes(7) & 0xff) diff --git a/tests-jdk11/src/test/scala/sloth/AgentPatchingTests.scala b/tests-jdk11/src/test/scala/sloth/AgentPatchingTests.scala index 94e4bd0..b3e665f 100644 --- a/tests-jdk11/src/test/scala/sloth/AgentPatchingTests.scala +++ b/tests-jdk11/src/test/scala/sloth/AgentPatchingTests.scala @@ -7,12 +7,11 @@ import sloth.lazyval.SemanticLazyValComparator import java.nio.file.Files import java.io.ByteArrayInputStream -/** Tests for SlothTransformer — the agent's ClassFileTransformer that uses - * group-based patching via BytecodePatcher.patch(group). +/** Tests for SlothTransformer — the agent's ClassFileTransformer that uses group-based patching via + * BytecodePatcher.patch(group). * - * Verifies that the transformer correctly patches lazy val bytecode when classes - * are loaded through a MockClassLoader, including companion pair handling where - * loading order may vary. + * Verifies that the transformer correctly patches lazy val bytecode when classes are loaded through a MockClassLoader, + * including companion pair handling where loading order may vary. */ class AgentPatchingTests extends FunSuite with ExampleLoader { @@ -24,16 +23,30 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { override val quietTests: Boolean = true val testVersions: Seq[String] = Seq( - "3.0.2", "3.1.3", "3.2.2", - "3.3.0", "3.3.6", "3.4.3", "3.5.2", "3.6.4", "3.7.3", + "3.0.2", + "3.1.3", + "3.2.2", + "3.3.0", + "3.3.6", + "3.4.3", + "3.5.2", + "3.6.4", + "3.7.3", "3.8.1" ) override def requiredScalaVersions: Seq[String] = testVersions val patchableVersions: Set[String] = Set( - "3.0.2", "3.1.3", "3.2.2", - "3.3.0", "3.3.6", "3.4.3", "3.5.2", "3.6.4", "3.7.3" + "3.0.2", + "3.1.3", + "3.2.2", + "3.3.0", + "3.3.6", + "3.4.3", + "3.5.2", + "3.6.4", + "3.7.3" ) private def log(msg: => String): Unit = if !quietTests then println(msg) @@ -54,8 +67,7 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { .replace() } - /** ClassLoader that serves class bytes from an in-memory map. - * Keys are resource names like "Foo$.class". + /** ClassLoader that serves class bytes from an in-memory map. Keys are resource names like "Foo$.class". */ class MockClassLoader(classes: Map[String, Array[Byte]]) extends ClassLoader(null) { override def getResourceAsStream(name: String): java.io.InputStream = @@ -74,10 +86,14 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { /** Simulate class loading through the transformer in a given order. * - * @param transformer fresh transformer instance (stateful due to companion buffer) - * @param classBytesMap internalName -> original bytes - * @param loadOrder sequence of internal names in desired loading order - * @return map of internalName -> patched bytes (only classes that were actually patched) + * @param transformer + * fresh transformer instance (stateful due to companion buffer) + * @param classBytesMap + * internalName -> original bytes + * @param loadOrder + * sequence of internal names in desired loading order + * @return + * map of internalName -> patched bytes (only classes that were actually patched) */ def simulateLoading( transformer: SlothTransformer, @@ -96,8 +112,8 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { if (example.metadata.expectedClasses.exists(_.lazyVals.nonEmpty)) { log(s"\n[${example.name}] Testing agent transformer patching") - val compiledPatchableVersions = patchableVersions.filter(v => - example.compilationResult.results.get(v).exists(_.success)) + val compiledPatchableVersions = + patchableVersions.filter(v => example.compilationResult.results.get(v).exists(_.success)) // Get 3.8 reference bytes for semantic comparison val ref38Result = example.compilationResult.results.get("3.8.1").filter(_.success) @@ -113,8 +129,10 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { patched.foreach { case (internalName, patchedBytes) => // Verify patched bytes are parseable val parsed = ClassfileParser.parse(patchedBytes) - assert(parsed.isRight, - s"Patched classfile should be parseable for $internalName ($version): ${parsed.left.getOrElse("")}") + assert( + parsed.isRight, + s"Patched classfile should be parseable for $internalName ($version): ${parsed.left.getOrElse("")}" + ) // Semantic comparison with 3.8 reference if available ref38Result.foreach { ref38 => @@ -125,12 +143,16 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { val refClassInfo = ClassfileParser.parse(refBytes).toOption.get val patchedClassInfo = parsed.toOption.get val comparison = SemanticLazyValComparator.compare(patchedClassInfo, refClassInfo) - assert(comparison.areIdentical, - s"[${example.name}/$version] Patched $internalName should be semantically identical to 3.8, but got: $comparison") + assert( + comparison.areIdentical, + s"[${example.name}/$version] Patched $internalName should be semantically identical to 3.8, but got: $comparison" + ) } } - log(s" [$version] $internalName: Patched (${classBytesMap(internalName).length} -> ${patchedBytes.length} bytes)") + log( + s" [$version] $internalName: Patched (${classBytesMap(internalName).length} -> ${patchedBytes.length} bytes)" + ) } } } @@ -142,8 +164,8 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { // Focus on examples where companion pairing matters for lazy val patching. examples.foreach { example => if (example.metadata.expectedClasses.exists(_.lazyVals.nonEmpty)) { - val compiledPatchableVersions = patchableVersions.filter(v => - example.compilationResult.results.get(v).exists(_.success)) + val compiledPatchableVersions = + patchableVersions.filter(v => example.compilationResult.results.get(v).exists(_.success)) compiledPatchableVersions.foreach { version => val classBytesMap = buildClassBytesMap(example, version) @@ -190,8 +212,10 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { patched.get(name).foreach { patchedBytes => val patchedClassInfo = ClassfileParser.parse(patchedBytes).toOption.get val comparison = SemanticLazyValComparator.compare(patchedClassInfo, refClassInfo) - assert(comparison.areIdentical, - s"[${example.name}/$version] $label order: $name should be semantically identical to 3.8, but got: $comparison") + assert( + comparison.areIdentical, + s"[${example.name}/$version] $label order: $name should be semantically identical to 3.8, but got: $comparison" + ) } } } @@ -219,8 +243,10 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { val allNames = classBytesMap.keys.toSeq val patched = simulateLoading(transformer, classBytesMap, allNames) - assert(patched.isEmpty, - s"[${example.name}] 3.8+ classes should all return null (not be patched), but got: ${patched.keySet}") + assert( + patched.isEmpty, + s"[${example.name}] 3.8+ classes should all return null (not be patched), but got: ${patched.keySet}" + ) log(s" [3.8.1] All classes returned null (as expected)") } } @@ -240,8 +266,10 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { val allNames = classBytesMap.keys.toSeq val patched = simulateLoading(transformer, classBytesMap, allNames) - assert(patched.isEmpty, - s"[${example.name}/$version] Non-lazy-val classes should all return null, but got: ${patched.keySet}") + assert( + patched.isEmpty, + s"[${example.name}/$version] Non-lazy-val classes should all return null, but got: ${patched.keySet}" + ) log(s" [$version] All classes returned null (as expected)") } } @@ -255,7 +283,9 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { val firstPatchableVersion = patchableVersions .filter(v => example.compilationResult.results.get(v).exists(_.success)) - .toSeq.sorted.headOption + .toSeq + .sorted + .headOption firstPatchableVersion.foreach { version => val classBytesMap = buildClassBytesMap(example, version) @@ -273,8 +303,10 @@ class AgentPatchingTests extends FunSuite with ExampleLoader { val transformer2 = new SlothTransformer(AgentConfig()) val repatched = simulateLoading(transformer2, patchedClassBytesMap, allNames) - assert(repatched.isEmpty, - s"[${example.name}/$version] Re-patching should return null for all classes, but got: ${repatched.keySet}") + assert( + repatched.isEmpty, + s"[${example.name}/$version] Re-patching should return null for all classes, but got: ${repatched.keySet}" + ) log(s" [$version] Idempotent (all return null on re-patch)") } } diff --git a/tests-jdk11/src/test/scala/sloth/ClassfileVersionTests.scala b/tests-jdk11/src/test/scala/sloth/ClassfileVersionTests.scala index 2238f73..c28260d 100644 --- a/tests-jdk11/src/test/scala/sloth/ClassfileVersionTests.scala +++ b/tests-jdk11/src/test/scala/sloth/ClassfileVersionTests.scala @@ -2,11 +2,11 @@ package sloth import munit.FunSuite -/** Guards that the published artifacts are emitted at Java 9 bytecode level (classfile major - * version <= 53) so they load on a Java 9 JVM. +/** Guards that the published artifacts are emitted at Java 9 bytecode level (classfile major version <= 53) so they + * load on a Java 9 JVM. * - * - The assembled agent jar bundles our shaded code plus the shaded scala/asm/etc. runtime; - * EVERY class in it must be <= v53 for the agent to attach on Java 9. + * - The assembled agent jar bundles our shaded code plus the shaded scala/asm/etc. runtime; EVERY class in it must + * be <= v53 for the agent to attach on Java 9. * - The core library's own classes (the other published artifact) must be <= v53. * * 53 = Java 9, 52 = Java 8 (both fine on a Java 9 runtime); 61 = Java 17 (would fail to load). @@ -45,8 +45,9 @@ class ClassfileVersionTests extends FunSuite { ) } - /** The scala target directory name of the running build (e.g. "scala-3.3.8"), taken from this - * test's own code-source path so it always matches the current scalaVersion. */ + /** The scala target directory name of the running build (e.g. "scala-3.3.8"), taken from this test's own code-source + * path so it always matches the current scalaVersion. + */ private def currentScalaTargetDir: String = Option(getClass.getProtectionDomain.getCodeSource) .map(cs => os.Path(java.nio.file.Paths.get(cs.getLocation.toURI))) diff --git a/tests-jdk11/src/test/scala/sloth/Jdk9RuntimeTests.scala b/tests-jdk11/src/test/scala/sloth/Jdk9RuntimeTests.scala index 7800415..4f4e5cc 100644 --- a/tests-jdk11/src/test/scala/sloth/Jdk9RuntimeTests.scala +++ b/tests-jdk11/src/test/scala/sloth/Jdk9RuntimeTests.scala @@ -5,14 +5,14 @@ import scala.compiletime.uninitialized /** Java-9 runtime proof for the published artifacts. * - * The agent rewrites Unsafe-based lazy vals into the VarHandle-based scheme, and VarHandle is a - * Java 9 API. This suite runs the agent (built at `-release:9`) against Scala 3.0-3.7 bytecode on - * whatever JVM is hosting the tests and asserts the application still produces correct output. When - * this suite runs on a Java 11 JVM (the `tests-jdk11` CI job), a green run proves the patched - * VarHandle bytecode verifies and executes on Java 9 — i.e. the published artifacts work there. + * The agent rewrites Unsafe-based lazy vals into the VarHandle-based scheme, and VarHandle is a Java 9 API. This suite + * runs the agent (built at `-release:9`) against Scala 3.0-3.7 bytecode on whatever JVM is hosting the tests and + * asserts the application still produces correct output. When this suite runs on a Java 11 JVM (the `tests-jdk11` CI + * job), a green run proves the patched VarHandle bytecode verifies and executes on Java 9 — i.e. the published + * artifacts work there. * - * Unlike the JDK-24+ suites, this one makes NO assertions about `sun.misc.Unsafe` warnings (Java 9 - * does not emit them); it only checks correctness, so it is valid on any JDK >= 9. + * Unlike the JDK-24+ suites, this one makes NO assertions about `sun.misc.Unsafe` warnings (Java 9 does not emit + * them); it only checks correctness, so it is valid on any JDK >= 9. */ class Jdk9RuntimeTests extends FunSuite with ExampleLoader { @@ -24,11 +24,11 @@ class Jdk9RuntimeTests extends FunSuite with ExampleLoader { override val quietTests: Boolean = true /** Only the versions we actually run on Java 9. We exclude: - * - 3.0-3.2: the agent rewrites their bitmap lazy vals to the full 3.8 scheme, which needs the - * 3.8 scala-library runtime helpers — and that library is v61 (Java 17), unloadable on Java 9. + * - 3.0-3.2: the agent rewrites their bitmap lazy vals to the full 3.8 scheme, which needs the 3.8 scala-library + * runtime helpers — and that library is v61 (Java 17), unloadable on Java 9. * - 3.8.1: its compiler can't emit < v61, so the fixture itself can't run on Java 9. - * That leaves the 3.3-3.7 Unsafe->VarHandle path, whose own scala-library is v53 and ships the - * runtime control-state classes the patched bytecode needs. + * That leaves the 3.3-3.7 Unsafe->VarHandle path, whose own scala-library is v53 and ships the runtime control-state + * classes the patched bytecode needs. */ val runtimeVersions: Seq[String] = Seq("3.3.0", "3.3.6", "3.4.3", "3.5.2", "3.6.4", "3.7.3") @@ -57,9 +57,23 @@ class Jdk9RuntimeTests extends FunSuite with ExampleLoader { /** Full classpath (scala-library + deps) for a compiled example, via scala-cli. */ def getScalaCliClasspath(targetDir: os.Path, scalaVersion: String): String = - os.proc("scala-cli", "compile", "--print-classpath", "--jvm", "17", "--release", "9", "--bloop-startup-timeout", "180s", "-S", scalaVersion, targetDir.toString) - .call(cwd = targetDir, stderr = os.Pipe, stdout = os.Pipe) - .out.text().trim + os.proc( + "scala-cli", + "compile", + "--print-classpath", + "--jvm", + "17", + "--release", + "9", + "--bloop-startup-timeout", + "180s", + "-S", + scalaVersion, + targetDir.toString + ).call(cwd = targetDir, stderr = os.Pipe, stdout = os.Pipe) + .out + .text() + .trim /** Run a main class with the agent attached on the host JVM. */ def runWithAgent(outputDir: os.Path, scalaLibClasspath: String, mainClass: String): (Int, String, String) = { diff --git a/tests-jdk11/src/test/scala/sloth/LazyValDetectionTests.scala b/tests-jdk11/src/test/scala/sloth/LazyValDetectionTests.scala index 619a758..9463573 100644 --- a/tests-jdk11/src/test/scala/sloth/LazyValDetectionTests.scala +++ b/tests-jdk11/src/test/scala/sloth/LazyValDetectionTests.scala @@ -147,7 +147,11 @@ class LazyValDetectionTests extends FunSuite with ExampleLoader { // IMPORTANT: Tests should never detect Unknown version since we compile with known Scala 3 versions version match { case ScalaVersion.Unknown(reason) => - fail(s"Detected Unknown version for known Scala $scalaVersion - this indicates a bug in detection logic. Reason: $reason. LazyVals: ${lazyVals.map(lv => s"${lv.name} (version=${lv.version})").mkString(", ")}") + fail( + s"Detected Unknown version for known Scala $scalaVersion - this indicates a bug in detection logic. Reason: $reason. LazyVals: ${lazyVals + .map(lv => s"${lv.name} (version=${lv.version})") + .mkString(", ")}" + ) case _ => // OK } @@ -214,8 +218,13 @@ class LazyValDetectionTests extends FunSuite with ExampleLoader { } case LazyValDetectionResult.MixedVersions(lazyVals) => - val versionBreakdown = lazyVals.groupBy(_.version).map { case (v, lvs) => s"$v: ${lvs.map(_.name).mkString(", ")}" }.mkString("; ") - fail(s"Unexpected mixed versions for known Scala $scalaVersion - this indicates a bug in detection logic. Breakdown: $versionBreakdown") + val versionBreakdown = lazyVals + .groupBy(_.version) + .map { case (v, lvs) => s"$v: ${lvs.map(_.name).mkString(", ")}" } + .mkString("; ") + fail( + s"Unexpected mixed versions for known Scala $scalaVersion - this indicates a bug in detection logic. Breakdown: $versionBreakdown" + ) } } } diff --git a/tests-jdk11/src/test/scala/sloth/SemanticLazyValComparisonTests.scala b/tests-jdk11/src/test/scala/sloth/SemanticLazyValComparisonTests.scala index 11e2bdb..cd9b575 100644 --- a/tests-jdk11/src/test/scala/sloth/SemanticLazyValComparisonTests.scala +++ b/tests-jdk11/src/test/scala/sloth/SemanticLazyValComparisonTests.scala @@ -144,12 +144,11 @@ class SemanticLazyValComparisonTests extends FunSuite with ExampleLoader { inspectOnFailure(example, version1, s"Semantic comparison failure: Expected IDENTICAL but got $result") inspectOnFailure(example, version2, s"Semantic comparison failure: Expected IDENTICAL but got $result") fail(s"Expected IDENTICAL but got: $result") - else - if result.areIdentical then - // Inspect bytecode on failure - inspectOnFailure(example, version1, s"Semantic comparison failure: Expected DIFFERENT but got IDENTICAL") - inspectOnFailure(example, version2, s"Semantic comparison failure: Expected DIFFERENT but got IDENTICAL") - fail(s"Expected DIFFERENT but got IDENTICAL") + else if result.areIdentical then + // Inspect bytecode on failure + inspectOnFailure(example, version1, s"Semantic comparison failure: Expected DIFFERENT but got IDENTICAL") + inspectOnFailure(example, version2, s"Semantic comparison failure: Expected DIFFERENT but got IDENTICAL") + fail(s"Expected DIFFERENT but got IDENTICAL") // Print detailed results for debugging result match { diff --git a/tests-jdk25/src/test/scala/sloth/AgentIntegrationTests.scala b/tests-jdk25/src/test/scala/sloth/AgentIntegrationTests.scala index 8869cd5..8fa7f7c 100644 --- a/tests-jdk25/src/test/scala/sloth/AgentIntegrationTests.scala +++ b/tests-jdk25/src/test/scala/sloth/AgentIntegrationTests.scala @@ -5,8 +5,8 @@ import scala.compiletime.uninitialized /** End-to-end integration tests for the sloth java agent. * - * Verifies that attaching `-javaagent:sloth-agent.jar` to a JVM running - * Scala 3.3.6 code eliminates sun.misc.Unsafe warnings and produces correct output. + * Verifies that attaching `-javaagent:sloth-agent.jar` to a JVM running Scala 3.3.6 code eliminates sun.misc.Unsafe + * warnings and produces correct output. * * Requires Java 24+ (when Unsafe warnings appear). */ @@ -39,7 +39,9 @@ class AgentIntegrationTests extends FunSuite { case versionPattern(major) => val majorVersion = major.toInt if (majorVersion < 24) { - throw new RuntimeException(s"Java 24+ required for agent integration tests (Unsafe warnings), found Java $majorVersion") + throw new RuntimeException( + s"Java 24+ required for agent integration tests (Unsafe warnings), found Java $majorVersion" + ) } case _ => throw new RuntimeException(s"Could not parse Java version from: $javaVersionLine") @@ -55,11 +57,32 @@ class AgentIntegrationTests extends FunSuite { os.write(tempDir / "AgentTestApp.scala", testSource) println(s"Compiling test source with Scala $scalaVersion...") - os.proc("scala-cli", "compile", "--jvm", "17", "--bloop-startup-timeout", "180s", "-S", scalaVersion, tempDir.toString) - .call(cwd = tempDir, stdout = os.Inherit, stderr = os.Inherit) + os.proc( + "scala-cli", + "compile", + "--jvm", + "17", + "--bloop-startup-timeout", + "180s", + "-S", + scalaVersion, + tempDir.toString + ).call(cwd = tempDir, stdout = os.Inherit, stderr = os.Inherit) // Get classpath - val cpResult = os.proc("scala-cli", "compile", "--print-classpath", "--jvm", "17", "--bloop-startup-timeout", "180s", "-S", scalaVersion, tempDir.toString) + val cpResult = os + .proc( + "scala-cli", + "compile", + "--print-classpath", + "--jvm", + "17", + "--bloop-startup-timeout", + "180s", + "-S", + scalaVersion, + tempDir.toString + ) .call(cwd = tempDir, stderr = os.Pipe, stdout = os.Pipe) classpath = cpResult.out.text().trim @@ -68,7 +91,8 @@ class AgentIntegrationTests extends FunSuite { } test("Running without agent produces Unsafe warnings") { - val result = os.proc("java", "-cp", classpath, "AgentTestApp") + val result = os + .proc("java", "-cp", classpath, "AgentTestApp") .call(check = false, stderr = os.Pipe) val stdout = result.out.text().trim @@ -99,7 +123,8 @@ class AgentIntegrationTests extends FunSuite { } test("Running with agent eliminates Unsafe warnings") { - val result = os.proc("java", s"-javaagent:$agentJarPath", "-cp", classpath, "AgentTestApp") + val result = os + .proc("java", s"-javaagent:$agentJarPath", "-cp", classpath, "AgentTestApp") .call(check = false, stderr = os.Pipe) val stdout = result.out.text().trim @@ -116,7 +141,8 @@ class AgentIntegrationTests extends FunSuite { } test("Running with agent in verbose mode logs patched class") { - val result = os.proc("java", s"-javaagent:$agentJarPath=verbose", "-cp", classpath, "AgentTestApp") + val result = os + .proc("java", s"-javaagent:$agentJarPath=verbose", "-cp", classpath, "AgentTestApp") .call(check = false, stderr = os.Pipe) val stdout = result.out.text().trim diff --git a/tests-jdk25/src/test/scala/sloth/BytecodePatchingTests.scala b/tests-jdk25/src/test/scala/sloth/BytecodePatchingTests.scala index af3d028..d2ae380 100644 --- a/tests-jdk25/src/test/scala/sloth/BytecodePatchingTests.scala +++ b/tests-jdk25/src/test/scala/sloth/BytecodePatchingTests.scala @@ -140,7 +140,9 @@ class BytecodePatchingTests extends FunSuite with ExampleLoader { /** Finds a compiled classfile for a specific example, Scala version, and class name. */ def findClassFile(example: LoadedExample, scalaVersion: String, className: String): Option[Path] = { example.compilationResult.results.get(scalaVersion).flatMap { versionResult => - versionResult.classFiles.find(cf => cf.relativePath == s"$className.class" || cf.relativePath.endsWith(s"/$className.class")).map(_.absolutePath.toNIO) + versionResult.classFiles + .find(cf => cf.relativePath == s"$className.class" || cf.relativePath.endsWith(s"/$className.class")) + .map(_.absolutePath.toNIO) } } @@ -189,11 +191,24 @@ class BytecodePatchingTests extends FunSuite with ExampleLoader { detectionResult match { case LazyValDetectionResult.NoLazyVals => // OK case LazyValDetectionResult.LazyValsFound(lazyVals, ScalaVersion.Unknown(reason)) => - break(Left(s"Detected Unknown version for ${group.primaryName} compiled with Scala $version: $reason. LazyVals: ${lazyVals.map(lv => s"${lv.name} (version=${lv.version})").mkString(", ")}")) + break( + Left( + s"Detected Unknown version for ${group.primaryName} compiled with Scala $version: $reason. LazyVals: ${lazyVals + .map(lv => s"${lv.name} (version=${lv.version})") + .mkString(", ")}" + ) + ) case LazyValDetectionResult.LazyValsFound(_, _) => // OK case LazyValDetectionResult.MixedVersions(lazyVals) => - val versionBreakdown = lazyVals.groupBy(_.version).map { case (v, lvs) => s"$v: ${lvs.map(_.name).mkString(", ")}" }.mkString("; ") - break(Left(s"Detected mixed versions for ${group.primaryName} compiled with Scala $version. Breakdown: $versionBreakdown")) + val versionBreakdown = lazyVals + .groupBy(_.version) + .map { case (v, lvs) => s"$v: ${lvs.map(_.name).mkString(", ")}" } + .mkString("; ") + break( + Left( + s"Detected mixed versions for ${group.primaryName} compiled with Scala $version. Breakdown: $versionBreakdown" + ) + ) } } @@ -258,7 +273,18 @@ class BytecodePatchingTests extends FunSuite with ExampleLoader { */ def getScalaCliClasspath(targetDir: os.Path, scalaVersion: String): String = { val result = os - .proc("scala-cli", "compile", "--print-classpath", "--jvm", "17", "--bloop-startup-timeout", "180s", "-S", scalaVersion, targetDir.toString) + .proc( + "scala-cli", + "compile", + "--print-classpath", + "--jvm", + "17", + "--bloop-startup-timeout", + "180s", + "-S", + scalaVersion, + targetDir.toString + ) .call(cwd = targetDir, stderr = os.Pipe, stdout = os.Pipe) result.out.text().trim @@ -286,10 +312,9 @@ class BytecodePatchingTests extends FunSuite with ExampleLoader { (result.exitCode, result.out.text().trim, result.err.text().trim) } - /** Check that stderr has no Unsafe warnings from the application's own scala runtime. - * The agent's shaded internal runtime (sloth.shaded.scala.runtime.LazyVals$) may - * still trigger Unsafe warnings since the agent itself is compiled with Scala 3.7.x, - * but those don't affect the user's application. + /** Check that stderr has no Unsafe warnings from the application's own scala runtime. The agent's shaded internal + * runtime (sloth.shaded.scala.runtime.LazyVals$) may still trigger Unsafe warnings since the agent itself is + * compiled with Scala 3.7.x, but those don't affect the user's application. */ private def hasAppUnsafeWarning(stderr: String): Boolean = stderr.linesIterator.exists { line => @@ -315,8 +340,8 @@ class BytecodePatchingTests extends FunSuite with ExampleLoader { log(s"\n[${example.name}] Testing bytecode patching") // Only test patchable versions that were actually compiled - val compiledPatchableVersions = patchableVersions.filter(v => - example.compilationResult.results.get(v).exists(_.success)) + val compiledPatchableVersions = + patchableVersions.filter(v => example.compilationResult.results.get(v).exists(_.success)) example.metadata.expectedClasses.filter(_.lazyVals.nonEmpty).foreach { expectedClass => val className = expectedClass.className @@ -347,9 +372,19 @@ class BytecodePatchingTests extends FunSuite with ExampleLoader { if !comparison.areIdentical then // Inspect bytecode on failure - show both the patched version and the 3.8 reference - inspectOnFailure(example, version, s"Patching semantic comparison failure: Expected patched $version to be identical to 3.8 for $className, but got: $comparison") - inspectOnFailure(example, targetVersion, s"Patching semantic comparison failure: 3.8 reference for $className") - fail(s"Patched $version bytecode should be semantically identical to 3.8 for $className, but got: $comparison") + inspectOnFailure( + example, + version, + s"Patching semantic comparison failure: Expected patched $version to be identical to 3.8 for $className, but got: $comparison" + ) + inspectOnFailure( + example, + targetVersion, + s"Patching semantic comparison failure: 3.8 reference for $className" + ) + fail( + s"Patched $version bytecode should be semantically identical to 3.8 for $className, but got: $comparison" + ) log(s" ✓ Semantically identical to 3.8") @@ -377,8 +412,8 @@ class BytecodePatchingTests extends FunSuite with ExampleLoader { val mainClassName = example.metadata.mainClassName // Only test patchable versions that were actually compiled - val compiledPatchableVersions = patchableVersions.filter(v => - example.compilationResult.results.get(v).exists(_.success)) + val compiledPatchableVersions = + patchableVersions.filter(v => example.compilationResult.results.get(v).exists(_.success)) // Test pre-patched versions - should have Unsafe warning compiledPatchableVersions.foreach { version => @@ -487,8 +522,8 @@ class BytecodePatchingTests extends FunSuite with ExampleLoader { if (example.metadata.expectedClasses.exists(_.lazyVals.nonEmpty)) { log(s"\n[${example.name}] Testing idempotency") - val compiledPatchableVersions = patchableVersions.filter(v => - example.compilationResult.results.get(v).exists(_.success)) + val compiledPatchableVersions = + patchableVersions.filter(v => example.compilationResult.results.get(v).exists(_.success)) compiledPatchableVersions.headOption.foreach { version => patchAllClassFilesForVersion(example, version) match { case Right(patchedFilesMap) if patchedFilesMap.nonEmpty => diff --git a/tests/src/test/resources/fixtures/examples/abstract-class-companion-given-lazy-val/AbstractClassCompanionGivenLazyVal.scala b/tests/src/test/resources/fixtures/examples/abstract-class-companion-given-lazy-val/AbstractClassCompanionGivenLazyVal.scala index 1407249..ec9b49e 100644 --- a/tests/src/test/resources/fixtures/examples/abstract-class-companion-given-lazy-val/AbstractClassCompanionGivenLazyVal.scala +++ b/tests/src/test/resources/fixtures/examples/abstract-class-companion-given-lazy-val/AbstractClassCompanionGivenLazyVal.scala @@ -12,8 +12,7 @@ abstract class Parser[+T]: new Parser.Mapped[T, U](this, f) object Parser: - private final class Mapped[T, +U](underlying: Parser[T], f: T => U) - extends Parser[U]: + private final class Mapped[T, +U](underlying: Parser[T], f: T => U) extends Parser[U]: def parse(input: String): U = f(underlying.parse(input)) diff --git a/tests/src/test/resources/fixtures/examples/complex-initialization/ComplexInit.scala b/tests/src/test/resources/fixtures/examples/complex-initialization/ComplexInit.scala index 9930a95..94884d1 100644 --- a/tests/src/test/resources/fixtures/examples/complex-initialization/ComplexInit.scala +++ b/tests/src/test/resources/fixtures/examples/complex-initialization/ComplexInit.scala @@ -7,16 +7,15 @@ object ComplexInit: lazy val multiStep: String = val builder = StringBuilder() - for i <- 1 to 3 do - builder.append(s"step$i ") + for i <- 1 to 3 do builder.append(s"step$i ") builder.toString.trim lazy val withPatternMatch: String = val input: Any = 42 input match case i: Int if i > 0 => s"positive-$i" - case i: Int => s"non-positive-$i" - case _ => "unknown" + case i: Int => s"non-positive-$i" + case _ => "unknown" @main def main(): Unit = println(s"computed = ${ComplexInit.computed}") diff --git a/tests/src/test/resources/fixtures/examples/enum-lazy-val/Main.scala b/tests/src/test/resources/fixtures/examples/enum-lazy-val/Main.scala index 6f1bb72..25acda2 100644 --- a/tests/src/test/resources/fixtures/examples/enum-lazy-val/Main.scala +++ b/tests/src/test/resources/fixtures/examples/enum-lazy-val/Main.scala @@ -1,9 +1,9 @@ enum Color: case Red, Green, Blue lazy val hex: String = this match - case Red => "#FF0000" + case Red => "#FF0000" case Green => "#00FF00" - case Blue => "#0000FF" + case Blue => "#0000FF" @main def main() = println(Color.Red.hex) diff --git a/tests/src/test/resources/fixtures/examples/lazy-val-exception/LazyValException.scala b/tests/src/test/resources/fixtures/examples/lazy-val-exception/LazyValException.scala index e0a209c..e562aab 100644 --- a/tests/src/test/resources/fixtures/examples/lazy-val-exception/LazyValException.scala +++ b/tests/src/test/resources/fixtures/examples/lazy-val-exception/LazyValException.scala @@ -7,12 +7,8 @@ object LazyValException: @main def main(): Unit = println(s"safe = ${LazyValException.safe}") - try - println(LazyValException.failing) - catch - case e: RuntimeException => println(s"caught: ${e.getMessage}") - try - println(LazyValException.failing) - catch - case e: RuntimeException => println(s"caught again: ${e.getMessage}") + try println(LazyValException.failing) + catch case e: RuntimeException => println(s"caught: ${e.getMessage}") + try println(LazyValException.failing) + catch case e: RuntimeException => println(s"caught again: ${e.getMessage}") println(s"attempts = ${LazyValException.attempts}") diff --git a/tests/src/test/resources/fixtures/examples/multiple-lazy-vals/MultipleLazyVals.scala b/tests/src/test/resources/fixtures/examples/multiple-lazy-vals/MultipleLazyVals.scala index 3c13464..5c908d7 100644 --- a/tests/src/test/resources/fixtures/examples/multiple-lazy-vals/MultipleLazyVals.scala +++ b/tests/src/test/resources/fixtures/examples/multiple-lazy-vals/MultipleLazyVals.scala @@ -5,4 +5,6 @@ object MultipleLazyVals: lazy val fourth: Boolean = true @main def main() = - println(s"${MultipleLazyVals.first}, ${MultipleLazyVals.second}, ${MultipleLazyVals.third}, ${MultipleLazyVals.fourth}") + println( + s"${MultipleLazyVals.first}, ${MultipleLazyVals.second}, ${MultipleLazyVals.third}, ${MultipleLazyVals.fourth}" + ) diff --git a/tests/src/test/resources/fixtures/examples/sealed-inner-hierarchy/SealedInnerHierarchy.scala b/tests/src/test/resources/fixtures/examples/sealed-inner-hierarchy/SealedInnerHierarchy.scala index 24a65e4..0e9cdf8 100644 --- a/tests/src/test/resources/fixtures/examples/sealed-inner-hierarchy/SealedInnerHierarchy.scala +++ b/tests/src/test/resources/fixtures/examples/sealed-inner-hierarchy/SealedInnerHierarchy.scala @@ -12,9 +12,9 @@ abstract class Container: case class Third(value: String) extends Step(2) def process(step: Step): String = step match - case Step.First(v) => s"first: $v" + case Step.First(v) => s"first: $v" case Step.Second(v) => s"second: $v" - case Step.Third(v) => s"third: $v" + case Step.Third(v) => s"third: $v" class MyContainer extends Container