DotNetCommentRedactor.java

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

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

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.dotnet.parser.CSharpTestLexer;
import org.egothor.methodatlas.util.CommentBlanker;

/**
 * {@link CommentRedactor} for C# source text, built on the ANTLR lexer of the
 * .NET discovery plugin.
 *
 * <p>
 * C# has two comment forms: single-line comments (introduced by two slashes,
 * which includes the three-slash XML documentation comments) and delimited
 * comments (slash-star to 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. Regular,
 * verbatim, interpolated and raw string literals and character literals are
 * lexed as single tokens, so comment markers inside them are never touched.
 * </p>
 *
 * <p>
 * Preprocessor directives are not comments and keep their keyword and operands
 * (for example {@code #if DEBUG}, {@code #pragma warning disable CS1591}, {@code #define},
 * {@code #nullable}, and the file name of {@code #line} or {@code #pragma checksum}), because
 * they affect which code is compiled. The free text of {@code #region}/{@code #endregion}
 * (the region name) and of {@code #error}/{@code #warning} (the message) is blanked, keeping only
 * the keyword: it is prose that carries no classification signal and may hold personal
 * data. A single-line comment that trails any other directive (for example
 * {@code #if DEBUG // note}) is a comment and is blanked.
 * </p>
 *
 * <p>
 * The redactor fails closed. A {@link CommentRedactionException} is thrown if
 * the lexer reports an error (an unterminated or unrecognised literal); if a
 * delimited comment is never closed; if a
 * delimited comment trails a directive; if an interpolated string contains a
 * comment marker inside an interpolation hole, or a raw string uses four or
 * more quotes as delimiter (neither is modelled by the lexer, so a comment in
 * them could be missed); or if a comment 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 DotNetCommentRedactor implements CommentRedactor {

    private static final String CS_SUFFIX = ".cs";
    /** Directives whose text up to the end of the line is free text, never a comment. */
    private static final Set<String> FREE_TEXT_DIRECTIVES = Set.of("region", "endregion", "error", "warning");
    private static final String RAW_DELIMITER = "\"\"\"";
    private static final char QUOTE = '"';
    private static final char OPEN_BRACE = '{';
    private static final char CLOSE_BRACE = '}';

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

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

    /**
     * Tells whether {@code sourceFile} is C# source: its name ends in
     * {@code .cs} (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(CS_SUFFIX);
    }

    /**
     * Replaces every comment in {@code source} with spaces.
     *
     * @param sourceFile the file the text belongs to; used for diagnostics only
     * @param source     C# 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<int[]> spans = lexComments(sourceFile, source);
        CommentBlanker blanker = new CommentBlanker(source);
        for (int[] span : spans) {
            blanker.blankChars(span[0], span[1]);
        }
        String result = blanker.result();
        if (!spans.isEmpty() && !lexComments(sourceFile, result).isEmpty()) {
            throw new CommentRedactionException("a comment survived redaction in " + sourceFile);
        }
        return result;
    }

    /**
     * Lexes {@code text} and returns the {@code [from, to)} {@code char} ranges
     * of every comment, after checking that nothing the lexer could not
     * classify may hide a comment.
     */
    private static List<int[]> lexComments(Path sourceFile, String text) throws CommentRedactionException {
        List<String> lexerErrors = new ArrayList<>();
        CSharpTestLexer lexer = new CSharpTestLexer(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));
        }
        CommentBlanker positions = new CommentBlanker(text);
        List<int[]> spans = new ArrayList<>();
        for (Token token : stream.getTokens()) {
            int type = token.getType();
            int from = positions.charIndex(token.getStartIndex());
            int to = positions.charIndex(token.getStopIndex() + 1);
            if (type == CSharpTestLexer.LINE_COMMENT || type == CSharpTestLexer.BLOCK_COMMENT) {
                spans.add(new int[] {from, to});
            } else if (type == CSharpTestLexer.PREPROCESSOR) {
                int comment = directiveCommentStart(sourceFile, token);
                if (comment >= 0) {
                    spans.add(new int[] {from + comment, to});
                }
            } else if (type == CSharpTestLexer.DIV && to < text.length() && text.charAt(to) == '*') {
                throw new CommentRedactionException("unterminated delimited comment in " + sourceFile
                        + " at line " + token.getLine());
            } else if (type == CSharpTestLexer.RAW_STRING) {
                rejectLongRawDelimiter(sourceFile, token, text, to);
            } else if (type == CSharpTestLexer.INTERPOLATED_STRING) {
                rejectLongRawDelimiter(sourceFile, token, text, to);
                rejectCommentInHole(sourceFile, token);
            }
        }
        return spans;
    }

    /**
     * Returns the offset within a directive token from which text is blanked to
     * the end of the token, or {@code -1} if nothing is blanked: the free text of
     * {@code #region}/{@code #endregion}/{@code #error}/{@code #warning}, or the trailing
     * single-line comment of any other directive.
     */
    private static int directiveCommentStart(Path sourceFile, Token directive) throws CommentRedactionException {
        String text = directive.getText();
        int i = 1;
        while (i < text.length() && Character.isWhitespace(text.charAt(i))) {
            i++;
        }
        int nameStart = i;
        while (i < text.length() && Character.isLetter(text.charAt(i))) {
            i++;
        }
        if (FREE_TEXT_DIRECTIVES.contains(text.substring(nameStart, i))) {
            // Keyword kept, free text blanked (it carries no classification signal and may hold
            // personal data); an already blank remainder yields no span, which keeps redact idempotent.
            return text.substring(i).isBlank() ? -1 : i;
        }
        boolean quoted = false;
        for (; i + 1 < text.length(); i++) {
            char c = text.charAt(i);
            if (c == QUOTE) {
                quoted = !quoted;
            } else if (!quoted && c == '/' && text.charAt(i + 1) == '/') {
                return i;
            } else if (!quoted && c == '/' && text.charAt(i + 1) == '*') {
                throw new CommentRedactionException("delimited comment in a preprocessor directive in "
                        + sourceFile + " at line " + directive.getLine());
            }
        }
        return -1;
    }

    /**
     * Fails for a raw string that opens or closes with four or more quotes: the
     * lexer ends the token at the first three, so the rest would be lexed as code.
     */
    private static void rejectLongRawDelimiter(Path sourceFile, Token token, String text, int tokenEnd)
            throws CommentRedactionException {
        String body = token.getText();
        int q = 0;
        while (q < body.length() && body.charAt(q) == '$') {
            q++;
        }
        if (!body.startsWith(RAW_DELIMITER, q)) {
            return;
        }
        boolean longOpener = body.length() > q + RAW_DELIMITER.length()
                && body.charAt(q + RAW_DELIMITER.length()) == '"';
        boolean longCloser = tokenEnd < text.length() && text.charAt(tokenEnd) == '"';
        if (longOpener || longCloser) {
            throw new CommentRedactionException("raw string with four or more quote delimiters in "
                    + sourceFile + " at line " + token.getLine());
        }
    }

    /**
     * Fails if a comment marker appears inside an interpolation hole of an
     * interpolated string: the lexer treats the whole string as one token, so
     * a real comment in a hole would not be found. Markers in the literal part
     * (for example a URL) are fine. Braces are counted individually, which is
     * conservative for escaped and raw-string braces.
     */
    private static void rejectCommentInHole(Path sourceFile, Token token) throws CommentRedactionException {
        String body = token.getText();
        int depth = 0;
        for (int i = 0; i < body.length(); i++) {
            char c = body.charAt(i);
            if (c == OPEN_BRACE) {
                depth++;
            } else if (c == CLOSE_BRACE) {
                depth--;
            } else if (depth > 0 && c == '/' && i + 1 < body.length()
                    && (body.charAt(i + 1) == '/' || body.charAt(i + 1) == '*')) {
                throw new CommentRedactionException("comment marker inside an interpolation hole in "
                        + sourceFile + " at line " + token.getLine());
            }
        }
    }
}