JavaCommentRedactor.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.discovery.jvm;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.egothor.methodatlas.api.CommentRedactionException;
import org.egothor.methodatlas.api.CommentRedactor;
import com.github.javaparser.JavaParser;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.ParserConfiguration.LanguageLevel;
import com.github.javaparser.Range;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.comments.Comment;
/**
* {@link CommentRedactor} for Java source text, built on JavaParser.
*
* <p>
* Every comment (Javadoc, block, line, including orphan and inline comments) is
* replaced by U+0020 spaces. Line terminators inside comments are kept, so the
* result has exactly the same length, the same line structure and the same
* offset for every non-comment token as the input. String literals and text
* blocks are never touched because comments are located by the parser, not by
* scanning for {@code //} or {@code /*} sequences.
* </p>
*
* <p>
* The redactor fails closed: if the text does not parse, if the parser reports
* a comment without a position, if a reported position does not delimit a
* comment in the text, or if a comment survives in the result (checked by
* parsing the result again), a {@link CommentRedactionException} is thrown and
* no text is returned.
* </p>
*
* <p>
* Instances are stateless and safe for concurrent use: a fresh {@link JavaParser} is created for every call.
* </p>
*
* @see CommentRedactor
* @since 4.1.0
*/
public final class JavaCommentRedactor implements CommentRedactor {
private static final String JAVA_SUFFIX = ".java";
private static final String LINE_COMMENT_START = "//";
private static final String BLOCK_COMMENT_START = "/*";
private static final String BLOCK_COMMENT_END = "*/";
/**
* Creates a redactor; required by {@link java.util.ServiceLoader}.
* It accepts every file whose name ends in {@code .java}.
*/
public JavaCommentRedactor() {
// Required by ServiceLoader
}
/**
* Returns the identifier of this redactor, equal to that of
* {@link JavaTestDiscovery#pluginId()}.
*
* @return {@code "java"}
*/
@Override
public String pluginId() {
return "java";
}
/**
* Tells whether {@code sourceFile} is Java source: its name ends in
* {@code .java} (case-insensitive). Configured suffixes are deliberately not
* consulted: unscoped suffixes are shared by all plugins and would let this
* redactor claim another language's files.
*
* @param sourceFile path to test; may be {@code null}
* @return {@code true} if this redactor handles the file
*/
@Override
public boolean supports(Path sourceFile) {
if (sourceFile == null || sourceFile.getFileName() == null) {
return false;
}
String name = sourceFile.getFileName().toString();
return name.toLowerCase(Locale.ROOT).endsWith(JAVA_SUFFIX);
}
/**
* Replaces every comment in {@code source} with spaces.
*
* @param sourceFile the file the text belongs to; used for diagnostics only
* @param source Java source text (a compilation unit or a type
* declaration); must not be {@code null}
* @return text of identical length in which every comment character other
* than a line terminator is a space; never {@code null}
* @throws CommentRedactionException if the text cannot be parsed or the
* redaction cannot be guaranteed complete
*/
@Override
public String redact(Path sourceFile, String source) throws CommentRedactionException {
List<Comment> comments = parseComments(sourceFile, source);
char[] chars = source.toCharArray();
if (!comments.isEmpty()) {
int[] lineStarts = lineStarts(source);
for (Comment comment : comments) {
blank(sourceFile, source, chars, lineStarts, comment);
}
}
String result = new String(chars);
if (result.length() != source.length()) {
throw new CommentRedactionException("internal error: redaction changed the length of " + sourceFile);
}
if (!comments.isEmpty() && !parseComments(sourceFile, result).isEmpty()) {
throw new CommentRedactionException("a comment survived redaction in " + sourceFile);
}
return result;
}
private static List<Comment> parseComments(Path sourceFile, String text) throws CommentRedactionException {
ParserConfiguration cfg = new ParserConfiguration();
cfg.setLanguageLevel(LanguageLevel.JAVA_21);
ParseResult<CompilationUnit> parsed = new JavaParser(cfg).parse(text);
Optional<CompilationUnit> unit = parsed.getResult();
if (!parsed.isSuccessful() || unit.isEmpty()) {
throw new CommentRedactionException("cannot parse " + sourceFile + " to locate comments: "
+ parsed.getProblems());
}
List<Comment> all = new ArrayList<>();
parsed.getCommentsCollection().ifPresent(c -> all.addAll(c.getComments()));
// The tree view is merged in as a second source: a comment known to the AST
// must never be missed even if the lexer-level collection omitted it.
unit.get().getComment().ifPresent(all::add);
all.addAll(unit.get().getAllContainedComments());
// Node equality is structural, so de-duplicate by position, never by equals().
Map<Optional<Range>, Comment> byRange = new LinkedHashMap<>();
List<Comment> comments = new ArrayList<>();
for (Comment comment : all) {
if (comment.getRange().isEmpty()) {
comments.add(comment);
} else {
byRange.putIfAbsent(comment.getRange(), comment);
}
}
comments.addAll(byRange.values());
return comments;
}
private static void blank(Path sourceFile, String source, char[] chars, int[] lineStarts, Comment comment)
throws CommentRedactionException {
Optional<Range> range = comment.getRange();
if (range.isEmpty()) {
throw new CommentRedactionException("comment without a source position in " + sourceFile);
}
int begin = offset(lineStarts, source.length(), range.get().begin.line, range.get().begin.column);
int end = offset(lineStarts, source.length(), range.get().end.line, range.get().end.column);
if (begin < 0 || end < begin || !delimitsComment(source, begin, end)) {
throw new CommentRedactionException("comment position does not match the text of " + sourceFile
+ " at line " + range.get().begin.line);
}
for (int i = begin; i <= end; i++) {
if (chars[i] != '\n' && chars[i] != '\r') {
chars[i] = ' ';
}
}
}
private static boolean delimitsComment(String source, int begin, int end) {
boolean block = source.startsWith(BLOCK_COMMENT_START, begin) && end - begin >= 3
&& source.startsWith(BLOCK_COMMENT_END, end - 1);
return source.startsWith(LINE_COMMENT_START, begin) || block;
}
/** Returns the char offset of a 1-based line/column, or -1 when it lies outside the text. */
private static int offset(int[] lineStarts, int length, int line, int column) {
if (line < 1 || line > lineStarts.length || column < 1) {
return -1;
}
long offset = (long) lineStarts[line - 1] + column - 1;
return offset < length ? (int) offset : -1;
}
/** Offsets of the first char of every line; a lone CR, LF or CRLF each ends one line. */
private static int[] lineStarts(String source) {
List<Integer> starts = new ArrayList<>();
starts.add(0);
for (int i = 0; i < source.length(); i++) {
char c = source.charAt(i);
boolean crlf = c == '\r' && i + 1 < source.length() && source.charAt(i + 1) == '\n';
if (c == '\n' || (c == '\r' && !crlf)) {
starts.add(i + 1);
}
}
return starts.stream().mapToInt(Integer::intValue).toArray();
}
}