From 75e9c0cdc0eb3c0182330dfa54580501e150ad66 Mon Sep 17 00:00:00 2001
From: sqersters <109853788+bouclem@users.noreply.github.com>
Date: Mon, 25 May 2026 17:05:27 +0200
Subject: [PATCH] feat: re-add Javadoc support via source post-processing
Reads class and field comments from the Tiny mapping file and injects them as Javadoc blocks above the matching declarations after Fernflower decompiles. Implemented as a new Source modification step in TaskDecompile, runs after patches and existing source adapters.
Method comments are intentionally skipped: matching overloads from rewritten source without a real Java parser is not reliable enough. Brace-depth tracking with quote/comment skipping limits insertions to top-level type members so locals and inner-class members are never touched.
Refs #220
---
.../mcphackers/mcp/tasks/TaskDecompile.java | 4 +
.../mcp/tools/javadoc/JavadocMappings.java | 121 +++++++++
.../mcp/tools/javadoc/JavadocSource.java | 237 ++++++++++++++++++
3 files changed, 362 insertions(+)
create mode 100644 src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocMappings.java
create mode 100644 src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocSource.java
diff --git a/src/main/java/org/mcphackers/mcp/tasks/TaskDecompile.java b/src/main/java/org/mcphackers/mcp/tasks/TaskDecompile.java
index fb7061d..34db249 100644
--- a/src/main/java/org/mcphackers/mcp/tasks/TaskDecompile.java
+++ b/src/main/java/org/mcphackers/mcp/tasks/TaskDecompile.java
@@ -5,6 +5,7 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Collections;
import org.mcphackers.mcp.MCP;
import org.mcphackers.mcp.MCPPaths;
@@ -13,6 +14,8 @@
import org.mcphackers.mcp.tools.FileUtil;
import org.mcphackers.mcp.tools.fernflower.Decompiler;
import org.mcphackers.mcp.tools.injector.GLConstants;
+import org.mcphackers.mcp.tools.javadoc.JavadocMappings;
+import org.mcphackers.mcp.tools.javadoc.JavadocSource;
import org.mcphackers.mcp.tools.mappings.MappingUtil;
import org.mcphackers.mcp.tools.project.EclipseProjectWriter;
import org.mcphackers.mcp.tools.project.IdeaProjectWriter;
@@ -83,6 +86,7 @@ protected Stage[] setStages() {
}
Source.modify(ffOut, MCP.SOURCE_ADAPTERS);
+ Source.modify(ffOut, Collections.singletonList(new JavadocSource(JavadocMappings.read(MCPPaths.get(mcp, MAPPINGS)))));
}), stage(getLocalizedStage("copysrc"), 90, () -> {
if (!mcp.getOptions().getBooleanParameter(TaskParameter.DECOMPILE_RESOURCES)) {
for (Path p : FileUtil.walkDirectory(ffOut, p -> !Files.isDirectory(p) && !p.getFileName().toString().endsWith(".java"))) {
diff --git a/src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocMappings.java b/src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocMappings.java
new file mode 100644
index 0000000..9d7c9e2
--- /dev/null
+++ b/src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocMappings.java
@@ -0,0 +1,121 @@
+package org.mcphackers.mcp.tools.javadoc;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import net.fabricmc.mappingio.MappingReader;
+import net.fabricmc.mappingio.adapter.MappingSourceNsSwitch;
+import net.fabricmc.mappingio.tree.MappingTree;
+import net.fabricmc.mappingio.tree.MemoryMappingTree;
+
+/**
+ * Loads Javadoc comments from a Tiny mapping file and exposes them keyed by the
+ * "named" namespace identifiers used in decompiled source.
+ *
+ *
Tiny v2 supports comments on classes, fields and methods. This class only
+ * surfaces class and field comments. Methods are intentionally skipped because
+ * matching overloads from regenerated source is not reliable enough without a
+ * full Java parser.
+ */
+public final class JavadocMappings {
+
+ /** Class internal name (e.g. {@code net/minecraft/src/Block}) -> Javadoc text. */
+ private final Map classDocs;
+
+ /** {@code internal/Class.fieldName} -> Javadoc text. */
+ private final Map fieldDocs;
+
+ private JavadocMappings(Map classDocs, Map fieldDocs) {
+ this.classDocs = classDocs;
+ this.fieldDocs = fieldDocs;
+ }
+
+ public static JavadocMappings empty() {
+ return new JavadocMappings(Collections.emptyMap(), Collections.emptyMap());
+ }
+
+ public boolean isEmpty() {
+ return classDocs.isEmpty() && fieldDocs.isEmpty();
+ }
+
+ public String getClassDoc(String namedInternal) {
+ return classDocs.get(namedInternal);
+ }
+
+ public String getFieldDoc(String namedInternal, String fieldName) {
+ return fieldDocs.get(namedInternal + "." + fieldName);
+ }
+
+ /**
+ * Reads a Tiny mapping file and collects every Javadoc comment that targets
+ * the {@code named} namespace.
+ *
+ * @param mappingFile path to a Tiny v1 or v2 mapping file
+ * @return loaded mappings, or {@link #empty()} if the file does not exist
+ * @throws IOException if the file cannot be read
+ */
+ public static JavadocMappings read(Path mappingFile) throws IOException {
+ if (mappingFile == null || !Files.exists(mappingFile)) {
+ return empty();
+ }
+
+ MemoryMappingTree tree = new MemoryMappingTree();
+ try (BufferedReader reader = Files.newBufferedReader(mappingFile)) {
+ MappingReader.read(reader, new MappingSourceNsSwitch(tree, "named"));
+ }
+
+ Map classDocs = new HashMap<>();
+ Map fieldDocs = new HashMap<>();
+ Set ambiguousFields = new HashSet<>();
+
+ for (MappingTree.ClassMapping cls : tree.getClasses()) {
+ String namedClass = cls.getName("named");
+ if (namedClass == null) {
+ namedClass = cls.getSrcName();
+ }
+ if (namedClass == null) {
+ continue;
+ }
+
+ String classComment = cls.getComment();
+ if (classComment != null && !classComment.isEmpty()) {
+ classDocs.put(namedClass, classComment);
+ }
+
+ for (MappingTree.FieldMapping field : cls.getFields()) {
+ String fieldComment = field.getComment();
+ if (fieldComment == null || fieldComment.isEmpty()) {
+ continue;
+ }
+ String namedField = field.getName("named");
+ if (namedField == null) {
+ namedField = field.getSrcName();
+ }
+ if (namedField == null) {
+ continue;
+ }
+ String key = namedClass + "." + namedField;
+ // Java forbids two fields sharing a name, but mapping files can still
+ // contain duplicates; drop them rather than guess.
+ if (ambiguousFields.contains(key)) {
+ continue;
+ }
+ if (fieldDocs.containsKey(key)) {
+ fieldDocs.remove(key);
+ ambiguousFields.add(key);
+ continue;
+ }
+ fieldDocs.put(key, fieldComment);
+ }
+ }
+
+ return new JavadocMappings(classDocs, fieldDocs);
+ }
+}
diff --git a/src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocSource.java b/src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocSource.java
new file mode 100644
index 0000000..c0e8761
--- /dev/null
+++ b/src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocSource.java
@@ -0,0 +1,237 @@
+package org.mcphackers.mcp.tools.javadoc;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.mcphackers.mcp.tools.source.Source;
+
+/**
+ * Source modification step that injects Javadoc comments above class and field
+ * declarations using metadata loaded from {@link JavadocMappings}.
+ *
+ * This is a textual rewrite, not a parser. To stay reliable without a real
+ * Java parser the implementation tracks brace depth and only documents members
+ * that live directly in the top-level type body (depth 1 inside the type).
+ * Anything else (locals, inner-class members, anonymous bodies, etc.) is left
+ * untouched.
+ */
+public final class JavadocSource extends Source {
+
+ // First class / interface / enum declaration in the file.
+ private static final Pattern TOP_LEVEL_TYPE = Pattern.compile(
+ "(?m)^(\\s*)(?:public\\s+|abstract\\s+|final\\s+|strictfp\\s+)*(?:class|interface|enum)\\s+(\\w+)");
+
+ // Field declaration on a single line. Captures the field's first identifier.
+ // Excludes lines containing '(' so method/constructor declarations are not
+ // matched. Requires the line to end with ';' or '=' so a partial match in
+ // the middle of an expression is impossible.
+ private static final Pattern FIELD_LINE = Pattern.compile(
+ "^[ \\t]*(?:(?:public|protected|private|static|final|volatile|transient)\\s+)+"
+ + "[\\w<>\\[\\]\\.,\\s\\?]+?\\s+(\\w+)\\s*[=;]");
+
+ private final JavadocMappings mappings;
+
+ public JavadocSource(JavadocMappings mappings) {
+ this.mappings = mappings;
+ }
+
+ @Override
+ public void apply(String classNameOnDisk, StringBuilder source) {
+ if (mappings.isEmpty()) {
+ return;
+ }
+ String internalName = toInternalName(classNameOnDisk);
+ if (internalName == null) {
+ return;
+ }
+
+ List insertions = new ArrayList<>();
+ collectClassDoc(source, internalName, insertions);
+ collectFieldDocs(source, internalName, insertions);
+
+ // Apply back-to-front so earlier offsets stay valid.
+ for (int i = insertions.size() - 1; i >= 0; i--) {
+ Insertion ins = insertions.get(i);
+ source.insert(ins.offset, ins.text);
+ }
+ }
+
+ private void collectClassDoc(StringBuilder source, String internalName, List out) {
+ String doc = mappings.getClassDoc(internalName);
+ if (doc == null || doc.isEmpty()) {
+ return;
+ }
+ Matcher m = TOP_LEVEL_TYPE.matcher(source);
+ if (!m.find()) {
+ return;
+ }
+ if (hasPrecedingJavadoc(source, m.start())) {
+ return;
+ }
+ out.add(new Insertion(m.start(), formatJavadoc(doc, m.group(1))));
+ }
+
+ private void collectFieldDocs(StringBuilder source, String internalName, List out) {
+ // Anchor brace tracking just after the top-level type keyword so that
+ // braces inside class-level annotations cannot be mistaken for the body.
+ Matcher type = TOP_LEVEL_TYPE.matcher(source);
+ if (!type.find()) {
+ return;
+ }
+ int scanStart = source.indexOf("{", type.end());
+ if (scanStart < 0) {
+ return;
+ }
+ int depth = 1; // we are now inside the top-level type body
+ int len = source.length();
+
+ for (int i = scanStart + 1; i < len; i++) {
+ char c = source.charAt(i);
+
+ if (c == '"' || c == '\'') {
+ i = skipQuoted(source, i, c);
+ continue;
+ }
+ if (c == '/' && i + 1 < len) {
+ char next = source.charAt(i + 1);
+ if (next == '/') {
+ int eol = source.indexOf("\n", i);
+ if (eol < 0) return;
+ i = eol;
+ // fall through so the '\n' branch below runs on next iteration
+ i--;
+ continue;
+ }
+ if (next == '*') {
+ int end = source.indexOf("*/", i + 2);
+ if (end < 0) return;
+ i = end + 1;
+ continue;
+ }
+ }
+
+ if (c == '{') {
+ depth++;
+ continue;
+ }
+ if (c == '}') {
+ depth--;
+ if (depth == 0) {
+ return; // closed the top-level type body
+ }
+ continue;
+ }
+ if (c == '\n') {
+ if (depth == 1) {
+ int eol = source.indexOf("\n", i + 1);
+ if (eol < 0) eol = len;
+ tryFieldOnLine(source, i + 1, eol, internalName, out);
+ }
+ }
+ }
+ }
+
+ private void tryFieldOnLine(StringBuilder source, int start, int end, String internalName, List out) {
+ String line = source.substring(start, end);
+ // Cheap reject: method/constructor declarations contain '('.
+ if (line.indexOf('(') >= 0) {
+ return;
+ }
+ Matcher m = FIELD_LINE.matcher(line);
+ if (!m.find()) {
+ return;
+ }
+ String fieldName = m.group(1);
+ String doc = mappings.getFieldDoc(internalName, fieldName);
+ if (doc == null || doc.isEmpty()) {
+ return;
+ }
+ if (hasPrecedingJavadoc(source, start)) {
+ return;
+ }
+ String indent = leadingWhitespace(line);
+ out.add(new Insertion(start, formatJavadoc(doc, indent)));
+ }
+
+ private static int skipQuoted(StringBuilder source, int start, char quote) {
+ int len = source.length();
+ for (int i = start + 1; i < len; i++) {
+ char c = source.charAt(i);
+ if (c == '\\') {
+ i++;
+ continue;
+ }
+ if (c == quote) {
+ return i;
+ }
+ if (c == '\n') {
+ // Unterminated literal; bail out at end of line.
+ return i - 1;
+ }
+ }
+ return len - 1;
+ }
+
+ private static String leadingWhitespace(String line) {
+ int i = 0;
+ while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) {
+ i++;
+ }
+ return line.substring(0, i);
+ }
+
+ private static boolean hasPrecedingJavadoc(StringBuilder source, int pos) {
+ int i = pos - 1;
+ while (i >= 0 && Character.isWhitespace(source.charAt(i))) {
+ i--;
+ }
+ return i >= 1 && source.charAt(i - 1) == '*' && source.charAt(i) == '/';
+ }
+
+ private static String formatJavadoc(String text, String indent) {
+ StringBuilder out = new StringBuilder();
+ out.append(indent).append("/**\n");
+ for (String line : text.split("\\r?\\n", -1)) {
+ out.append(indent).append(" * ").append(line).append('\n');
+ }
+ out.append(indent).append(" */\n");
+ return out.toString();
+ }
+
+ /**
+ * Converts the path-like file identifier passed by {@link Source#modify} into
+ * the internal JVM class name used as the mapping key.
+ *
+ * {@code Source.modify} hands us the file path with the {@code .java}
+ * extension stripped, e.g. {@code .../src_original/net/minecraft/src/Block}.
+ * Mappings key classes by internal name ({@code net/minecraft/src/Block}).
+ */
+ static String toInternalName(String classNameOnDisk) {
+ if (classNameOnDisk == null) {
+ return null;
+ }
+ String normalised = classNameOnDisk.replace('\\', '/');
+ int marker = normalised.lastIndexOf("/net/minecraft/");
+ if (marker >= 0) {
+ return normalised.substring(marker + 1);
+ }
+ int lastSlash = normalised.lastIndexOf('/');
+ if (lastSlash <= 0) {
+ return normalised;
+ }
+ int prevSlash = normalised.lastIndexOf('/', lastSlash - 1);
+ return prevSlash < 0 ? normalised : normalised.substring(prevSlash + 1);
+ }
+
+ private static final class Insertion {
+ final int offset;
+ final String text;
+
+ Insertion(int offset, String text) {
+ this.offset = offset;
+ this.text = text;
+ }
+ }
+}