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: + *

+ * + *

The implementation is deliberately conservative for: + *

+ * + *

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