GoCommentRedactor.java

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.discovery.go;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

import org.antlr.v4.runtime.BaseErrorListener;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.RecognitionException;
import org.antlr.v4.runtime.Recognizer;
import org.antlr.v4.runtime.Token;
import org.egothor.methodatlas.api.CommentRedactionException;
import org.egothor.methodatlas.api.CommentRedactor;
import org.egothor.methodatlas.discovery.go.parser.GoTestLexer;
import org.egothor.methodatlas.util.CommentBlanker;

/**
 * {@link CommentRedactor} for Go source text, built on the ANTLR lexer of the
 * Go discovery plugin.
 *
 * <p>
 * Go has exactly two comment forms, line comments (introduced by two slashes)
 * and general comments (delimited by slash-star and star-slash); the grammar's
 * {@code LINE_COMMENT} and {@code BLOCK_COMMENT} rules recognise both. Every such token is replaced
 * by U+0020 spaces; line terminators inside it are kept, so the result has the
 * same length, line structure and token offsets as the input. Interpreted
 * strings, raw strings and rune literals are lexed as single tokens, so comment
 * markers inside them are never touched.
 * </p>
 *
 * <p>
 * The redactor fails closed. A {@link CommentRedactionException} is thrown if
 * the lexer reports an error, if a quote or a slash-star sequence is left
 * unrecognised (an unterminated string, rune or comment, which could hide a
 * comment from the lexer), or if a comment token survives in the result
 * (checked by lexing the result again).
 * </p>
 *
 * <p>
 * Instances are stateless and safe for concurrent use: a fresh lexer is created
 * for every call.
 * </p>
 *
 * @see CommentRedactor
 * @since 4.1.0
 */
public final class GoCommentRedactor implements CommentRedactor {

    private static final String GO_SUFFIX = ".go";

    /**
     * Creates a redactor; required by {@link java.util.ServiceLoader}.
     * It accepts every file whose name ends in {@code .go}.
     */
    public GoCommentRedactor() {
        // Required by ServiceLoader
    }

    /**
     * Returns the identifier of this redactor, equal to that of
     * {@link GoTestDiscovery#pluginId()}.
     *
     * @return {@code "go"}
     */
    @Override
    public String pluginId() {
        return "go";
    }

    /**
     * Tells whether {@code sourceFile} is Go source: its name ends in
     * {@code .go} (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;
        }
        return sourceFile.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(GO_SUFFIX);
    }

    /**
     * Replaces every comment in {@code source} with spaces.
     *
     * @param sourceFile the file the text belongs to; used for diagnostics only
     * @param source     Go source text; 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 lexed
     *                                   unambiguously or the redaction cannot
     *                                   be guaranteed complete
     */
    @Override
    public String redact(Path sourceFile, String source) throws CommentRedactionException {
        List<Token> comments = lexComments(sourceFile, source);
        CommentBlanker blanker = new CommentBlanker(source);
        for (Token comment : comments) {
            blanker.blank(comment.getStartIndex(), comment.getStopIndex());
        }
        String result = blanker.result();
        if (!comments.isEmpty() && !lexComments(sourceFile, result).isEmpty()) {
            throw new CommentRedactionException("a comment survived redaction in " + sourceFile);
        }
        return result;
    }

    /**
     * Lexes {@code text} and returns its comment tokens after checking that
     * nothing the lexer could not classify may hide a comment.
     */
    private static List<Token> lexComments(Path sourceFile, String text) throws CommentRedactionException {
        List<String> lexerErrors = new ArrayList<>();
        GoTestLexer lexer = new GoTestLexer(CharStreams.fromString(text));
        lexer.removeErrorListeners();
        lexer.addErrorListener(new BaseErrorListener() {
            @Override
            public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line,
                    int charPositionInLine, String msg, RecognitionException e) {
                lexerErrors.add(line + ":" + charPositionInLine + " " + msg);
            }
        });
        CommonTokenStream stream = new CommonTokenStream(lexer);
        stream.fill();
        if (!lexerErrors.isEmpty()) {
            throw new CommentRedactionException("cannot lex " + sourceFile + ": " + lexerErrors.get(0));
        }
        List<Token> comments = new ArrayList<>();
        List<Token> tokens = stream.getTokens();
        for (int i = 0; i < tokens.size(); i++) {
            Token token = tokens.get(i);
            int type = token.getType();
            if (type == GoTestLexer.LINE_COMMENT || type == GoTestLexer.BLOCK_COMMENT) {
                comments.add(token);
            } else if (type == GoTestLexer.OTHER) {
                rejectUnrecognisedOpener(sourceFile, token, i + 1 < tokens.size() ? tokens.get(i + 1) : null);
            }
        }
        return comments;
    }

    /**
     * Fails if an unrecognised character could be the start of a string, rune
     * or comment the lexer did not understand; text after it may then be
     * misclassified.
     */
    private static void rejectUnrecognisedOpener(Path sourceFile, Token other, Token next)
            throws CommentRedactionException {
        String text = other.getText();
        boolean quote = "\"".equals(text) || "'".equals(text) || "`".equals(text);
        boolean blockOpener = "/".equals(text) && next != null && next.getType() == GoTestLexer.STAR
                && next.getStartIndex() == other.getStopIndex() + 1;
        if (quote || blockOpener) {
            throw new CommentRedactionException("unterminated or unrecognised literal or comment in "
                    + sourceFile + " at line " + other.getLine());
        }
    }
}