diff --git a/src/main/java/com/heliosdecompiler/transformerapi/StandardTransformers.java b/src/main/java/com/heliosdecompiler/transformerapi/StandardTransformers.java
index 1953421..fb097b2 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/StandardTransformers.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/StandardTransformers.java
@@ -92,7 +92,7 @@ public static Decompiler> valueOf(String engineName) {
*
* Gets the default decompiler (currently Vineflower)
* @since 4.2.1
- * @return the default decompiler (currently Vineflower)
+ * @return the default decompiler
*/
public static Decompiler> getDefault() {
return valueOf("");
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/common/AbstractResultSaver.java b/src/main/java/com/heliosdecompiler/transformerapi/common/AbstractResultSaver.java
index 4011d7e..398e3c6 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/common/AbstractResultSaver.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/common/AbstractResultSaver.java
@@ -16,13 +16,15 @@
package com.heliosdecompiler.transformerapi.common;
+import org.jetbrains.java.decompiler.main.extern.IResultSaver;
+
import java.util.HashMap;
import java.util.Map;
import java.util.jar.Manifest;
import jd.core.DecompilationResult;
-public abstract class AbstractResultSaver {
+public abstract class AbstractResultSaver implements IResultSaver {
private static final String UNEXPECTED = "Unexpected";
@@ -57,6 +59,7 @@ public void saveFolder(String path) {
public void copyFile(String source, String path, String entryName) {
}
+ @Override
public void saveClassFile(String path, String qualifiedName, String entryName, String content, int[] mapping) {
if (mapping != null) {
lineNumbers = true;
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/common/BytecodeSourceLinker.java b/src/main/java/com/heliosdecompiler/transformerapi/common/BytecodeSourceLinker.java
new file mode 100644
index 0000000..b1fae7a
--- /dev/null
+++ b/src/main/java/com/heliosdecompiler/transformerapi/common/BytecodeSourceLinker.java
@@ -0,0 +1,1192 @@
+/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package com.heliosdecompiler.transformerapi.common;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Strings;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.tree.ClassNode;
+import org.objectweb.asm.tree.FieldNode;
+import org.objectweb.asm.tree.MethodNode;
+
+import jd.core.DecompilationResult;
+import jd.core.links.ReferenceData;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Best-effort source linker for decompilers that only provide rendered Java text.
+ * Rebuilds JD-style declarations and hyperlinks from plain decompiled Java source when a decompiler
+ * only returns rendered text and bytecode.
+ *
+ *
This linker is intentionally heuristic. It tokenizes the rendered source, aligns the tokens with
+ * bytecode members collected from the requested type, its nested types, and any directly loaded
+ * superclasses, then emits links for the symbols it can match confidently.
+ *
+ *
The implementation is precise for:
+ *
+ * - Type declarations and most nested-type references that can be matched by simple name.
+ * - Method and constructor declarations whose visible parameter list can be converted back to a
+ * JVM descriptor from the rendered source.
+ * - Field declarations and explicit field accesses such as {@code this.field}, {@code Outer.this.field},
+ * {@code super.field}, and {@code Type.field}.
+ * - Constructor calls written as {@code this(...)}, {@code super(...)}, or {@code new Type(...)}.
+ *
+ *
+ * The implementation is deliberately conservative for:
+ *
+ * - Method and constructor call references: calls are resolved from the explicit owner when one is
+ * visible and then matched primarily by visible argument count. The linker does not infer runtime
+ * argument types from expressions.
+ * - Overloaded calls where multiple candidates share the same visible arity. In those cases the
+ * source descriptor is usually unavailable, so the link may stay unresolved or fall back to the
+ * only matching arity candidate.
+ * - Lambdas, method references, and anonymous classes passed as call arguments. They contribute to
+ * the call arity, but the functional interface target type is not reconstructed from source, so
+ * overloads distinguished only by lambda or anonymous-class target type are not resolved precisely.
+ * - Unqualified field accesses that could refer to locals, parameters, inherited fields, or implicit
+ * receiver fields. The linker only attaches field references when the owner is explicit in source.
+ * - Cases that depend on imports, local variable types, generic substitutions, flow analysis, or full
+ * Java name resolution. This class does not build an AST or symbol table.
+ *
+ *
+ * As a result, this class should be treated as a compatibility layer for text-only decompilers, not
+ * as a full Java semantic resolver.
+ *
+ * @since 4.2.3
+ */
+public final class BytecodeSourceLinker {
+
+ private static final String CONSTRUCTOR_NAME = "";
+ private static final String THIS_KEYWORD = "this";
+ private static final String SUPER_KEYWORD = "super";
+
+ private static final Map PRIMITIVES = Map.of(
+ "void", "V",
+ "boolean", "Z",
+ "byte", "B",
+ "char", "C",
+ "short", "S",
+ "int", "I",
+ "long", "J",
+ "float", "F",
+ "double", "D"
+ );
+
+ private BytecodeSourceLinker() {
+ }
+
+ /**
+ * Populate declarations and references after a decompiler has produced source text.
+ */
+ public static void link(DecompilationResult result, String source, String rootInternalName, Map importantData) {
+ if (source == null || source.isEmpty() || importantData.isEmpty()) {
+ return;
+ }
+ BytecodeIndex index = BytecodeIndex.of(rootInternalName, importantData);
+ SourceIndex sourceIndex = SourceIndex.build(source, index);
+ sourceIndex.addDeclarations(result);
+ sourceIndex.addReferences(result);
+ }
+
+ private record TypeInfo(
+ String internalName,
+ String simpleName,
+ String parentInternalName,
+ String superInternalName,
+ boolean requiresOuterInstance,
+ Map fields,
+ Map> methods
+ ) {
+ }
+
+ private record FieldInfo(
+ String ownerInternalName,
+ String name,
+ String descriptor
+ ) {
+ }
+
+ private record MethodInfo(
+ String ownerInternalName,
+ String displayName,
+ String jvmName,
+ String descriptor,
+ int parameterCount
+ ) {
+ }
+
+ private record ClassScope(
+ String internalName,
+ String simpleName,
+ int declarationStart,
+ int declarationLength,
+ int bodyStart,
+ int bodyEnd,
+ int bodyDepth
+ ) {
+ boolean contains(int position) {
+ return position >= bodyStart && position < bodyEnd;
+ }
+ }
+
+ private record MethodDeclaration(
+ String ownerInternalName,
+ String displayName,
+ String jvmName,
+ String descriptor,
+ int start,
+ int length
+ ) {
+ }
+
+ private record FieldDeclaration(
+ String ownerInternalName,
+ String name,
+ String descriptor,
+ int start,
+ int length
+ ) {
+ }
+
+ private record Token(
+ String text,
+ int start,
+ int end
+ ) {
+ }
+
+ private record BytecodeIndex(
+ Map byInternalName,
+ Map> bySimpleName,
+ String rootInternalName
+ ) {
+
+ static BytecodeIndex of(String rootInternalName, Map importantData) {
+ Map byInternalName = new LinkedHashMap<>();
+ for (Map.Entry entry : importantData.entrySet()) {
+ ClassReader reader = new ClassReader(entry.getValue());
+ ClassNode classNode = new ClassNode();
+ reader.accept(classNode, ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG);
+ boolean requiresOuterInstance = classNode.outerClass != null && (classNode.access & Opcodes.ACC_STATIC) == 0;
+ Map fields = new LinkedHashMap<>();
+ for (FieldNode field : classNode.fields) {
+ fields.put(field.name, new FieldInfo(classNode.name, field.name, field.desc));
+ }
+ Map> methods = new LinkedHashMap<>();
+ for (MethodNode method : classNode.methods) {
+ // Group overloaded methods under the same JVM name for later disambiguation by arity.
+ methods.computeIfAbsent(method.name, ignored -> new ArrayList<>())
+ .add(new MethodInfo(classNode.name, displayMethodName(classNode.name, method.name), method.name, method.desc, visibleParameterCount(method, requiresOuterInstance)));
+ }
+ byInternalName.put(classNode.name, new TypeInfo(classNode.name, simpleName(classNode.name), parentInternalName(classNode.name), classNode.superName, requiresOuterInstance, fields, methods));
+ }
+ Map> bySimpleName = new HashMap<>();
+ for (TypeInfo info : byInternalName.values()) {
+ bySimpleName.computeIfAbsent(info.simpleName(), ignored -> new ArrayList<>()).add(info);
+ }
+ return new BytecodeIndex(byInternalName, bySimpleName, rootInternalName);
+ }
+
+ TypeInfo resolveDeclaredType(String simpleName, String parentInternalName) {
+ List candidates = bySimpleName.get(simpleName);
+ if (candidates == null) {
+ return null;
+ }
+ for (TypeInfo candidate : candidates) {
+ if (Objects.equals(candidate.parentInternalName(), parentInternalName)) {
+ return candidate;
+ }
+ }
+ if (parentInternalName == null) {
+ for (TypeInfo candidate : candidates) {
+ if (candidate.internalName().equals(rootInternalName)) {
+ return candidate;
+ }
+ }
+ }
+ return candidates.size() == 1 ? candidates.get(0) : null;
+ }
+
+ TypeInfo resolveTypeReference(String simpleName, String currentInternalName) {
+ // Prefer the current nesting scope before falling back to any matching known type.
+ TypeInfo local = resolveDeclaredType(simpleName, currentInternalName);
+ if (local != null) {
+ return local;
+ }
+ List candidates = bySimpleName.get(simpleName);
+ if (candidates == null) {
+ return null;
+ }
+ for (TypeInfo candidate : candidates) {
+ if (candidate.internalName().equals(rootInternalName)) {
+ return candidate;
+ }
+ }
+ return null;
+ }
+
+ FieldInfo resolveField(String ownerInternalName, String name) {
+ TypeInfo typeInfo = byInternalName.get(ownerInternalName);
+ return typeInfo == null ? null : typeInfo.fields().get(name);
+ }
+
+ MethodInfo resolveMethod(String ownerInternalName, String name, int parameterCount) {
+ TypeInfo typeInfo = byInternalName.get(ownerInternalName);
+ if (typeInfo == null) {
+ return null;
+ }
+ List candidates = typeInfo.methods().get(name);
+ if (candidates == null) {
+ return null;
+ }
+ if (candidates.size() == 1) {
+ return candidates.get(0);
+ }
+ for (MethodInfo candidate : candidates) {
+ if (candidate.parameterCount() == parameterCount) {
+ return candidate;
+ }
+ }
+ return null;
+ }
+
+ MethodInfo resolveMethod(String ownerInternalName, String displayName, String signature) {
+ TypeInfo typeInfo = byInternalName.get(ownerInternalName);
+ List candidates = typeInfo == null ? null : typeInfo.methods().get(jvmMethodName(ownerInternalName, displayName));
+ if (candidates == null) {
+ return null;
+ }
+ for (MethodInfo candidate : candidates) {
+ if (candidate.descriptor().equals(signature)) {
+ return candidate;
+ }
+ }
+ return null;
+ }
+
+ String superInternalName(String internalName) {
+ TypeInfo typeInfo = byInternalName.get(internalName);
+ return typeInfo == null ? null : typeInfo.superInternalName();
+ }
+
+ private static String jvmMethodName(String ownerInternalName, String displayName) {
+ return simpleName(ownerInternalName).equals(displayName) ? CONSTRUCTOR_NAME : displayName;
+ }
+
+ private static int visibleParameterCount(MethodNode method, boolean requiresOuterInstance) {
+ int parameterCount = Type.getArgumentTypes(method.desc).length;
+ if (requiresOuterInstance && CONSTRUCTOR_NAME.equals(method.name) && parameterCount > 0) {
+ return parameterCount - 1;
+ }
+ return parameterCount;
+ }
+ }
+
+ private record SourceIndex(
+ BytecodeIndex bytecodeIndex,
+ List tokens,
+ List classScopes,
+ Map fieldDeclarationsByStart,
+ Map methodDeclarationsByStart,
+ Map declarationLengths
+ ) {
+
+ static SourceIndex build(String source, BytecodeIndex bytecodeIndex) {
+ String masked = maskNonCode(source);
+ List tokens = tokenize(masked);
+ List classScopes = new ArrayList<>();
+ Map fieldDeclarationsByStart = new LinkedHashMap<>();
+ Map methodDeclarationsByStart = new LinkedHashMap<>();
+ Map declarationLengths = new HashMap<>();
+ Deque pendingClasses = new ArrayDeque<>();
+ Deque openClasses = new ArrayDeque<>();
+ BuildState buildState = new BuildState(declarationLengths, pendingClasses, openClasses, classScopes, fieldDeclarationsByStart, methodDeclarationsByStart);
+ int braceDepth = 0;
+ /* First pass: collect the declarations that appear directly in the rendered source. */
+ for (int i = 0; i < tokens.size(); i++) {
+ braceDepth = handleBuildToken(tokens, i, bytecodeIndex, buildState, braceDepth);
+ }
+ classScopes.sort(Comparator.comparingInt(ClassScope::bodyStart).thenComparingInt(ClassScope::bodyEnd));
+ return new SourceIndex(bytecodeIndex, tokens, classScopes, fieldDeclarationsByStart, methodDeclarationsByStart, declarationLengths);
+ }
+
+ void addDeclarations(DecompilationResult result) {
+ for (ClassScope classScope : classScopes) {
+ ResultLinkSupport.addDeclaration(result, classScope.declarationStart(), classScope.declarationLength(), new ResultLinkSupport.LinkTarget(classScope.internalName(), null, null));
+ }
+ for (FieldDeclaration field : fieldDeclarationsByStart.values()) {
+ ResultLinkSupport.addDeclaration(result, field.start(), field.length(), target(field.ownerInternalName(), field.name(), field.descriptor()));
+ }
+ for (MethodDeclaration method : methodDeclarationsByStart.values()) {
+ ResultLinkSupport.addDeclaration(result, method.start(), method.length(), target(method.ownerInternalName(), method.jvmName(), method.descriptor()));
+ }
+ }
+
+ void addReferences(DecompilationResult result) {
+ Map references = new HashMap<>();
+ for (int i = 0; i < tokens.size(); i++) {
+ addReferenceForToken(result, references, i);
+ }
+ }
+
+ private boolean addMethodReference(DecompilationResult result, Map references, String scopeInternalName, Token token, String ownerInternalName, int parameterCount) {
+ MethodInfo methodInfo = bytecodeIndex.resolveMethod(ownerInternalName, token.text(), parameterCount);
+ if (methodInfo == null) {
+ return false;
+ }
+ ResultLinkSupport.addReference(result, references, token.start(), token.end() - token.start(), target(methodInfo.ownerInternalName(), methodInfo.jvmName(), methodInfo.descriptor()), scopeInternalName);
+ return true;
+ }
+
+ private ClassScope enclosingClass(int position) {
+ ClassScope match = null;
+ for (ClassScope scope : classScopes) {
+ if (scope.contains(position) && isMoreSpecificScope(match, scope)) {
+ match = scope;
+ }
+ }
+ return match;
+ }
+
+ private ClassScope outerClass(ClassScope scope) {
+ for (ClassScope candidate : classScopes) {
+ if (candidate.bodyStart() < scope.bodyStart() && candidate.bodyEnd() > scope.bodyEnd()) {
+ return candidate;
+ }
+ }
+ return null;
+ }
+
+ private String explicitMethodOwner(Token previous, Token previousPrevious, ClassScope ownerScope) {
+ String previousText = textOf(previous);
+ String previousPreviousText = textOf(previousPrevious);
+ if (".".equals(previousText) && THIS_KEYWORD.equals(previousPreviousText)) {
+ return qualifiedThisOwner(tokens.indexOf(previousPrevious), ownerScope);
+ }
+ if (".".equals(previousText) && SUPER_KEYWORD.equals(previousPreviousText)) {
+ return bytecodeIndex.superInternalName(ownerScope.internalName());
+ }
+ if (".".equals(previousText) && previousPreviousText != null) {
+ TypeInfo typeInfo = bytecodeIndex.resolveTypeReference(previousPreviousText, ownerScope.internalName());
+ if (typeInfo != null) {
+ return typeInfo.internalName();
+ }
+ }
+ TypeInfo typeInfo = bytecodeIndex.resolveTypeReference(previousText, ownerScope.internalName());
+ return typeInfo == null ? null : typeInfo.internalName();
+ }
+
+ private void addReferenceForToken(DecompilationResult result, Map references, int index) {
+ Token token = tokens.get(index);
+ if (isLinkableToken(token)) {
+ ClassScope ownerScope = enclosingClass(token.start());
+ if (ownerScope != null) {
+ if (looksLikeMethodCall(tokens, index)) {
+ addMethodCallReference(result, references, index, token, ownerScope);
+ } else {
+ if (!addFieldReference(result, references, index, token, ownerScope)) {
+ addTypeReference(result, references, token, ownerScope);
+ }
+ }
+ }
+ }
+ }
+
+ private boolean isLinkableToken(Token token) {
+ return isIdentifier(token.text()) && !declarationLengths.containsKey(token.start());
+ }
+
+ private void addMethodCallReference(DecompilationResult result, Map references, int index, Token token, ClassScope ownerScope) {
+ if (addConstructorReference(result, references, index, token, ownerScope)) {
+ return;
+ }
+ String owner = resolveMethodOwner(index, ownerScope);
+ int parameterCount = countCallArguments(tokens, index);
+ boolean resolved = owner != null && addMethodReference(result, references, ownerScope.internalName(), token, owner, parameterCount);
+ if (!resolved) {
+ resolved = addMethodReference(result, references, ownerScope.internalName(), token, ownerScope.internalName(), parameterCount);
+ }
+ if (!resolved) {
+ addOuterMethodReference(result, references, token, ownerScope, parameterCount);
+ }
+ }
+
+ private String resolveMethodOwner(int index, ClassScope ownerScope) {
+ Token previous = previousSignificant(tokens, index);
+ Token previousPrevious = previousSignificant(tokens, index - 1);
+ return explicitMethodOwner(previous, previousPrevious, ownerScope);
+ }
+
+ private boolean addConstructorReference(DecompilationResult result, Map references, int index, Token token, ClassScope ownerScope) {
+ String ownerInternalName = constructorOwner(index, token, ownerScope);
+ if (ownerInternalName == null) {
+ return false;
+ }
+ MethodInfo constructor = bytecodeIndex.resolveMethod(ownerInternalName, CONSTRUCTOR_NAME, countCallArguments(tokens, index));
+ if (constructor == null) {
+ return false;
+ }
+ ResultLinkSupport.addReference(result, references, token.start(), token.end() - token.start(), target(constructor.ownerInternalName(), constructor.jvmName(), constructor.descriptor()), ownerScope.internalName());
+ return true;
+ }
+
+ private String constructorOwner(int index, Token token, ClassScope ownerScope) {
+ String tokenText = token.text();
+ if (THIS_KEYWORD.equals(tokenText)) {
+ return ownerScope.internalName();
+ }
+ if (SUPER_KEYWORD.equals(tokenText)) {
+ return bytecodeIndex.superInternalName(ownerScope.internalName());
+ }
+ Token previous = previousSignificant(tokens, index);
+ if (previous == null || !"new".equals(previous.text())) {
+ return null;
+ }
+ TypeInfo typeInfo = bytecodeIndex.resolveTypeReference(tokenText, ownerScope.internalName());
+ return typeInfo == null ? null : typeInfo.internalName();
+ }
+
+ private void addOuterMethodReference(DecompilationResult result, Map references, Token token, ClassScope ownerScope, int parameterCount) {
+ ClassScope outerScope = outerClass(ownerScope);
+ while (outerScope != null && !addMethodReference(result, references, ownerScope.internalName(), token, outerScope.internalName(), parameterCount)) {
+ outerScope = outerClass(outerScope);
+ }
+ }
+
+ private boolean addFieldReference(DecompilationResult result, Map references, int index, Token token, ClassScope ownerScope) {
+ String ownerInternalName = fieldOwner(index, ownerScope);
+ if (ownerInternalName == null) {
+ return false;
+ }
+ FieldInfo fieldInfo = bytecodeIndex.resolveField(ownerInternalName, token.text());
+ if (fieldInfo == null) {
+ return false;
+ }
+ ResultLinkSupport.addReference(result, references, token.start(), token.end() - token.start(), target(fieldInfo.ownerInternalName(), fieldInfo.name(), fieldInfo.descriptor()), ownerScope.internalName());
+ return true;
+ }
+
+ private String fieldOwner(int index, ClassScope ownerScope) {
+ Token previous = previousSignificant(tokens, index);
+ if (previous == null || !".".equals(previous.text())) {
+ return null;
+ }
+ Token qualifier = previousSignificant(tokens, index - 1);
+ String qualifierText = textOf(qualifier);
+ if (THIS_KEYWORD.equals(qualifierText)) {
+ return qualifiedThisOwner(tokens.indexOf(qualifier), ownerScope);
+ }
+ if (SUPER_KEYWORD.equals(qualifierText)) {
+ return bytecodeIndex.superInternalName(ownerScope.internalName());
+ }
+ TypeInfo typeInfo = bytecodeIndex.resolveTypeReference(qualifierText, ownerScope.internalName());
+ return typeInfo == null ? null : typeInfo.internalName();
+ }
+
+ private String qualifiedThisOwner(int thisIndex, ClassScope ownerScope) {
+ Token dotBeforeThis = previousSignificant(tokens, thisIndex);
+ Token typeToken = previousSignificant(tokens, thisIndex - 1);
+ if (dotBeforeThis != null && ".".equals(dotBeforeThis.text()) && typeToken != null) {
+ TypeInfo typeInfo = bytecodeIndex.resolveTypeReference(typeToken.text(), ownerScope.internalName());
+ if (typeInfo != null) {
+ return typeInfo.internalName();
+ }
+ }
+ return ownerScope.internalName();
+ }
+
+ private void addTypeReference(DecompilationResult result, Map references, Token token, ClassScope ownerScope) {
+ TypeInfo typeInfo = bytecodeIndex.resolveTypeReference(token.text(), ownerScope.internalName());
+ if (typeInfo != null) {
+ ResultLinkSupport.addReference(result, references, token.start(), token.end() - token.start(), target(typeInfo.internalName(), null, null), ownerScope.internalName());
+ }
+ }
+ }
+
+ private record PendingClass(
+ String internalName,
+ String simpleName,
+ int declarationStart,
+ int declarationLength
+ ) {
+ }
+
+ private record OpenClass(
+ String internalName,
+ String simpleName,
+ int declarationStart,
+ int declarationLength,
+ int bodyStart,
+ int bodyDepth
+ ) {
+ }
+
+ private record BuildState(
+ Map declarationLengths,
+ Deque pendingClasses,
+ Deque openClasses,
+ List classScopes,
+ Map fieldDeclarationsByStart,
+ Map methodDeclarationsByStart
+ ) {
+ }
+
+ private record ParameterType(
+ String rawType,
+ int arrayDepth,
+ boolean varArgs
+ ) {
+ }
+
+ private static MethodDeclaration tryCreateMethodDeclaration(List tokens, int index, OpenClass currentClass, BytecodeIndex bytecodeIndex, Map declarationLengths) {
+ Token nameToken = tokens.get(index);
+ if (!startsMethodDeclaration(tokens, index)) {
+ return null;
+ }
+ if (isQualifiedMethodName(tokens, index)) {
+ return null;
+ }
+ int closeIndex = findMatchingParen(tokens, index + 1);
+ Token after = declarationTerminator(tokens, closeIndex);
+ if (after == null || (!"{".equals(after.text()) && !";".equals(after.text()))) {
+ return null;
+ }
+ String signature = buildDescriptor(tokens, index + 1, closeIndex, currentClass.internalName(), bytecodeIndex);
+ MethodInfo methodInfo = signature == null ? null : bytecodeIndex.resolveMethod(currentClass.internalName(), nameToken.text(), signature);
+ if (methodInfo == null) {
+ methodInfo = bytecodeIndex.resolveMethod(currentClass.internalName(), currentClass.simpleName().equals(nameToken.text()) ? CONSTRUCTOR_NAME : nameToken.text(), countParameters(tokens, index + 1, closeIndex));
+ }
+ if (methodInfo == null) {
+ return null;
+ }
+ declarationLengths.put(nameToken.start(), nameToken.end() - nameToken.start());
+ return new MethodDeclaration(methodInfo.ownerInternalName(), nameToken.text(), methodInfo.jvmName(), methodInfo.descriptor(), nameToken.start(), nameToken.end() - nameToken.start());
+ }
+
+ private static String buildDescriptor(List tokens, int openParenIndex, int closeParenIndex, String currentInternalName, BytecodeIndex bytecodeIndex) {
+ List parameterDescriptors = new ArrayList<>();
+ List current = new ArrayList<>();
+ int genericDepth = 0;
+ for (int i = openParenIndex + 1; i < closeParenIndex; i++) {
+ genericDepth = consumeParameterToken(tokens.get(i), current, genericDepth, parameterDescriptors, currentInternalName, bytecodeIndex);
+ }
+ addParameterDescriptor(current, parameterDescriptors, currentInternalName, bytecodeIndex);
+ StringBuilder descriptor = new StringBuilder();
+ descriptor.append('(');
+ for (String parameterDescriptor : parameterDescriptors) {
+ descriptor.append(parameterDescriptor);
+ }
+ descriptor.append(')');
+ descriptor.append('V');
+ return descriptor.toString();
+ }
+
+ private static String toParameterDescriptor(List tokens, String currentInternalName, BytecodeIndex bytecodeIndex) {
+ ParameterType parameterType = parameterType(tokens);
+ int arrayDepth = parameterType.arrayDepth() + (parameterType.varArgs() ? 1 : 0);
+ return "[".repeat(arrayDepth) + typeDescriptor(parameterType.rawType(), currentInternalName, bytecodeIndex);
+ }
+
+ private static String typeDescriptor(String rawType, String currentInternalName, BytecodeIndex bytecodeIndex) {
+ String primitive = PRIMITIVES.get(rawType);
+ if (primitive != null) {
+ return primitive;
+ }
+ TypeInfo typeInfo = resolveKnownType(rawType, currentInternalName, bytecodeIndex);
+ if (typeInfo != null) {
+ return 'L' + typeInfo.internalName() + ';';
+ }
+ if (rawType.contains(".")) {
+ return 'L' + rawType.replace('.', '/') + ';';
+ }
+ return 'L' + rawType + ';';
+ }
+
+ private static int countParameters(List tokens, int openParenIndex, int closeParenIndex) {
+ if (openParenIndex + 1 == closeParenIndex) {
+ return 0;
+ }
+ int count = 1;
+ int genericDepth = 0;
+ for (int i = openParenIndex + 1; i < closeParenIndex; i++) {
+ String text = tokens.get(i).text();
+ if ("<".equals(text)) {
+ genericDepth++;
+ } else if (">".equals(text) && genericDepth > 0) {
+ genericDepth--;
+ } else if (",".equals(text) && genericDepth == 0) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private static boolean looksLikeMethodCall(List tokens, int index) {
+ Token next = nextSignificant(tokens, index);
+ if (next == null || !"(".equals(next.text())) {
+ return false;
+ }
+ Token previous = previousSignificant(tokens, index);
+ return previous == null || !isTypeKeyword(previous.text());
+ }
+
+ private static int countCallArguments(List tokens, int nameIndex) {
+ int openIndex = nameIndex + 1;
+ if (openIndex >= tokens.size() || !"(".equals(tokens.get(openIndex).text())) {
+ return 0;
+ }
+ int closeIndex = findMatchingParen(tokens, openIndex);
+ if (closeIndex < 0 || openIndex + 1 == closeIndex) {
+ return 0;
+ }
+ CallArgumentNesting nesting = new CallArgumentNesting();
+ int count = 1;
+ for (int i = openIndex + 1; i < closeIndex; i++) {
+ if (",".equals(tokens.get(i).text()) && nesting.isTopLevel()) {
+ count++;
+ continue;
+ }
+ nesting.accept(tokens, i, closeIndex);
+ }
+ return count;
+ }
+
+ private static int findMatchingParen(List tokens, int openIndex) {
+ int depth = 0;
+ for (int i = openIndex; i < tokens.size(); i++) {
+ String text = tokens.get(i).text();
+ if ("(".equals(text)) {
+ depth++;
+ } else if (")".equals(text)) {
+ depth--;
+ if (depth == 0) {
+ return i;
+ }
+ }
+ }
+ return -1;
+ }
+
+ private static Token nextIdentifier(List tokens, int startIndex) {
+ for (int i = startIndex; i < tokens.size(); i++) {
+ if (isIdentifier(tokens.get(i).text())) {
+ return tokens.get(i);
+ }
+ }
+ return null;
+ }
+
+ private static Token nextSignificant(List tokens, int index) {
+ int nextIndex = index + 1;
+ return nextIndex < tokens.size() ? tokens.get(nextIndex) : null;
+ }
+
+ private static Token previousSignificant(List tokens, int index) {
+ int previousIndex = index - 1;
+ return previousIndex >= 0 ? tokens.get(previousIndex) : null;
+ }
+
+ private static String textOf(Token token) {
+ return token == null ? null : token.text();
+ }
+
+ private static List tokenize(String source) {
+ List tokens = new ArrayList<>();
+ int i = 0;
+ while (i < source.length()) {
+ TokenMatch match = nextToken(source, i);
+ if (match.token() != null) {
+ tokens.add(match.token());
+ }
+ i = match.nextIndex();
+ }
+ return tokens;
+ }
+
+ private static String maskNonCode(String source) {
+ char[] chars = source.toCharArray();
+ MaskState state = MaskState.CODE;
+ /*
+ * Blank comments and literals so token offsets stay aligned with the original source while
+ * the token scanner only sees structural Java syntax.
+ */
+ for (int i = 0; i < chars.length; i++) {
+ state = switch (state) {
+ case CODE -> handleCodeState(chars, i);
+ case LINE_COMMENT -> handleLineCommentState(chars, i);
+ case BLOCK_COMMENT -> handleBlockCommentState(chars, i);
+ case STRING -> handleQuotedState(chars, i, '"', MaskState.STRING);
+ case CHAR -> handleQuotedState(chars, i, '\'', MaskState.CHAR);
+ };
+ }
+ return new String(chars);
+ }
+
+ private static boolean isIdentifier(String text) {
+ return StringUtils.isNotEmpty(text) && Character.isJavaIdentifierStart(text.charAt(0));
+ }
+
+ private static boolean isTypeKeyword(String text) {
+ return "class".equals(text) || "interface".equals(text) || "enum".equals(text) || "record".equals(text);
+ }
+
+ private static boolean isAnnotation(String text) {
+ return "@".equals(text) || text.startsWith("@");
+ }
+
+ private static boolean isModifier(String text) {
+ return switch (text) {
+ case "public", "protected", "private", "static", "final", "abstract", "synchronized", "native", "strictfp", "default", "transient", "volatile" -> true;
+ default -> false;
+ };
+ }
+
+ private static String displayMethodName(String internalName, String name) {
+ return CONSTRUCTOR_NAME.equals(name) ? simpleName(internalName) : name;
+ }
+
+ private static String simpleName(String internalName) {
+ int inner = internalName.lastIndexOf('$');
+ if (inner >= 0) {
+ return internalName.substring(inner + 1);
+ }
+ int pkg = internalName.lastIndexOf('/');
+ return pkg >= 0 ? internalName.substring(pkg + 1) : internalName;
+ }
+
+ private static String parentInternalName(String internalName) {
+ int inner = internalName.lastIndexOf('$');
+ return inner >= 0 ? internalName.substring(0, inner) : null;
+ }
+
+ private static int handleBuildToken(List tokens, int index, BytecodeIndex bytecodeIndex, BuildState buildState, int braceDepth) {
+ Token token = tokens.get(index);
+ String tokenText = token.text();
+ if (isTypeKeyword(tokenText)) {
+ registerTypeDeclaration(tokens, index, bytecodeIndex, buildState);
+ return braceDepth;
+ }
+ if ("{".equals(tokenText)) {
+ openPendingClass(token, braceDepth, buildState);
+ return braceDepth + 1;
+ }
+ if ("}".equals(tokenText)) {
+ closeClassScope(token, braceDepth, buildState);
+ return braceDepth - 1;
+ }
+ registerMethodDeclaration(tokens, index, bytecodeIndex, buildState, braceDepth);
+ registerFieldDeclaration(tokens, index, bytecodeIndex, buildState, braceDepth);
+ return braceDepth;
+ }
+
+ private static void registerTypeDeclaration(List tokens, int index, BytecodeIndex bytecodeIndex, BuildState buildState) {
+ Token nameToken = nextIdentifier(tokens, index + 1);
+ if (nameToken != null) {
+ String parentInternalName = buildState.openClasses().isEmpty() ? null : buildState.openClasses().peek().internalName();
+ TypeInfo typeInfo = bytecodeIndex.resolveDeclaredType(nameToken.text(), parentInternalName);
+ if (typeInfo != null) {
+ buildState.declarationLengths().put(nameToken.start(), nameToken.end() - nameToken.start());
+ buildState.pendingClasses().push(new PendingClass(typeInfo.internalName(), typeInfo.simpleName(), nameToken.start(), nameToken.end() - nameToken.start()));
+ }
+ }
+ }
+
+ private static void openPendingClass(Token token, int braceDepth, BuildState buildState) {
+ if (!buildState.pendingClasses().isEmpty()) {
+ PendingClass pending = buildState.pendingClasses().pop();
+ buildState.openClasses().push(new OpenClass(pending.internalName(), pending.simpleName(), pending.declarationStart(), pending.declarationLength(), token.end(), braceDepth + 1));
+ }
+ }
+
+ private static void closeClassScope(Token token, int braceDepth, BuildState buildState) {
+ if (!buildState.openClasses().isEmpty() && buildState.openClasses().peek().bodyDepth() == braceDepth) {
+ OpenClass open = buildState.openClasses().pop();
+ buildState.classScopes().add(new ClassScope(open.internalName(), open.simpleName(), open.declarationStart(), open.declarationLength(), open.bodyStart(), token.start(), open.bodyDepth()));
+ }
+ }
+
+ private static void registerMethodDeclaration(List tokens, int index, BytecodeIndex bytecodeIndex, BuildState buildState, int braceDepth) {
+ if (!buildState.openClasses().isEmpty() && braceDepth == buildState.openClasses().peek().bodyDepth() && isIdentifier(tokens.get(index).text())) {
+ MethodDeclaration declaration = tryCreateMethodDeclaration(tokens, index, buildState.openClasses().peek(), bytecodeIndex, buildState.declarationLengths());
+ if (declaration != null) {
+ buildState.methodDeclarationsByStart().put(declaration.start(), declaration);
+ }
+ }
+ }
+
+ private static void registerFieldDeclaration(List tokens, int index, BytecodeIndex bytecodeIndex, BuildState buildState, int braceDepth) {
+ if (!buildState.openClasses().isEmpty() && braceDepth == buildState.openClasses().peek().bodyDepth() && isIdentifier(tokens.get(index).text())) {
+ FieldDeclaration declaration = tryCreateFieldDeclaration(tokens, index, buildState.openClasses().peek(), bytecodeIndex, buildState.declarationLengths());
+ if (declaration != null) {
+ buildState.fieldDeclarationsByStart().put(declaration.start(), declaration);
+ }
+ }
+ }
+
+ private static FieldDeclaration tryCreateFieldDeclaration(List tokens, int index, OpenClass currentClass, BytecodeIndex bytecodeIndex, Map declarationLengths) {
+ Token nameToken = tokens.get(index);
+ if (startsMethodDeclaration(tokens, index) || isQualifiedFieldName(tokens, index) || !hasFieldTerminator(tokens, index)) {
+ return null;
+ }
+ FieldInfo fieldInfo = bytecodeIndex.resolveField(currentClass.internalName(), nameToken.text());
+ if (fieldInfo == null) {
+ return null;
+ }
+ declarationLengths.put(nameToken.start(), nameToken.end() - nameToken.start());
+ return new FieldDeclaration(fieldInfo.ownerInternalName(), fieldInfo.name(), fieldInfo.descriptor(), nameToken.start(), nameToken.end() - nameToken.start());
+ }
+
+ private static boolean isMoreSpecificScope(ClassScope currentMatch, ClassScope candidate) {
+ return currentMatch == null || (candidate.bodyStart() >= currentMatch.bodyStart() && candidate.bodyEnd() <= currentMatch.bodyEnd());
+ }
+
+ private static boolean startsMethodDeclaration(List tokens, int index) {
+ Token next = nextSignificant(tokens, index);
+ return next != null && "(".equals(next.text());
+ }
+
+ private static boolean isQualifiedMethodName(List tokens, int index) {
+ String previousText = textOf(previousSignificant(tokens, index));
+ return "new".equals(previousText) || ".".equals(previousText);
+ }
+
+ private static boolean isQualifiedFieldName(List tokens, int index) {
+ return ".".equals(textOf(previousSignificant(tokens, index)));
+ }
+
+ private static boolean hasFieldTerminator(List tokens, int index) {
+ Token next = nextSignificant(tokens, index);
+ return next != null && ";".equals(next.text());
+ }
+
+ private static Token declarationTerminator(List tokens, int closeIndex) {
+ if (closeIndex < 0) {
+ return null;
+ }
+ Token after = nextSignificant(tokens, closeIndex);
+ while (after != null && "throws".equals(after.text())) {
+ after = nextTypeBoundary(tokens, tokens.indexOf(after));
+ }
+ return after;
+ }
+
+ private static void addParameterDescriptor(List current, List parameterDescriptors, String currentInternalName, BytecodeIndex bytecodeIndex) {
+ if (!current.isEmpty()) {
+ parameterDescriptors.add(toParameterDescriptor(current, currentInternalName, bytecodeIndex));
+ current.clear();
+ }
+ }
+
+ private static Token nextTypeBoundary(List tokens, int startIndex) {
+ for (int i = startIndex + 1; i < tokens.size(); i++) {
+ Token candidate = tokens.get(i);
+ if ("{".equals(candidate.text()) || ";".equals(candidate.text())) {
+ return candidate;
+ }
+ }
+ return null;
+ }
+
+ private static int consumeParameterToken(Token token, List current, int genericDepth, List parameterDescriptors, String currentInternalName, BytecodeIndex bytecodeIndex) {
+ String tokenText = token.text();
+ if ("<".equals(tokenText)) {
+ current.add(token);
+ return genericDepth + 1;
+ }
+ if (">".equals(tokenText) && genericDepth > 0) {
+ current.add(token);
+ return genericDepth - 1;
+ }
+ if (",".equals(tokenText) && genericDepth == 0) {
+ addParameterDescriptor(current, parameterDescriptors, currentInternalName, bytecodeIndex);
+ return genericDepth;
+ }
+ current.add(token);
+ return genericDepth;
+ }
+
+ private static TypeInfo resolveKnownType(String rawType, String currentInternalName, BytecodeIndex bytecodeIndex) {
+ TypeInfo typeInfo = bytecodeIndex.resolveTypeReference(rawType, currentInternalName);
+ if (typeInfo != null) {
+ return typeInfo;
+ }
+ int separator = rawType.lastIndexOf('.');
+ if (separator < 0) {
+ return null;
+ }
+ return bytecodeIndex.resolveTypeReference(rawType.substring(separator + 1), currentInternalName);
+ }
+
+ private static ParameterType parameterType(List tokens) {
+ List typeTokens = new ArrayList<>();
+ int arrayDepth = 0;
+ boolean varArgs = false;
+ int index = 0;
+ while (index < tokens.size()) {
+ String tokenText = tokens.get(index).text();
+ if (isAnnotation(tokenText)) {
+ index = skipAnnotation(tokens, index) + 1;
+ } else if ("[".equals(tokenText)) {
+ arrayDepth++;
+ index++;
+ } else if ("...".equals(tokenText)) {
+ varArgs = true;
+ index++;
+ } else if (!isModifier(tokenText)) {
+ typeTokens.add(tokenText);
+ index++;
+ } else {
+ index++;
+ }
+ }
+ return new ParameterType(rawParameterType(typeTokens), arrayDepth, varArgs);
+ }
+
+ private static int skipAnnotation(List tokens, int index) {
+ int currentIndex = index + 1;
+ while (currentIndex < tokens.size()) {
+ String tokenText = tokens.get(currentIndex).text();
+ if (!".".equals(tokenText) && !isIdentifier(tokenText)) {
+ break;
+ }
+ currentIndex++;
+ }
+ if (currentIndex < tokens.size() && "(".equals(tokens.get(currentIndex).text())) {
+ int closeIndex = findMatchingParen(tokens, currentIndex);
+ return closeIndex >= 0 ? closeIndex : tokens.size() - 1;
+ }
+ return currentIndex - 1;
+ }
+
+ private static String rawParameterType(List typeTokens) {
+ if (typeTokens.size() < 2) {
+ return "java.lang.Object";
+ }
+ StringBuilder rawType = new StringBuilder();
+ int genericDepth = 0;
+ for (int i = 0; i < typeTokens.size() - 1; i++) {
+ String tokenText = typeTokens.get(i);
+ if ("<".equals(tokenText)) {
+ genericDepth++;
+ } else if (">".equals(tokenText) && genericDepth > 0) {
+ genericDepth--;
+ } else if (genericDepth == 0) {
+ if (".".equals(tokenText)) {
+ rawType.append('.');
+ } else {
+ rawType.append(tokenText);
+ }
+ }
+ }
+ return rawType.isEmpty() ? "java.lang.Object" : rawType.toString();
+ }
+
+ private static TokenMatch nextToken(String source, int index) {
+ char current = source.charAt(index);
+ if (Character.isWhitespace(current)) {
+ return new TokenMatch(null, index + 1);
+ }
+ if (Character.isJavaIdentifierStart(current)) {
+ return identifierToken(source, index);
+ }
+ if (isVarArgs(source, index)) {
+ return new TokenMatch(new Token("...", index, index + 3), index + 3);
+ }
+ return new TokenMatch(new Token(String.valueOf(current), index, index + 1), index + 1);
+ }
+
+ private static TokenMatch identifierToken(String source, int index) {
+ int end = index + 1;
+ while (end < source.length() && Character.isJavaIdentifierPart(source.charAt(end))) {
+ end++;
+ }
+ return new TokenMatch(new Token(source.substring(index, end), index, end), end);
+ }
+
+ private static boolean isVarArgs(String source, int index) {
+ return source.charAt(index) == '.' && index + 2 < source.length() && source.charAt(index + 1) == '.' && source.charAt(index + 2) == '.';
+ }
+
+ private static MaskState handleCodeState(char[] chars, int index) {
+ if (startsLineComment(chars, index)) {
+ blankPair(chars, index);
+ return MaskState.LINE_COMMENT;
+ }
+ if (startsBlockComment(chars, index)) {
+ blankPair(chars, index);
+ return MaskState.BLOCK_COMMENT;
+ }
+ if (chars[index] == '"') {
+ chars[index] = ' ';
+ return MaskState.STRING;
+ }
+ if (chars[index] == '\'') {
+ chars[index] = ' ';
+ return MaskState.CHAR;
+ }
+ return MaskState.CODE;
+ }
+
+ private static MaskState handleLineCommentState(char[] chars, int index) {
+ if (chars[index] == '\n') {
+ return MaskState.CODE;
+ }
+ chars[index] = ' ';
+ return MaskState.LINE_COMMENT;
+ }
+
+ private static MaskState handleBlockCommentState(char[] chars, int index) {
+ if (endsBlockComment(chars, index)) {
+ blankPair(chars, index);
+ return MaskState.CODE;
+ }
+ if (chars[index] != '\n') {
+ chars[index] = ' ';
+ }
+ return MaskState.BLOCK_COMMENT;
+ }
+
+ private static MaskState handleQuotedState(char[] chars, int index, char quote, MaskState quotedState) {
+ boolean closingQuote = chars[index] == quote && !isEscaped(chars, index);
+ if (chars[index] != '\n') {
+ chars[index] = ' ';
+ }
+ return closingQuote ? MaskState.CODE : quotedState;
+ }
+
+ private static boolean startsLineComment(char[] chars, int index) {
+ return chars[index] == '/' && index + 1 < chars.length && chars[index + 1] == '/';
+ }
+
+ private static boolean startsBlockComment(char[] chars, int index) {
+ return chars[index] == '/' && index + 1 < chars.length && chars[index + 1] == '*';
+ }
+
+ private static boolean endsBlockComment(char[] chars, int index) {
+ return chars[index] == '*' && index + 1 < chars.length && chars[index + 1] == '/';
+ }
+
+ private static void blankPair(char[] chars, int index) {
+ chars[index] = ' ';
+ if (index + 1 < chars.length) {
+ chars[index + 1] = ' ';
+ }
+ }
+
+ private static boolean isEscaped(char[] chars, int index) {
+ int backslashCount = 0;
+ for (int i = index - 1; i >= 0 && chars[i] == '\\'; i--) {
+ backslashCount++;
+ }
+ return (backslashCount & 1) == 1;
+ }
+
+ private static final class CallArgumentNesting {
+ private int parenDepth;
+ private int braceDepth;
+ private int bracketDepth;
+ private int angleDepth;
+
+ private boolean isTopLevel() {
+ return parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && angleDepth == 0;
+ }
+
+ private void accept(List tokens, int index, int closeIndex) {
+ switch (tokens.get(index).text()) {
+ case String s when "<".equals(s) && startsGenericArgumentList(tokens, index, closeIndex) -> angleDepth++;
+ case ">" -> decreaseAngleDepth();
+ case "(" -> parenDepth++;
+ case ")" -> parenDepth = decreaseDepth(parenDepth);
+ case "{" -> braceDepth++;
+ case "}" -> braceDepth = decreaseDepth(braceDepth);
+ case "[" -> bracketDepth++;
+ case "]" -> bracketDepth = decreaseDepth(bracketDepth);
+ default -> {
+ // No nesting state change for other tokens.
+ }
+ }
+ }
+
+ private boolean startsGenericArgumentList(List tokens, int openIndex, int limitIndex) {
+ Token previous = previousSignificant(tokens, openIndex);
+ Token next = nextSignificant(tokens, openIndex);
+ if (previous == null || next == null) {
+ return false;
+ }
+ String previousText = previous.text();
+ String nextText = next.text();
+ if (!Strings.CS.equalsAny(previousText, ".", "?", ">","]") && !isIdentifier(previousText)) {
+ return false;
+ }
+ if (!isIdentifier(nextText) && !"?".equals(nextText)) {
+ return false;
+ }
+ int closeIndex = findMatchingAngle(tokens, openIndex, limitIndex);
+ if (closeIndex < 0) {
+ return false;
+ }
+ Token after = nextSignificant(tokens, closeIndex);
+ String afterText = textOf(after);
+ if (".".equals(previousText)) {
+ return isIdentifier(afterText) || "(".equals(afterText);
+ }
+ return Strings.CS.equalsAny(afterText, "(", "[", ")", ",", ".", ";", ":", "}");
+ }
+
+ private static int findMatchingAngle(List tokens, int openIndex, int limitIndex) {
+ int depth = 0;
+ for (int i = openIndex; i < limitIndex; i++) {
+ String text = tokens.get(i).text();
+ if ("<".equals(text)) {
+ depth++;
+ } else if (">".equals(text)) {
+ depth--;
+ if (depth == 0) {
+ return i;
+ }
+ }
+ }
+ return -1;
+ }
+
+ private void decreaseAngleDepth() {
+ if (angleDepth > 0) {
+ angleDepth--;
+ }
+ }
+
+ private static int decreaseDepth(int depth) {
+ return depth > 0 ? depth - 1 : 0;
+ }
+ }
+
+ private static ResultLinkSupport.LinkTarget target(String typeName, String name, String descriptor) {
+ return new ResultLinkSupport.LinkTarget(typeName, name, descriptor);
+ }
+
+ private enum MaskState {
+ CODE,
+ LINE_COMMENT,
+ BLOCK_COMMENT,
+ STRING,
+ CHAR
+ }
+
+ private record TokenMatch(Token token, int nextIndex) {
+ }
+}
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/common/FileLoader.java b/src/main/java/com/heliosdecompiler/transformerapi/common/FileLoader.java
index 0f21049..8a64f08 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/common/FileLoader.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/common/FileLoader.java
@@ -34,13 +34,15 @@ public class FileLoader implements Loader {
protected static final Pattern CLASS_SUFFIX_PATTERN = Pattern.compile("\\.class$");
+ private final Path rootDirectory;
+
private final HashMap map = new HashMap<>();
public FileLoader(String rootLocation, String pkg, String className) throws IOException {
Objects.requireNonNull(rootLocation, "rootLocation");
Objects.requireNonNull(className, "className");
- Path rootDirectory = Paths.get(rootLocation);
+ this.rootDirectory = Paths.get(rootLocation);
Validate.isTrue(Files.isDirectory(rootDirectory), "Not a directory: " + rootDirectory);
String topLevelTypeName = stripClassSuffix(className);
@@ -95,11 +97,27 @@ protected String makeEntryName(String entryName) {
@Override
public byte[] load(String internalName) throws IOException {
- return map.get(stripClassSuffix(internalName));
+ String key = stripClassSuffix(internalName);
+ byte[] data = map.get(key);
+ if (data != null) {
+ return data;
+ }
+ Path classFile = classFilePath(key);
+ if (!Files.isRegularFile(classFile)) {
+ return data;
+ }
+ data = Files.readAllBytes(classFile);
+ map.put(key, data);
+ return data;
}
@Override
public boolean canLoad(String internalName) {
- return map.containsKey(stripClassSuffix(internalName));
+ String key = stripClassSuffix(internalName);
+ return map.containsKey(key) || Files.isRegularFile(classFilePath(key));
+ }
+
+ protected Path classFilePath(String internalName) {
+ return rootDirectory.resolve(internalName + ".class");
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/common/Loader.java b/src/main/java/com/heliosdecompiler/transformerapi/common/Loader.java
index 1a9e7b3..e1fa49e 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/common/Loader.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/common/Loader.java
@@ -117,7 +117,7 @@ public byte[] load(String internalName) throws IOException {
}
}
}
- return null;
+ return classContents;
}
private static ZipFile openZipFile(String classpathEntry) throws IOException {
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/common/ResultLinkSupport.java b/src/main/java/com/heliosdecompiler/transformerapi/common/ResultLinkSupport.java
new file mode 100644
index 0000000..82bc0df
--- /dev/null
+++ b/src/main/java/com/heliosdecompiler/transformerapi/common/ResultLinkSupport.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package com.heliosdecompiler.transformerapi.common;
+
+import java.util.Map;
+
+import jd.core.DecompilationResult;
+import jd.core.links.DeclarationData;
+import jd.core.links.HyperlinkReferenceData;
+import jd.core.links.ReferenceData;
+
+/**
+ * Shared helpers for writing declarations and hyperlinks into a {@link DecompilationResult}.
+ *
+ * @since 4.2.3
+ */
+public final class ResultLinkSupport {
+
+ /**
+ * Normalized symbol target used before writing JD-style link entries.
+ */
+ public record LinkTarget(String typeName, String name, String descriptor) {
+ }
+
+ private ResultLinkSupport() {
+ }
+
+ /**
+ * Register a declaration using the key format expected by JD-style consumers.
+ */
+ public static void addDeclaration(DecompilationResult result, int start, int length, LinkTarget target) {
+ DeclarationData data = new DeclarationData(start, length, target.typeName(), target.name(), target.descriptor());
+ result.addDeclaration(declarationKey(target), data);
+ if (target.name() == null) {
+ result.addTypeDeclaration(start, data);
+ }
+ }
+
+ /**
+ * Register a hyperlink backed by a cached reference object.
+ */
+ public static void addReference(DecompilationResult result, Map cache, int start, int length, LinkTarget target, String scopeInternalName) {
+ ReferenceData reference = reference(result, cache, target, scopeInternalName);
+ result.addHyperLink(start, new HyperlinkReferenceData(start, length, reference));
+ }
+
+ /**
+ * Resolve or create a reference so repeated mentions reuse the same instance.
+ */
+ public static ReferenceData reference(DecompilationResult result, Map cache, LinkTarget target, String scopeInternalName) {
+ // A stable cache key keeps the result model compact when the same target is referenced many times.
+ String key = declarationKey(target) + '-' + scopeInternalName;
+ return cache.computeIfAbsent(key, ignored -> {
+ ReferenceData reference = new ReferenceData(target.typeName(), target.name(), target.descriptor(), scopeInternalName);
+ reference.setEnabled(true);
+ result.addReference(reference);
+ return reference;
+ });
+ }
+
+ /**
+ * Build the declaration lookup key used by {@link DecompilationResult#getDeclarations()}.
+ */
+ public static String declarationKey(LinkTarget target) {
+ if (target.name() == null) {
+ return target.typeName();
+ }
+ return target.typeName() + '-' + target.name() + '-' + target.descriptor();
+ }
+
+ /**
+ * Measure the identifier length at a source offset.
+ */
+ public static int identifierLength(String code, int start) {
+ int end = start;
+ while (end < code.length() && Character.isJavaIdentifierPart(code.charAt(end))) {
+ end++;
+ }
+ return end - start;
+ }
+}
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/Decompiler.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/Decompiler.java
index 2243d05..4e85629 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/Decompiler.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/Decompiler.java
@@ -84,6 +84,9 @@ default ClassStruct readClassAndInnerClasses(Map importantData,
}
}
}
+ if (classNode.superName != null && !"java/lang/Object".equals(classNode.superName) && loader.canLoad(classNode.superName)) {
+ importantData.putAll(readClassAndInnerClasses(importantData, loader, classNode.superName).importantData);
+ }
}
return new ClassStruct(fullClassName, importantData);
}
@@ -114,15 +117,17 @@ public IOutputSink createOutputSink(IResultSaver saver) {
return new IOutputSink() {
@Override
public void close() throws IOException {
+ // not used
}
@Override
public void begin() {
+ // not used
}
@Override
public void acceptOther(String path) {
- // not used
+ // not used
}
@Override
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/cfr/CFRDataSource.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/cfr/CFRDataSource.java
index 67dfbb8..33b9079 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/cfr/CFRDataSource.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/cfr/CFRDataSource.java
@@ -40,6 +40,7 @@ public CFRDataSource(Loader loader, byte[] data, String name, Options options) {
@Override
public void informAnalysisRelativePathDetail(String usePath, String classFilePath) {
+ // not used
}
@Override
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/cfr/CFRDecompiler.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/cfr/CFRDecompiler.java
index 8e642b7..c8d4fb1 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/cfr/CFRDecompiler.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/cfr/CFRDecompiler.java
@@ -1,4 +1,5 @@
/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
* Copyright 2017 Sam Sun
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -21,6 +22,7 @@
import org.benf.cfr.reader.util.CfrVersionInfo;
import org.benf.cfr.reader.util.getopt.OptionsImpl;
+import com.heliosdecompiler.transformerapi.common.BytecodeSourceLinker;
import com.heliosdecompiler.transformerapi.common.Loader;
import com.heliosdecompiler.transformerapi.decompilers.Decompiler;
@@ -30,6 +32,9 @@
import java.util.Scanner;
import jd.core.DecompilationResult;
+/**
+ * Provides a gateway to the CFR decompiler.
+ */
public class CFRDecompiler extends Decompiler.AbstractDecompiler implements Decompiler {
public CFRDecompiler(String name) {
@@ -39,6 +44,7 @@ public CFRDecompiler(String name) {
@Override
public DecompilationResult decompile(Loader loader, String internalName, CFRSettings settings) throws IOException {
StopWatch stopWatch = StopWatch.createStarted();
+ ClassStruct classStruct = readClassAndInnerClasses(loader, internalName);
CFROutputStreamFactory sink = new CFROutputStreamFactory();
String entryPath = internalName + ".class";
OptionsImpl options = new OptionsImpl(settings.getSettings());
@@ -55,6 +61,8 @@ public DecompilationResult decompile(Loader loader, String internalName, CFRSett
}
DecompilationResult decompilationResult = new DecompilationResult();
decompilationResult.setDecompiledOutput(resultCode);
+ // CFR emits text only, so link metadata is reconstructed from the source after decompilation.
+ BytecodeSourceLinker.link(decompilationResult, resultCode, classStruct.fullClassName(), classStruct.importantData());
time = stopWatch.getTime();
return decompilationResult;
}
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/fernflower/FernflowerDecompiler.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/fernflower/FernflowerDecompiler.java
index 0413104..2121cf3 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/fernflower/FernflowerDecompiler.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/fernflower/FernflowerDecompiler.java
@@ -1,4 +1,5 @@
/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
* Copyright 2017 Sam Sun
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -23,6 +24,7 @@
import org.jetbrains.java.decompiler.main.extern.IBytecodeProvider;
import org.jetbrains.java.decompiler.struct.StructContext;
+import com.heliosdecompiler.transformerapi.common.BytecodeSourceLinker;
import com.heliosdecompiler.transformerapi.common.Loader;
import com.heliosdecompiler.transformerapi.decompilers.Decompiler;
@@ -33,7 +35,7 @@
import jd.core.DecompilationResult;
/**
- * Provides a gateway to the Fernflower decompiler
+ * Provides a gateway to the Fernflower decompiler.
*/
public class FernflowerDecompiler extends Decompiler.AbstractDecompiler implements Decompiler {
@@ -63,7 +65,10 @@ public DecompilationResult decompile(Loader loader, String internalName, Fernflo
baseDecompiler.clearContext();
}
String key = classStruct.fullClassName();
- decompilationResult.setDecompiledOutput(saver.getResults().get(key));
+ String source = saver.getResults().get(key);
+ decompilationResult.setDecompiledOutput(source);
+ // Fernflower does not expose token callbacks, so links are synthesized from source and bytecode.
+ BytecodeSourceLinker.link(decompilationResult, source, key, classStruct.importantData());
}
time = stopWatch.getTime();
return decompilationResult;
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/jadx/JADXDecompiler.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/jadx/JADXDecompiler.java
index 889c651..0d9a777 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/jadx/JADXDecompiler.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/jadx/JADXDecompiler.java
@@ -1,5 +1,5 @@
-/*******************************************************************************
- * Copyright (C) 2022 GPLv3
+/*
+ * Copyright 2022-2026 Nicolas Baumann (@nbauma109)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -13,12 +13,14 @@
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
- ******************************************************************************/
+ */
package com.heliosdecompiler.transformerapi.decompilers.jadx;
import org.apache.commons.lang3.time.StopWatch;
+import com.heliosdecompiler.transformerapi.common.BytecodeSourceLinker;
import com.heliosdecompiler.transformerapi.common.Loader;
+import com.heliosdecompiler.transformerapi.common.ResultLinkSupport;
import com.heliosdecompiler.transformerapi.decompilers.Decompiler;
import java.io.IOException;
@@ -28,14 +30,27 @@
import java.util.List;
import java.util.Map;
+import jadx.api.ICodeInfo;
import jadx.api.JadxArgs;
import jadx.api.JadxDecompiler;
import jadx.api.JavaClass;
+import jadx.api.JavaField;
+import jadx.api.JavaMethod;
+import jadx.api.JavaNode;
+import jadx.api.JavaVariable;
import jadx.core.Jadx;
+import jadx.core.codegen.TypeGen;
import jadx.plugins.input.java.JavaClassReader;
import jadx.plugins.input.java.JavaLoadResult;
import jd.core.DecompilationResult;
+import jd.core.links.ReferenceData;
+import jadx.api.metadata.ICodeAnnotation;
+import jadx.api.metadata.ICodeNodeRef;
+import jadx.api.metadata.annotations.NodeDeclareRef;
+/**
+ * Provides a gateway to the JADX decompiler.
+ */
public class JADXDecompiler extends Decompiler.AbstractDecompiler implements Decompiler {
public JADXDecompiler(String name) {
@@ -59,7 +74,12 @@ public DecompilationResult decompile(Loader loader, String internalName, JadxArg
jadx.load();
for (JavaClass cls : jadx.getClasses()) {
if (cls.getClassNode().getClsData().getInputFileName().equals(internalName)) {
- decompilationResult.setDecompiledOutput(cls.getCode());
+ ICodeInfo codeInfo = cls.getCodeInfo();
+ decompilationResult.setDecompiledOutput(codeInfo.getCodeStr());
+ addLinks(decompilationResult, jadx, codeInfo);
+ // Supplement JADX's native annotations with bytecode-driven links for missed constructor cases.
+ BytecodeSourceLinker.link(decompilationResult, codeInfo.getCodeStr(), internalName, importantData);
+ codeInfo.getCodeMetadata().getLineMapping().forEach(decompilationResult::putLineNumber);
break;
}
}
@@ -95,4 +115,119 @@ public JadxArgs lineNumberSettings() throws IllegalAccessException, IllegalArgum
public boolean supportsRealignment() {
return false;
}
+
+ private static void addLinks(DecompilationResult result, JadxDecompiler jadx, ICodeInfo codeInfo) {
+ String code = codeInfo.getCodeStr();
+ Map references = new HashMap<>();
+ /* JADX already exposes source positions, so one pass is enough to rebuild the link model. */
+ for (Map.Entry entry : codeInfo.getCodeMetadata().getAsMap().entrySet()) {
+ addLinkAtPosition(result, jadx, codeInfo, code, references, entry.getKey(), entry.getValue());
+ }
+ }
+
+ private static void addLinkAtPosition(DecompilationResult result, JadxDecompiler jadx, ICodeInfo codeInfo, String code, Map references, int position, ICodeAnnotation annotation) {
+ if (annotation instanceof NodeDeclareRef declaration) {
+ addDeclaration(result, jadx, declaration.getNode(), position, code);
+ return;
+ }
+ if (isReferenceAnnotation(annotation)) {
+ addReference(result, jadx, codeInfo, code, references, position, annotation);
+ }
+ }
+
+ private static boolean isReferenceAnnotation(ICodeAnnotation annotation) {
+ return switch (annotation.getAnnType()) {
+ case CLASS, METHOD, FIELD, VAR, VAR_REF -> true;
+ default -> false;
+ };
+ }
+
+ private static void addDeclaration(DecompilationResult result, JadxDecompiler jadx, ICodeNodeRef node, int position, String code) {
+ JavaNode javaNode = jadx.getJavaNodeByRef(node);
+ addDeclaration(result, javaNode, position, code);
+ }
+
+ private static void addDeclaration(DecompilationResult result, JavaNode javaNode, int position, String code) {
+ if (javaNode == null) {
+ return;
+ }
+ ResultLinkSupport.LinkTarget target = toTarget(javaNode);
+ if (target == null) {
+ return;
+ }
+ int length = ResultLinkSupport.identifierLength(code, position);
+ if (length <= 0) {
+ return;
+ }
+ ResultLinkSupport.addDeclaration(result, position, length, target);
+ }
+
+ private static void addReference(DecompilationResult result, JadxDecompiler jadx, ICodeInfo codeInfo, String code, Map references, int position, ICodeAnnotation annotation) {
+ JavaNode javaNode = jadx.getJavaNodeByCodeAnnotation(codeInfo, annotation);
+ if (javaNode == null) {
+ return;
+ }
+ if (position == javaNode.getDefPos()) {
+ addDeclaration(result, javaNode, position, code);
+ return;
+ }
+ int length = ResultLinkSupport.identifierLength(code, position);
+ if (length <= 0) {
+ return;
+ }
+ ResultLinkSupport.LinkTarget target = toTarget(javaNode);
+ if (target != null) {
+ String scope = enclosingType(jadx, codeInfo, position);
+ ResultLinkSupport.addReference(result, references, position, length, target, scope);
+ }
+ }
+
+ private static String enclosingType(JadxDecompiler jadx, ICodeInfo codeInfo, int position) {
+ JavaNode enclosing = jadx.getEnclosingNode(codeInfo, position);
+ if (enclosing instanceof JavaClass javaClass) {
+ return internalName(javaClass.getClassNode().getClassInfo().getRawName());
+ }
+ if (enclosing instanceof JavaMethod javaMethod) {
+ return internalName(javaMethod.getDeclaringClass().getClassNode().getClassInfo().getRawName());
+ }
+ return null;
+ }
+
+ private static ResultLinkSupport.LinkTarget toTarget(JavaNode node) {
+ if (node instanceof JavaClass javaClass) {
+ return new ResultLinkSupport.LinkTarget(internalName(javaClass.getClassNode().getClassInfo().getRawName()), null, null);
+ }
+ if (node instanceof JavaMethod javaMethod) {
+ String owner = internalName(javaMethod.getDeclaringClass().getClassNode().getClassInfo().getRawName());
+ String name = javaMethod.getMethodNode().getMethodInfo().getName();
+ String descriptor = methodDescriptor(javaMethod);
+ return new ResultLinkSupport.LinkTarget(owner, name, descriptor);
+ }
+ if (node instanceof JavaField javaField) {
+ String owner = internalName(javaField.getDeclaringClass().getClassNode().getClassInfo().getRawName());
+ String name = javaField.getFieldNode().getFieldInfo().getName();
+ String descriptor = TypeGen.signature(javaField.getFieldNode().getFieldInfo().getType());
+ return new ResultLinkSupport.LinkTarget(owner, name, descriptor);
+ }
+ if (node instanceof JavaVariable javaVariable) {
+ String owner = internalName(javaVariable.getDeclaringClass().getClassNode().getClassInfo().getRawName());
+ String name = javaVariable.getMth().getMethodNode().getMethodInfo().getName();
+ String descriptor = javaVariable.getMth().getMethodNode().getMethodInfo().getShortId()
+ + "-v" + javaVariable.getReg() + '-' + javaVariable.getSsa();
+ return new ResultLinkSupport.LinkTarget(owner, name, descriptor);
+ }
+ return null;
+ }
+
+ private static String internalName(String rawName) {
+ return rawName.replace('.', '/');
+ }
+
+ private static String methodDescriptor(JavaMethod javaMethod) {
+ // Jadx short ids are ``, so the JVM descriptor starts right after the name.
+ String name = javaMethod.getMethodNode().getMethodInfo().getName();
+ String shortId = javaMethod.getMethodNode().getMethodInfo().getShortId();
+ return shortId.substring(name.length());
+ }
+
}
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/MapDecompilerSettings.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/MapDecompilerSettings.java
index 6071b42..40fa5ad 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/MapDecompilerSettings.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/MapDecompilerSettings.java
@@ -1,3 +1,19 @@
+/*
+ * Copyright 2022-2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
package com.heliosdecompiler.transformerapi.decompilers.procyon;
import com.heliosdecompiler.transformerapi.common.SettingsApplicable;
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonDecompiler.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonDecompiler.java
index f510eaf..90f9ee7 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonDecompiler.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonDecompiler.java
@@ -21,12 +21,6 @@
import com.heliosdecompiler.transformerapi.common.Loader;
import com.heliosdecompiler.transformerapi.decompilers.Decompiler;
import com.strobel.Procyon;
-import com.strobel.assembler.metadata.FieldDefinition;
-import com.strobel.assembler.metadata.FieldReference;
-import com.strobel.assembler.metadata.MethodDefinition;
-import com.strobel.assembler.metadata.MethodReference;
-import com.strobel.assembler.metadata.TypeDefinition;
-import com.strobel.assembler.metadata.TypeReference;
import com.strobel.decompiler.CommandLineOptions;
import com.strobel.decompiler.DecompilerSettings;
import com.strobel.decompiler.InMemoryLineNumberFormatter;
@@ -46,10 +40,7 @@
import java.util.Map;
import jd.core.DecompilationResult;
-import jd.core.links.DeclarationData;
-import jd.core.links.HyperlinkReferenceData;
import jd.core.links.ReferenceData;
-import jd.core.links.StringData;
public class ProcyonDecompiler extends Decompiler.AbstractDecompiler implements Decompiler {
@@ -114,92 +105,7 @@ public DecompilationResult decompile(Loader loader, String internalName, Command
DecompilationResult result = new DecompilationResult();
Map referencesCache = new HashMap<>();
- PlainTextOutput plainTextOutput = new PlainTextOutput(stringwriter) {
-
- private void addDeclaration(String text, int from, String descriptor, String internalTypeName, String name) {
- String key = internalTypeName + '-' + name + '-' + descriptor;
- result.addDeclaration(key, new DeclarationData(from, text.length(), internalTypeName, name, descriptor));
- }
-
- @Override
- public void writeDefinition(String text, Object definition, boolean isLocal) {
- super.writeDefinition(text, definition, isLocal);
- try {
- if (text != null && definition != null) {
- int from = stringwriter.getBuffer().length() - text.length();
- if (definition instanceof TypeDefinition type) {
- String internalTypeName = type.getInternalName();
- DeclarationData data = new DeclarationData(from, text.length(), internalTypeName, null, null);
- result.addDeclaration(internalTypeName, data);
- result.addTypeDeclaration(from, data);
- } else if (definition instanceof MethodDefinition method) {
- String descriptor = method.getErasedSignature();
- TypeReference type = method.getDeclaringType();
- String internalTypeName = type.getInternalName();
- String name = method.getName();
- addDeclaration(text, from, descriptor, internalTypeName, name);
- } else if (definition instanceof FieldDefinition field) {
- String descriptor = field.getErasedSignature();
- TypeReference type = field.getDeclaringType();
- String internalTypeName = type.getInternalName();
- String name = field.getName();
- addDeclaration(text, from, descriptor, internalTypeName, name);
- }
- }
- } catch (Exception e) {
- System.err.println(e);
- }
- }
-
- @Override
- public void writeReference(String text, Object reference, boolean isLocal) {
- super.writeReference(text, reference, isLocal);
- try {
- if (text != null && reference != null) {
- int from = stringwriter.getBuffer().length() - text.length();
- if (reference instanceof TypeReference type) {
- String internalTypeName = type.getInternalName();
- ReferenceData data = newReferenceData(internalTypeName, null, null, internalName);
- result.addHyperLink(from, new HyperlinkReferenceData(from, text.length(), data));
- } else if (reference instanceof MethodReference method) {
- String descriptor = method.getErasedSignature();
- TypeReference type = method.getDeclaringType();
- String internalTypeName = type.getInternalName();
- String name = method.getName();
- ReferenceData data = newReferenceData(internalTypeName, name, descriptor, internalName);
- result.addHyperLink(from, new HyperlinkReferenceData(from, text.length(), data));
- } else if (reference instanceof FieldReference field) {
- String descriptor = field.getErasedSignature();
- TypeReference type = field.getDeclaringType();
- String internalTypeName = type.getInternalName();
- String name = field.getName();
- ReferenceData data = newReferenceData(internalTypeName, name, descriptor, internalName);
- result.addHyperLink(from, new HyperlinkReferenceData(from, text.length(), data));
- }
- }
- } catch (Exception e) {
- System.err.println(e);
- }
- }
-
- @Override
- public void writeTextLiteral(Object value) {
- super.writeTextLiteral(value);
- String text = value.toString();
- int from = stringwriter.getBuffer().length() - text.length();
- result.addString(new StringData(from, text, internalName));
- }
-
- // --- Add references --- //
- public ReferenceData newReferenceData(String internalName, String name, String descriptor, String scopeInternalName) {
- String key = internalName + '-' + name + '-' + descriptor + '-' + scopeInternalName;
- return referencesCache.computeIfAbsent(key, k -> {
- ReferenceData reference = new ReferenceData(internalName, name, descriptor, scopeInternalName);
- result.addReference(reference);
- return reference;
- });
- }
- };
+ PlainTextOutput plainTextOutput = new ProcyonLinkProvider(stringwriter, stringwriter, result, internalName, referencesCache);
TypeDecompilationResults typeDecompilationResults = com.strobel.decompiler.Decompiler.decompile(internalName, plainTextOutput, settings);
if (options.getIncludeLineNumbers() || options.getStretchLines()) {
List lineNumberPositions = typeDecompilationResults.getLineNumberPositions();
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonFastTypeLoader.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonFastTypeLoader.java
index 74690f6..3033349 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonFastTypeLoader.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonFastTypeLoader.java
@@ -1,5 +1,24 @@
+/*
+ * Copyright 2022-2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
package com.heliosdecompiler.transformerapi.decompilers.procyon;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import com.heliosdecompiler.transformerapi.common.Loader;
import com.strobel.assembler.metadata.Buffer;
import com.strobel.assembler.metadata.ITypeLoader;
@@ -10,6 +29,8 @@
public class ProcyonFastTypeLoader implements ITypeLoader {
+ private static final Logger log = LoggerFactory.getLogger(ProcyonFastTypeLoader.class);
+
private final Map importantData = new HashMap<>();
private final Loader loader;
@@ -33,7 +54,7 @@ public boolean tryLoadType(String s, Buffer buffer) {
buffer.position(0);
return true;
} catch (IOException e) {
- System.err.println(e);
+ log.error(e.getMessage(), e);
}
}
return false;
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonLinkProvider.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonLinkProvider.java
new file mode 100644
index 0000000..ce96c63
--- /dev/null
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/procyon/ProcyonLinkProvider.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2022-2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.heliosdecompiler.transformerapi.decompilers.procyon;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.strobel.assembler.metadata.FieldDefinition;
+import com.strobel.assembler.metadata.FieldReference;
+import com.strobel.assembler.metadata.MethodDefinition;
+import com.strobel.assembler.metadata.MethodReference;
+import com.strobel.assembler.metadata.TypeDefinition;
+import com.strobel.assembler.metadata.TypeReference;
+import com.strobel.decompiler.PlainTextOutput;
+
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Map;
+
+import jd.core.DecompilationResult;
+import jd.core.links.DeclarationData;
+import jd.core.links.HyperlinkReferenceData;
+import jd.core.links.ReferenceData;
+import jd.core.links.StringData;
+
+public final class ProcyonLinkProvider extends PlainTextOutput {
+
+ private static final Logger log = LoggerFactory.getLogger(ProcyonLinkProvider.class);
+
+ private final StringWriter stringwriter;
+ private final DecompilationResult result;
+ private final String internalName;
+ private final Map referencesCache;
+
+ ProcyonLinkProvider(Writer writer, StringWriter stringwriter, DecompilationResult result,
+ String internalName, Map referencesCache) {
+ super(writer);
+ this.stringwriter = stringwriter;
+ this.result = result;
+ this.internalName = internalName;
+ this.referencesCache = referencesCache;
+ }
+
+ private void addDeclaration(String text, int from, String descriptor, String internalTypeName, String name) {
+ String key = internalTypeName + '-' + name + '-' + descriptor;
+ result.addDeclaration(key, new DeclarationData(from, text.length(), internalTypeName, name, descriptor));
+ }
+
+ @Override
+ public void writeDefinition(String text, Object definition, boolean isLocal) {
+ super.writeDefinition(text, definition, isLocal);
+ try {
+ if (text != null && definition != null) {
+ int from = stringwriter.getBuffer().length() - text.length();
+ if (definition instanceof TypeDefinition type) {
+ String internalTypeName = type.getInternalName();
+ DeclarationData data = new DeclarationData(from, text.length(), internalTypeName, null, null);
+ result.addDeclaration(internalTypeName, data);
+ result.addTypeDeclaration(from, data);
+ } else if (definition instanceof MethodDefinition method) {
+ String descriptor = method.getErasedSignature();
+ TypeReference type = method.getDeclaringType();
+ String internalTypeName = type.getInternalName();
+ String name = method.getName();
+ addDeclaration(text, from, descriptor, internalTypeName, name);
+ } else if (definition instanceof FieldDefinition field) {
+ String descriptor = field.getErasedSignature();
+ TypeReference type = field.getDeclaringType();
+ String internalTypeName = type.getInternalName();
+ String name = field.getName();
+ addDeclaration(text, from, descriptor, internalTypeName, name);
+ }
+ }
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void writeReference(String text, Object reference, boolean isLocal) {
+ super.writeReference(text, reference, isLocal);
+ try {
+ if (text != null && reference != null) {
+ int from = stringwriter.getBuffer().length() - text.length();
+ if (reference instanceof TypeReference type) {
+ String internalTypeName = type.getInternalName();
+ ReferenceData data = newReferenceData(internalTypeName, null, null, internalName);
+ result.addHyperLink(from, new HyperlinkReferenceData(from, text.length(), data));
+ } else if (reference instanceof MethodReference method) {
+ String descriptor = method.getErasedSignature();
+ TypeReference type = method.getDeclaringType();
+ String internalTypeName = type.getInternalName();
+ String name = method.getName();
+ ReferenceData data = newReferenceData(internalTypeName, name, descriptor, internalName);
+ result.addHyperLink(from, new HyperlinkReferenceData(from, text.length(), data));
+ } else if (reference instanceof FieldReference field) {
+ String descriptor = field.getErasedSignature();
+ TypeReference type = field.getDeclaringType();
+ String internalTypeName = type.getInternalName();
+ String name = field.getName();
+ ReferenceData data = newReferenceData(internalTypeName, name, descriptor, internalName);
+ result.addHyperLink(from, new HyperlinkReferenceData(from, text.length(), data));
+ }
+ }
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void writeTextLiteral(Object value) {
+ super.writeTextLiteral(value);
+ String text = value.toString();
+ int from = stringwriter.getBuffer().length() - text.length();
+ result.addString(new StringData(from, text, internalName));
+ }
+
+ // --- Add references --- //
+ public ReferenceData newReferenceData(String internalName, String name, String descriptor, String scopeInternalName) {
+ String key = internalName + '-' + name + '-' + descriptor + '-' + scopeInternalName;
+ return referencesCache.computeIfAbsent(key, k -> {
+ ReferenceData reference = new ReferenceData(internalName, name, descriptor, scopeInternalName);
+ result.addReference(reference);
+ return reference;
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerBytecodeProvider.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerBytecodeProvider.java
deleted file mode 100644
index e7a8a05..0000000
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerBytecodeProvider.java
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright 2017 Sam Sun
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.heliosdecompiler.transformerapi.decompilers.vineflower;
-
-import org.vineflower.java.decompiler.main.extern.IBytecodeProvider;
-
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.Map;
-
-public class VineflowerBytecodeProvider implements IBytecodeProvider {
-
- private final Map byteData = new HashMap<>();
-
- public VineflowerBytecodeProvider(Map data) {
- byteData.putAll(data);
- }
-
- @Override
- public byte[] getBytecode(String externalPath, String internalPath) throws IOException {
- if (!byteData.containsKey(externalPath)) {
- throw new IllegalStateException("Expected data to be present for " + externalPath);
- }
-
- byte[] data = byteData.get(externalPath);
- return Arrays.copyOf(data, data.length);
- }
-}
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerResultSaver.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerResultSaver.java
index 12042e4..a8d2f8e 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerResultSaver.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerResultSaver.java
@@ -16,11 +16,12 @@
package com.heliosdecompiler.transformerapi.decompilers.vineflower;
-import jd.core.DecompilationResult;
import org.vineflower.java.decompiler.main.extern.IResultSaver;
import com.heliosdecompiler.transformerapi.common.AbstractResultSaver;
+import jd.core.DecompilationResult;
+
public class VineflowerResultSaver extends AbstractResultSaver implements IResultSaver {
public VineflowerResultSaver(DecompilationResult result) {
diff --git a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerTokenConsumer.java b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerTokenConsumer.java
index 7ab9163..1fff127 100644
--- a/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerTokenConsumer.java
+++ b/src/main/java/com/heliosdecompiler/transformerapi/decompilers/vineflower/VineflowerTokenConsumer.java
@@ -58,15 +58,15 @@ public void visitMethod(TextRange range, boolean declaration, String className,
@Override
public void visitLocal(TextRange range, boolean declaration, String className, String methodName, MethodDescriptor methodDescriptor, int index, String name) {
- visitMethodBound(range, declaration, className, methodName, methodDescriptor, index, name, false);
+ visitMethodBound(range, declaration, className, methodName, methodDescriptor, index, false);
}
@Override
public void visitParameter(TextRange range, boolean declaration, String className, String methodName, MethodDescriptor methodDescriptor, int index, String name) {
- visitMethodBound(range, declaration, className, methodName, methodDescriptor, index, name, true);
+ visitMethodBound(range, declaration, className, methodName, methodDescriptor, index, true);
}
- private void visitMethodBound(TextRange range, boolean declaration, String className, String methodName, MethodDescriptor methodDescriptor, int index, String name, boolean isParameter) {
+ private void visitMethodBound(TextRange range, boolean declaration, String className, String methodName, MethodDescriptor methodDescriptor, int index, boolean isParameter) {
String fakeDesc = methodDescriptor.toString() + '-' + (isParameter ? 'p' : 'l') + index;
if (declaration) {
DeclarationData data = new DeclarationData(range.start, range.length, className, methodName, fakeDesc);
diff --git a/src/test/java/com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage.java b/src/test/java/com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage.java
new file mode 100644
index 0000000..0c192b0
--- /dev/null
+++ b/src/test/java/com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package com.heliosdecompiler.transformerapi;
+
+public class BytecodeSourceLinkerRegressionCoverage {
+
+ public void arrayTarget(String value) {
+ // Intentional no-op fixture method: linker tests only need a resolvable declaration target.
+ }
+
+ public void genericTarget(String value) {
+ // Intentional no-op fixture method: linker tests only need a resolvable declaration target.
+ }
+
+ public void nestedArrayArgument() {
+ arrayTarget(new String[]{"a", "b"}[0]);
+ }
+
+ public void nestedGenericArgument() {
+ genericTarget(new java.util.HashMap().toString());
+ }
+
+ public void escapedStringLiteral() {
+ String value = "\\\\";
+ if (value != null) {
+ escapedTarget();
+ }
+ }
+
+ private void escapedTarget() {
+ // Intentional no-op fixture method: linker tests only need a resolvable declaration target.
+ }
+
+ public void afterEscapedString() {
+ afterEscapedTarget();
+ }
+
+ private void afterEscapedTarget() {
+ // Intentional no-op fixture method: linker tests only need a resolvable declaration target.
+ }
+}
diff --git a/src/test/java/com/heliosdecompiler/transformerapi/StandardTransformersTest.java b/src/test/java/com/heliosdecompiler/transformerapi/StandardTransformersTest.java
index 3452819..fb95fdc 100644
--- a/src/test/java/com/heliosdecompiler/transformerapi/StandardTransformersTest.java
+++ b/src/test/java/com/heliosdecompiler/transformerapi/StandardTransformersTest.java
@@ -5,6 +5,7 @@
import org.jd.core.v1.util.ZipLoader;
import org.junit.Test;
+import com.heliosdecompiler.transformerapi.StandardTransformers.Decompilers;
import com.heliosdecompiler.transformerapi.common.Loader;
import com.heliosdecompiler.transformerapi.decompilers.Decompiler;
import com.heliosdecompiler.transformerapi.decompilers.cfr.CFRSettings;
@@ -57,6 +58,11 @@ public void testValueOf() {
assertEquals(JADX, valueOf(ENGINE_JADX));
}
+ @Test
+ public void testGetDefault() {
+ assertEquals(VINEFLOWER, Decompilers.getDefault());
+ }
+
@Test
public void testDecompileCFR() throws Exception {
testDecompile("/TestCompactCFR.txt", ENGINE_CFR, CFRSettings.defaults(), "test/TestCompact", "/test-compact-expand-inline.jar");
diff --git a/src/test/java/com/heliosdecompiler/transformerapi/TestBean.java b/src/test/java/com/heliosdecompiler/transformerapi/TestBean.java
new file mode 100644
index 0000000..c538feb
--- /dev/null
+++ b/src/test/java/com/heliosdecompiler/transformerapi/TestBean.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package com.heliosdecompiler.transformerapi;
+
+/**
+ * Bean-shaped fixture used to verify links for fields, constructors, allocations, and inner classes.
+ */
+public class TestBean extends TestBeanBase {
+
+ private final String name;
+
+ private final int age;
+
+ private final Helper helper;
+
+ public TestBean() {
+ this("bean", 42);
+ }
+
+ public TestBean(String name, int age) {
+ this(name, age, new Helper(name));
+ }
+
+ public TestBean(String name, int age, Helper helper) {
+ super(name);
+ this.name = name;
+ this.age = age;
+ this.helper = helper;
+ }
+
+ public String getName() {
+ return this.name;
+ }
+
+ public int getAge() {
+ return this.age;
+ }
+
+ public Helper getHelper() {
+ return this.helper;
+ }
+
+ public TestBean copy() {
+ return new TestBean(this.name, this.age, new Helper(this.name));
+ }
+
+ public InnerBean newInner(String value) {
+ return new InnerBean(new Helper(value));
+ }
+
+ public class InnerBean extends InnerBase {
+
+ private final Helper innerHelper;
+
+ public InnerBean() {
+ this(new Helper(TestBean.this.getName()));
+ }
+
+ public InnerBean(Helper innerHelper) {
+ super(innerHelper);
+ this.innerHelper = innerHelper;
+ }
+
+ public Helper getInnerHelper() {
+ return this.innerHelper;
+ }
+ }
+
+ public static class Helper {
+
+ private final String value;
+
+ public Helper(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return this.value;
+ }
+ }
+}
+
+abstract class TestBeanBase {
+
+ private final String label;
+
+ protected TestBeanBase(String label) {
+ this.label = label;
+ }
+
+ public String getLabel() {
+ return this.label;
+ }
+}
+
+class InnerBase {
+
+ private final TestBean.Helper helper;
+
+ InnerBase(TestBean.Helper helper) {
+ this.helper = helper;
+ }
+
+ public TestBean.Helper getHelper() {
+ return this.helper;
+ }
+}
diff --git a/src/test/java/com/heliosdecompiler/transformerapi/TestLinkCoverage.java b/src/test/java/com/heliosdecompiler/transformerapi/TestLinkCoverage.java
new file mode 100644
index 0000000..7d19ec0
--- /dev/null
+++ b/src/test/java/com/heliosdecompiler/transformerapi/TestLinkCoverage.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package com.heliosdecompiler.transformerapi;
+
+import java.io.IOException;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.function.Supplier;
+
+/**
+ * Coverage fixture for source-link reconstruction edge cases that still occur in normal Java code.
+ */
+@CoverageMarker
+public abstract class TestLinkCoverage extends TestLinkCoverageBase {
+
+ protected transient volatile int flags;
+
+ protected TestLinkCoverage() {
+ this("coverage");
+ }
+
+ protected TestLinkCoverage(String label) {
+ super(label);
+ }
+
+ public native void nativeMethod() throws IOException;
+
+ public synchronized void ownerCalls() {
+ this.localCall();
+ super.baseCall();
+ Helper.staticCall();
+ }
+
+ public String readShared() {
+ return super.shared;
+ }
+
+ public T echo(T value) {
+ return value;
+ }
+
+ public void varArgMethod(@CoverageMarker String... values) {
+ this.flags += values.length;
+ }
+
+ public void charLiteral() {
+ char quote = '\'';
+ if (quote == '\'') {
+ this.flags++;
+ }
+ }
+
+ public Inner createInner() {
+ return new Inner();
+ }
+
+ private void localCall() {
+ useSupplier(() -> new Helper(super.getLabel()).value());
+ useRunnable(new Runnable() {
+ @Override
+ public void run() {
+ Helper.staticCall();
+ }
+ });
+ }
+
+ private void useRunnable(Runnable runnable) {
+ runnable.run();
+ }
+
+ protected void useSupplier(Supplier supplier) {
+ supplier.get();
+ }
+
+ protected void useCollection(java.util.List values) {
+ this.flags += values.size();
+ }
+
+ protected void useCollection(java.util.Set values) {
+ this.flags += values.size();
+ }
+
+ protected void annotatedValue(@CoverageMarker("string") java.lang.String value) {
+ this.flags += value.length();
+ }
+
+ protected void annotatedValue(@CoverageMarker("number") java.lang.Integer value) {
+ this.flags += value.intValue();
+ }
+
+ public class Inner extends InnerBaseCoverage {
+
+ private final String innerLabel;
+
+ public Inner() {
+ this(TestLinkCoverage.this.getLabel());
+ }
+
+ public Inner(String innerLabel) {
+ super(innerLabel);
+ this.innerLabel = innerLabel;
+ }
+
+ public void run() {
+ this.innerCall();
+ super.baseInner();
+ TestLinkCoverage.this.ownerCalls();
+ TestLinkCoverage.super.baseCall();
+ if (this.innerLabel != null) {
+ TestLinkCoverage.this.varArgMethod(this.innerLabel);
+ }
+ }
+
+ public void accept(final Inner other) {
+ other.innerCall();
+ }
+
+ public void acceptRoot(final TestLinkCoverage other) {
+ other.ownerCalls();
+ }
+
+ public void acceptBase(final InnerBaseCoverage base) {
+ base.baseInner();
+ }
+
+ private void innerCall() {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ public static class Helper {
+
+ private final String value;
+
+ public Helper(String value) {
+ this.value = value;
+ }
+
+ public static void staticCall() {
+ throw new UnsupportedOperationException();
+ }
+
+ public String value() {
+ return this.value;
+ }
+ }
+}
+
+@Target({ElementType.TYPE, ElementType.PARAMETER})
+@Retention(RetentionPolicy.CLASS)
+@interface CoverageMarker {
+ String value() default "";
+}
+
+abstract class TestLinkCoverageBase {
+
+ protected final String shared;
+
+ private final String label;
+
+ protected TestLinkCoverageBase(String label) {
+ this.shared = label;
+ this.label = label;
+ }
+
+ public String getLabel() {
+ return this.label;
+ }
+
+ public void baseCall() {
+ throw new UnsupportedOperationException();
+ }
+}
+
+class InnerBaseCoverage {
+
+ private final String label;
+
+ InnerBaseCoverage(String label) {
+ this.label = label;
+ }
+
+ public void baseInner() {
+ throw new UnsupportedOperationException();
+ }
+
+ public String getLabel() {
+ return this.label;
+ }
+}
diff --git a/src/test/java/com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage.java b/src/test/java/com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage.java
new file mode 100644
index 0000000..7a5521d
--- /dev/null
+++ b/src/test/java/com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package com.heliosdecompiler.transformerapi;
+
+public class TestTypeResolutionCoverage extends TypeResolutionBase {
+
+ @SuppressWarnings("all")
+ public void run() {
+ }
+
+ public static class SharedType {
+ }
+
+ public class Inner {
+
+ public void acceptRoot(TestTypeResolutionCoverage other) {
+ other.run();
+ }
+
+ public void acceptAmbiguous(SharedType value) {
+ if (value != null) {
+ value.toString();
+ }
+ }
+ }
+}
+
+class TypeResolutionBase {
+
+ @SuppressWarnings("all")
+ private class TestTypeResolutionCoverage {
+ }
+
+ @SuppressWarnings("all")
+ private class SharedType {
+ }
+}
diff --git a/src/test/java/com/heliosdecompiler/transformerapi/common/BytecodeSourceLinkerTest.java b/src/test/java/com/heliosdecompiler/transformerapi/common/BytecodeSourceLinkerTest.java
new file mode 100644
index 0000000..311408b
--- /dev/null
+++ b/src/test/java/com/heliosdecompiler/transformerapi/common/BytecodeSourceLinkerTest.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package com.heliosdecompiler.transformerapi.common;
+
+import org.junit.Test;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import jd.core.DecompilationResult;
+import jd.core.links.ReferenceData;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class BytecodeSourceLinkerTest {
+
+ @Test
+ public void testLinkIgnoresMissingSourceOrBytecode() {
+ DecompilationResult result = new DecompilationResult();
+
+ BytecodeSourceLinker.link(result, null, "com/heliosdecompiler/transformerapi/TestLinkCoverage", Map.of());
+ BytecodeSourceLinker.link(result, "", "com/heliosdecompiler/transformerapi/TestLinkCoverage", importantData());
+
+ assertTrue(result.getDeclarations().isEmpty());
+ assertTrue(result.getReferences().isEmpty());
+ assertTrue(result.getHyperlinks().isEmpty());
+ }
+
+ @Test
+ public void testLinkSupportsInnerClassAsRootSource() {
+ DecompilationResult result = new DecompilationResult();
+
+ BytecodeSourceLinker.link(
+ result,
+ """
+ package com.heliosdecompiler.transformerapi;
+
+ class Inner extends InnerBaseCoverage {
+ private final String innerLabel;
+
+ Inner() {
+ this("coverage");
+ }
+
+ Inner(String innerLabel) {
+ super(innerLabel);
+ this.innerLabel = innerLabel;
+ }
+
+ Inner copy() {
+ return new Inner();
+ }
+
+ void run() {
+ this.innerCall();
+ super.baseInner();
+ TestLinkCoverage.this.ownerCalls();
+ }
+
+ void accept(final Inner other) {
+ other.innerCall();
+ }
+
+ void acceptRoot(final TestLinkCoverage other) {
+ other.ownerCalls();
+ }
+
+ void acceptBase(final InnerBaseCoverage base) {
+ base.baseInner();
+ }
+
+ void innerCall() {
+ }
+ }
+ """,
+ "com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner",
+ importantData(
+ "com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner",
+ "com/heliosdecompiler/transformerapi/TestLinkCoverage",
+ "com/heliosdecompiler/transformerapi/InnerBaseCoverage"
+ )
+ );
+
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner-run-()V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner-accept-(Lcom/heliosdecompiler/transformerapi/TestLinkCoverage$Inner;)V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner-acceptRoot-(Lcom/heliosdecompiler/transformerapi/TestLinkCoverage;)V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner-acceptBase-(Lcom/heliosdecompiler/transformerapi/InnerBaseCoverage;)V"));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isInnerCallReference));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isBaseInnerReference));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isOwnerCallsReference));
+ }
+
+ @Test
+ public void testLinkBuildsDescriptorsForQualifiedTypesAndFinalParameters() {
+ DecompilationResult result = new DecompilationResult();
+
+ BytecodeSourceLinker.link(
+ result,
+ readSource("src/test/java/com/heliosdecompiler/transformerapi/TestLinkCoverage.java"),
+ "com/heliosdecompiler/transformerapi/TestLinkCoverage",
+ importantData(
+ "com/heliosdecompiler/transformerapi/TestLinkCoverage",
+ "com/heliosdecompiler/transformerapi/TestLinkCoverageBase"
+ )
+ );
+
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage-nativeMethod-()V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage-useSupplier-(Ljava/util/function/Supplier;)V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage-useCollection-(Ljava/util/List;)V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage-useCollection-(Ljava/util/Set;)V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage-annotatedValue-(Ljava/lang/String;)V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage-annotatedValue-(Ljava/lang/Integer;)V"));
+ }
+
+ @Test
+ public void testLinkResolvesRootTypeReferenceInsideInnerScope() {
+ DecompilationResult result = new DecompilationResult();
+
+ BytecodeSourceLinker.link(
+ result,
+ readSource("src/test/java/com/heliosdecompiler/transformerapi/TestLinkCoverage.java"),
+ "com/heliosdecompiler/transformerapi/TestLinkCoverage",
+ importantData(
+ "com/heliosdecompiler/transformerapi/TestLinkCoverage",
+ "com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner",
+ "com/heliosdecompiler/transformerapi/TestLinkCoverageBase",
+ "com/heliosdecompiler/transformerapi/InnerBaseCoverage"
+ )
+ );
+
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isRootTypeReference));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isOwnerCallsReference));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner-acceptRoot-(Lcom/heliosdecompiler/transformerapi/TestLinkCoverage;)V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner-acceptBase-(Lcom/heliosdecompiler/transformerapi/InnerBaseCoverage;)V"));
+ }
+
+ @Test
+ public void testLinkFallsBackToRootAndAmbiguousTypeResolutionWithRealInput() {
+ DecompilationResult result = new DecompilationResult();
+
+ BytecodeSourceLinker.link(
+ result,
+ readSource("src/test/java/com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage.java"),
+ "com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage",
+ importantData(
+ "com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage",
+ "com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage$Inner",
+ "com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage$SharedType",
+ "com/heliosdecompiler/transformerapi/TypeResolutionBase",
+ "com/heliosdecompiler/transformerapi/TypeResolutionBase$TestTypeResolutionCoverage",
+ "com/heliosdecompiler/transformerapi/TypeResolutionBase$SharedType"
+ )
+ );
+
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage$Inner-acceptRoot-(Lcom/heliosdecompiler/transformerapi/TestTypeResolutionCoverage;)V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage$Inner-acceptAmbiguous-(Lcom/heliosdecompiler/transformerapi/TestTypeResolutionCoverage$SharedType;)V"));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isTypeResolutionRootReference));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isTypeResolutionRunReference));
+ }
+
+ @Test
+ public void testLinkCountsOnlyTopLevelCommasForArrayAndGenericArguments() {
+ DecompilationResult result = new DecompilationResult();
+
+ BytecodeSourceLinker.link(
+ result,
+ readSource("src/test/java/com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage.java"),
+ "com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage",
+ importantData("com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage")
+ );
+
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage-arrayTarget-(Ljava/lang/String;)V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage-genericTarget-(Ljava/lang/String;)V"));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isRegressionArrayTargetReference));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isRegressionGenericTargetReference));
+ }
+
+ @Test
+ public void testLinkHandlesEvenBackslashRunsBeforeClosingQuotes() {
+ DecompilationResult result = new DecompilationResult();
+
+ BytecodeSourceLinker.link(
+ result,
+ readSource("src/test/java/com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage.java"),
+ "com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage",
+ importantData("com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage")
+ );
+
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage-afterEscapedString-()V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage-afterEscapedTarget-()V"));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isRegressionEscapedTargetReference));
+ assertTrue(result.getReferences().stream().anyMatch(BytecodeSourceLinkerTest::isRegressionAfterEscapedTargetReference));
+ }
+
+ private static boolean isInnerCallReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner".equals(reference.getTypeName())
+ && "innerCall".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isBaseInnerReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/InnerBaseCoverage".equals(reference.getTypeName())
+ && "baseInner".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isOwnerCallsReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestLinkCoverage".equals(reference.getTypeName())
+ && "ownerCalls".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isRootTypeReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestLinkCoverage".equals(reference.getTypeName())
+ && reference.getName() == null
+ && reference.getDescriptor() == null;
+ }
+
+ private static boolean isTypeResolutionRootReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage".equals(reference.getTypeName())
+ && reference.getName() == null
+ && reference.getDescriptor() == null;
+ }
+
+ private static boolean isTypeResolutionRunReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestTypeResolutionCoverage".equals(reference.getTypeName())
+ && "run".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isRegressionArrayTargetReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage".equals(reference.getTypeName())
+ && "arrayTarget".equals(reference.getName())
+ && "(Ljava/lang/String;)V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isRegressionGenericTargetReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage".equals(reference.getTypeName())
+ && "genericTarget".equals(reference.getName())
+ && "(Ljava/lang/String;)V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isRegressionEscapedTargetReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage".equals(reference.getTypeName())
+ && "escapedTarget".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isRegressionAfterEscapedTargetReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/BytecodeSourceLinkerRegressionCoverage".equals(reference.getTypeName())
+ && "afterEscapedTarget".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static Map importantData(String... internalNames) {
+ Map data = new LinkedHashMap<>();
+ for (String internalName : internalNames) {
+ data.put(internalName, readClassBytes(internalName));
+ }
+ return data;
+ }
+
+ private static String readSource(String path) {
+ try {
+ return Files.readString(Path.of(path));
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to read source " + path, e);
+ }
+ }
+
+ private static byte[] readClassBytes(String internalName) {
+ try {
+ return Files.readAllBytes(Path.of("target/test-classes", internalName + ".class"));
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to read test class " + internalName, e);
+ }
+ }
+}
diff --git a/src/test/java/com/heliosdecompiler/transformerapi/common/FileLoaderTest.java b/src/test/java/com/heliosdecompiler/transformerapi/common/FileLoaderTest.java
new file mode 100644
index 0000000..310c0f7
--- /dev/null
+++ b/src/test/java/com/heliosdecompiler/transformerapi/common/FileLoaderTest.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package com.heliosdecompiler.transformerapi.common;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Regression coverage for on-demand class loading from a compiled test output directory.
+ */
+public class FileLoaderTest {
+
+ @Test
+ public void testLoadReadsSiblingSuperclassOnDemand() throws Exception {
+ FileLoader loader = new FileLoader("target/test-classes", "com/heliosdecompiler/transformerapi", "TestLinkCoverage.class");
+
+ byte[] data = loader.load("com/heliosdecompiler/transformerapi/TestLinkCoverageBase");
+
+ assertTrue(data.length > 0);
+ }
+
+ @Test
+ public void testLoadReturnsEmptyArrayForMissingType() throws Exception {
+ FileLoader loader = new FileLoader("target/test-classes", "com/heliosdecompiler/transformerapi", "TestLinkCoverage.class");
+
+ assertNull(loader.load("com/heliosdecompiler/transformerapi/DoesNotExist"));
+ }
+}
diff --git a/src/test/java/com/heliosdecompiler/transformerapi/decompilers/DecompilerLinksTest.java b/src/test/java/com/heliosdecompiler/transformerapi/decompilers/DecompilerLinksTest.java
new file mode 100644
index 0000000..9b2c71a
--- /dev/null
+++ b/src/test/java/com/heliosdecompiler/transformerapi/decompilers/DecompilerLinksTest.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2026 Nicolas Baumann (@nbauma109)
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package com.heliosdecompiler.transformerapi.decompilers;
+
+import com.heliosdecompiler.transformerapi.StandardTransformers.Decompilers;
+import com.heliosdecompiler.transformerapi.common.Loader;
+import com.heliosdecompiler.transformerapi.decompilers.cfr.CFRSettings;
+import com.heliosdecompiler.transformerapi.decompilers.fernflower.FernflowerSettings;
+import com.heliosdecompiler.transformerapi.decompilers.jadx.MapJadxArgs;
+
+import org.apache.commons.io.IOUtils;
+import org.jd.core.v1.util.ZipLoader;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+
+import jd.core.DecompilationResult;
+import jd.core.links.ReferenceData;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Regression coverage for hyperlink metadata produced by the supported decompilers.
+ */
+public class DecompilerLinksTest {
+
+ @Test
+ public void testFernflowerProvidesLinks() throws Exception {
+ assertProvidesBasicLinks(Decompilers.FERNFLOWER.decompile(loader("/test-compact-expand-inline.jar"), "test/TestCompact", new FernflowerSettings(FernflowerSettings.defaults())));
+ }
+
+ @Test
+ public void testCfrProvidesLinks() throws Exception {
+ assertProvidesBasicLinks(Decompilers.CFR.decompile(loader("/test-compact-expand-inline.jar"), "test/TestCompact", new CFRSettings(CFRSettings.defaults())));
+ }
+
+ @Test
+ public void testJadxProvidesLinks() throws Exception {
+ assertProvidesBasicLinks(Decompilers.JADX.decompile(loader("/test-compact-expand-inline.jar"), "test/TestCompact", new MapJadxArgs(MapJadxArgs.defaults())));
+ }
+
+ @Test
+ public void testFernflowerProvidesBeanLinks() throws Exception {
+ assertProvidesBeanLinks(Decompilers.FERNFLOWER.decompile(loader("/test-bean.jar"), "com/heliosdecompiler/transformerapi/TestBean", new FernflowerSettings(FernflowerSettings.defaults())));
+ }
+
+ @Test
+ public void testCfrProvidesBeanLinks() throws Exception {
+ assertProvidesBeanLinks(Decompilers.CFR.decompile(loader("/test-bean.jar"), "com/heliosdecompiler/transformerapi/TestBean", new CFRSettings(CFRSettings.defaults())));
+ }
+
+ @Test
+ public void testJadxProvidesBeanLinks() throws Exception {
+ assertProvidesBeanLinks(Decompilers.JADX.decompile(loader("/test-bean.jar"), "com/heliosdecompiler/transformerapi/TestBean", new MapJadxArgs(MapJadxArgs.defaults())));
+ }
+
+ @Test
+ public void testFernflowerProvidesCoverageLinks() throws Exception {
+ DecompilationResult result = Decompilers.FERNFLOWER.decompile("target/test-classes", "com/heliosdecompiler/transformerapi", "TestLinkCoverage.class");
+ assertProvidesCoverageLinks(result);
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isSharedFieldReference));
+ }
+
+ @Test
+ public void testCfrProvidesCoverageLinks() throws Exception {
+ assertProvidesCoverageLinks(Decompilers.CFR.decompile("target/test-classes", "com/heliosdecompiler/transformerapi", "TestLinkCoverage.class"));
+ }
+
+ @Test
+ public void testJadxProvidesCoverageLinks() throws Exception {
+ DecompilationResult result = Decompilers.JADX.decompile("target/test-classes", "com/heliosdecompiler/transformerapi", "TestLinkCoverage.class");
+ assertProvidesCoverageLinks(result);
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isSharedFieldReference));
+ }
+
+ private static void assertProvidesBasicLinks(DecompilationResult result) {
+ assertNotNull(result.getDeclarations().get("test/TestCompact"));
+ assertNotNull(result.getDeclarations().get("test/TestCompact-log-(Ljava/lang/String;)V"));
+ assertFalse(result.getHyperlinks().isEmpty());
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isLogReference));
+ }
+
+ private static void assertProvidesBeanLinks(DecompilationResult result) {
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestBean"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestBean$InnerBean"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestBean$Helper"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestBean-name-Ljava/lang/String;"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestBean-helper-Lcom/heliosdecompiler/transformerapi/TestBean$Helper;"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestBean--(Ljava/lang/String;ILcom/heliosdecompiler/transformerapi/TestBean$Helper;)V"));
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isBeanFieldReference));
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isBeanConstructorReference));
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isBaseConstructorReference));
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isHelperConstructorReference));
+ }
+
+ private static void assertProvidesCoverageLinks(DecompilationResult result) {
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage$Inner"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage$Helper"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage-nativeMethod-()V"));
+ assertNotNull(result.getDeclarations().get("com/heliosdecompiler/transformerapi/TestLinkCoverage-varArgMethod-([Ljava/lang/String;)V"));
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isLocalCallReference));
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isBaseCallReference));
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isStaticHelperCallReference));
+ assertTrue(result.getReferences().stream().anyMatch(DecompilerLinksTest::isOwnerCallsReference));
+ }
+
+ private static boolean isLogReference(ReferenceData reference) {
+ return "test/TestCompact".equals(reference.getTypeName())
+ && "log".equals(reference.getName())
+ && "(Ljava/lang/String;)V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isBeanFieldReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestBean".equals(reference.getTypeName())
+ && "name".equals(reference.getName())
+ && "Ljava/lang/String;".equals(reference.getDescriptor());
+ }
+
+ private static boolean isBeanConstructorReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestBean".equals(reference.getTypeName())
+ && "".equals(reference.getName())
+ && "(Ljava/lang/String;I)V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isBaseConstructorReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestBeanBase".equals(reference.getTypeName())
+ && "".equals(reference.getName())
+ && "(Ljava/lang/String;)V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isHelperConstructorReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestBean$Helper".equals(reference.getTypeName())
+ && "".equals(reference.getName())
+ && "(Ljava/lang/String;)V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isLocalCallReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestLinkCoverage".equals(reference.getTypeName())
+ && "localCall".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isBaseCallReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestLinkCoverageBase".equals(reference.getTypeName())
+ && "baseCall".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isSharedFieldReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestLinkCoverageBase".equals(reference.getTypeName())
+ && "shared".equals(reference.getName())
+ && "Ljava/lang/String;".equals(reference.getDescriptor());
+ }
+
+ private static boolean isStaticHelperCallReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestLinkCoverage$Helper".equals(reference.getTypeName())
+ && "staticCall".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static boolean isOwnerCallsReference(ReferenceData reference) {
+ return "com/heliosdecompiler/transformerapi/TestLinkCoverage".equals(reference.getTypeName())
+ && "ownerCalls".equals(reference.getName())
+ && "()V".equals(reference.getDescriptor());
+ }
+
+ private static Loader loader(String resourcePath) throws Exception {
+ try (InputStream in = DecompilerLinksTest.class.getResourceAsStream(resourcePath)) {
+ byte[] data = IOUtils.toByteArray(in);
+ ZipLoader zipLoader = new ZipLoader(new ByteArrayInputStream(data));
+ return new Loader(zipLoader::canLoad, zipLoader::load, DecompilerLinksTest.class.getResource(resourcePath).toURI());
+ }
+ }
+}
diff --git a/src/test/resources/test-bean.jar b/src/test/resources/test-bean.jar
new file mode 100644
index 0000000..d24f45b
Binary files /dev/null and b/src/test/resources/test-bean.jar differ