diff --git a/build.mill b/build.mill
index ee3c816..7f81d7f 100644
--- a/build.mill
+++ b/build.mill
@@ -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",
@@ -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 `true` 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")
diff --git a/lib/src/cellar/ContextResource.scala b/lib/src/cellar/ContextResource.scala
index ae63bc0..4342bf5 100644
--- a/lib/src/cellar/ContextResource.scala
+++ b/lib/src/cellar/ContextResource.scala
@@ -33,6 +33,7 @@ object ContextResource:
)
classpath = jreClasspath ++ jarClasspath
ctx <- IO.blocking(Context.initialize(classpath))
+ _ = JavaParamNames.register(ctx, classpath)
yield (ctx, classpath)
}
}
diff --git a/lib/src/cellar/JavaParamNames.scala b/lib/src/cellar/JavaParamNames.scala
new file mode 100644
index 0000000..45a515b
--- /dev/null
+++ b/lib/src/cellar/JavaParamNames.scala
@@ -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 == "" => "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
+ }
diff --git a/lib/src/cellar/TypePrinter.scala b/lib/src/cellar/TypePrinter.scala
index ec5abe7..c1b0e56 100644
--- a/lib/src/cellar/TypePrinter.scala
+++ b/lib/src/cellar/TypePrinter.scala
@@ -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 =
@@ -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)
@@ -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
@@ -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 ""
diff --git a/lib/test/src/cellar/TypePrinterTest.scala b/lib/test/src/cellar/TypePrinterTest.scala
index e52a252..1745751 100644
--- a/lib/test/src/cellar/TypePrinterTest.scala
+++ b/lib/test/src/cellar/TypePrinterTest.scala
@@ -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, ""), "def [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]")
}
}