Skip to content

Commit 75e9c0c

Browse files
committed
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
1 parent c094346 commit 75e9c0c

3 files changed

Lines changed: 362 additions & 0 deletions

File tree

src/main/java/org/mcphackers/mcp/tasks/TaskDecompile.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.io.IOException;
66
import java.nio.file.Files;
77
import java.nio.file.Path;
8+
import java.util.Collections;
89

910
import org.mcphackers.mcp.MCP;
1011
import org.mcphackers.mcp.MCPPaths;
@@ -13,6 +14,8 @@
1314
import org.mcphackers.mcp.tools.FileUtil;
1415
import org.mcphackers.mcp.tools.fernflower.Decompiler;
1516
import org.mcphackers.mcp.tools.injector.GLConstants;
17+
import org.mcphackers.mcp.tools.javadoc.JavadocMappings;
18+
import org.mcphackers.mcp.tools.javadoc.JavadocSource;
1619
import org.mcphackers.mcp.tools.mappings.MappingUtil;
1720
import org.mcphackers.mcp.tools.project.EclipseProjectWriter;
1821
import org.mcphackers.mcp.tools.project.IdeaProjectWriter;
@@ -83,6 +86,7 @@ protected Stage[] setStages() {
8386
}
8487

8588
Source.modify(ffOut, MCP.SOURCE_ADAPTERS);
89+
Source.modify(ffOut, Collections.singletonList(new JavadocSource(JavadocMappings.read(MCPPaths.get(mcp, MAPPINGS)))));
8690
}), stage(getLocalizedStage("copysrc"), 90, () -> {
8791
if (!mcp.getOptions().getBooleanParameter(TaskParameter.DECOMPILE_RESOURCES)) {
8892
for (Path p : FileUtil.walkDirectory(ffOut, p -> !Files.isDirectory(p) && !p.getFileName().toString().endsWith(".java"))) {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package org.mcphackers.mcp.tools.javadoc;
2+
3+
import java.io.BufferedReader;
4+
import java.io.IOException;
5+
import java.nio.file.Files;
6+
import java.nio.file.Path;
7+
import java.util.Collections;
8+
import java.util.HashMap;
9+
import java.util.HashSet;
10+
import java.util.Map;
11+
import java.util.Set;
12+
13+
import net.fabricmc.mappingio.MappingReader;
14+
import net.fabricmc.mappingio.adapter.MappingSourceNsSwitch;
15+
import net.fabricmc.mappingio.tree.MappingTree;
16+
import net.fabricmc.mappingio.tree.MemoryMappingTree;
17+
18+
/**
19+
* Loads Javadoc comments from a Tiny mapping file and exposes them keyed by the
20+
* "named" namespace identifiers used in decompiled source.
21+
*
22+
* <p>Tiny v2 supports comments on classes, fields and methods. This class only
23+
* surfaces class and field comments. Methods are intentionally skipped because
24+
* matching overloads from regenerated source is not reliable enough without a
25+
* full Java parser.
26+
*/
27+
public final class JavadocMappings {
28+
29+
/** Class internal name (e.g. {@code net/minecraft/src/Block}) -&gt; Javadoc text. */
30+
private final Map<String, String> classDocs;
31+
32+
/** {@code internal/Class.fieldName} -&gt; Javadoc text. */
33+
private final Map<String, String> fieldDocs;
34+
35+
private JavadocMappings(Map<String, String> classDocs, Map<String, String> fieldDocs) {
36+
this.classDocs = classDocs;
37+
this.fieldDocs = fieldDocs;
38+
}
39+
40+
public static JavadocMappings empty() {
41+
return new JavadocMappings(Collections.emptyMap(), Collections.emptyMap());
42+
}
43+
44+
public boolean isEmpty() {
45+
return classDocs.isEmpty() && fieldDocs.isEmpty();
46+
}
47+
48+
public String getClassDoc(String namedInternal) {
49+
return classDocs.get(namedInternal);
50+
}
51+
52+
public String getFieldDoc(String namedInternal, String fieldName) {
53+
return fieldDocs.get(namedInternal + "." + fieldName);
54+
}
55+
56+
/**
57+
* Reads a Tiny mapping file and collects every Javadoc comment that targets
58+
* the {@code named} namespace.
59+
*
60+
* @param mappingFile path to a Tiny v1 or v2 mapping file
61+
* @return loaded mappings, or {@link #empty()} if the file does not exist
62+
* @throws IOException if the file cannot be read
63+
*/
64+
public static JavadocMappings read(Path mappingFile) throws IOException {
65+
if (mappingFile == null || !Files.exists(mappingFile)) {
66+
return empty();
67+
}
68+
69+
MemoryMappingTree tree = new MemoryMappingTree();
70+
try (BufferedReader reader = Files.newBufferedReader(mappingFile)) {
71+
MappingReader.read(reader, new MappingSourceNsSwitch(tree, "named"));
72+
}
73+
74+
Map<String, String> classDocs = new HashMap<>();
75+
Map<String, String> fieldDocs = new HashMap<>();
76+
Set<String> ambiguousFields = new HashSet<>();
77+
78+
for (MappingTree.ClassMapping cls : tree.getClasses()) {
79+
String namedClass = cls.getName("named");
80+
if (namedClass == null) {
81+
namedClass = cls.getSrcName();
82+
}
83+
if (namedClass == null) {
84+
continue;
85+
}
86+
87+
String classComment = cls.getComment();
88+
if (classComment != null && !classComment.isEmpty()) {
89+
classDocs.put(namedClass, classComment);
90+
}
91+
92+
for (MappingTree.FieldMapping field : cls.getFields()) {
93+
String fieldComment = field.getComment();
94+
if (fieldComment == null || fieldComment.isEmpty()) {
95+
continue;
96+
}
97+
String namedField = field.getName("named");
98+
if (namedField == null) {
99+
namedField = field.getSrcName();
100+
}
101+
if (namedField == null) {
102+
continue;
103+
}
104+
String key = namedClass + "." + namedField;
105+
// Java forbids two fields sharing a name, but mapping files can still
106+
// contain duplicates; drop them rather than guess.
107+
if (ambiguousFields.contains(key)) {
108+
continue;
109+
}
110+
if (fieldDocs.containsKey(key)) {
111+
fieldDocs.remove(key);
112+
ambiguousFields.add(key);
113+
continue;
114+
}
115+
fieldDocs.put(key, fieldComment);
116+
}
117+
}
118+
119+
return new JavadocMappings(classDocs, fieldDocs);
120+
}
121+
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package org.mcphackers.mcp.tools.javadoc;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.regex.Matcher;
6+
import java.util.regex.Pattern;
7+
8+
import org.mcphackers.mcp.tools.source.Source;
9+
10+
/**
11+
* Source modification step that injects Javadoc comments above class and field
12+
* declarations using metadata loaded from {@link JavadocMappings}.
13+
*
14+
* <p>This is a textual rewrite, not a parser. To stay reliable without a real
15+
* Java parser the implementation tracks brace depth and only documents members
16+
* that live directly in the top-level type body (depth 1 inside the type).
17+
* Anything else (locals, inner-class members, anonymous bodies, etc.) is left
18+
* untouched.
19+
*/
20+
public final class JavadocSource extends Source {
21+
22+
// First class / interface / enum declaration in the file.
23+
private static final Pattern TOP_LEVEL_TYPE = Pattern.compile(
24+
"(?m)^(\\s*)(?:public\\s+|abstract\\s+|final\\s+|strictfp\\s+)*(?:class|interface|enum)\\s+(\\w+)");
25+
26+
// Field declaration on a single line. Captures the field's first identifier.
27+
// Excludes lines containing '(' so method/constructor declarations are not
28+
// matched. Requires the line to end with ';' or '=' so a partial match in
29+
// the middle of an expression is impossible.
30+
private static final Pattern FIELD_LINE = Pattern.compile(
31+
"^[ \\t]*(?:(?:public|protected|private|static|final|volatile|transient)\\s+)+"
32+
+ "[\\w<>\\[\\]\\.,\\s\\?]+?\\s+(\\w+)\\s*[=;]");
33+
34+
private final JavadocMappings mappings;
35+
36+
public JavadocSource(JavadocMappings mappings) {
37+
this.mappings = mappings;
38+
}
39+
40+
@Override
41+
public void apply(String classNameOnDisk, StringBuilder source) {
42+
if (mappings.isEmpty()) {
43+
return;
44+
}
45+
String internalName = toInternalName(classNameOnDisk);
46+
if (internalName == null) {
47+
return;
48+
}
49+
50+
List<Insertion> insertions = new ArrayList<>();
51+
collectClassDoc(source, internalName, insertions);
52+
collectFieldDocs(source, internalName, insertions);
53+
54+
// Apply back-to-front so earlier offsets stay valid.
55+
for (int i = insertions.size() - 1; i >= 0; i--) {
56+
Insertion ins = insertions.get(i);
57+
source.insert(ins.offset, ins.text);
58+
}
59+
}
60+
61+
private void collectClassDoc(StringBuilder source, String internalName, List<Insertion> out) {
62+
String doc = mappings.getClassDoc(internalName);
63+
if (doc == null || doc.isEmpty()) {
64+
return;
65+
}
66+
Matcher m = TOP_LEVEL_TYPE.matcher(source);
67+
if (!m.find()) {
68+
return;
69+
}
70+
if (hasPrecedingJavadoc(source, m.start())) {
71+
return;
72+
}
73+
out.add(new Insertion(m.start(), formatJavadoc(doc, m.group(1))));
74+
}
75+
76+
private void collectFieldDocs(StringBuilder source, String internalName, List<Insertion> out) {
77+
// Anchor brace tracking just after the top-level type keyword so that
78+
// braces inside class-level annotations cannot be mistaken for the body.
79+
Matcher type = TOP_LEVEL_TYPE.matcher(source);
80+
if (!type.find()) {
81+
return;
82+
}
83+
int scanStart = source.indexOf("{", type.end());
84+
if (scanStart < 0) {
85+
return;
86+
}
87+
int depth = 1; // we are now inside the top-level type body
88+
int len = source.length();
89+
90+
for (int i = scanStart + 1; i < len; i++) {
91+
char c = source.charAt(i);
92+
93+
if (c == '"' || c == '\'') {
94+
i = skipQuoted(source, i, c);
95+
continue;
96+
}
97+
if (c == '/' && i + 1 < len) {
98+
char next = source.charAt(i + 1);
99+
if (next == '/') {
100+
int eol = source.indexOf("\n", i);
101+
if (eol < 0) return;
102+
i = eol;
103+
// fall through so the '\n' branch below runs on next iteration
104+
i--;
105+
continue;
106+
}
107+
if (next == '*') {
108+
int end = source.indexOf("*/", i + 2);
109+
if (end < 0) return;
110+
i = end + 1;
111+
continue;
112+
}
113+
}
114+
115+
if (c == '{') {
116+
depth++;
117+
continue;
118+
}
119+
if (c == '}') {
120+
depth--;
121+
if (depth == 0) {
122+
return; // closed the top-level type body
123+
}
124+
continue;
125+
}
126+
if (c == '\n') {
127+
if (depth == 1) {
128+
int eol = source.indexOf("\n", i + 1);
129+
if (eol < 0) eol = len;
130+
tryFieldOnLine(source, i + 1, eol, internalName, out);
131+
}
132+
}
133+
}
134+
}
135+
136+
private void tryFieldOnLine(StringBuilder source, int start, int end, String internalName, List<Insertion> out) {
137+
String line = source.substring(start, end);
138+
// Cheap reject: method/constructor declarations contain '('.
139+
if (line.indexOf('(') >= 0) {
140+
return;
141+
}
142+
Matcher m = FIELD_LINE.matcher(line);
143+
if (!m.find()) {
144+
return;
145+
}
146+
String fieldName = m.group(1);
147+
String doc = mappings.getFieldDoc(internalName, fieldName);
148+
if (doc == null || doc.isEmpty()) {
149+
return;
150+
}
151+
if (hasPrecedingJavadoc(source, start)) {
152+
return;
153+
}
154+
String indent = leadingWhitespace(line);
155+
out.add(new Insertion(start, formatJavadoc(doc, indent)));
156+
}
157+
158+
private static int skipQuoted(StringBuilder source, int start, char quote) {
159+
int len = source.length();
160+
for (int i = start + 1; i < len; i++) {
161+
char c = source.charAt(i);
162+
if (c == '\\') {
163+
i++;
164+
continue;
165+
}
166+
if (c == quote) {
167+
return i;
168+
}
169+
if (c == '\n') {
170+
// Unterminated literal; bail out at end of line.
171+
return i - 1;
172+
}
173+
}
174+
return len - 1;
175+
}
176+
177+
private static String leadingWhitespace(String line) {
178+
int i = 0;
179+
while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) {
180+
i++;
181+
}
182+
return line.substring(0, i);
183+
}
184+
185+
private static boolean hasPrecedingJavadoc(StringBuilder source, int pos) {
186+
int i = pos - 1;
187+
while (i >= 0 && Character.isWhitespace(source.charAt(i))) {
188+
i--;
189+
}
190+
return i >= 1 && source.charAt(i - 1) == '*' && source.charAt(i) == '/';
191+
}
192+
193+
private static String formatJavadoc(String text, String indent) {
194+
StringBuilder out = new StringBuilder();
195+
out.append(indent).append("/**\n");
196+
for (String line : text.split("\\r?\\n", -1)) {
197+
out.append(indent).append(" * ").append(line).append('\n');
198+
}
199+
out.append(indent).append(" */\n");
200+
return out.toString();
201+
}
202+
203+
/**
204+
* Converts the path-like file identifier passed by {@link Source#modify} into
205+
* the internal JVM class name used as the mapping key.
206+
*
207+
* <p>{@code Source.modify} hands us the file path with the {@code .java}
208+
* extension stripped, e.g. {@code .../src_original/net/minecraft/src/Block}.
209+
* Mappings key classes by internal name ({@code net/minecraft/src/Block}).
210+
*/
211+
static String toInternalName(String classNameOnDisk) {
212+
if (classNameOnDisk == null) {
213+
return null;
214+
}
215+
String normalised = classNameOnDisk.replace('\\', '/');
216+
int marker = normalised.lastIndexOf("/net/minecraft/");
217+
if (marker >= 0) {
218+
return normalised.substring(marker + 1);
219+
}
220+
int lastSlash = normalised.lastIndexOf('/');
221+
if (lastSlash <= 0) {
222+
return normalised;
223+
}
224+
int prevSlash = normalised.lastIndexOf('/', lastSlash - 1);
225+
return prevSlash < 0 ? normalised : normalised.substring(prevSlash + 1);
226+
}
227+
228+
private static final class Insertion {
229+
final int offset;
230+
final String text;
231+
232+
Insertion(int offset, String text) {
233+
this.offset = offset;
234+
this.text = text;
235+
}
236+
}
237+
}

0 commit comments

Comments
 (0)