Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/java/org/mcphackers/mcp/tasks/TaskDecompile.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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"))) {
Expand Down
121 changes: 121 additions & 0 deletions src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocMappings.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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}) -&gt; Javadoc text. */
private final Map<String, String> classDocs;

/** {@code internal/Class.fieldName} -&gt; Javadoc text. */
private final Map<String, String> fieldDocs;

private JavadocMappings(Map<String, String> classDocs, Map<String, String> 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<String, String> classDocs = new HashMap<>();
Map<String, String> fieldDocs = new HashMap<>();
Set<String> 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);
}
}
237 changes: 237 additions & 0 deletions src/main/java/org/mcphackers/mcp/tools/javadoc/JavadocSource.java
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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<Insertion> 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<Insertion> 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<Insertion> 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<Insertion> 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.
*
* <p>{@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;
}
}
}