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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ object lib extends CellarPublishModule {
mvn"co.fs2::fs2-io:3.13.0",
mvn"io.get-coursier:interface:1.0.28",
mvn"ch.epfl.scala::tasty-query:1.8.0",
mvn"org.ow2.asm:asm:9.7.1",
mvn"org.scala-lang:scala3-tasty-inspector_3:3.8.4",
mvn"com.github.pureconfig::pureconfig-core:0.17.10",
mvn"org.typelevel::log4cats-core:2.7.1",
Expand Down Expand Up @@ -224,7 +225,9 @@ def fixturePom(desc: String) = PomSettings(
)

object fixtureJava extends JavaModule with PublishModule {
def javacOptions = Seq("--release", "17")
// `-g` emits LocalVariableTable, the parameter-name source for Java jars built without
// `-parameters` (Maven's default `<debug>true</debug>` passes it too)
def javacOptions = Seq("--release", "17", "-g")
def publishVersion = "0.1.0-SNAPSHOT"
def artifactName = "cellar-fixture-java"
def pomSettings = fixturePom("Cellar Java test fixture")
Expand Down
1 change: 1 addition & 0 deletions lib/src/cellar/ContextResource.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ object ContextResource:
)
classpath = jreClasspath ++ jarClasspath
ctx <- IO.blocking(Context.initialize(classpath))
_ = JavaParamNames.register(ctx, classpath)
yield (ctx, classpath)
}
}
Expand Down
158 changes: 158 additions & 0 deletions lib/src/cellar/JavaParamNames.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cellar

import org.objectweb.asm.{ClassReader, ClassVisitor, Label, MethodVisitor, Opcodes, Type as AsmType}
import tastyquery.Classpaths.Classpath
import tastyquery.Contexts.Context
import tastyquery.Symbols.{ClassSymbol, PackageSymbol, TermSymbol}
import tastyquery.Types.*

import java.util.{Collections, WeakHashMap}
import scala.collection.concurrent.TrieMap

/** Parameter names for Java methods whose classfile lacks `MethodParameters` (javac only emits it
* with `-parameters`, which the JDK itself does not use). The `LocalVariableTable` from `-g` still
* names every parameter of a method that has a body, so we read it with ASM and match the method
* by name plus erased parameter types, the only identity a classfile knows.
*/
object JavaParamNames:
private type Table = Map[(String, List[String]), List[String]]

private final class Registered(val classpath: Classpath):
val tables = TrieMap.empty[String, Option[Table]]

// tasty-query's Context does not expose its classpath, and the printer only has the Context,
// so the classpath is remembered here at construction time.
private val registry = Collections.synchronizedMap(new WeakHashMap[Context, Registered])

def register(ctx: Context, classpath: Classpath): Unit =
registry.put(ctx, Registered(classpath)): Unit

def namesFor(method: TermSymbol)(using ctx: Context): Option[List[String]] =
for
registered <- Option(registry.get(ctx))
owner <- method.owner match
case c: ClassSymbol => Some(c)
case _ => None
binary <- binaryName(owner)
table <- registered.tables.getOrElseUpdate(binary, readTable(registered.classpath, binary))
erased <- erasedParams(method, owner)
names <- table.get((method.name.toString, erased))
yield names

/** Java statics are declared on tasty-query's synthetic module class, but they live in the
* same classfile as the instance members, so the `$` module suffix is dropped.
*/
private def binaryName(cls: ClassSymbol): Option[String] =
val name = if cls.isModuleClass then cls.name.toString.stripSuffix("$") else cls.name.toString
cls.owner match
case pkg: PackageSymbol => Some(s"${pkg.fullName}.$name")
case outer: ClassSymbol => binaryName(outer).map(o => s"$o$$$name")
case _ => None

private def readTable(classpath: Classpath, binary: String): Option[Table] =
val dot = binary.lastIndexOf('.')
val pkg = if dot < 0 then "" else binary.substring(0, dot)
val simpleBinary = binary.substring(dot + 1)
classpath.iterator
.flatMap(_.listAllPackages())
.filter(_.dotSeparatedName == pkg)
.flatMap(_.getClassDataByBinaryName(simpleBinary))
.find(_.hasClassFile)
.map(data => parse(data.readClassFileBytes().unsafeArray))

private def parse(bytes: Array[Byte]): Table =
val table = Map.newBuilder[(String, List[String]), List[String]]
val visitor = new ClassVisitor(Opcodes.ASM9):
override def visitMethod(
access: Int,
name: String,
descriptor: String,
signature: String,
exceptions: Array[String]
): MethodVisitor =
val argTypes = AsmType.getArgumentTypes(descriptor)
val fromAttr = Array.fill[String](argTypes.length)(null)
val fromLvt = Array.fill[String](argTypes.length)(null)
var attrIndex = 0
// slot of each parameter: `this` takes slot 0 of an instance method, long/double take two
val slots = argTypes.scanLeft(if (access & Opcodes.ACC_STATIC) != 0 then 0 else 1)(_ + _.getSize)
new MethodVisitor(Opcodes.ASM9):
override def visitParameter(name: String, access: Int): Unit =
if attrIndex < fromAttr.length then fromAttr(attrIndex) = name
attrIndex += 1
override def visitLocalVariable(
name: String,
descriptor: String,
signature: String,
start: Label,
end: Label,
index: Int
): Unit =
val i = slots.indexOf(index)
if i >= 0 && i < fromLvt.length && fromLvt(i) == null then fromLvt(i) = name
override def visitEnd(): Unit =
val names = List(fromAttr, fromLvt).find(_.forall(_ != null))
names.foreach(n => table += (name, argTypes.map(_.getClassName).toList) -> n.toList)
new ClassReader(bytes).accept(visitor, ClassReader.SKIP_FRAMES)
table.result()

/** Erases a Java method's parameter types to the names ASM's `Type.getClassName` produces.
* Done structurally, without resolving symbols: tasty-query's own erasure throws on some Java
* generic arrays, and a failure here must only cost the names, never the signature.
*/
private def erasedParams(method: TermSymbol, owner: ClassSymbol): Option[List[String]] =
def clauses(tpe: TypeOrMethodic): List[Type] =
tpe match
case t: MethodType => t.paramTypes ++ clauses(t.resultType)
case t: PolyType => clauses(t.resultType)
case _ => Nil
try Some(clauses(method.declaredType).map(erase(_, owner)))
catch case _: Exception => None

private val primitives = Map(
"scala.Int" -> "int",
"scala.Long" -> "long",
"scala.Double" -> "double",
"scala.Float" -> "float",
"scala.Boolean" -> "boolean",
"scala.Byte" -> "byte",
"scala.Short" -> "short",
"scala.Char" -> "char",
"scala.Unit" -> "void",
"scala.Any" -> "java.lang.Object",
"scala.AnyRef" -> "java.lang.Object"
)

private def erase(tpe: Type, owner: ClassSymbol): String =
tpe match
case t: TypeRef if t.name.toString == "<FromJavaObject>" => "java.lang.Object"
case t: TypeRef =>
t.prefix match
case p: PackageRef => primitives.getOrElse(s"${p.fullyQualifiedName}.${t.name}", s"${p.fullyQualifiedName}.${t.name}")
case p: TypeRef => s"${erase(p, owner)}$$${t.name}"
case _ =>
classTypeParamBound(owner, t.name.toString) match
case Some(bound) => erase(bound, owner)
case None => s"${binaryName(owner).get}$$${t.name}"
case t: AppliedType =>
t.tycon match
case tycon: TypeRef if tycon.name.toString == "Array" => s"${eraseArg(t.args.head, owner)}[]"
case tycon => erase(tycon, owner)
case t: TypeParamRef => erase(t.binder.paramTypeBounds(t.paramNum).high, owner)
case t: RepeatedType => s"${erase(t.elemType, owner)}[]"
case t: AnnotatedType => erase(t.typ, owner)
case t: FlexibleType => erase(t.nonNullableType, owner)
case t: AndType => erase(t.first, owner)
case other => throw IllegalArgumentException(s"cannot erase ${other.getClass.getSimpleName}")

private def eraseArg(arg: TypeOrWildcard, owner: ClassSymbol): String =
arg match
case t: Type => erase(t, owner)
case w: WildcardTypeArg => erase(w.bounds.high, owner)

private def classTypeParamBound(cls: ClassSymbol, name: String): Option[Type] =
cls.typeParams.find(_.name.toString == name).map(_.declaredBounds.high).orElse {
cls.owner match
case outer: ClassSymbol => classTypeParamBound(outer, name)
case _ => None
}
31 changes: 22 additions & 9 deletions lib/src/cellar/TypePrinter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ object TypePrinter:
/** `paramSymss` runs alongside the type: the type alone has no notion of a default argument,
* only the parameter symbol does, and a method's clauses line up one-to-one with them.
*/
def printMethodic(tpe: TypeOrMethodic, paramSymss: List[ParamSymbolsClause] = Nil)(using ctx: Context): String =
def printMethodic(
tpe: TypeOrMethodic,
paramSymss: List[ParamSymbolsClause] = Nil,
javaNames: Option[List[String]] = None
)(using ctx: Context): String =
tpe match
case t: MethodType =>
val prefix =
Expand All @@ -100,19 +104,20 @@ object TypePrinter:
val defaults = paramSymss.headOption match
case Some(Left(syms)) => syms.map(_.isParamWithDefault)
case _ => Nil
val params = t.paramNames.zip(t.paramTypes).zipWithIndex.map { case ((n, tp), i) =>
val names = javaNames.filter(_.length == t.paramNames.length).getOrElse(t.paramNames.map(_.toString))
val params = names.zip(t.paramTypes).zipWithIndex.map { case ((n, tp), i) =>
val default = if defaults.lift(i).contains(true) then " = ..." else ""
s"$n: ${printType(tp)}$default"
}
val paramStr = s"($prefix${params.mkString(", ")})"
val rest = t.resultType match
case _: MethodType | _: PolyType => printMethodic(t.resultType, paramSymss.drop(1))
case r => s": ${printMethodic(r, Nil)}"
case _: MethodType | _: PolyType => printMethodic(t.resultType, paramSymss.drop(1), None)
case r => s": ${printMethodic(r, Nil, None)}"
s"$paramStr$rest"

case t: PolyType =>
val typeParams = t.paramNames.zip(t.paramTypeBounds).map(printTypeParam)
s"[${typeParams.mkString(", ")}]${printMethodic(t.resultType, paramSymss.drop(1))}"
s"[${typeParams.mkString(", ")}]${printMethodic(t.resultType, paramSymss.drop(1), javaNames)}"

case t: Type => printType(t)

Expand Down Expand Up @@ -141,7 +146,11 @@ object TypePrinter:
case term: TermSymbol =>
val keyword = termKeyword(term)
if term.isModuleVal then s"$keyword ${term.name}"
else s"$keyword ${term.name}${printTopLevelMethodic(term.declaredType, term.paramSymss)}"
else
val javaNames =
if detectLanguage(term) == DetectedLanguage.Java && term.isMethod then JavaParamNames.namesFor(term)
else None
s"$keyword ${term.name}${printTopLevelMethodic(term.declaredType, term.paramSymss, javaNames)}"

case tm: TypeMemberSymbol =>
tm.typeDef match
Expand Down Expand Up @@ -174,13 +183,17 @@ object TypePrinter:
else if sym.isModuleVal then "object"
else "val"

private def printTopLevelMethodic(tpe: TypeOrMethodic, paramSymss: List[ParamSymbolsClause])(using ctx: Context): String =
private def printTopLevelMethodic(
tpe: TypeOrMethodic,
paramSymss: List[ParamSymbolsClause],
javaNames: Option[List[String]]
)(using ctx: Context): String =
tpe match
case t: Type => s": ${printType(t)}"
case t: PolyType =>
val typeParams = t.paramNames.zip(t.paramTypeBounds).map(printTypeParam)
s"[${typeParams.mkString(", ")}]${printTopLevelMethodic(t.resultType, paramSymss.drop(1))}"
case t: MethodType => printMethodic(t, paramSymss)
s"[${typeParams.mkString(", ")}]${printTopLevelMethodic(t.resultType, paramSymss.drop(1), javaNames)}"
case t: MethodType => printMethodic(t, paramSymss, javaNames)

private def printClassTypeParams(params: List[ClassTypeParamSymbol])(using ctx: Context): String =
if params.isEmpty then ""
Expand Down
25 changes: 24 additions & 1 deletion lib/test/src/cellar/TypePrinterTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,30 @@ class TypePrinterTest extends CatsEffectSuite:
given Context = ctx
val sig = sugarSig("java.lang.String", "equals")
assert(!sig.contains("FromJavaObject"), s"leaked internal type name: $sig")
assertEquals(sig, "def equals(x$0: Object): Boolean")
assertEquals(sig, "def equals(anObject: Object): Boolean")
}
}

test("printSymbolSignature names Java parameters from LocalVariableTable when MethodParameters is absent"):
withJavaCtx { ctx =>
IO.blocking {
given Context = ctx
val fqn = "cellar.fixture.java.CellarJavaClass"
val cls = ctx.findStaticClass(fqn)
val formats = cls.declarations.filter(_.name.toString == "format").map(TypePrinter.printSymbolSignature).sorted
assertEquals(
formats,
List(
"def format(value: Int): String",
"def format(value: Int, verbose: Boolean): String",
"def format(value: String): String"
)
)
assertEquals(sugarSig(fqn, "repeat"), "def repeat(value: T, times: Int): List[T]")
assertEquals(sugarSig(fqn, "<init>"), "def <init>[T <: Comparable[T]](defaultValue: T): Unit")
val module = ctx.findStaticModuleClass(fqn)
val of = module.declarations.find(_.name.toString == "of").get
assertEquals(TypePrinter.printSymbolSignature(of), "def of[E <: Comparable[E]](value: E): CellarJavaClass[E]")
}
}

Expand Down