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]") } }