diff --git a/maddi-cst-api/src/main/java/io/codelaser/maddi/cst/api/element/DetailedSources.java b/maddi-cst-api/src/main/java/io/codelaser/maddi/cst/api/element/DetailedSources.java index 1179d22fe..d197c96f3 100644 --- a/maddi-cst-api/src/main/java/io/codelaser/maddi/cst/api/element/DetailedSources.java +++ b/maddi-cst-api/src/main/java/io/codelaser/maddi/cst/api/element/DetailedSources.java @@ -73,6 +73,14 @@ public interface DetailedSources { * Source range spanning the entire field declaration (type + name + initialiser). */ Object FIELD_DECLARATION = new Object(); + /** + * Source range of a javadoc comment as written, from its opening {@code /**} through its closing + * {@code */}, set on the {@link JavaDoc}'s own source. That source spans the comment's TEXT only, and + * consumers placing a word inside the comment count from it. This range is for a consumer that removes or moves + * the comment itself and needs its first character -- which it cannot infer when the line also holds code, as + * in {@code /** the second */ B,}. + */ + Object WHOLE_COMMENT = new Object(); /** * Position of the comma that precedes an element in a comma-separated list. * Computed for formal type parameter lists, formal method parameter lists, field declaration lists, and diff --git a/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/ScanCompilationUnit.java b/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/ScanCompilationUnit.java index 9ffb30dd9..bb91beb92 100644 --- a/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/ScanCompilationUnit.java +++ b/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/ScanCompilationUnit.java @@ -117,7 +117,8 @@ private record BlockData(Block.Builder blockBuilder, String index, int numberOfS classSymbolScanner.syntheticListField()); DocSourcePositions docSourcePositions = docTrees.getSourcePositions(); - this.scanJavaDoc = new ScanJavaDoc(runtime, typeData, docSourcePositions, compilationUnitTree, lineMap); + this.scanJavaDoc = new ScanJavaDoc(runtime, typeData, docSourcePositions, compilationUnitTree, lineMap, + new ScanJavaDoc.SourceText(compilationUnitTree)); convertType = classSymbolScanner; convertType.startCompilationUnit(this, elementStack); diff --git a/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/ScanJavaDoc.java b/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/ScanJavaDoc.java index e8ccbaa9d..31c3efc98 100644 --- a/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/ScanJavaDoc.java +++ b/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/ScanJavaDoc.java @@ -7,11 +7,14 @@ import com.sun.source.util.DocTreeScanner; import com.sun.tools.javac.tree.DCTree; import com.sun.tools.javac.util.JCDiagnostic; +import io.codelaser.maddi.cst.api.element.DetailedSources; import io.codelaser.maddi.cst.api.element.JavaDoc; import io.codelaser.maddi.cst.api.element.Source; import io.codelaser.maddi.cst.api.runtime.Runtime; +import org.jetbrains.annotations.Nullable; import javax.tools.Diagnostic; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -20,16 +23,69 @@ public record ScanJavaDoc(Runtime runtime, TypeData typeData, DocSourcePositions docSourcePositions, CompilationUnitTree compilationUnitTree, - LineMap lineMap) { + LineMap lineMap, + SourceText sourceText) { public JavaDoc scan(DocCommentTree docCommentTree) { MyScanner myScanner = new MyScanner(docCommentTree); Source source = myScanner.source(docCommentTree); if (source == runtime.noSource()) source = rawCommentSource(docCommentTree); + Source wholeComment = wholeCommentSource(docCommentTree); + if (wholeComment != null && source != runtime.noSource()) { + source = source.withDetailedSources(runtime.newDetailedSourcesBuilder() + .put(DetailedSources.WHOLE_COMMENT, wholeComment).build()); + } myScanner.scan(docCommentTree, null); return runtime.newJavaDoc(source, myScanner.comment.toString(), List.copyOf(myScanner.tags)); } + /** + * The text of the compilation unit, read once and only when a javadoc asks for it: {@link #scan} runs for every + * javadoc of the unit, and the file object may go back to the disk on each read. + */ + static final class SourceText { + private final CompilationUnitTree compilationUnitTree; + private String text; + private boolean read; + + SourceText(CompilationUnitTree compilationUnitTree) { + this.compilationUnitTree = compilationUnitTree; + } + + @Nullable String get() { + if (!read) { + read = true; + try { + text = compilationUnitTree.getSourceFile().getCharContent(true).toString(); + } catch (IOException | RuntimeException e) { + text = null; + } + } + return text; + } + } + + /** + * The comment as written, {@code /**} through {@code */} (see {@link DetailedSources#WHOLE_COMMENT}). The + * tokenizer's comment knows where its {@code /**} is, as in {@link #rawCommentSource}; the closing + * {@code */} is the first one after it, since a comment cannot contain one. Null when either end cannot be + * found. + */ + private @Nullable Source wholeCommentSource(DocCommentTree docCommentTree) { + if (!(docCommentTree instanceof DCTree.DCDocComment dc) || dc.comment == null) return null; + JCDiagnostic.DiagnosticPosition pos = dc.comment.getPos(); + if (pos == null) return null; + int start = pos.getStartPosition(); + if (start == Diagnostic.NOPOS) return null; + String text = sourceText.get(); + if (text == null || !text.startsWith("/**", start)) return null; + int close = text.indexOf("*/", start + 3); + if (close < 0) return null; + int lastChar = close + 1; // inclusive: the '/' of '*/' + return runtime.newParserSource("-", (int) lineMap.getLineNumber(start), (int) lineMap.getColumnNumber(start), + (int) lineMap.getLineNumber(lastChar), (int) lineMap.getColumnNumber(lastChar)); + } + /** * #31: javac positions a doc comment tree by its BODY, so an empty {@code /** */} has no position at all, * and the JavaDoc read as line 0 to every consumer placing something relative to a member's comments. The diff --git a/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/SourceCodeScan.java b/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/SourceCodeScan.java index 75861dd39..cd0e816b8 100644 --- a/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/SourceCodeScan.java +++ b/maddi-java-openjdk/src/main/java/io/codelaser/maddi/java/openjdk/SourceCodeScan.java @@ -370,12 +370,41 @@ private void scanTypeBody(Node body) { case CompactConstructorDeclaration ccd -> scanMethodDeclaration(ccd); case MethodDeclaration _, AnnotationMethodDeclaration _ -> scanMethodDeclaration(node2); case FieldDeclaration fd -> scanFieldDeclaration(fd); + case EnumConstant ec -> scanEnumConstant(ec); default -> { } } } } + /** + * ⛔ AN ENUM CONSTANT HAD NO COMMENTS AT ALL. This switch had no case for it, so a {@code //} or {@code /* */} + * comment in front of a constant was recorded for nothing, and a consumer removing the constant could not tell + * that the comment belonged to it: removing B from + *
+     *     // the first
+     *     A,
+     *     // the second
+     *     B,
+     *     // the third
+     *     C;
+     * 
+ * deleted from B's name to C's name, which took "// the third" and left "// the second" above C. + *

+ * A single-line comment after the constant on its last line -- {@code B, // the second} -- belongs to B as + * well. The comma (or the ';' that ends the list) stands between B and that comment, so the lookahead looks + * past it: the comment is a preceding token of whatever comes after the separator, and without this the next + * constant takes it as its own leading comment. + */ + private void scanEnumConstant(EnumConstant ec) { + Node after = ec.nextSibling(); + if (after != null && (after.getType() == Token.TokenType.COMMA + || after.getType() == Token.TokenType.SEMICOLON)) { + after = after.nextSibling(); + } + addComments(ec, after); + } + private void scanModifiers(Modifiers modifiers) { for (Node node : modifiers.children()) { String string = node.getSource(); @@ -689,19 +718,24 @@ private void visit(Node node, Predicate test) { */ private void addComments(Node node, boolean lookahead) { + addComments(node, lookahead ? node.nextSibling() : null); + } + + /** + * @param nextSibling the node whose preceding comments are searched for a single-line comment on the last line + * of {@code node}; null for no lookahead + */ + private void addComments(Node node, @Nullable Node nextSibling) { Source source = source(node); - if (lookahead) { - Node nextSibling = node.nextSibling(); - if (nextSibling != null) { - List comments = comments(nextSibling) - .filter(c -> c.source().endLine() == c.source().beginLine() - && c.source().beginLine() == source.endLine()) - .filter(c -> seen.add(c.source())) - .toList(); - if (!comments.isEmpty()) { - List prev = this.comments.put(source, comments); - assert prev == null; - } + if (nextSibling != null) { + List comments = comments(nextSibling) + .filter(c -> c.source().endLine() == c.source().beginLine() + && c.source().beginLine() == source.endLine()) + .filter(c -> seen.add(c.source())) + .toList(); + if (!comments.isEmpty()) { + List prev = this.comments.put(source, comments); + assert prev == null; } } List comments = comments(node).filter(c -> seen.add(c.source())).toList(); diff --git a/maddi-java-openjdk/src/test/java/io/codelaser/maddi/java/openjdk/other/TestEnumConstantComments.java b/maddi-java-openjdk/src/test/java/io/codelaser/maddi/java/openjdk/other/TestEnumConstantComments.java new file mode 100644 index 000000000..4fbb1addf --- /dev/null +++ b/maddi-java-openjdk/src/test/java/io/codelaser/maddi/java/openjdk/other/TestEnumConstantComments.java @@ -0,0 +1,97 @@ +/* + * maddi: a modification analyzer for duplication detection and immutability. + * Copyright 2020-2025, Bart Naudts, https://github.com/CodeLaser/maddi + * + * This program is free software: you can redistribute it and/or modify it under the + * terms of the GNU Lesser General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for + * more details. You should have received a copy of the GNU Lesser General Public + * License along with this program. If not, see . + */ + +package io.codelaser.maddi.java.openjdk.other; + +import io.codelaser.maddi.cst.api.element.Comment; +import io.codelaser.maddi.cst.api.element.DetailedSources; +import io.codelaser.maddi.cst.api.element.Source; +import io.codelaser.maddi.cst.api.info.TypeInfo; +import io.codelaser.maddi.java.openjdk.CommonTest; +import org.intellij.lang.annotations.Language; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * An enum constant carries the comments written for it, as a field declaration does: a comment above it, and a + * single-line comment after it on its own line. Until this test the comment pre-scan had no case for an enum + * constant, so both were recorded for nothing, and a consumer removing a constant could not tell which comment went + * with it. + *

+ * And a javadoc carries where it is WRITTEN, {@code /**} through {@code */}, as + * {@link DetailedSources.WHOLE_COMMENT} on its source. The javadoc's own source spans its text only, so on a line + * shared with code -- {@code /** the second */ B,} -- nothing said where the comment began. + */ +public class TestEnumConstantComments extends CommonTest { + + @Language("java") + private static final String INPUT = """ + package a.b; + public enum E { + // above A + A, // after A + /* above B */ + B, + /** before C */ C, + /** + * Above D. + */ + D; // after D + } + """; + + @DisplayName("each enum constant has the comments written for it, and a javadoc knows where it is written") + @Test + public void test() { + TypeInfo e = scan("a.b.E", INPUT); + + assertEquals("[SingleLineComment at 3:5, SingleLineComment at 4:8]", comments(e, "A"), + "A: '// above A' on the line above it (line 3, column 5), then '// after A' after it on its own" + + " line (line 4, column 8)"); + assertEquals("[MultiLineComment at 5:5]", comments(e, "B"), + "B: '/* above B */' on the line above it (line 5, column 5), and not A's '// after A'"); + assertEquals("[JavaDoc at 7:9]", comments(e, "C"), + "C: its javadoc, whose source starts at its text 'before C' (line 7, column 9)"); + assertEquals("[JavaDoc at 9:8, SingleLineComment at 11:8]", comments(e, "D"), + "D: its javadoc block, whose source starts at its text 'Above D.' (line 9, column 8), then" + + " '// after D' after the ';' on its line (line 11, column 8)"); + + assertEquals("7:5-7:19", wholeComment(e, "C"), + "C's javadoc as written: '/** before C */' runs from column 5 to column 19 of line 7"); + assertEquals("8:5-10:7", wholeComment(e, "D"), + "D's javadoc as written: '/**' at line 8 column 5 through '*/' at line 10 columns 6-7"); + } + + /** Each comment of the constant as its kind and the line:column where its source begins. */ + private static String comments(TypeInfo e, String constant) { + List comments = e.getFieldByName(constant, true).comments(); + return comments.stream() + .map(c -> c.getClass().getSimpleName().replace("Impl", "") + + " at " + c.source().beginLine() + ":" + c.source().beginPos()) + .toList().toString(); + } + + private static String wholeComment(TypeInfo e, String constant) { + Source source = e.getFieldByName(constant, true).javaDoc().source(); + return position(source.detailedSources().detail(DetailedSources.WHOLE_COMMENT)); + } + + private static String position(Source s) { + return s.beginLine() + ":" + s.beginPos() + "-" + s.endLine() + ":" + s.endPos(); + } +}