diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebugEngine.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebugEngine.java index 200d5808..9d51d8f9 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebugEngine.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebugEngine.java @@ -103,32 +103,24 @@ public static Target local(int port, Duration timeout) { } } - record Source( - URI uri, - String binaryName, - String contents, - SourceLineMap lineMap, - SourceVariableNames variableNames - ) { + record Source(URI uri, com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument document) { public Source { Objects.requireNonNull(uri, "uri"); - if (!uri.isAbsolute()) { - throw new IllegalArgumentException("Debug source URI must be absolute"); - } - if (binaryName == null || binaryName.isBlank()) { - throw new IllegalArgumentException("Debug source binary name must not be blank"); - } - Objects.requireNonNull(contents, "contents"); - Objects.requireNonNull(lineMap, "lineMap"); - Objects.requireNonNull(variableNames, "variableNames"); + if (!uri.isAbsolute()) throw new IllegalArgumentException("Debug source URI must be absolute"); + Objects.requireNonNull(document); + } + public String binaryName() { return document.binaryName(); } + public String contents() { return document.contents(); } + public SourceLineMap lineMap() { return document.lineMap(); } + public SourceVariableNames variableNames() { return document.variableNames(); } + public Source(URI uri, String binaryName, String contents, SourceLineMap lineMap, SourceVariableNames names) { + this(uri, new com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument(binaryName, contents, lineMap, names, List.of())); } - public Source(URI uri, String binaryName, String contents, SourceLineMap lineMap) { this(uri, binaryName, contents, lineMap, SourceVariableNames.empty()); } - public Source(URI uri, String binaryName, String contents) { - this(uri, binaryName, contents, SourceLineMap.empty(), SourceVariableNames.empty()); + this(uri, new com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument(binaryName, contents)); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerBreakpointResolver.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerBreakpointResolver.java index 4520fb17..8919548d 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerBreakpointResolver.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerBreakpointResolver.java @@ -1,64 +1,27 @@ package com.github.minecraft_ta.totalDebugCompanion.debugger; -import com.github.minecraft_ta.totalDebugCompanion.jdt.JavaAst; -import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; -import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.JavaSymbolResolver; -import org.eclipse.jdt.core.dom.ASTVisitor; -import org.eclipse.jdt.core.dom.CompilationUnit; -import org.eclipse.jdt.core.dom.MethodDeclaration; - import java.util.Objects; import java.util.Optional; -/** Resolves displayed source lines identically for the editor and remote debugger callers. */ +/** Breakpoint policy over the same source scopes used by navigation and MCP. */ public final class DebuggerBreakpointResolver { - private DebuggerBreakpointResolver() { - } - - public static Optional resolve( - DebugEngine.Source source, int line, String condition, String hitCondition - ) { - return resolve(source, JavaAst.parse("DebuggerBreakpoint", source.contents()), - line, condition, hitCondition); - } - + private DebuggerBreakpointResolver() {} public static Optional resolve( - DebugEngine.Source source, CompilationUnit unit, int line, String condition, String hitCondition - ) { - Objects.requireNonNull(source, "source"); - Objects.requireNonNull(unit, "unit"); + DebugEngine.Source source, int line, String condition, String hitCondition) { + Objects.requireNonNull(source); if (line < 1 || line > source.contents().lines().count()) { throw new IllegalArgumentException("Source has no displayed line " + line); } - if (source.lineMap().isEmpty()) { - return Optional.of(new DebugEngine.SourceBreakpoint(line, condition, hitCondition)); + if (source.lineMap().isEmpty()) return Optional.of(new DebugEngine.SourceBreakpoint(line, condition, hitCondition)); + var scope = source.document().methodAtLine(line); + if (scope.isEmpty()) { + return source.lineMap().containsDisplayedLine(line) + ? Optional.of(new DebugEngine.SourceBreakpoint(line, condition, hitCondition)) : Optional.empty(); } - MethodDeclaration[] selected = new MethodDeclaration[1]; - unit.accept(new ASTVisitor() { - @Override - public boolean visit(MethodDeclaration declaration) { - if (selected[0] == null && unit.getLineNumber(declaration.getName().getStartPosition()) == line) { - selected[0] = declaration; - } - return true; - } - }); - MethodDeclaration declaration = selected[0]; - if (declaration == null) { - if (!source.lineMap().containsDisplayedLine(line)) { - return Optional.empty(); - } - return Optional.of(new DebugEngine.SourceBreakpoint(line, condition, hitCondition)); - } - int endLine = unit.getLineNumber(declaration.getStartPosition() + declaration.getLength() - 1); - var debuggerLine = source.lineMap().firstMappedDisplayedLine(line, endLine); + var debuggerLine = source.lineMap().firstMappedDisplayedLine(line, scope.get().lastLine()); if (debuggerLine.isEmpty()) return Optional.empty(); - var binding = declaration.resolveBinding(); - if (binding == null || !(JavaSymbolResolver.trySymbolForBinding(binding) instanceof CodeSymbol.MethodSymbol method)) { - throw new IllegalArgumentException("Cannot resolve the runtime method declared at line " + line); - } + var method = scope.get().method(); return Optional.of(DebugEngine.SourceBreakpoint.methodEntry(line, debuggerLine.getAsInt(), - new DebugEngine.MethodTarget(method.ownerClassName(), method.name(), method.descriptor()), - condition, hitCondition)); + new DebugEngine.MethodTarget(method.ownerClassName(), method.name(), method.descriptor()), condition, hitCondition)); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/MicrosoftSourceRegistry.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/MicrosoftSourceRegistry.java index 9352ef7b..6fb3e0ff 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/MicrosoftSourceRegistry.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/MicrosoftSourceRegistry.java @@ -1,32 +1,19 @@ package com.github.minecraft_ta.totalDebugCompanion.debugger; import com.github.minecraft_ta.totalDebugCompanion.debugger.expression.DebuggerTypeScope; -import com.github.minecraft_ta.totalDebugCompanion.jdt.JdtConfiguration; import com.github.minecraft_ta.totalDebugCompanion.source.SourceVariableNames; import com.microsoft.java.debug.core.JavaBreakpointLocation; import com.microsoft.java.debug.core.adapter.ISourceLookUpProvider; import com.microsoft.java.debug.core.adapter.SourceType; import com.microsoft.java.debug.core.protocol.Types; -import org.eclipse.jdt.core.dom.ASTNode; -import org.eclipse.jdt.core.dom.ASTParser; -import org.eclipse.jdt.core.dom.AbstractTypeDeclaration; -import org.eclipse.jdt.core.dom.AnonymousClassDeclaration; -import org.eclipse.jdt.core.dom.CompilationUnit; -import org.eclipse.jdt.core.dom.Initializer; -import org.eclipse.jdt.core.dom.LambdaExpression; -import org.eclipse.jdt.core.dom.MethodDeclaration; import java.net.URI; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; /** Source lookup and exact source-line ownership for the Microsoft Java debug adapter. */ final class MicrosoftSourceRegistry implements ISourceLookUpProvider { @@ -269,134 +256,12 @@ static URI sourceUri(Types.Source source) { } } - private record RegisteredSource( - DebugEngine.Source source, - DebuggerTypeScope typeScope, - List typeRegions - ) { + private record RegisteredSource(DebugEngine.Source source, DebuggerTypeScope typeScope) { static RegisteredSource parse(DebugEngine.Source source) { - ASTParser parser = JdtConfiguration.createParser(); - parser.setKind(ASTParser.K_COMPILATION_UNIT); - parser.setSource(source.contents().toCharArray()); - parser.setStatementsRecovery(true); - CompilationUnit unit = (CompilationUnit) parser.createAST(null); - String parseErrors = Arrays.stream(unit.getProblems()) - .filter(org.eclipse.jdt.core.compiler.IProblem::isError) - .map(problem -> "line " + problem.getSourceLineNumber() + ": " + problem.getMessage()) - .collect(Collectors.joining("; ")); - if (!parseErrors.isEmpty()) { - throw new IllegalArgumentException( - "Unable to parse debugger source " + source.uri() + ": " + parseErrors - ); - } - - String packageName = unit.getPackage() == null - ? packageName(source.binaryName()) - : unit.getPackage().getName().getFullyQualifiedName(); - Map names = new HashMap<>(); - List regions = new ArrayList<>(); - unit.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { - @Override - public void preVisit(ASTNode node) { - if (!(node instanceof AbstractTypeDeclaration declaration) || isLocalType(declaration)) { - return; - } - AbstractTypeDeclaration parent = enclosingNamedType(declaration.getParent()); - String simpleName = declaration.getName().getIdentifier(); - String binaryName; - if (parent != null) { - String parentName = names.get(parent); - if (parentName == null) { - return; - } - binaryName = parentName + "$" + simpleName; - } else if (simpleBinaryName(source.binaryName()).equals(simpleName)) { - binaryName = source.binaryName(); - } else { - binaryName = packageName.isBlank() ? simpleName : packageName + "." + simpleName; - } - names.put(declaration, binaryName); - int firstLine = unit.getLineNumber(declaration.getStartPosition()); - int lastLine = unit.getLineNumber( - declaration.getStartPosition() + Math.max(0, declaration.getLength() - 1) - ); - if (firstLine > 0 && lastLine >= firstLine) { - regions.add(new TypeRegion(firstLine, lastLine, binaryName)); - } - } - }); - regions.sort(Comparator.comparingInt(TypeRegion::span)); - return new RegisteredSource( - source, - DebuggerTypeScope.parse(source), - List.copyOf(regions) - ); - } - - String binaryNameAt(int line) { - return this.typeRegions.stream() - .filter(region -> region.contains(line)) - .map(TypeRegion::binaryName) - .findFirst() - .orElse(this.source.binaryName()); - } - - List binaryNames() { - List result = new ArrayList<>(this.typeRegions.size() + 1); - result.add(this.source.binaryName()); - this.typeRegions.stream() - .map(TypeRegion::binaryName) - .filter(name -> !result.contains(name)) - .forEach(result::add); - return result; - } - - private static boolean isLocalType(AbstractTypeDeclaration declaration) { - for (ASTNode current = declaration.getParent(); current != null; current = current.getParent()) { - if (current instanceof AbstractTypeDeclaration) { - return false; - } - if (current instanceof MethodDeclaration - || current instanceof Initializer - || current instanceof LambdaExpression - || current instanceof AnonymousClassDeclaration) { - return true; - } - } - return false; - } - - private static AbstractTypeDeclaration enclosingNamedType(ASTNode node) { - for (ASTNode current = node; current != null; current = current.getParent()) { - if (current instanceof AbstractTypeDeclaration declaration) { - return declaration; - } - } - return null; - } - - private static String packageName(String binaryName) { - String topLevelName = binaryName.substring(0, binaryName.indexOf('$') < 0 - ? binaryName.length() - : binaryName.indexOf('$')); - int separator = topLevelName.lastIndexOf('.'); - return separator < 0 ? "" : topLevelName.substring(0, separator); - } - - private static String simpleBinaryName(String binaryName) { - int packageSeparator = binaryName.lastIndexOf('.'); - int nestedSeparator = binaryName.lastIndexOf('$'); - return binaryName.substring(Math.max(packageSeparator, nestedSeparator) + 1); - } - } - - private record TypeRegion(int firstLine, int lastLine, String binaryName) { - boolean contains(int line) { - return line >= this.firstLine && line <= this.lastLine; - } - - int span() { - return this.lastLine - this.firstLine; + source.document().binaryNames(); // Validate the source before publishing a registration. + return new RegisteredSource(source, DebuggerTypeScope.from(source.document())); } + String binaryNameAt(int line) { return source.document().ownerAtLine(line); } + List binaryNames() { return source.document().binaryNames(); } } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/expression/DebuggerTypeScope.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/expression/DebuggerTypeScope.java index 3010f276..ef832d3a 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/expression/DebuggerTypeScope.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/expression/DebuggerTypeScope.java @@ -1,13 +1,9 @@ package com.github.minecraft_ta.totalDebugCompanion.debugger.expression; -import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; -import com.github.minecraft_ta.totalDebugCompanion.jdt.JdtConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; import com.sun.jdi.ReferenceType; import com.sun.jdi.VirtualMachine; -import org.eclipse.jdt.core.dom.ASTParser; -import org.eclipse.jdt.core.dom.CompilationUnit; -import org.eclipse.jdt.core.dom.ImportDeclaration; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -36,30 +32,18 @@ private DebuggerTypeScope( this.onDemandImports = List.copyOf(onDemandImports); } - public static DebuggerTypeScope parse(DebugEngine.Source source) { - Objects.requireNonNull(source, "source"); - ASTParser parser = JdtConfiguration.createParser(); - parser.setKind(ASTParser.K_COMPILATION_UNIT); - parser.setSource(source.contents().toCharArray()); - parser.setStatementsRecovery(true); - CompilationUnit unit = (CompilationUnit) parser.createAST(null); - - String packageName = unit.getPackage() == null - ? packageName(source.binaryName()) - : unit.getPackage().getName().getFullyQualifiedName(); + public static DebuggerTypeScope from(SourceDocument document) { + Objects.requireNonNull(document); + String packageName = document.packageName().isEmpty() ? packageName(document.binaryName()) : document.packageName(); Map singleImports = new LinkedHashMap<>(); List onDemandImports = new ArrayList<>(); - for (Object value : unit.imports()) { - ImportDeclaration declaration = (ImportDeclaration) value; - String importedName = declaration.getName().getFullyQualifiedName(); - if (declaration.isOnDemand()) { - onDemandImports.add(importedName); - } else { - singleImports.put(simpleName(importedName), importedName); - } - } List compilerImports = new ArrayList<>(); - for (Object declaration : unit.imports()) compilerImports.add(declaration.toString()); + for (String imported : document.imports()) { + compilerImports.add("import " + imported + ";\n"); + String name = imported.startsWith("static ") ? imported.substring(7) : imported; + if (name.endsWith(".*")) onDemandImports.add(name.substring(0, name.length() - 2)); + else singleImports.put(simpleName(name), name); + } return new DebuggerTypeScope(packageName, singleImports, onDemandImports, compilerImports); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationService.java index ff166b5b..0472f276 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationService.java @@ -6,6 +6,7 @@ import com.github.minecraft_ta.totalDebugCompanion.decompiler.JavaDecompiler; import com.github.minecraft_ta.totalDebugCompanion.decompiler.VineflowerDecompiler; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; import java.io.IOException; import java.nio.file.Path; @@ -20,7 +21,7 @@ import java.util.concurrent.Executors; public final class CompanionDecompilationService implements AutoCloseable { - private static final String DECOMPILER_FORMAT = "vineflower-1.12.0-selective-naming-debug-metadata-5"; + private static final String DECOMPILER_FORMAT = "vineflower-1.12.0-source-symbols-9"; private final DecompiledSourceStore sourceStore; private final RuntimeSnapshotBytecodeSource bytecodeSource; @@ -142,22 +143,12 @@ private DecompiledSource decompileNow(String binaryName) throws IOException { throw new IOException("Vineflower produced partial source for " + binaryName); } this.bytecodeSource.requireCurrent(); + SourceDocument document = new SourceDocument(binaryName, result.source(), result.lineMap(), result.variableNames(), result.symbols()); + document.prepare(); synchronized (this.publicationLock) { ensureOpen(); - Path path = this.sourceStore.write( - binaryName, - result.source(), - result.lineMap(), - result.variableNames() - ); - return new DecompiledSource( - path, - binaryName, - result.source(), - result.lineMap(), - result.variableNames(), - this.bytecodeSource.findClassOrigin(binaryName) - ); + Path path = this.sourceStore.write(document); + return new DecompiledSource(path, document, this.bytecodeSource.findClassOrigin(binaryName)); } } @@ -168,15 +159,8 @@ private DecompiledSource readStoredSource(String binaryName) throws IOException return null; } this.bytecodeSource.requireCurrent(); - var metadata = stored.debug(); - return new DecompiledSource( - stored.path(), - binaryName, - stored.source(), - metadata.lines(), - metadata.names(), - this.bytecodeSource.findClassOrigin(binaryName) - ); + stored.document().prepare(); + return new DecompiledSource(stored.path(), stored.document(), this.bytecodeSource.findClassOrigin(binaryName)); } private static String requireBinaryName(String binaryName) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSource.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSource.java index c5e598c6..d45a208c 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSource.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSource.java @@ -2,37 +2,26 @@ import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; +import com.github.minecraft_ta.totalDebugCompanion.model.EditorLocation; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; import com.github.minecraft_ta.totalDebugCompanion.source.SourceLineMap; import com.github.minecraft_ta.totalDebugCompanion.source.SourceVariableNames; - +import com.github.minecraft_ta.totalDebugCompanion.ui.presentation.RuntimeModulePresentation; import java.nio.file.Path; import java.util.Objects; -public record DecompiledSource( - Path path, - String binaryName, - String contents, - SourceLineMap lineMap, - SourceVariableNames variableNames, - RuntimeSnapshotBytecodeSource.ClassOrigin origin -) { +public record DecompiledSource(Path path, SourceDocument document, RuntimeSnapshotBytecodeSource.ClassOrigin origin) { public DecompiledSource { - path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); - if (Objects.requireNonNull(binaryName, "binaryName").isBlank()) { - throw new IllegalArgumentException("Decompiled source binary name is blank"); - } - Objects.requireNonNull(contents, "contents"); - Objects.requireNonNull(lineMap, "lineMap"); - Objects.requireNonNull(variableNames, "variableNames"); + path = Objects.requireNonNull(path).toAbsolutePath().normalize(); + Objects.requireNonNull(document); } - - public DebugEngine.Source debugSource() { - return new DebugEngine.Source( - this.path.toUri(), - this.binaryName, - this.contents, - this.lineMap, - this.variableNames - ); + public String binaryName() { return document.binaryName(); } + public String contents() { return document.contents(); } + public SourceLineMap lineMap() { return document.lineMap(); } + public SourceVariableNames variableNames() { return document.variableNames(); } + public DebugEngine.Source debugSource() { return new DebugEngine.Source(path.toUri(), document); } + public EditorLocation location() { + return origin == null ? EditorLocation.forFile(path, null) : EditorLocation.forRuntimeClass( + binaryName(), origin.logicalSource(), RuntimeModulePresentation.of(origin.module()).label(), origin.module().id()); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSourceStore.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSourceStore.java index 81e6580d..8940c722 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSourceStore.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSourceStore.java @@ -1,6 +1,8 @@ package com.github.minecraft_ta.totalDebugCompanion.decompile; import com.github.minecraft_ta.totalDebugCompanion.source.SourceLineMap; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; +import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; import com.github.minecraft_ta.totalDebugCompanion.source.SourceVariableNames; import com.github.minecraft_ta.totaldebug.storage.AtomicFiles; import com.github.minecraft_ta.totaldebug.storage.InstancePaths; @@ -23,7 +25,7 @@ /** One current runtime's readable source/debug pairs. The manifest commits each complete pair. */ final class DecompiledSourceStore { private static final int MAGIC = 0x54444442; - private static final int FORMAT = 2; + private static final int FORMAT = 4; private final Path directory; private final String identity; @@ -76,11 +78,11 @@ StoredSource read(String binaryName) throws IOException { } Path file = this.directory.resolve(stem + ".java"); String source = Files.readString(file, StandardCharsets.UTF_8); - return new StoredSource(file, source, readDebug(this.directory.resolve(stem + ".debug"), binaryName, source)); + return new StoredSource(file, readDebug(this.directory.resolve(stem + ".debug"), binaryName, source)); }); } - private static DebugMetadata readDebug(Path file, String binaryName, String source) throws IOException { + private static SourceDocument readDebug(Path file, String binaryName, String source) throws IOException { try (DataInputStream input = new DataInputStream(Files.newInputStream(file))) { if (!readHeader(input).equals(binaryName) || !input.readUTF().equals(fingerprint(source))) { throw new IOException("Decompiled source/debug pair does not match: " + file); @@ -109,19 +111,35 @@ private static DebugMetadata readDebug(Path file, String binaryName, String sour throw new IOException("Duplicate method variable names: " + method); } } + int symbolCount = readCount(input, 12); + var symbols = new java.util.ArrayList(symbolCount); + for (int i = 0; i < symbolCount; i++) { + int kind = input.readUnsignedByte(); + String owner = input.readUTF(); + CodeSymbol symbol = switch (kind) { + case 0 -> new CodeSymbol.ClassSymbol(owner); + case 1 -> new CodeSymbol.FieldSymbol(owner, input.readUTF(), input.readUTF()); + case 2 -> new CodeSymbol.MethodSymbol(owner, input.readUTF(), input.readUTF()); + default -> throw new IOException("Invalid source symbol kind: " + kind); + }; + int role = input.readUnsignedByte(); + if (role >= SourceDocument.SymbolRole.values().length) throw new IOException("Invalid source symbol role: " + role); + symbols.add(new SourceDocument.SymbolSpan(symbol, SourceDocument.SymbolRole.values()[role], input.readInt(), input.readInt())); + } if (input.read() != -1) { throw new IOException("Trailing data in decompiled debug metadata: " + file); } - return new DebugMetadata(SourceLineMap.fromOriginalToDisplayed(mapping), SourceVariableNames.of(methods)); + return new SourceDocument(binaryName, source, SourceLineMap.fromOriginalToDisplayed(mapping), SourceVariableNames.of(methods), symbols); } catch (IllegalArgumentException exception) { throw new IOException("Invalid decompiled debug metadata: " + file, exception); } } - Path write(String binaryName, String source, SourceLineMap lines, SourceVariableNames names) throws IOException { - Objects.requireNonNull(source); - Objects.requireNonNull(lines); - Objects.requireNonNull(names); + Path write(SourceDocument document) throws IOException { + String binaryName = document.binaryName(); + String source = document.contents(); + SourceLineMap lines = document.lineMap(); + SourceVariableNames names = document.variableNames(); if (Objects.requireNonNull(binaryName).isBlank() || binaryName.contains("/") || binaryName.contains("\\")) { throw new IllegalArgumentException("Expected a Java binary name: " + binaryName); } @@ -159,6 +177,15 @@ Path write(String binaryName, String source, SourceLineMap lines, SourceVariable output.writeUTF(variable.getValue()); } } + output.writeInt(document.symbols().size()); + for (var span : document.symbols()) { + switch (span.symbol()) { + case CodeSymbol.ClassSymbol symbol -> { output.writeByte(0); output.writeUTF(symbol.className()); } + case CodeSymbol.FieldSymbol symbol -> { output.writeByte(1); output.writeUTF(symbol.ownerClassName()); output.writeUTF(symbol.name()); output.writeUTF(symbol.descriptor()); } + case CodeSymbol.MethodSymbol symbol -> { output.writeByte(2); output.writeUTF(symbol.ownerClassName()); output.writeUTF(symbol.name()); output.writeUTF(symbol.descriptor()); } + } + output.writeByte(span.role().ordinal()); output.writeInt(span.offset()); output.writeInt(span.length()); + } } }); classes.addProperty(binaryName, stem); @@ -225,7 +252,7 @@ private List generatedFiles() throws IOException { } } - record StoredSource(Path path, String source, DebugMetadata debug) { } + record StoredSource(Path path, SourceDocument document) { } private static String fingerprint(String... values) { try { @@ -256,5 +283,4 @@ private static int readCount(DataInputStream input, int minimumBytesPerEntry) th return count; } - record DebugMetadata(SourceLineMap lines, SourceVariableNames names) { } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceFileNavigation.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceFileNavigation.java deleted file mode 100644 index 1212e7d9..00000000 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceFileNavigation.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.github.minecraft_ta.totalDebugCompanion.decompile; - -import com.github.minecraft_ta.totalDebugCompanion.jdt.JavaAst; -import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; -import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceLocation; -import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceQuery; -import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.SourceReferenceLocator; -import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; -import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.JavaSymbolResolver; -import com.github.minecraft_ta.totalDebugCompanion.model.EditorLocation; -import com.github.minecraft_ta.totalDebugCompanion.navigation.RuntimeMember; -import com.github.minecraft_ta.totalDebugCompanion.ui.presentation.RuntimeModulePresentation; -import org.eclipse.jdt.core.dom.ASTNode; -import org.eclipse.jdt.core.dom.AbstractTypeDeclaration; -import org.eclipse.jdt.core.dom.BodyDeclaration; -import org.eclipse.jdt.core.dom.EnumConstantDeclaration; -import org.eclipse.jdt.core.dom.EnumDeclaration; -import org.eclipse.jdt.core.dom.FieldDeclaration; -import org.eclipse.jdt.core.dom.MethodDeclaration; -import org.eclipse.jdt.core.dom.RecordDeclaration; -import org.eclipse.jdt.core.dom.SingleVariableDeclaration; -import org.eclipse.jdt.core.dom.VariableDeclarationFragment; - -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.OptionalInt; - -public final class SourceFileNavigation { - private static final String COMPILATION_UNIT_NAME = "Name"; - - private SourceFileNavigation() { - } - - public static int lineOffset(String source, int line) { - if (line < 1) { - throw new IllegalArgumentException("Source line must be positive"); - } - int currentLine = 1; - int offset = 0; - while (currentLine < line) { - int newline = source.indexOf('\n', offset); - if (newline < 0) { - throw new IllegalArgumentException("Source has no line " + line); - } - offset = newline + 1; - currentLine++; - } - return offset; - } - - public static int topLevelTypeOffset(String source) { - Objects.requireNonNull(source, "source"); - var ast = JavaAst.parse(COMPILATION_UNIT_NAME, source); - if (ast.types().isEmpty() || !(ast.types().getFirst() instanceof AbstractTypeDeclaration type)) { - throw new IllegalStateException("Decompiled source has no top-level type"); - } - return type.getName().getStartPosition(); - } - - public static EditorLocation location(DecompiledSource source) { - Objects.requireNonNull(source, "source"); - return location(source.path(), source.binaryName(), source.origin()); - } - - private static EditorLocation location( - Path filePath, - String binaryName, - RuntimeSnapshotBytecodeSource.ClassOrigin origin - ) { - return origin == null - ? EditorLocation.forFile(filePath, null) - : EditorLocation.forRuntimeClass( - binaryName, - origin.logicalSource(), - RuntimeModulePresentation.of(origin.module()).label(), - origin.module().id() - ); - } - - public static int usageOffset(String source, ReferenceLocation location, ReferenceQuery query) { - Objects.requireNonNull(source, "source"); - Objects.requireNonNull(location, "location"); - Objects.requireNonNull(query, "query"); - - var ast = JavaAst.parse(COMPILATION_UNIT_NAME, source); - if (ast.types().isEmpty() || !(ast.types().getFirst() instanceof AbstractTypeDeclaration type)) { - throw new IllegalStateException("Decompiled source has no top-level type"); - } - ASTNode site = findSite(location.site(), type) - .orElseThrow(() -> new IllegalStateException("Usage declaration not found in decompiled source: " - + location)); - return SourceReferenceLocator.findFirst(site, query).orElse(site.getStartPosition()); - } - - private static Optional findSite(ReferenceLocation.Site site, AbstractTypeDeclaration type) { - return switch (site) { - case ReferenceLocation.ClassDeclaration ignored -> Optional.of(type); - case ReferenceLocation.Method method -> findTargetMethod(method, type).map(ASTNode.class::cast); - case ReferenceLocation.Field field -> findFieldSite(field.name(), type); - case ReferenceLocation.RecordComponent component -> findRecordComponentSite(component.name(), type); - }; - } - - @SuppressWarnings("unchecked") - private static Optional findFieldSite(String name, AbstractTypeDeclaration type) { - List declarations = new ArrayList<>(bodyDeclarations(type)); - if (type instanceof EnumDeclaration enumDeclaration) { - declarations.addAll(enumDeclaration.enumConstants()); - } - return declarations.stream() - .filter(declaration -> declaration instanceof FieldDeclaration - || declaration instanceof EnumConstantDeclaration) - .filter(declaration -> fieldMatches(declaration, name)) - .map(ASTNode.class::cast) - .findFirst(); - } - - private static Optional findRecordComponentSite(String name, AbstractTypeDeclaration type) { - if (!(type instanceof RecordDeclaration recordDeclaration)) { - return Optional.empty(); - } - for (Object candidate : recordDeclaration.recordComponents()) { - SingleVariableDeclaration component = (SingleVariableDeclaration) candidate; - if (component.getName().getIdentifier().equals(name)) { - return Optional.of(component); - } - } - return Optional.empty(); - } - - private static Optional findTargetMethod( - ReferenceLocation.Method target, - AbstractTypeDeclaration type - ) { - return bodyDeclarations(type).stream() - .filter(MethodDeclaration.class::isInstance) - .map(MethodDeclaration.class::cast) - .filter(method -> { - CodeSymbol symbol = JavaSymbolResolver.trySymbolForBinding(method.resolveBinding()); - return symbol instanceof CodeSymbol.MethodSymbol candidate - && candidate.name().equals(target.name()) - && candidate.descriptor().equals(target.descriptor()); - }) - .findFirst(); - } - - @SuppressWarnings("unchecked") - private static OptionalInt findTargetField(String targetIdentifier, AbstractTypeDeclaration type) { - List declarations = new ArrayList<>(bodyDeclarations(type)); - if (type instanceof EnumDeclaration enumDeclaration) { - declarations.addAll(enumDeclaration.enumConstants()); - } else if (type instanceof RecordDeclaration recordDeclaration) { - declarations.addAll(recordDeclaration.recordComponents()); - } - return declarations.stream() - .filter(declaration -> declaration instanceof FieldDeclaration - || declaration instanceof EnumConstantDeclaration - || declaration instanceof SingleVariableDeclaration) - .filter(declaration -> fieldMatches(declaration, targetIdentifier)) - .mapToInt(declaration -> fieldOffset(declaration, targetIdentifier)) - .findFirst(); - } - - private static boolean fieldMatches(Object declaration, String targetIdentifier) { - return switch (declaration) { - case EnumConstantDeclaration constant -> constant.getName().getIdentifier().equals(targetIdentifier); - case SingleVariableDeclaration component -> component.getName().getIdentifier().equals(targetIdentifier); - case FieldDeclaration field -> field.fragments().stream().anyMatch(fragment -> - ((VariableDeclarationFragment) fragment).getName().getIdentifier().equals(targetIdentifier) - ); - default -> false; - }; - } - - private static int fieldOffset(Object declaration, String targetIdentifier) { - return switch (declaration) { - case EnumConstantDeclaration constant -> constant.getName().getStartPosition(); - case SingleVariableDeclaration component -> component.getName().getStartPosition(); - case FieldDeclaration field -> fieldFragmentOffset(field, targetIdentifier); - default -> throw new IllegalArgumentException("Not a field declaration"); - }; - } - - public static int memberOffset(String source, RuntimeMember member) { - Objects.requireNonNull(source, "source"); - Objects.requireNonNull(member, "member"); - var ast = JavaAst.parse(COMPILATION_UNIT_NAME, source); - if (ast.types().isEmpty() || !(ast.types().getFirst() instanceof AbstractTypeDeclaration type)) { - throw new IllegalStateException("Decompiled source has no top-level type"); - } - return switch (member) { - case RuntimeMember.Field field -> findTargetField(field.name(), type) - .orElseThrow(() -> new IllegalStateException( - "Field not found in decompiled source: " + field.name() - )); - case RuntimeMember.Method method -> findTargetMethod( - new ReferenceLocation.Method(method.name(), method.descriptor()), - type - ).map(candidate -> candidate.getName().getStartPosition()).orElseThrow(() -> - new IllegalStateException("Method not found in decompiled source: " - + method.name() + method.descriptor()) - ); - }; - } - - private static int fieldFragmentOffset(FieldDeclaration field, String targetIdentifier) { - for (Object candidate : field.fragments()) { - VariableDeclarationFragment fragment = (VariableDeclarationFragment) candidate; - if (fragment.getName().getIdentifier().equals(targetIdentifier)) { - return fragment.getName().getStartPosition(); - } - } - throw new IllegalArgumentException("Field declaration does not contain " + targetIdentifier); - } - - @SuppressWarnings("unchecked") - private static List bodyDeclarations(AbstractTypeDeclaration type) { - return (List) type.bodyDeclarations(); - } -} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/DecompilationResult.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/DecompilationResult.java index 054d4e68..32b34bbf 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/DecompilationResult.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/DecompilationResult.java @@ -4,6 +4,7 @@ import com.github.minecraft_ta.totalDebugCompanion.source.SourceVariableNames; import java.util.List; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; import java.util.Objects; public record DecompilationResult( @@ -11,7 +12,8 @@ public record DecompilationResult( Status status, List diagnostics, SourceLineMap lineMap, - SourceVariableNames variableNames + SourceVariableNames variableNames, + List symbols ) { public DecompilationResult { Objects.requireNonNull(source, "source"); @@ -19,6 +21,7 @@ public record DecompilationResult( diagnostics = List.copyOf(diagnostics); Objects.requireNonNull(lineMap, "lineMap"); Objects.requireNonNull(variableNames, "variableNames"); + symbols = List.copyOf(symbols); if (source.isBlank()) { throw new IllegalArgumentException("Decompiled source must not be blank"); } @@ -29,7 +32,7 @@ public DecompilationResult( Status status, List diagnostics ) { - this(source, status, diagnostics, SourceLineMap.empty(), SourceVariableNames.empty()); + this(source, status, diagnostics, SourceLineMap.empty(), SourceVariableNames.empty(), List.of()); } public boolean isComplete() { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/SourceSymbolCapture.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/SourceSymbolCapture.java new file mode 100644 index 00000000..5f2069a7 --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/SourceSymbolCapture.java @@ -0,0 +1,94 @@ +package com.github.minecraft_ta.totalDebugCompanion.decompiler; + +import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument.SymbolRole; +import org.jetbrains.java.decompiler.main.DecompilerContext; +import org.jetbrains.java.decompiler.main.extern.TextTokenVisitor; +import org.jetbrains.java.decompiler.struct.gen.FieldDescriptor; +import org.jetbrains.java.decompiler.struct.gen.MethodDescriptor; +import org.jetbrains.java.decompiler.struct.attr.StructGeneralAttribute; +import org.jetbrains.java.decompiler.util.token.TextRange; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** A per-decompilation collector, supplied through Vineflower's context rather than global mutable state. */ +public final class SourceSymbolCapture { + public static final String CONTEXT_KEY = "totaldebug.source-symbol-capture"; + private final Map> byContents = new HashMap<>(); + + public static void registerVisitor() { + Object value = DecompilerContext.getProperty(CONTEXT_KEY); + if (value instanceof SourceSymbolCapture capture) TextTokenVisitor.addVisitor(capture::visitor); + } + + synchronized List forSource(String source) { + List spans = this.byContents.get(source); + if (spans == null) throw new IllegalStateException("Vineflower did not capture the emitted source"); + return spans; + } + + private TextTokenVisitor visitor(TextTokenVisitor next) { + return new TextTokenVisitor(next) { + private String contents; + private final List spans = new ArrayList<>(); + + @Override public void start(String contents) { + super.start(contents); + this.contents = contents; + this.spans.clear(); + } + + @Override public void visitClass(TextRange range, boolean declaration, String name) { + super.visitClass(range, declaration, name); + add(range, declaration ? SymbolRole.DECLARATION : SymbolRole.REFERENCE, new CodeSymbol.ClassSymbol(name.replace('/', '.'))); + } + + @Override public void visitField(TextRange range, boolean declaration, String owner, String name, FieldDescriptor descriptor) { + super.visitField(range, declaration, owner, name, descriptor); + SymbolRole role = declaration ? SymbolRole.DECLARATION : SymbolRole.REFERENCE; + if (declaration) { + var type = DecompilerContext.getStructContext().getClass(owner); + var field = type == null ? null : type.getField(name, descriptor.descriptorString); + if (field != null && field.hasAttribute(StructGeneralAttribute.ATTRIBUTE_CONSTANT_VALUE)) role = SymbolRole.CONSTANT_FIELD; + } + add(range, role, new CodeSymbol.FieldSymbol(owner.replace('/', '.'), name, descriptor.descriptorString)); + } + + @Override public void visitMethod(TextRange range, boolean declaration, String owner, String name, MethodDescriptor descriptor) { + super.visitMethod(range, declaration, owner, name, descriptor); + add(range, declaration ? SymbolRole.DECLARATION : SymbolRole.REFERENCE, new CodeSymbol.MethodSymbol(owner.replace('/', '.'), name, descriptor.toString())); + } + + @Override public void visitParameter(TextRange range, boolean declaration, String owner, String method, + MethodDescriptor descriptor, int index, String name) { + super.visitParameter(range, declaration, owner, method, descriptor, index, name); + if (declaration) add(range, SymbolRole.METHOD_PARAMETER, + new CodeSymbol.MethodSymbol(owner.replace('/', '.'), method, descriptor.toString())); + } + + @Override public void visitLocal(TextRange range, boolean declaration, String owner, String method, + MethodDescriptor descriptor, int index, String name) { + super.visitLocal(range, declaration, owner, method, descriptor, index, name); + // References to captured variables report their original declaration's method. + // Only declarations establish the lambda that owns this source scope. + if (declaration) add(range, SymbolRole.METHOD_LOCAL, + new CodeSymbol.MethodSymbol(owner.replace('/', '.'), method, descriptor.toString())); + } + + private void add(TextRange range, SymbolRole role, CodeSymbol symbol) { + if (range.length > 0) this.spans.add(new SourceDocument.SymbolSpan(symbol, role, range.start, range.length)); + } + + @Override public void end() { + super.end(); + synchronized (SourceSymbolCapture.this) { + byContents.put(this.contents, List.copyOf(this.spans)); + } + } + }; + } +} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/VineflowerDecompiler.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/VineflowerDecompiler.java index 3f872ba1..74b6356e 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/VineflowerDecompiler.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/VineflowerDecompiler.java @@ -58,6 +58,7 @@ public DecompilationResult decompile(String binaryName, ClassBytecodeSource byte InMemoryResultSaver resultSaver = new InMemoryResultSaver(); DiagnosticLogger logger = new DiagnosticLogger(); + SourceSymbolCapture symbolCapture = new SourceSymbolCapture(); SourceVariableNames variableNames; SourceLineMap lineMap; try (VariableNameCapture capture = VariableNameCapture.open(internalName)) { @@ -67,6 +68,7 @@ public DecompilationResult decompile(String binaryName, ClassBytecodeSource byte .libraries(new BytecodeLookupContext(bytecodeSource)) .output(resultSaver) .logger(logger) + .option(SourceSymbolCapture.CONTEXT_KEY, symbolCapture) .option(IFernflowerPreferences.THREADS, "1") .option(IFernflowerPreferences.INCLUDE_JAVA_RUNTIME, "current") .option(IFernflowerPreferences.DECOMPILER_COMMENTS, true) @@ -97,7 +99,8 @@ public DecompilationResult decompile(String binaryName, ClassBytecodeSource byte status, diagnostics, lineMap, - variableNames + variableNames, + symbolCapture.forSource(source) ); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/naming/SelectiveVariableNamingPlugin.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/naming/SelectiveVariableNamingPlugin.java index 628bbc1f..6bf886a1 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/naming/SelectiveVariableNamingPlugin.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/naming/SelectiveVariableNamingPlugin.java @@ -1,6 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.decompiler.naming; import org.jetbrains.java.decompiler.api.plugin.Plugin; +import com.github.minecraft_ta.totalDebugCompanion.decompiler.SourceSymbolCapture; import org.jetbrains.java.decompiler.main.extern.IVariableNamingFactory; import org.jetbrains.java.decompiler.main.extern.TextTokenVisitor; @@ -18,6 +19,7 @@ public String description() { @Override public void initialize() { TextTokenVisitor.addVisitor(VariableNameCapture::textTokenVisitor); + SourceSymbolCapture.registerVisitor(); } @Override diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/symbol/SourceReferenceLocator.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/symbol/SourceReferenceLocator.java deleted file mode 100644 index e065afa4..00000000 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/symbol/SourceReferenceLocator.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.github.minecraft_ta.totalDebugCompanion.jdt.symbol; - -import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceQuery; -import org.eclipse.jdt.core.dom.ASTNode; -import org.eclipse.jdt.core.dom.ASTVisitor; -import org.eclipse.jdt.core.dom.AbstractTypeDeclaration; -import org.eclipse.jdt.core.dom.AnnotationTypeMemberDeclaration; -import org.eclipse.jdt.core.dom.BodyDeclaration; -import org.eclipse.jdt.core.dom.ClassInstanceCreation; -import org.eclipse.jdt.core.dom.ConstructorInvocation; -import org.eclipse.jdt.core.dom.EnumConstantDeclaration; -import org.eclipse.jdt.core.dom.IBinding; -import org.eclipse.jdt.core.dom.MethodDeclaration; -import org.eclipse.jdt.core.dom.SimpleName; -import org.eclipse.jdt.core.dom.SuperConstructorInvocation; -import org.eclipse.jdt.core.dom.StringLiteral; -import org.eclipse.jdt.core.dom.TextBlock; -import org.eclipse.jdt.core.dom.VariableDeclaration; - -import java.util.Objects; -import java.util.OptionalInt; - -/** Finds the first rendered source occurrence of an exact indexed JVM symbol. */ -public final class SourceReferenceLocator { - private SourceReferenceLocator() { - } - - public static OptionalInt findFirst(ASTNode declarationSite, ReferenceQuery query) { - Objects.requireNonNull(declarationSite, "declarationSite"); - Objects.requireNonNull(query, "query"); - - MatchVisitor visitor = new MatchVisitor(declarationSite, query); - declarationSite.accept(visitor); - return visitor.offset < 0 ? OptionalInt.empty() : OptionalInt.of(visitor.offset); - } - - private static final class MatchVisitor extends ASTVisitor { - private final ASTNode declarationSite; - private final ReferenceQuery query; - private int offset = -1; - - private MatchVisitor(ASTNode declarationSite, ReferenceQuery query) { - this.declarationSite = declarationSite; - this.query = query; - } - - @Override - public boolean preVisit2(ASTNode node) { - if (this.offset >= 0) { - return false; - } - if (node != this.declarationSite - && (node instanceof AbstractTypeDeclaration - || node instanceof org.eclipse.jdt.core.dom.AnonymousClassDeclaration)) { - return false; - } - return !(this.declarationSite instanceof AbstractTypeDeclaration - && node != this.declarationSite - && node instanceof BodyDeclaration); - } - - @Override - public boolean visit(SimpleName name) { - if (!isDeclarationName(name) && matches(name.resolveBinding())) { - this.offset = name.getStartPosition(); - } - return this.offset < 0; - } - - @Override - public boolean visit(ClassInstanceCreation creation) { - if (matches(creation.resolveConstructorBinding())) { - this.offset = creation.getType().getStartPosition(); - return false; - } - return true; - } - - @Override - public boolean visit(ConstructorInvocation invocation) { - if (matches(invocation.resolveConstructorBinding())) { - this.offset = invocation.getStartPosition(); - return false; - } - return true; - } - - @Override - public boolean visit(SuperConstructorInvocation invocation) { - if (matches(invocation.resolveConstructorBinding())) { - this.offset = invocation.getStartPosition(); - return false; - } - return true; - } - - @Override - public boolean visit(EnumConstantDeclaration declaration) { - if (matches(declaration.resolveConstructorBinding())) { - this.offset = declaration.getName().getStartPosition(); - return false; - } - return true; - } - - @Override - public boolean visit(StringLiteral literal) { - if (this.query instanceof ReferenceQuery.StringLiteralReference target - && literal.getLiteralValue().equals(target.value())) { - this.offset = literal.getStartPosition(); - return false; - } - return true; - } - - @Override - public boolean visit(TextBlock literal) { - if (this.query instanceof ReferenceQuery.StringLiteralReference target - && literal.getLiteralValue().equals(target.value())) { - this.offset = literal.getStartPosition(); - return false; - } - return true; - } - - private boolean matches(IBinding binding) { - if (binding == null) { - return false; - } - CodeSymbol symbol = JavaSymbolResolver.trySymbolForBinding(binding); - return symbol != null && symbol.referenceQuery().equals(this.query); - } - } - - private static boolean isDeclarationName(SimpleName name) { - ASTNode parent = name.getParent(); - return switch (parent) { - case AbstractTypeDeclaration declaration -> declaration.getName() == name; - case MethodDeclaration declaration -> declaration.getName() == name; - case VariableDeclaration declaration -> declaration.getName() == name; - case EnumConstantDeclaration declaration -> declaration.getName() == name; - case AnnotationTypeMemberDeclaration declaration -> declaration.getName() == name; - default -> false; - }; - } -} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpRuntimeSource.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpRuntimeSource.java index 420a8783..8dca005f 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpRuntimeSource.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpRuntimeSource.java @@ -2,34 +2,16 @@ import com.github.minecraft_ta.totalDebugCompanion.decompile.CompanionDecompilationService; import com.github.minecraft_ta.totalDebugCompanion.decompile.DecompiledSource; -import com.github.minecraft_ta.totalDebugCompanion.jdt.JavaAst; -import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; -import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.JavaSymbolResolver; -import org.eclipse.jdt.core.dom.ASTNode; -import org.eclipse.jdt.core.dom.AbstractTypeDeclaration; -import org.eclipse.jdt.core.dom.CompilationUnit; -import org.eclipse.jdt.core.dom.EnumConstantDeclaration; -import org.eclipse.jdt.core.dom.EnumDeclaration; -import org.eclipse.jdt.core.dom.FieldDeclaration; -import org.eclipse.jdt.core.dom.ImportDeclaration; -import org.eclipse.jdt.core.dom.ITypeBinding; -import org.eclipse.jdt.core.dom.MethodDeclaration; -import org.eclipse.jdt.core.dom.RecordDeclaration; -import org.eclipse.jdt.core.dom.SingleVariableDeclaration; -import org.eclipse.jdt.core.dom.VariableDeclarationFragment; +import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceLocation; import org.objectweb.asm.Type; -import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Supplier; /** Returns exact source scopes from runtime classes. */ final class CompanionMcpRuntimeSource { - private static final String COMPILATION_UNIT_NAME = "RuntimeSource"; - private final RuntimeClassAccess runtimeClasses; CompanionMcpRuntimeSource(Supplier decompilationService) { @@ -52,197 +34,25 @@ Map source(Map requestedTarget) { throw new IllegalArgumentException("Class not found: " + target.binaryName()); } - String contents = decompiled.contents(); - CompilationUnit unit = JavaAst.parse(COMPILATION_UNIT_NAME, contents); - AbstractTypeDeclaration type = findType(unit, decompiled.binaryName(), target.binaryName()); - ASTNode scope = switch (target.kind()) { - case "class" -> type; - case "field" -> findField(type, target); - case "method" -> findMethod(type, target); - case "record_component" -> findRecordComponent(type, target); + var document = decompiled.document(); + var location = switch (target.kind()) { + case "class" -> ReferenceLocation.classDeclaration(target.binaryName()); + case "field" -> ReferenceLocation.field(target.binaryName(), target.name(), target.descriptor()); + case "method" -> ReferenceLocation.method(target.binaryName(), target.name(), target.descriptor()); + case "record_component" -> ReferenceLocation.recordComponent(target.binaryName(), target.name(), target.descriptor()); default -> throw new IllegalStateException("Unsupported source target: " + target.kind()); }; - - int start = scope.getStartPosition(); - int length = scope.getLength(); - if (start < 0 || length < 1 || start + length > contents.length()) { - throw new IllegalStateException("The decompiled source contains an invalid scope range"); - } - int startLine = unit.getLineNumber(start); - if (startLine < 1) { - throw new IllegalStateException("The decompiled source has no line for the requested scope"); - } - + var scope = document.declaration(location).orElseThrow(() -> new IllegalArgumentException( + "Source declaration not found: " + location)); Map result = new LinkedHashMap<>(); result.put("target", target.asMap()); - if (unit.getPackage() != null) { - result.put("package", unit.getPackage().getName().getFullyQualifiedName()); - } - result.put("imports", imports(unit)); - result.put("start_line", startLine); - result.put("source", contents.substring(start, start + length)); + if (!document.packageName().isEmpty()) result.put("package", document.packageName()); + result.put("imports", document.imports()); + result.put("start_line", document.lineAt(scope.start())); + result.put("source", document.contents().substring(scope.start(), scope.start() + scope.length())); return result; } - private static AbstractTypeDeclaration findType( - CompilationUnit unit, - String decompiledBinaryName, - String requestedBinaryName - ) { - String packageName = unit.getPackage() == null - ? "" - : unit.getPackage().getName().getFullyQualifiedName(); - List types = new ArrayList<>(); - for (Object declaration : unit.types()) { - if (declaration instanceof AbstractTypeDeclaration type) { - collectTypes(type, packageName, null, types); - } - } - for (TypeScope candidate : types) { - if (candidate.binaryName().equals(requestedBinaryName)) { - return candidate.declaration(); - } - } - if (decompiledBinaryName.equals(requestedBinaryName) && types.size() == 1) { - return types.getFirst().declaration(); - } - throw new IllegalArgumentException( - "No Java type declaration for runtime class " + requestedBinaryName - ); - } - - private static void collectTypes( - AbstractTypeDeclaration type, - String packageName, - String parentBinaryName, - List result - ) { - String binaryName = parentBinaryName == null - ? (packageName.isEmpty() ? "" : packageName + '.') + type.getName().getIdentifier() - : parentBinaryName + '$' + type.getName().getIdentifier(); - result.add(new TypeScope(binaryName, type)); - for (Object declaration : type.bodyDeclarations()) { - if (declaration instanceof AbstractTypeDeclaration nested) { - collectTypes(nested, packageName, binaryName, result); - } - } - } - - private static ASTNode findField(AbstractTypeDeclaration type, SourceTarget target) { - for (Object declaration : type.bodyDeclarations()) { - if (!(declaration instanceof FieldDeclaration field)) { - continue; - } - for (Object candidate : field.fragments()) { - VariableDeclarationFragment fragment = (VariableDeclarationFragment) candidate; - if (matchesField(fragment.resolveBinding(), target)) { - return field; - } - } - } - if (type instanceof EnumDeclaration enumDeclaration) { - for (Object candidate : enumDeclaration.enumConstants()) { - EnumConstantDeclaration constant = (EnumConstantDeclaration) candidate; - if (matchesField(constant.resolveVariable(), target)) { - return constant; - } - } - } - throw missingMember(target); - } - - private static boolean matchesField(org.eclipse.jdt.core.dom.IBinding binding, SourceTarget target) { - CodeSymbol symbol = JavaSymbolResolver.trySymbolForBinding(binding); - return symbol instanceof CodeSymbol.FieldSymbol exact - && exact.ownerClassName().equals(target.binaryName()) - && exact.name().equals(target.name()) - && exact.descriptor().equals(target.descriptor()); - } - - private static MethodDeclaration findMethod(AbstractTypeDeclaration type, SourceTarget target) { - for (Object declaration : type.bodyDeclarations()) { - if (!(declaration instanceof MethodDeclaration method)) { - continue; - } - CodeSymbol symbol = JavaSymbolResolver.trySymbolForBinding(method.resolveBinding()); - if (symbol instanceof CodeSymbol.MethodSymbol exact - && exact.ownerClassName().equals(target.binaryName()) - && exact.name().equals(target.name()) - && exact.descriptor().equals(target.descriptor())) { - return method; - } - } - throw missingMember(target); - } - - private static SingleVariableDeclaration findRecordComponent( - AbstractTypeDeclaration type, - SourceTarget target - ) { - if (type instanceof RecordDeclaration record) { - for (Object candidate : record.recordComponents()) { - SingleVariableDeclaration component = (SingleVariableDeclaration) candidate; - if (component.getName().getIdentifier().equals(target.name()) - && target.descriptor().equals(descriptor(component.getType().resolveBinding()))) { - return component; - } - } - } - throw missingMember(target); - } - - private static String descriptor(ITypeBinding originalType) { - if (originalType == null) { - return null; - } - ITypeBinding type = originalType.getErasure(); - if (type.isArray()) { - return "[".repeat(type.getDimensions()) + descriptor(type.getElementType()); - } - if (type.isPrimitive()) { - return switch (type.getName()) { - case "boolean" -> "Z"; - case "byte" -> "B"; - case "char" -> "C"; - case "double" -> "D"; - case "float" -> "F"; - case "int" -> "I"; - case "long" -> "J"; - case "short" -> "S"; - case "void" -> "V"; - default -> null; - }; - } - String binaryName = type.getBinaryName(); - return binaryName == null ? null : 'L' + binaryName.replace('.', '/') + ';'; - } - - private static IllegalArgumentException missingMember(SourceTarget target) { - return new IllegalArgumentException( - "Source member not found: " + target.binaryName() + '.' + target.name() + target.descriptor() - ); - } - - private static List imports(CompilationUnit unit) { - List imports = new ArrayList<>(unit.imports().size()); - for (Object candidate : unit.imports()) { - ImportDeclaration declaration = (ImportDeclaration) candidate; - StringBuilder value = new StringBuilder(); - if (declaration.isStatic()) { - value.append("static "); - } - value.append(declaration.getName().getFullyQualifiedName()); - if (declaration.isOnDemand()) { - value.append(".*"); - } - imports.add(value.toString()); - } - return List.copyOf(imports); - } - - private record TypeScope(String binaryName, AbstractTypeDeclaration declaration) { - } - private record SourceTarget(String kind, String binaryName, String name, String descriptor) { private static SourceTarget parse(Map value) { Objects.requireNonNull(value, "target"); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationService.java index ad9a8568..c3d0784c 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationService.java @@ -6,7 +6,6 @@ import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; import com.github.minecraft_ta.totalDebugCompanion.decompile.DecompiledSource; -import com.github.minecraft_ta.totalDebugCompanion.decompile.SourceFileNavigation; import com.github.minecraft_ta.totalDebugCompanion.model.CodeView; import com.github.minecraft_ta.totalDebugCompanion.model.LiteralUsagesView; import com.github.minecraft_ta.totalDebugCompanion.model.IEditorPanel; @@ -167,19 +166,19 @@ private CompletableFuture performNavigation(NavigationTarget target, Activ navigation = switch (target) { case NavigationTarget.RuntimeClass runtimeClass -> openRuntimeSource( runtimeClass.binaryName(), - source -> SourceFileNavigation.topLevelTypeOffset(source.contents()), + source -> source.document().classFallback(source.binaryName()).caret(), -1, activation ); case NavigationTarget.RuntimeDeclaration declaration -> openRuntimeSource( declaration.member().ownerClassName(), - source -> SourceFileNavigation.memberOffset(source.contents(), declaration.member()), + source -> source.document().navigate(declaration.member()).caret(), -1, activation ); case NavigationTarget.RuntimeLine line -> openRuntimeSource( line.binaryName(), - source -> SourceFileNavigation.lineOffset(source.contents(), line.displayedLine()), + source -> source.document().lineOffset(line.displayedLine()), line.displayedLine(), activation ); @@ -192,11 +191,10 @@ private CompletableFuture performNavigation(NavigationTarget target, Activ case NavigationTarget.ArchiveDirectory directory -> revealArchiveDirectory(directory); case NavigationTarget.UsageSite site -> openRuntimeSource( site.usage().location().className(), - source -> SourceFileNavigation.usageOffset( - source.contents(), + source -> source.document().usage( site.usage().location(), site.query() - ), + ).caret(), -1, activation ); @@ -413,7 +411,7 @@ private CompletableFuture openRuntimeSource( return openRuntimeEditor(installed, CodeView.class, view -> view.getPath().equals(source.path()), - () -> new CodeView(editors.get(), source, offset, SourceFileNavigation.location(source), installed) + () -> new CodeView(editors.get(), source, offset, source.location(), installed) ).thenAccept(view -> { view.navigateToOffset(offset); if (executionLine > 0) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/source/SourceDocument.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/source/SourceDocument.java new file mode 100644 index 00000000..a31fd19a --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/source/SourceDocument.java @@ -0,0 +1,528 @@ +package com.github.minecraft_ta.totalDebugCompanion.source; + +import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceLocation; +import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceQuery; +import com.github.minecraft_ta.totalDebugCompanion.jdt.CompanionClassIndex; +import com.github.minecraft_ta.totalDebugCompanion.jdt.JavaAst; +import com.github.minecraft_ta.totalDebugCompanion.jdt.JdtConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; +import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.JavaSymbolResolver; +import com.github.minecraft_ta.totalDebugCompanion.navigation.RuntimeMember; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ASTParser; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.AbstractTypeDeclaration; +import org.eclipse.jdt.core.dom.AnnotationTypeDeclaration; +import org.eclipse.jdt.core.dom.AnnotationTypeMemberDeclaration; +import org.eclipse.jdt.core.dom.AnonymousClassDeclaration; +import org.eclipse.jdt.core.dom.BodyDeclaration; +import org.eclipse.jdt.core.dom.ClassInstanceCreation; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.ConstructorInvocation; +import org.eclipse.jdt.core.dom.EnumConstantDeclaration; +import org.eclipse.jdt.core.dom.EnumDeclaration; +import org.eclipse.jdt.core.dom.FieldDeclaration; +import org.eclipse.jdt.core.dom.IBinding; +import org.eclipse.jdt.core.dom.IVariableBinding; +import org.eclipse.jdt.core.dom.ImportDeclaration; +import org.eclipse.jdt.core.dom.Initializer; +import org.eclipse.jdt.core.dom.LambdaExpression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.Modifier; +import org.eclipse.jdt.core.dom.NodeFinder; +import org.eclipse.jdt.core.dom.RecordDeclaration; +import org.eclipse.jdt.core.dom.SimpleName; +import org.eclipse.jdt.core.dom.SingleVariableDeclaration; +import org.eclipse.jdt.core.dom.StringLiteral; +import org.eclipse.jdt.core.dom.SuperConstructorInvocation; +import org.eclipse.jdt.core.dom.TextBlock; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; + +/** One immutable source snapshot. All consumers share its symbol identities, scopes and line mappings. */ +public final class SourceDocument { + private final String binaryName; + private final String contents; + private final SourceLineMap lineMap; + private final SourceVariableNames variableNames; + private final List symbols; + private final int[] lineStarts; + // JDT nodes/bindings are confined to synchronized queries; they never escape this document. + private CompilationUnit unit; + private final Map declarations = new LinkedHashMap<>(); + + public SourceDocument(String binaryName, String contents, SourceLineMap lineMap, + SourceVariableNames variableNames, List symbols) { + if (Objects.requireNonNull(binaryName).isBlank()) throw new IllegalArgumentException("Source binary name is blank"); + this.binaryName = binaryName; + this.contents = Objects.requireNonNull(contents); + this.lineMap = Objects.requireNonNull(lineMap); + this.variableNames = Objects.requireNonNull(variableNames); + this.symbols = symbols.stream().sorted(Comparator.comparingInt(SymbolSpan::offset)).toList(); + this.lineStarts = new int[(int) contents.chars().filter(c -> c == '\n').count() + 1]; + for (int offset = 0, line = 1; offset < contents.length(); offset++) { + if (contents.charAt(offset) == '\n') this.lineStarts[line++] = offset + 1; + } + for (SymbolSpan symbol : this.symbols) { + if (symbol.offset() > contents.length() - symbol.length()) { + throw new IllegalArgumentException("Symbol lies outside its source snapshot"); + } + } + } + + public SourceDocument(String binaryName, String contents) { + this(binaryName, contents, SourceLineMap.empty(), SourceVariableNames.empty(), List.of()); + } + + public String binaryName() { return this.binaryName; } + public String contents() { return this.contents; } + public SourceLineMap lineMap() { return this.lineMap; } + public SourceVariableNames variableNames() { return this.variableNames; } + public List symbols() { return this.symbols; } + + /** Prepare on the source-loading worker before an editor can query this snapshot on the EDT. */ + public synchronized void prepare() { initialize(); } + + public enum SymbolRole { DECLARATION, REFERENCE, METHOD_PARAMETER, METHOD_LOCAL, CONSTANT_FIELD } + + public record SymbolSpan(CodeSymbol symbol, SymbolRole role, int offset, int length) { + public SymbolSpan { + Objects.requireNonNull(symbol); + Objects.requireNonNull(role); + if (role == SymbolRole.CONSTANT_FIELD && !(symbol instanceof CodeSymbol.FieldSymbol)) { + throw new IllegalArgumentException("A constant must identify its field"); + } + if ((role == SymbolRole.METHOD_PARAMETER || role == SymbolRole.METHOD_LOCAL) + && !(symbol instanceof CodeSymbol.MethodSymbol)) { + throw new IllegalArgumentException("A variable declaration must identify its owning method"); + } + if (offset < 0 || length < 1) throw new IllegalArgumentException("Invalid symbol range"); + } + } + + public enum Kind { OCCURRENCE, DECLARATION, CONSTRUCT, CLASS } + + public record Resolution(int start, int length, int caret, Kind kind) { + public Resolution { + Objects.requireNonNull(kind); + if (start < 0 || length < 1 || caret < start || caret >= start + length) { + throw new IllegalArgumentException("Invalid source resolution"); + } + } + } + + public record MethodScope(CodeSymbol.MethodSymbol method, int firstLine, int lastLine) {} + + /** Exact visible declarations only. Callers requesting a method body must not receive a class fallback. */ + public synchronized Optional declaration(ReferenceLocation location) { + initialize(); + Entry entry = entry(location); + return entry == null || entry.kind != Kind.DECLARATION ? Optional.empty() : Optional.of(entry.resolution()); + } + + public synchronized Resolution navigate(RuntimeMember member) { + initialize(); + Entry entry = switch (member) { + case RuntimeMember.Method method -> this.declarations.get( + new CodeSymbol.MethodSymbol(method.ownerClassName(), method.name(), method.descriptor())); + case RuntimeMember.Field field -> this.declarations.entrySet().stream() + .filter(e -> e.getKey() instanceof CodeSymbol.FieldSymbol symbol + && symbol.ownerClassName().equals(field.ownerClassName()) && symbol.name().equals(field.name())) + .map(Map.Entry::getValue).findFirst().orElse(null); + }; + return entry == null ? classFallback(member.ownerClassName()) : entry.resolution(); + } + + public synchronized Resolution usage(ReferenceLocation location, ReferenceQuery query) { + initialize(); + Objects.requireNonNull(query); + if (location.site() instanceof ReferenceLocation.Method method && method.name().equals("")) { + Entry owner = this.declarations.get(new CodeSymbol.ClassSymbol(location.className())); + if (owner == null) return classFallback(location.className()); + for (ASTNode scope : initializers(owner.node, true)) { + OptionalInt offset = occurrence(scope, query); + if (offset.isPresent()) return occurrenceResolution(offset.getAsInt()); + } + return classFallback(location.className()); + } + Entry entry = entry(location); + if (entry == null) return classFallback(location.className()); + if (entry.node instanceof MethodDeclaration constructor && constructor.isConstructor() && constructor.getBody() != null) { + var body = constructor.getBody(); + OptionalInt bodyOffset = occurrence(body, query); + if (bodyOffset.isPresent()) return occurrenceResolution(bodyOffset.getAsInt()); + // A this(...) constructor delegates initialization to the target constructor. + if (body.statements().isEmpty() || !(body.statements().getFirst() instanceof ConstructorInvocation)) { + Entry owner = this.declarations.get(new CodeSymbol.ClassSymbol(location.className())); + for (ASTNode scope : owner == null ? List.of() : initializers(owner.node, false)) { + OptionalInt initializerOffset = occurrence(scope, query); + if (initializerOffset.isPresent()) return occurrenceResolution(initializerOffset.getAsInt()); + } + } + } + OptionalInt offset = occurrence(entry.node, query); + if (offset.isPresent()) return occurrenceResolution(offset.getAsInt()); + // Usage navigation traditionally anchors a containing method at its declaration start. + return new Resolution(entry.node.getStartPosition(), entry.node.getLength(), + entry.node instanceof MethodDeclaration ? entry.node.getStartPosition() : entry.caret, entry.kind); + } + + public synchronized Resolution classFallback(String owner) { + initialize(); + Entry entry = owner(owner); + return new Resolution(entry.node.getStartPosition(), entry.node.getLength(), entry.caret, Kind.CLASS); + } + + public synchronized String ownerAtLine(int line) { + initialize(); + lineOffset(line); + return this.declarations.entrySet().stream() + .filter(e -> e.getKey() instanceof CodeSymbol.ClassSymbol + && line >= lineAt(e.getValue().node.getStartPosition()) + && line <= lineAt(e.getValue().node.getStartPosition() + e.getValue().node.getLength() - 1)) + .min(Comparator.comparingInt(e -> e.getValue().node.getLength())) + .map(e -> e.getKey().ownerClassName()).orElse(this.binaryName); + } + + public synchronized List binaryNames() { + initialize(); + var names = new LinkedHashSet(); + names.add(this.binaryName); + this.declarations.keySet().stream().filter(CodeSymbol.ClassSymbol.class::isInstance) + .map(CodeSymbol::ownerClassName).forEach(names::add); + return List.copyOf(names); + } + + public synchronized Optional methodAtLine(int line) { + initialize(); + return this.declarations.entrySet().stream() + .filter(e -> e.getKey() instanceof CodeSymbol.MethodSymbol && e.getValue().kind == Kind.DECLARATION + && e.getValue().node instanceof MethodDeclaration && lineAt(e.getValue().caret) == line) + .map(e -> new MethodScope((CodeSymbol.MethodSymbol) e.getKey(), line, + lineAt(e.getValue().node.getStartPosition() + e.getValue().node.getLength() - 1))) + .findFirst(); + } + + public int lineOffset(int line) { + if (line < 1 || line > this.lineStarts.length) throw new IllegalArgumentException("Source has no line " + line); + return this.lineStarts[line - 1]; + } + + public int lineAt(int offset) { + if (offset < 0 || offset > this.contents.length()) throw new IllegalArgumentException("Invalid source offset"); + int index = Arrays.binarySearch(this.lineStarts, offset); + return index >= 0 ? index + 1 : -index - 1; + } + + public synchronized String packageName() { + initialize(); + return this.unit.getPackage() == null ? "" : this.unit.getPackage().getName().getFullyQualifiedName(); + } + + public synchronized List imports() { + initialize(); + var imports = new ArrayList(); + for (Object value : this.unit.imports()) { + ImportDeclaration declaration = (ImportDeclaration) value; + imports.add((declaration.isStatic() ? "static " : "") + declaration.getName().getFullyQualifiedName() + + (declaration.isOnDemand() ? ".*" : "")); + } + return List.copyOf(imports); + } + + private void initialize() { + if (this.unit != null) return; + CompilationUnit parsed; + if (this.symbols.isEmpty() && CompanionClassIndex.isOpen()) { + parsed = JavaAst.parse(this.binaryName, this.contents); + } else { + ASTParser parser = JdtConfiguration.createParser(); + parser.setSource(this.contents.toCharArray()); + parser.setKind(ASTParser.K_COMPILATION_UNIT); + parser.setStatementsRecovery(true); + parsed = (CompilationUnit) parser.createAST(null); + } + // Syntax errors are distinct from a valid class whose synthetic methods were omitted. + for (var problem : parsed.getProblems()) { + if (problem.isError() && (problem.getID() & org.eclipse.jdt.core.compiler.IProblem.Syntax) != 0) { + throw new IllegalArgumentException("Unable to parse source " + this.binaryName + ": " + problem.getMessage()); + } + } + if (parsed.types().isEmpty()) throw new IllegalArgumentException("Source has no Java type declaration"); + this.unit = parsed; + for (SymbolSpan span : this.symbols) { + if (span.role() != SymbolRole.DECLARATION && span.role() != SymbolRole.CONSTANT_FIELD) continue; + ASTNode node = NodeFinder.perform(parsed, span.offset(), span.length()); + while (node != null && !declarationNode(node, span.symbol())) node = node.getParent(); + if (node != null) add(span.symbol(), node, span.offset()); + } + for (Object declaration : parsed.types()) { + if (declaration instanceof AbstractTypeDeclaration type) { + String simple = type.getName().getIdentifier(); + String rootSimple = this.binaryName.substring(Math.max(this.binaryName.lastIndexOf('.'), this.binaryName.lastIndexOf('$')) + 1); + String name = simple.equals(rootSimple) || simple.equals(this.binaryName.substring(this.binaryName.lastIndexOf('.') + 1)) + ? this.binaryName : (parsed.getPackage() == null ? "" : parsed.getPackage().getName().getFullyQualifiedName() + '.') + simple; + indexType(type, name); + } + } + indexLambdas(); + } + + private void indexLambdas() { + Map> candidates = new LinkedHashMap<>(); + for (SymbolSpan span : this.symbols) { + if (span.role() != SymbolRole.METHOD_PARAMETER && span.role() != SymbolRole.METHOD_LOCAL) continue; + ASTNode variable = NodeFinder.perform(this.unit, span.offset(), span.length()); + while (variable instanceof SimpleName) variable = variable.getParent(); + if (!(variable instanceof VariableDeclaration)) continue; + ASTNode parent = variable.getParent(); + if (span.role() == SymbolRole.METHOD_LOCAL) { + while (parent != null && !(parent instanceof LambdaExpression) && !(parent instanceof MethodDeclaration) + && !(parent instanceof AbstractTypeDeclaration) && !(parent instanceof AnonymousClassDeclaration)) { + parent = parent.getParent(); + } + } + if (parent instanceof LambdaExpression lambda && (span.role() == SymbolRole.METHOD_LOCAL + || lambda.parameters().contains(variable))) { + candidates.computeIfAbsent(span.symbol(), ignored -> new LinkedHashSet<>()).add(lambda); + } + } + candidates.forEach((method, lambdas) -> { + if (lambdas.size() == 1) { + LambdaExpression lambda = lambdas.iterator().next(); + this.declarations.putIfAbsent(method, new Entry(lambda, lambda.getStartPosition(), Kind.CONSTRUCT)); + } + }); + } + + private void indexType(AbstractTypeDeclaration type, String name) { + CodeSymbol.ClassSymbol identity = this.declarations.entrySet().stream() + .filter(e -> e.getValue().node == type && e.getKey() instanceof CodeSymbol.ClassSymbol) + .map(e -> (CodeSymbol.ClassSymbol) e.getKey()).findFirst().orElse(new CodeSymbol.ClassSymbol(name)); + this.declarations.putIfAbsent(identity, new Entry(type, type.getName().getStartPosition(), Kind.DECLARATION)); + for (Object value : type.bodyDeclarations()) { + if (value instanceof AbstractTypeDeclaration nested) { + indexType(nested, identity.className() + '$' + nested.getName().getIdentifier()); + } else if (value instanceof MethodDeclaration method) { + addBinding(method.resolveBinding(), method, method.getName().getStartPosition()); + } else if (value instanceof AnnotationTypeMemberDeclaration method) { + addBinding(method.resolveBinding(), method, method.getName().getStartPosition()); + } else if (value instanceof FieldDeclaration field) { + for (Object fragment : field.fragments()) { + VariableDeclarationFragment variable = (VariableDeclarationFragment) fragment; + addBinding(variable.resolveBinding(), field, variable.getName().getStartPosition()); + } + } + } + if (type instanceof EnumDeclaration enumeration) { + for (Object value : enumeration.enumConstants()) { + EnumConstantDeclaration constant = (EnumConstantDeclaration) value; + addBinding(constant.resolveVariable(), constant, constant.getName().getStartPosition()); + } + } + if (type instanceof RecordDeclaration record && record.resolveBinding() != null) { + for (IVariableBinding field : record.resolveBinding().getDeclaredFields()) { + for (Object value : record.recordComponents()) { + SingleVariableDeclaration component = (SingleVariableDeclaration) value; + if (component.getName().getIdentifier().equals(field.getName())) { + addBinding(field, component, component.getName().getStartPosition()); + } + } + } + } + } + + private void addBinding(IBinding binding, ASTNode node, int caret) { + if (binding == null) return; + CodeSymbol symbol = JavaSymbolResolver.trySymbolForBinding(binding); + if (symbol != null && !this.declarations.containsKey(symbol)) add(symbol, node, caret); + } + + private void add(CodeSymbol symbol, ASTNode node, int caret) { + boolean component = node instanceof SingleVariableDeclaration && node.getParent() instanceof RecordDeclaration; + this.declarations.put(symbol, new Entry(node, caret, component ? Kind.CONSTRUCT : Kind.DECLARATION)); + if ((symbol instanceof CodeSymbol.MethodSymbol || symbol instanceof CodeSymbol.FieldSymbol) + && node.getParent() instanceof AnonymousClassDeclaration anonymous) { + // Emitted members identify this anonymous class without guessing compiler numbering. + this.declarations.putIfAbsent(new CodeSymbol.ClassSymbol(symbol.ownerClassName()), + new Entry(anonymous, anonymous.getStartPosition(), Kind.DECLARATION)); + } + if (component && symbol instanceof CodeSymbol.FieldSymbol field) { + this.declarations.putIfAbsent(new CodeSymbol.MethodSymbol(field.ownerClassName(), field.name(), "()" + field.descriptor()), + new Entry(node, caret, Kind.CONSTRUCT)); + } + } + + private static boolean declarationNode(ASTNode node, CodeSymbol symbol) { + return switch (symbol) { + case CodeSymbol.ClassSymbol ignored -> node instanceof AbstractTypeDeclaration || node instanceof AnonymousClassDeclaration; + case CodeSymbol.MethodSymbol ignored -> node instanceof MethodDeclaration || node instanceof AnnotationTypeMemberDeclaration; + case CodeSymbol.FieldSymbol ignored -> node instanceof FieldDeclaration || node instanceof EnumConstantDeclaration + || node instanceof SingleVariableDeclaration && node.getParent() instanceof RecordDeclaration; + }; + } + + private Entry entry(ReferenceLocation location) { + CodeSymbol symbol = switch (location.site()) { + case ReferenceLocation.ClassDeclaration ignored -> new CodeSymbol.ClassSymbol(location.className()); + case ReferenceLocation.Method method -> new CodeSymbol.MethodSymbol(location.className(), method.name(), method.descriptor()); + case ReferenceLocation.Field field -> new CodeSymbol.FieldSymbol(location.className(), field.name(), field.descriptor()); + case ReferenceLocation.RecordComponent component -> new CodeSymbol.FieldSymbol(location.className(), component.name(), component.descriptor()); + }; + Entry entry = this.declarations.get(symbol); + if (location.site() instanceof ReferenceLocation.RecordComponent) { + return entry != null && entry.node instanceof SingleVariableDeclaration + ? new Entry(entry.node, entry.caret, Kind.DECLARATION) : null; + } + return entry; + } + + private Entry owner(String name) { + for (String candidate = name; candidate != null;) { + Entry entry = this.declarations.get(new CodeSymbol.ClassSymbol(candidate)); + if (entry != null) return entry; + int separator = candidate.lastIndexOf('$'); + candidate = separator < 0 ? null : candidate.substring(0, separator); + } + return this.declarations.entrySet().stream().filter(e -> e.getKey() instanceof CodeSymbol.ClassSymbol) + .map(Map.Entry::getValue).findFirst().orElseThrow(() -> new IllegalStateException("Source has no navigable type")); + } + + private OptionalInt occurrence(ASTNode scope, ReferenceQuery query) { + int tokenOffset = -1; + for (SymbolSpan span : this.symbols) { + if (span.role() == SymbolRole.REFERENCE && span.symbol().referenceQuery().equals(query) + && belongsTo(scope, NodeFinder.perform(this.unit, span.offset(), span.length()))) { + tokenOffset = span.offset(); + break; + } + } + MatchVisitor visitor = new MatchVisitor(scope, query); + scope.accept(visitor); + int offset = visitor.offset < 0 ? tokenOffset : tokenOffset < 0 ? visitor.offset : Math.min(tokenOffset, visitor.offset); + return offset < 0 ? OptionalInt.empty() : OptionalInt.of(offset); + } + + private static boolean belongsTo(ASTNode scope, ASTNode node) { + for (ASTNode current = node; current != null; current = current.getParent()) { + if (current == scope) return true; + if (current instanceof LambdaExpression) return false; + if (current instanceof AbstractTypeDeclaration || current instanceof AnonymousClassDeclaration) return false; + if ((scope instanceof AbstractTypeDeclaration || scope instanceof AnonymousClassDeclaration) + && current instanceof BodyDeclaration) return false; + } + return false; + } + + private List initializers(ASTNode node, boolean staticScope) { + List members = switch (node) { + case AbstractTypeDeclaration type -> type.bodyDeclarations(); + case AnonymousClassDeclaration anonymous -> anonymous.bodyDeclarations(); + default -> List.of(); + }; + var scopes = new ArrayList(); + if (staticScope && node instanceof EnumDeclaration enumeration) { + for (Object constant : enumeration.enumConstants()) scopes.add((ASTNode) constant); + } + for (Object value : members) { + if (value instanceof Initializer initializer && Modifier.isStatic(initializer.getModifiers()) == staticScope) { + scopes.add(initializer.getBody()); + } else if (value instanceof FieldDeclaration field) { + boolean staticField = Modifier.isStatic(field.getModifiers()) + || node instanceof TypeDeclaration owner && owner.isInterface() + || node instanceof AnnotationTypeDeclaration; + if (staticField != staticScope) continue; + for (Object fragment : field.fragments()) { + var variable = (VariableDeclarationFragment) fragment; + var initializer = variable.getInitializer(); + if (initializer != null && !(initializer instanceof LambdaExpression) + && (!staticScope || !constantField(variable))) scopes.add(initializer); + } + } + } + return scopes; + } + + private boolean constantField(VariableDeclarationFragment variable) { + int offset = variable.getName().getStartPosition(); + if (this.symbols.stream().anyMatch(span -> span.role() == SymbolRole.CONSTANT_FIELD && span.offset() == offset)) return true; + IVariableBinding binding = variable.resolveBinding(); + return binding != null && binding.getConstantValue() != null; + } + + private Resolution occurrenceResolution(int offset) { + int length = this.symbols.stream().filter(span -> span.offset() == offset) + .mapToInt(SymbolSpan::length).findFirst().orElseGet(() -> { + ASTNode node = NodeFinder.perform(this.unit, offset, 1); + return node != null && node.getStartPosition() == offset ? node.getLength() : 1; + }); + return new Resolution(offset, length, offset, Kind.OCCURRENCE); + } + + private record Entry(ASTNode node, int caret, Kind kind) { + Resolution resolution() { return new Resolution(node.getStartPosition(), node.getLength(), caret, kind); } + } + + private static final class MatchVisitor extends ASTVisitor { + private final ASTNode scope; + private final ReferenceQuery query; + private int offset = -1; + MatchVisitor(ASTNode scope, ReferenceQuery query) { this.scope = scope; this.query = query; } + @Override public boolean preVisit2(ASTNode node) { return this.offset < 0 && belongsTo(this.scope, node); } + @Override public boolean visit(SimpleName name) { + if (!isDeclarationName(name) && matches(name.resolveBinding())) this.offset = name.getStartPosition(); + return this.offset < 0; + } + @Override public boolean visit(ClassInstanceCreation creation) { + if (matches(creation.resolveConstructorBinding())) this.offset = creation.getType().getStartPosition(); + return this.offset < 0; + } + @Override public boolean visit(ConstructorInvocation invocation) { + if (matches(invocation.resolveConstructorBinding())) this.offset = invocation.getStartPosition(); + return this.offset < 0; + } + @Override public boolean visit(SuperConstructorInvocation invocation) { + if (matches(invocation.resolveConstructorBinding())) this.offset = invocation.getStartPosition(); + return this.offset < 0; + } + @Override public boolean visit(EnumConstantDeclaration declaration) { + if (matches(declaration.resolveConstructorBinding())) this.offset = declaration.getName().getStartPosition(); + return this.offset < 0; + } + @Override public boolean visit(StringLiteral literal) { + if (this.query instanceof ReferenceQuery.StringLiteralReference target && literal.getLiteralValue().equals(target.value())) this.offset = literal.getStartPosition(); + return this.offset < 0; + } + @Override public boolean visit(TextBlock literal) { + if (this.query instanceof ReferenceQuery.StringLiteralReference target && literal.getLiteralValue().equals(target.value())) this.offset = literal.getStartPosition(); + return this.offset < 0; + } + private boolean matches(IBinding binding) { + if (binding == null) return false; + CodeSymbol symbol = JavaSymbolResolver.trySymbolForBinding(binding); + return symbol != null && symbol.referenceQuery().equals(this.query); + } + private static boolean isDeclarationName(SimpleName name) { + return switch (name.getParent()) { + case AbstractTypeDeclaration declaration -> declaration.getName() == name; + case MethodDeclaration declaration -> declaration.getName() == name; + case VariableDeclaration declaration -> declaration.getName() == name; + case EnumConstantDeclaration declaration -> declaration.getName() == name; + case AnnotationTypeMemberDeclaration declaration -> declaration.getName() == name; + default -> false; + }; + } + } +} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeViewPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeViewPanel.java index 6dca5342..2ca69101 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeViewPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeViewPanel.java @@ -552,12 +552,7 @@ private void configureBreakpoint( } private Optional breakpointRequestAtLine(int displayedLine) { - var unit = context.astCache().getFromCache(this.identifier); - if (unit == null) { - throw new IllegalStateException("Source analysis is still loading"); - } - return DebuggerBreakpointResolver.resolve( - this.debugSource, unit, displayedLine, null, null); + return DebuggerBreakpointResolver.resolve(this.debugSource, displayedLine, null, null); } private void updateBreakpointMarkers(DebuggerSessionController debugger) { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java index 7fc2f6b7..d61fafa3 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java @@ -895,14 +895,7 @@ public static void main(String[] args) throws Exception { String sampleSource = CodeView.readCode(sample); int sampleEntryLine = sampleSource.substring(0, sampleSource.indexOf("double ratio")) .split("\\n", -1).length; - DecompiledSource decompiledSample = new DecompiledSource( - sample, - "sample.ThemeSample", - sampleSource, - SourceLineMap.fromOriginalToDisplayed(new int[]{sampleEntryLine, sampleEntryLine}), - SourceVariableNames.empty(), - null - ); + DecompiledSource decompiledSample = new DecompiledSource(sample, new com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument("sample.ThemeSample", sampleSource, SourceLineMap.fromOriginalToDisplayed(new int[]{sampleEntryLine, sampleEntryLine}), SourceVariableNames.empty(), java.util.List.of()), null); Path sampleClasses = Files.createDirectories(root.resolve("TotalDebug/build/classes/java/main")); compileSampleSource(sample, sampleClasses); Path indexFile = root.resolve("classes.jindex"); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerBreakpointResolverTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerBreakpointResolverTest.java index e367a3a6..e320fee0 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerBreakpointResolverTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerBreakpointResolverTest.java @@ -1,6 +1,5 @@ package com.github.minecraft_ta.totalDebugCompanion.debugger; -import com.github.minecraft_ta.totalDebugCompanion.jdt.JavaAst; import com.github.minecraft_ta.totalDebugCompanion.jdt.CompanionClassIndex; import com.github.tth05.jindex.ClassIndex; import org.junit.jupiter.api.BeforeAll; @@ -36,11 +35,9 @@ public int run() { """; @Test - void editorAndRemoteCallsResolveTheSameMethodEntryAndExecutableLine() { + void resolvesMethodEntryAndExecutableLinesFromTheSharedDocument() { DebugEngine.Source source = source(SourceLineMap.fromOriginalToDisplayed(new int[]{40, 4})); - var unit = JavaAst.parse("Test", SOURCE); var remote = DebuggerBreakpointResolver.resolve(source, 3, "true", "5").orElseThrow(); - assertEquals(remote, DebuggerBreakpointResolver.resolve(source, unit, 3, "true", "5").orElseThrow()); assertTrue(remote.isMethodEntry()); assertEquals(3, remote.line()); assertEquals(4, remote.debuggerLine()); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationServiceTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationServiceTest.java index 911fc1d2..90905faf 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationServiceTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationServiceTest.java @@ -28,6 +28,23 @@ class CompanionDecompilationServiceTest { @TempDir Path temporaryDirectory; + @Test + void publishesPreparedDocumentsForColdAndCachedEditorLoads() throws Exception { + byte[] bytes = classBytes(CacheFixture.class); + Path classes = writeClass(CacheFixture.class, bytes); + try (var index = ClassIndex.fromSources(List.of(IndexSource.classFile(0, bytes))); + var service = service(bytecodeSource(List.of(classes), index), new AtomicInteger(), "prepared")) { + for (int load = 0; load < 2; load++) { + var source = service.load(CacheFixture.class.getName()).get(5, TimeUnit.SECONDS); + // Assert preparation before posting to Swing, without relying on timing thresholds. + var syntax = source.document().getClass().getDeclaredField("unit"); + syntax.setAccessible(true); + org.junit.jupiter.api.Assertions.assertNotNull(syntax.get(source.document())); + SwingUtilities.invokeAndWait(() -> source.document().classFallback(source.binaryName())); + } + } + } + @Test void cachedSourceReadsDoNotBlockTheEventDispatchThread() throws Exception { byte[] bytes = classBytes(CacheFixture.class); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSourceStoreTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSourceStoreTest.java index e88c84a0..227885ad 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSourceStoreTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/DecompiledSourceStoreTest.java @@ -1,6 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.decompile; import com.github.minecraft_ta.totalDebugCompanion.source.SourceLineMap; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; import com.github.minecraft_ta.totalDebugCompanion.source.SourceVariableNames; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -23,18 +24,18 @@ void storesSourceAndBothLineMappingDirections(@TempDir Path directory) throws Ex "()V", java.util.Map.of("p_1_", "level") ); - Path source = store.write("sample.Target", "class Target {}", lineMap, variableNames); + Path source = store.write(new SourceDocument("sample.Target", "class Target {}", lineMap, variableNames, java.util.List.of())); assertEquals(source, store.read("sample.Target").path()); assertEquals("class Target {}", Files.readString(source)); assertEquals(java.util.List.of(".lock", "manifest.json", "sample.Target.debug", "sample.Target.java"), fileNames(source.getParent())); assertEquals(java.util.List.of("sample.Target"), store.cachedClasses()); SourceLineMap restored = DecompiledSourceStore.open(directory, "runtime", "format") - .read("sample.Target").debug().lines(); + .read("sample.Target").document().lineMap(); assertArrayEquals(new int[]{10, 4, 20, 8, 21, 8}, restored.originalToDisplayed()); assertArrayEquals(new int[]{4, 10, 8, 20, 8, 21}, restored.displayedToOriginal()); assertEquals(variableNames, DecompiledSourceStore.open(directory, "runtime", "format") - .read("sample.Target").debug().names()); + .read("sample.Target").document().variableNames()); } @Test @@ -50,15 +51,15 @@ void javaFileWithoutTheCurrentLineMapIsNotACacheEntry(@TempDir Path directory) t @Test void replacesTheCurrentRuntimeAndRejectsLateWrites(@TempDir Path directory) throws Exception { var old = DecompiledSourceStore.open(directory, "first", "format"); - Path target = old.write("sample.Target", "first", SourceLineMap.empty(), SourceVariableNames.empty()); - old.write("sample.Removed", "removed", SourceLineMap.empty(), SourceVariableNames.empty()); + Path target = old.write(new SourceDocument("sample.Target", "first", SourceLineMap.empty(), SourceVariableNames.empty(), java.util.List.of())); + old.write(new SourceDocument("sample.Removed", "removed", SourceLineMap.empty(), SourceVariableNames.empty(), java.util.List.of())); var current = DecompiledSourceStore.open(directory, "second", "format"); assertNull(current.read("sample.Target")); org.junit.jupiter.api.Assertions.assertThrows(java.io.IOException.class, - () -> old.write("sample.Late", "stale", SourceLineMap.empty(), SourceVariableNames.empty())); + () -> old.write(new SourceDocument("sample.Late", "stale", SourceLineMap.empty(), SourceVariableNames.empty(), java.util.List.of()))); org.junit.jupiter.api.Assertions.assertThrows(java.io.IOException.class, () -> old.read("sample.Target")); - assertEquals(target, current.write("sample.Target", "second", SourceLineMap.empty(), SourceVariableNames.empty())); - assertEquals("second", current.read("sample.Target").source()); + assertEquals(target, current.write(new SourceDocument("sample.Target", "second", SourceLineMap.empty(), SourceVariableNames.empty(), java.util.List.of()))); + assertEquals("second", current.read("sample.Target").document().contents()); assertEquals(java.util.List.of(".lock", "manifest.json", "sample.Target.debug", "sample.Target.java"), fileNames(current.directory())); } @@ -67,10 +68,10 @@ void replacesTheCurrentRuntimeAndRejectsLateWrites(@TempDir Path directory) thro void disambiguatesCaseReservedAndLongNamesWithoutHashDirectories(@TempDir Path directory) throws Exception { var store = DecompiledSourceStore.open(directory, "runtime", "format"); for (String name : java.util.List.of("sample.Target", "sample.target", "CON", "long.".repeat(60) + "Target")) { - Path file = store.write(name, name, SourceLineMap.empty(), SourceVariableNames.empty()); + Path file = store.write(new SourceDocument(name, name, SourceLineMap.empty(), SourceVariableNames.empty(), java.util.List.of())); assertEquals(store.directory(), file.getParent()); org.junit.jupiter.api.Assertions.assertTrue(file.getFileName().toString().length() < 140); - assertEquals(name, store.read(name).source()); + assertEquals(name, store.read(name).document().contents()); } assertEquals("sample.target-2.java", store.read("sample.target").path().getFileName().toString()); assertEquals("_CON.java", store.read("CON").path().getFileName().toString()); @@ -79,7 +80,7 @@ void disambiguatesCaseReservedAndLongNamesWithoutHashDirectories(@TempDir Path d @Test void detectsMismatchedSourceAndDebugFiles(@TempDir Path directory) throws Exception { var store = DecompiledSourceStore.open(directory, "runtime", "format"); - Path file = store.write("sample.Target", "complete", SourceLineMap.empty(), SourceVariableNames.empty()); + Path file = store.write(new SourceDocument("sample.Target", "complete", SourceLineMap.empty(), SourceVariableNames.empty(), java.util.List.of())); Files.writeString(file, "different"); org.junit.jupiter.api.Assertions.assertThrows(java.io.IOException.class, () -> store.read("sample.Target")); } @@ -88,13 +89,13 @@ void detectsMismatchedSourceAndDebugFiles(@TempDir Path directory) throws Except @Test void failedPairPublicationIsInvisibleAndItsFilesAreReclaimed(@TempDir Path directory) throws Exception { var store = DecompiledSourceStore.open(directory, "runtime", "format"); - store.write("sample.Complete", "complete", SourceLineMap.empty(), SourceVariableNames.empty()); + store.write(new SourceDocument("sample.Complete", "complete", SourceLineMap.empty(), SourceVariableNames.empty(), java.util.List.of())); String invalidHeader = "x".repeat(70_000); org.junit.jupiter.api.Assertions.assertThrows(java.io.IOException.class, - () -> store.write(invalidHeader, "incomplete", SourceLineMap.empty(), SourceVariableNames.empty())); + () -> store.write(new SourceDocument(invalidHeader, "incomplete", SourceLineMap.empty(), SourceVariableNames.empty(), java.util.List.of()))); assertNull(store.read(invalidHeader)); var reopened = DecompiledSourceStore.open(directory, "runtime", "format"); - assertEquals("complete", reopened.read("sample.Complete").source()); + assertEquals("complete", reopened.read("sample.Complete").document().contents()); assertEquals(java.util.List.of(".lock", "manifest.json", "sample.Complete.debug", "sample.Complete.java"), fileNames(reopened.directory())); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/GeneratedSourceNavigationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/GeneratedSourceNavigationTest.java new file mode 100644 index 00000000..dd617f98 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/GeneratedSourceNavigationTest.java @@ -0,0 +1,158 @@ +package com.github.minecraft_ta.totalDebugCompanion.decompile; + +import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceLocation; +import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceQuery; +import com.github.minecraft_ta.totalDebugCompanion.jdt.CompanionClassIndex; +import com.github.minecraft_ta.totalDebugCompanion.navigation.RuntimeMember; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; +import com.github.tth05.jindex.ClassIndex; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class GeneratedSourceNavigationTest { + @BeforeAll + static void initializeIndex() throws Exception { + var bytes = new ArrayList(); + for (Class type : List.of(Object.class, Record.class, String.class, + java.lang.annotation.Annotation.class, java.util.function.Supplier.class)) { + try (var input = type.getResourceAsStream('/' + type.getName().replace('.', '/') + ".class")) { + bytes.add(input.readAllBytes()); + } + } + CompanionClassIndex.set(ClassIndex.fromBytes(bytes)); + } + + @AfterAll + static void closeIndex() { + CompanionClassIndex.get().close(); + CompanionClassIndex.clear(); + } + + @Test + void recordBackingFieldsAndAccessorsNavigateToTheComponent() { + String source = "package example;\npublic record Data(String[] values) {}"; + assertEquals(source.indexOf("String[] values"), usage(source, "example.Data", "values", + "()[Ljava/lang/String;", ReferenceQuery.classReference("java.lang.String"))); + assertEquals(source.indexOf("String[] values"), new SourceDocument("example.Data", source).usage(ReferenceLocation.field("example.Data", "values", "[Ljava/lang/String;"), ReferenceQuery.classReference("java.lang.String")).caret()); + assertEquals(source.indexOf("values)"), new SourceDocument("example.Data", source).navigate(new RuntimeMember.Method("example.Data", "values", "()[Ljava/lang/String;")).caret()); + } + + @Test + void omittedMethodsFallBackToTheTypeRatherThanAnUnrelatedMatchingReference() { + String source = "package example;\npublic record Data(String value) { String unrelated() { return value.trim(); } }"; + for (String[] method : List.of(new String[]{"hashCode", "()I"}, + new String[]{"equals", "(Ljava/lang/Object;)Z"}, + new String[]{"toString", "()Ljava/lang/String;"}, + new String[]{"", "(Ljava/lang/String;)V"}, + new String[]{"lambda$static$16", "(I)[Ljava/lang/String;"}, + new String[]{"bridge", "()Ljava/lang/Object;"})) { + assertEquals(source.indexOf("Data("), usage(source, "example.Data", method[0], method[1], + ReferenceQuery.methodReference("java.lang.String", "trim", "()Ljava/lang/String;"))); + assertEquals(source.indexOf("Data("), new SourceDocument("example.Data", source).navigate(new RuntimeMember.Method("example.Data", method[0], method[1])).caret()); + } + } + + @Test + void staticInitializersSearchOnlyTheirExecutableSource() { + String source = """ + package example; + class Use { + java.util.function.Supplier instance = () -> "needle"; + static java.util.function.Supplier supplier = () -> "needle"; + static final String constant = "needle"; + static String first = "other"; + static String second = "needle"; + static { second = "block"; } + } + """; + assertEquals(source.indexOf("\"needle\";", source.indexOf("static String second")), + usage(source, "example.Use", "", "()V", ReferenceQuery.stringLiteral("needle"))); + assertEquals(source.indexOf("\"block\""), + usage(source, "example.Use", "", "()V", ReferenceQuery.stringLiteral("block"))); + assertEquals(source.indexOf("Use {"), + usage(source, "example.Use", "", "()V", ReferenceQuery.stringLiteral("missing"))); + } + + @Test + void annotationConstantsDoNotShadowExecutableStaticInitializers() { + String source = """ + package example; + @interface Marker { + String CONSTANT = "needle"; + String EXECUTED = "needle".trim(); + } + """; + assertEquals(source.indexOf("\"needle\".trim"), + usage(source, "example.Marker", "", "()V", ReferenceQuery.stringLiteral("needle"))); + } + + @Test + void ordinaryMethodsDoNotSearchInsideAnotherBytecodeMethodLambda() { + String source = """ + package example; + class Use { + String run() { + java.util.function.Supplier supplier = () -> "needle"; + return "needle"; + } + } + """; + assertEquals(source.indexOf("\"needle\"", source.indexOf("return")), + usage(source, "example.Use", "run", "()Ljava/lang/String;", ReferenceQuery.stringLiteral("needle"))); + } + + @Test + void constructorsSearchTheirOwnBodyThenInstanceInitializationWithoutFollowingThisCalls() { + String source = """ + package example; + class Use { + static String shared = "static-only"; + java.util.function.Supplier supplier = () -> "lambda-only"; + final String constant = "constant"; + String value = "needle"; + { value += "block"; } + Use() { } + Use(int ignored) { this(); value = "chained"; } + Use(boolean ignored) { super(); value = "needle"; } + } + """; + assertEquals(source.indexOf("\"needle\""), usage(source, "example.Use", "", "()V", ReferenceQuery.stringLiteral("needle"))); + assertEquals(source.indexOf("\"block\""), usage(source, "example.Use", "", "()V", ReferenceQuery.stringLiteral("block"))); + assertEquals(source.indexOf("\"constant\""), usage(source, "example.Use", "", "()V", ReferenceQuery.stringLiteral("constant"))); + assertEquals(source.lastIndexOf("\"needle\""), usage(source, "example.Use", "", "(Z)V", ReferenceQuery.stringLiteral("needle"))); + assertEquals(source.indexOf("\"chained\""), usage(source, "example.Use", "", "(I)V", ReferenceQuery.stringLiteral("chained"))); + assertEquals(source.indexOf("Use(int"), usage(source, "example.Use", "", "(I)V", ReferenceQuery.stringLiteral("needle"))); + for (String unrelated : List.of("static-only", "lambda-only")) { + assertEquals(source.indexOf("Use()"), usage(source, "example.Use", "", "()V", ReferenceQuery.stringLiteral(unrelated))); + } + assertEquals(source.indexOf("Use {"), usage(source, "example.Use", "", "(D)V", ReferenceQuery.stringLiteral("needle"))); + } + + @Test + void navigatesNestedTypesAndAnnotationElements() { + String source = """ + package example; + class Outer { + void run() { System.out.println("wrong"); } + interface Nested { String run(); } + @interface Marker { String value() default "annotation"; } + } + """; + assertEquals(source.indexOf("String run"), + usage(source, "example.Outer$Nested", "run", "()Ljava/lang/String;", ReferenceQuery.stringLiteral("missing"))); + assertEquals(source.indexOf("\"annotation\""), + usage(source, "example.Outer$Marker", "value", "()Ljava/lang/String;", ReferenceQuery.stringLiteral("annotation"))); + assertEquals(source.indexOf("Nested {"), + usage(source, "example.Outer$Nested", "missing", "()V", ReferenceQuery.stringLiteral("wrong"))); + } + + private static int usage(String source, String owner, String name, String descriptor, ReferenceQuery query) { + return new SourceDocument(owner, source).usage(ReferenceLocation.method(owner, name, descriptor), query).caret(); + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/LambdaSourceNavigationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/LambdaSourceNavigationTest.java new file mode 100644 index 00000000..cff2263d --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/LambdaSourceNavigationTest.java @@ -0,0 +1,91 @@ +package com.github.minecraft_ta.totalDebugCompanion.decompile; + +import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceLocation; +import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceQuery; +import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; +import com.github.minecraft_ta.totalDebugCompanion.navigation.RuntimeMember; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceLineMap; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceVariableNames; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class LambdaSourceNavigationTest { + private static final String OWNER = "example.Use"; + private static final String DESCRIPTOR = "(Ljava/lang/String;)Ljava/lang/String;"; + + @Test + void identifiesSameLineLambdasByMethodIdentityRatherThanNamesOrOriginalLines() { + String source = "package example;\nclass Use { Object a = x -> \"first\"; Object b = x -> \"second\"; }"; + SourceDocument document = document(source, + parameter("a", source.indexOf("x ->")), parameter("b", source.lastIndexOf("x ->"))); + assertEquals(source.indexOf("x ->"), document.navigate(member("a")).caret()); + assertEquals(source.lastIndexOf("x ->"), document.navigate(member("b")).caret()); + var use = document.usage(location("b"), ReferenceQuery.stringLiteral("second")); + assertEquals(SourceDocument.Kind.OCCURRENCE, use.kind()); + assertEquals(source.indexOf("\"second\""), use.caret()); + assertEquals(SourceDocument.Kind.CONSTRUCT, document.navigate(member("b")).kind()); + assertTrue(document.declaration(location("b")).isEmpty(), "A lambda is not an explicit method declaration"); + } + + @Test + void searchingAnOuterLambdaDoesNotStealAnOccurrenceFromAnInnerLambda() { + String source = "package example;\nclass Use { Object value = x -> y -> \"inner\"; }"; + var document = document(source, parameter("outer", source.indexOf("x ->")), parameter("inner", source.indexOf("y ->"))); + assertEquals(SourceDocument.Kind.CONSTRUCT, + document.usage(location("outer"), ReferenceQuery.stringLiteral("inner")).kind()); + assertEquals(source.indexOf("\"inner\""), + document.usage(location("inner"), ReferenceQuery.stringLiteral("inner")).caret()); + assertEquals(SourceDocument.Kind.CLASS, + document.usage(ReferenceLocation.method(OWNER, "", "()V"), ReferenceQuery.stringLiteral("inner")).kind()); + } + + @Test + void multipleSourceLambdasForOneMethodRemainAmbiguous() { + String source = "package example;\nclass Use { Object a = x -> \"one\"; Object b = x -> \"two\"; }"; + var document = document(source, parameter("shared", source.indexOf("x ->")), parameter("shared", source.lastIndexOf("x ->"))); + assertEquals(SourceDocument.Kind.CLASS, document.navigate(member("shared")).kind()); + } + + @Test + void ordinaryMethodParametersAreNotMistakenForLambdaOrigins() { + String source = "package example;\nclass Use { void method(String x) { Object f = y -> \"text\"; } }"; + var document = document(source, parameter("unrendered", source.indexOf("x)"))); + assertEquals(SourceDocument.Kind.CLASS, document.navigate(member("unrendered")).kind()); + } + + @Test + void localDeclarationsIdentifyZeroArgumentLambdaBlocks() { + String source = "package example;\nclass Use { Object value = () -> { int sum = 0; return sum; }; }"; + var symbol = new CodeSymbol.MethodSymbol(OWNER, "body", "()I"); + var document = document(source, new SourceDocument.SymbolSpan(symbol, + SourceDocument.SymbolRole.METHOD_LOCAL, source.indexOf("sum ="), 3)); + var destination = document.navigate(new RuntimeMember.Method(OWNER, "body", "()I")); + assertEquals(SourceDocument.Kind.CONSTRUCT, destination.kind()); + assertEquals(source.indexOf("() ->"), destination.caret()); + } + + @Test + void variableReferencesCannotBeUsedAsOwningMethodDeclarations() { + String source = "package example;\nclass Use { Object value = () -> input; }"; + var document = document(source, new SourceDocument.SymbolSpan( + new CodeSymbol.MethodSymbol(OWNER, "body", DESCRIPTOR), SourceDocument.SymbolRole.METHOD_LOCAL, + source.indexOf("input"), 5)); + assertEquals(SourceDocument.Kind.CLASS, document.navigate(member("body")).kind()); + } + + private static SourceDocument document(String source, SourceDocument.SymbolSpan... spans) { + return new SourceDocument(OWNER, source, SourceLineMap.empty(), SourceVariableNames.empty(), List.of(spans)); + } + + private static SourceDocument.SymbolSpan parameter(String method, int offset) { + return new SourceDocument.SymbolSpan(new CodeSymbol.MethodSymbol(OWNER, method, DESCRIPTOR), + SourceDocument.SymbolRole.METHOD_PARAMETER, offset, 1); + } + + private static RuntimeMember.Method member(String name) { return new RuntimeMember.Method(OWNER, name, DESCRIPTOR); } + private static ReferenceLocation location(String name) { return ReferenceLocation.method(OWNER, name, DESCRIPTOR); } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceDocumentIntegrationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceDocumentIntegrationTest.java new file mode 100644 index 00000000..62a9763b --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceDocumentIntegrationTest.java @@ -0,0 +1,196 @@ +package com.github.minecraft_ta.totalDebugCompanion.decompile; + +import com.github.minecraft_ta.totalDebugCompanion.bytecode.ClassBytecodeSource; +import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceLocation; +import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceQuery; +import com.github.minecraft_ta.totalDebugCompanion.decompiler.VineflowerDecompiler; +import com.github.minecraft_ta.totalDebugCompanion.decompiler.fixture.NavigationFixture; +import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerBreakpointResolver; +import com.github.minecraft_ta.totalDebugCompanion.navigation.RuntimeMember; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; +import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.io.TempDir; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; + +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +final class SourceDocumentIntegrationTest { + private static final String OWNER = NavigationFixture.class.getName(); + + @TempDir Path directory; + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void resolvesCompiledStructuresAndPreservesResultsThroughCache(boolean stripDebug) throws Exception { + Map classes = fixtureClasses(stripDebug); + ClassBytecodeSource bytecode = name -> { + String normalized = name.replace('.', '/'); + if (normalized.endsWith("/class")) normalized = normalized.substring(0, normalized.length() - 6); + byte[] bytes = classes.get(normalized); + if (bytes != null) return bytes; + try (var input = NavigationFixture.class.getResourceAsStream('/' + normalized + ".class")) { + return input == null ? null : input.readAllBytes(); + } + }; + var result = new VineflowerDecompiler().decompile(OWNER, bytecode); + assertTrue(result.isComplete(), result.diagnostics().toString()); + assertFalse(result.symbols().isEmpty()); + if (stripDebug) assertTrue(result.lineMap().isEmpty()); + var document = new SourceDocument(OWNER, result.source(), result.lineMap(), result.variableNames(), result.symbols()); + var store = DecompiledSourceStore.open(directory, "fixture", "symbols"); + Path path = store.write(document); + var restored = DecompiledSourceStore.open(directory, "fixture", "symbols").read(OWNER).document(); + assertEquals(document.symbols(), restored.symbols()); + assertArrayEquals(document.lineMap().originalToDisplayed(), restored.lineMap().originalToDisplayed()); + var decompiled = new DecompiledSource(path, document, null); + assertSame(document, decompiled.debugSource().document()); + + int methods = 0; + int classFallbacks = 0; + int lambdaConstructs = 0; + int zeroArgumentLambdas = 0; + for (var entry : classes.entrySet()) { + String owner = entry.getKey().replace('/', '.'); + var bytecodeMethods = new java.util.ArrayList(); + new ClassReader(entry.getValue()).accept(new ClassVisitor(Opcodes.ASM9) { + @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + bytecodeMethods.add(new Method(name, descriptor, access)); + return null; + } + }, ClassReader.SKIP_CODE); + for (var method : bytecodeMethods) { + methods++; + var member = new RuntimeMember.Method(owner, method.name, method.desc); + var destination = document.navigate(member); + assertEquals(destination, restored.navigate(member), member.toString()); + assertTrue(destination.caret() >= 0 && destination.caret() < result.source().length()); + if (destination.kind() == SourceDocument.Kind.CLASS) classFallbacks++; + if (method.name.startsWith("lambda$") && destination.kind() == SourceDocument.Kind.CONSTRUCT) { + lambdaConstructs++; + if (method.desc.startsWith("()")) zeroArgumentLambdas++; + assertTrue(document.contents().substring(destination.start(), destination.start() + destination.length()).contains("->")); + assertTrue(document.declaration(ReferenceLocation.method(owner, method.name, method.desc)).isEmpty()); + } + if ((method.access & Opcodes.ACC_BRIDGE) != 0) { + assertEquals(SourceDocument.Kind.CLASS, destination.kind(), member.toString()); + } + } + } + assertTrue(methods > 40, "The fixture must exercise its nested and generated methods"); + assertTrue(classFallbacks > 10, "Generated methods must be represented honestly"); + assertTrue(lambdaConstructs >= 4, "Parameterized and nested lambda identities must survive cache reloads"); + assertTrue(zeroArgumentLambdas > 0, "A zero-argument lambda with visible locals should resolve to its block"); + for (String descriptor : List.of("(Ljava/lang/String;)Ljava/lang/String;", "(Ljava/lang/Integer;)Ljava/lang/String;")) { + var member = new RuntimeMember.Method(OWNER, "overloaded", descriptor); + var exact = document.navigate(member); + assertEquals(SourceDocument.Kind.DECLARATION, exact.kind()); + assertTrue(document.contents().startsWith("overloaded(", exact.caret())); + var location = ReferenceLocation.method(OWNER, "overloaded", descriptor); + assertEquals(exact, document.declaration(location).orElseThrow()); + } + var dataOwner = OWNER + "$Data"; + var accessor = document.navigate(new RuntimeMember.Method(dataOwner, "text", "()Ljava/lang/String;")); + assertEquals(SourceDocument.Kind.CONSTRUCT, accessor.kind()); + assertTrue(document.contents().startsWith("text", accessor.caret())); + assertTrue(document.declaration(ReferenceLocation.method(dataOwner, "text", "()Ljava/lang/String;")).isEmpty()); + assertTrue(document.declaration(ReferenceLocation.recordComponent(dataOwner, "text", "Ljava/lang/String;")).isPresent()); + + var init = ReferenceLocation.method(OWNER, "", "()V"); + var staticUse = document.usage(init, ReferenceQuery.stringLiteral("first")); + assertEquals(SourceDocument.Kind.OCCURRENCE, staticUse.kind()); + assertTrue(document.contents().startsWith("\"first\"", staticUse.caret())); + assertEquals(staticUse, restored.usage(init, ReferenceQuery.stringLiteral("first"))); + assertTrue(document.symbols().stream().anyMatch(span -> span.role() == SourceDocument.SymbolRole.CONSTANT_FIELD)); + for (var field : List.of(new CodeSymbol.FieldSymbol(OWNER, "mutable", "Ljava/lang/String;"), + new CodeSymbol.FieldSymbol(OWNER, "numbers", "[I"))) { + var read = document.usage(init, field.referenceQuery()); + assertEquals(SourceDocument.Kind.OCCURRENCE, read.kind()); + assertTrue(document.contents().startsWith(field.name(), read.caret())); + assertEquals(read, restored.usage(init, field.referenceQuery())); + } + String anonymousOwner = document.symbols().stream().map(SourceDocument.SymbolSpan::symbol) + .filter(symbol -> symbol instanceof CodeSymbol.FieldSymbol field && field.name().equals("marker")) + .map(CodeSymbol::ownerClassName).findFirst().orElseThrow(); + assertTrue(document.binaryNames().contains(anonymousOwner)); + var anonymousInit = document.usage(ReferenceLocation.method(anonymousOwner, "", "()V"), + ReferenceQuery.stringLiteral("anon-initializer")); + var anonymousField = document.navigate(new RuntimeMember.Field(anonymousOwner, "marker")); + assertEquals(SourceDocument.Kind.OCCURRENCE, anonymousInit.kind()); + assertTrue(anonymousInit.caret() >= anonymousField.start() + && anonymousInit.caret() < anonymousField.start() + anonymousField.length()); + assertEquals(SourceDocument.Kind.CLASS, document.usage( + ReferenceLocation.method(OWNER + "$Missing", "", "()V"), + ReferenceQuery.stringLiteral("anon-initializer")).kind()); + + var constructor = ReferenceLocation.method(OWNER, "", "()V"); + var instanceField = document.usage(constructor, ReferenceQuery.stringLiteral("field")); + assertEquals(SourceDocument.Kind.OCCURRENCE, instanceField.kind()); + assertTrue(document.contents().startsWith("\"field\"", instanceField.caret())); + assertEquals(instanceField, restored.usage(constructor, ReferenceQuery.stringLiteral("field"))); + assertEquals(SourceDocument.Kind.DECLARATION, + document.usage(ReferenceLocation.method(OWNER, "", "(I)V"), ReferenceQuery.stringLiteral("field")).kind()); + + var normal = ReferenceLocation.method(OWNER, "overloaded", "(Ljava/lang/String;)Ljava/lang/String;"); + var trim = document.usage(normal, ReferenceQuery.methodReference("java.lang.String", "trim", "()Ljava/lang/String;")); + assertEquals(SourceDocument.Kind.OCCURRENCE, trim.kind()); + assertTrue(document.contents().startsWith("trim", trim.caret())); + + var nested = document.navigate(new RuntimeMember.Method(OWNER + "$Nested", "apply", "(Ljava/lang/String;)Ljava/lang/String;")); + assertEquals(SourceDocument.Kind.DECLARATION, nested.kind()); + assertEquals(OWNER + "$Nested", document.ownerAtLine(document.lineAt(nested.caret()))); + var annotation = document.navigate(new RuntimeMember.Method(OWNER + "$Marker", "type", "()Ljava/lang/Class;")); + assertEquals(SourceDocument.Kind.DECLARATION, annotation.kind()); + var anonymous = document.navigate(new RuntimeMember.Method(OWNER + "$1", "get", "()Ljava/lang/String;")); + assertEquals(SourceDocument.Kind.DECLARATION, anonymous.kind()); + assertEquals(OWNER + "$1", document.ownerAtLine(document.lineAt(anonymous.caret()))); + if (!stripDebug) { + var normalMethod = document.navigate(new RuntimeMember.Method(OWNER, "overloaded", "(Ljava/lang/String;)Ljava/lang/String;")); + var breakpoint = DebuggerBreakpointResolver.resolve(decompiled.debugSource(), document.lineAt(normalMethod.caret()), null, null).orElseThrow(); + assertTrue(breakpoint.isMethodEntry()); + assertEquals("overloaded", breakpoint.method().name()); + } + System.out.printf("Source navigation fixture: debug=%s methods=%d classFallbacks=%d symbols=%d%n", + !stripDebug, methods, classFallbacks, document.symbols().size()); + } + + private record Method(String name, String desc, int access) {} + + private static Map fixtureClasses(boolean stripDebug) throws Exception { + Map classes = new LinkedHashMap<>(); + var pending = new ArrayDeque(); + String root = OWNER.replace('.', '/'); + pending.add(root); + while (!pending.isEmpty()) { + String name = pending.removeFirst(); + if (classes.containsKey(name)) continue; + byte[] bytes; + try (var input = NavigationFixture.class.getResourceAsStream('/' + name + ".class")) { + bytes = java.util.Objects.requireNonNull(input, name).readAllBytes(); + } + if (stripDebug) { + var writer = new ClassWriter(0); + new ClassReader(bytes).accept(writer, ClassReader.SKIP_DEBUG); + bytes = writer.toByteArray(); + } + classes.put(name, bytes); + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override public void visitInnerClass(String inner, String outer, String simple, int access) { + if (inner.startsWith(root + '$')) pending.add(inner); + } + }, ClassReader.SKIP_CODE); + } + return classes; + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceFileNavigationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceDocumentNavigationTest.java similarity index 87% rename from companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceFileNavigationTest.java rename to companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceDocumentNavigationTest.java index a963ffa1..6f5c82f9 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceFileNavigationTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompile/SourceDocumentNavigationTest.java @@ -4,6 +4,7 @@ import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceQuery; import com.github.minecraft_ta.totalDebugCompanion.jdt.CompanionClassIndex; import com.github.minecraft_ta.totalDebugCompanion.navigation.RuntimeMember; +import com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument; import com.github.tth05.jindex.ClassIndex; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -15,7 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -final class SourceFileNavigationTest { +final class SourceDocumentNavigationTest { private static final String SOURCE = """ package example; @@ -109,14 +110,11 @@ void apply(int value) { assertEquals( source.indexOf("apply(int"), - SourceFileNavigation.memberOffset( - source, - new RuntimeMember.Method("sample.Target", "apply", "(I)V") - ) + new SourceDocument("sample.Target", source).navigate(new RuntimeMember.Method("sample.Target", "apply", "(I)V")).caret() ); assertEquals( source.indexOf("selected;"), - SourceFileNavigation.memberOffset(source, new RuntimeMember.Field("sample.Target", "selected")) + new SourceDocument("sample.Target", source).navigate(new RuntimeMember.Field("sample.Target", "selected")).caret() ); } @@ -134,11 +132,11 @@ public class Target { } """; - assertEquals(source.indexOf("Target {"), SourceFileNavigation.topLevelTypeOffset(source)); + assertEquals(source.indexOf("Target {"), new SourceDocument("sample.Target", source).classFallback("sample.Target").caret()); } private static int offset(ReferenceQuery query) { - return SourceFileNavigation.usageOffset(SOURCE, METHOD_SITE, query); + return new SourceDocument(METHOD_SITE.className(), SOURCE).usage(METHOD_SITE, query).caret(); } private static byte[] classBytes(Class type) throws IOException { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/fixture/NavigationFixture.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/fixture/NavigationFixture.java new file mode 100644 index 00000000..8a0b03e6 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/decompiler/fixture/NavigationFixture.java @@ -0,0 +1,72 @@ +package com.github.minecraft_ta.totalDebugCompanion.decompiler.fixture; +import java.lang.annotation.*; +import java.util.*; +import java.util.function.*; +import java.io.*; + +@NavigationFixture.Marker(type=String.class) +public class NavigationFixture { + @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.TYPE_USE}) + public @interface Marker { Class type() default Object.class; } + public record Data(String text, int number) {} + public record Checked(String text) { public Checked { Objects.requireNonNull(text); } } + public enum Kind { FIRST, SECOND { @Override public String label() { return "second"; } }; public String label() { return name(); } } + public interface Contract { A get(); default int size() { return get().toString().length(); } } + public static class Implementation implements Contract { public String get() { return "impl"; } } + public sealed interface Shape permits Circle {} + public record Circle(int radius) implements Shape {} + public interface Nested { String apply(String value); } + public class Inner { public Inner(String value) { field = value; } public String read() { return field; } } + public static class StaticNested { public String apply(String value) { return value.trim(); } } + public static final Supplier A = () -> "A"; public static final Supplier B = () -> "B"; + // Same-line and nested lambdas must remain distinguishable after line-number stripping. + public static final Function TRIM = value -> value.trim(); public static final Function UPPER = value -> value.toUpperCase(); + public static final Function> NESTED = outer -> inner -> outer + inner; + public static final Supplier COUNTER = () -> { int sum = 0; for (int i = 0; i < 3; i++) sum += i; return sum; }; + public static final IntFunction ARRAY = String[]::new; + public static final Function METHOD = String::trim; + public static final Supplier> CONSTRUCTOR = ArrayList::new; + public static String FIRST = "first".trim(); + public static String SECOND = "second".trim(); + public static String mutable = "mutable"; + public static final String copied = mutable; + public static int[] numbers = {1, 2}; + public static final int size = numbers.length; + public static final int CONSTANT = 7; + public static String outerMarker = "anon-initializer"; + static { FIRST = FIRST + SECOND; } + public String field = "field".trim(); + { field += "instance"; } + public NavigationFixture() {} + public NavigationFixture(int ignored) { this(); } + public String overloaded(String value) { return value.trim(); } + public String overloaded(Integer value) { return value.toString(); } + public String lambdas(String input) { + Supplier one = () -> input.trim(); Supplier two = () -> input.toUpperCase(); + Supplier> nested = () -> () -> input.toLowerCase(); + return one.get() + two.get() + nested.get().get(); + } + public String localAndAnonymous(String input) { + class Local { String read() { return input.trim(); } } + Supplier anonymous = new Supplier<>() { public String get() { return input.toUpperCase(); } }; + return new Local().read() + anonymous.get(); + } + public static Object fieldOnlyAnonymous() { return new Object() { static String marker = "anon-initializer"; }; } + public String pattern(Object value) { + return switch(value) { + case String text when !text.isEmpty() -> text.trim(); + case Data(String text, int number) -> text + number; + default -> value.toString(); + }; + } + public String resource(Object value) throws IOException { + try (var reader = new StringReader(value.toString())) { return Integer.toString(reader.read()); } + catch (IllegalArgumentException ex) { return ex.getMessage(); } + } + public synchronized String sync(Object value) { + synchronized(value) { assert value != null : "missing"; return value.toString(); } + } + public int arrayAndLoop(String... values) { int count=0; for (String v : values) { count+=v.length(); } return count; } + public List generic(List values) { return values; } + public native void nativeMethod(); +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpRuntimeSourceTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpRuntimeSourceTest.java index d37b93a2..89db3eee 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpRuntimeSourceTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpRuntimeSourceTest.java @@ -153,14 +153,7 @@ void rejectsAValidButNonexistentOverload() { } private CompanionMcpRuntimeSource source() { - DecompiledSource source = new DecompiledSource( - this.temporaryDirectory.resolve("RuntimeSourceFixture.java"), - RuntimeSourceFixture.class.getName(), - SOURCE, - SourceLineMap.empty(), - SourceVariableNames.empty(), - null - ); + DecompiledSource source = new DecompiledSource(this.temporaryDirectory.resolve("RuntimeSourceFixture.java"), new com.github.minecraft_ta.totalDebugCompanion.source.SourceDocument(RuntimeSourceFixture.class.getName(), SOURCE, SourceLineMap.empty(), SourceVariableNames.empty(), java.util.List.of()), null); return new CompanionMcpRuntimeSource(binaryName -> source); }