CommentBlanker.java

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

import org.egothor.methodatlas.api.CommentRedactionException;

/**
 * Overwrites ranges of a source text with U+0020 spaces while keeping line
 * terminators, so the result has exactly the length and line structure of the
 * input.
 *
 * <p>
 * Lexers built on ANTLR's {@code CharStreams.fromString} address the input in
 * <em>code points</em>, whereas Java strings are indexed in UTF-16 {@code char}
 * units. This class accepts code-point ranges (as reported by
 * {@code Token.getStartIndex()} / {@code Token.getStopIndex()}) and translates
 * them, so a supplementary character (an emoji in a string, for example) before
 * a comment cannot shift the redaction.
 * </p>
 *
 * <p>
 * Instances are single-use and not thread-safe.
 * </p>
 *
 * @since 4.1.0
 */
public final class CommentBlanker {

    private final char[] chars;
    /** Char index of every code point; {@code null} when the text has no surrogate pairs. */
    private final int[] charIndexOfCodePoint;
    private final int codePointCount;

    /**
     * Prepares to blank ranges of {@code source}.
     *
     * @param source text whose comments are to be blanked; must not be
     *               {@code null}
     */
    public CommentBlanker(String source) {
        this.chars = source.toCharArray();
        this.codePointCount = source.codePointCount(0, source.length());
        if (codePointCount == source.length()) {
            this.charIndexOfCodePoint = null;
        } else {
            this.charIndexOfCodePoint = new int[codePointCount + 1];
            int charIndex = 0;
            for (int cp = 0; cp < codePointCount; cp++) {
                charIndexOfCodePoint[cp] = charIndex;
                charIndex += Character.charCount(source.codePointAt(charIndex));
            }
            charIndexOfCodePoint[codePointCount] = source.length();
        }
    }

    /**
     * Translates a code-point index into the matching {@code char} index.
     *
     * @param codePointIndex index in code points, {@code 0..codePointCount}
     * @return index in {@code char} units
     * @throws CommentRedactionException if the index lies outside the text
     */
    public int charIndex(int codePointIndex) throws CommentRedactionException {
        if (codePointIndex < 0 || codePointIndex > codePointCount) {
            throw new CommentRedactionException("token position " + codePointIndex + " lies outside the text");
        }
        return charIndexOfCodePoint == null ? codePointIndex : charIndexOfCodePoint[codePointIndex];
    }

    /**
     * Replaces every character in the inclusive code-point range with a space,
     * except {@code '\n'} and {@code '\r'}, which are kept.
     *
     * @param startCodePoint first code point of the range
     * @param stopCodePoint  last code point of the range (inclusive)
     * @throws CommentRedactionException if the range is empty, reversed or
     *                                   outside the text
     */
    public void blank(int startCodePoint, int stopCodePoint) throws CommentRedactionException {
        if (stopCodePoint < startCodePoint || stopCodePoint >= codePointCount) {
            throw new CommentRedactionException(
                    "invalid comment range " + startCodePoint + ".." + stopCodePoint);
        }
        blankChars(charIndex(startCodePoint), charIndex(stopCodePoint + 1));
    }

    /**
     * Replaces every character in the half-open {@code char} range with a
     * space, except {@code '\n'} and {@code '\r'}, which are kept.
     *
     * @param fromChar first {@code char} index of the range
     * @param toChar   {@code char} index after the last character of the range
     * @throws CommentRedactionException if the range is empty, reversed or
     *                                   outside the text
     */
    public void blankChars(int fromChar, int toChar) throws CommentRedactionException {
        if (fromChar < 0 || toChar <= fromChar || toChar > chars.length) {
            throw new CommentRedactionException("invalid comment range " + fromChar + ".." + toChar);
        }
        for (int i = fromChar; i < toChar; i++) {
            if (chars[i] != '\n' && chars[i] != '\r') {
                chars[i] = ' ';
            }
        }
    }

    /**
     * Returns the text with all blanked ranges applied.
     *
     * @return text of the same length as the original source; never
     *         {@code null}
     */
    public String result() {
        return new String(chars);
    }
}