PowerShellCommentRedactor.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.discovery.powershell;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
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.powershell.parser.PowerShellTestLexer;
import org.egothor.methodatlas.util.CommentBlanker;
/**
* {@link CommentRedactor} for PowerShell source text, built on the ANTLR lexer
* of the PowerShell discovery plugin.
*
* <p>
* PowerShell has two comment forms: {@code #} line comments and
* {@code <# ... #>} block comments (which also carry comment-based help). 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. Single-quoted, double-quoted and here-strings are lexed as single
* tokens, so comment markers inside them are never touched.
* </p>
*
* <p>
* A {@code #requires} line is a directive for the PowerShell engine, not a
* comment, and is left unredacted only if it matches the strict {@code #requires}
* grammar: one or more of {@code -Version <n.n>}, {@code -PSEdition Core|Desktop},
* {@code -RunAsAdministrator}, {@code -Modules}, {@code -Assembly} and {@code -ShellId}
* with bare-name or module-hashtable operands, and nothing else. The line must start
* with the directive, preceded by white space only. Any other {@code #requires ...}
* text (free prose, unknown options) is an ordinary comment and is blanked.
* </p>
*
* <p>
* A {@code #} or {@code <#} inside a {@code $( ... )} sub-expression of a
* double-quoted or here-string is lexed inside the string token and would be missed,
* so such a string is rejected (fail closed).
* </p>
*
* <p>
* The redactor fails closed. A {@link CommentRedactionException} is thrown if
* the lexer reports an error; if a quote, a back-tick escape of a quote or
* comment marker, or the start of a block comment is left unrecognised (an
* unterminated string or block comment); if a typographic quote
* (U+2018..U+201E), which PowerShell treats as a quote character, occurs where
* it could end a string unexpectedly; 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 PowerShellCommentRedactor implements CommentRedactor {
private static final String PS1_SUFFIX = ".ps1";
private static final char OPEN_PAREN = '(';
private static final char CLOSE_PAREN = ')';
private static final char HASH = '#';
/** A version number such as {@code 5}, {@code 5.1} or {@code 7.4.1.0}. */
private static final String REQ_VERSION = "\\d+(?:\\.\\d+){0,3}";
/** A bare module, assembly or shell identifier: no white space, so no prose. */
private static final String REQ_NAME = "[\\w.\\-:\\\\/]+";
private static final String REQ_KEY =
"(?:ModuleName|ModuleVersion|RequiredVersion|MaximumVersion|GUID)\\s*=\\s*['\"]?[\\w.\\-{}]+['\"]?";
/** A module specification: a bare name or a hashtable of the fixed module keys. */
private static final String REQ_ITEM =
"(?:" + REQ_NAME + "|@\\{\\s*" + REQ_KEY + "(?:\\s*[;,]\\s*" + REQ_KEY + ")*\\s*;?\\s*\\})";
private static final String REQ_LIST = REQ_ITEM + "(?:\\s*,\\s*" + REQ_ITEM + ")*";
/** One {@code #requires} option with its operand, per the documented grammar. */
private static final String REQ_OPTION = "(?:-Version\\s+" + REQ_VERSION
+ "|-PSEdition\\s+(?:Core|Desktop)"
+ "|-RunAsAdministrator"
+ "|-Modules\\s+" + REQ_LIST
+ "|-Assembly\\s+" + REQ_LIST
+ "|-ShellId\\s+" + REQ_NAME + ")";
/**
* Strict {@code #requires} grammar: the keyword followed by one or more options and nothing else.
* Anything else starting with {@code #requires} is an ordinary comment.
*/
private static final Pattern REQUIRES_DIRECTIVE = Pattern
.compile("#requires(?:\\s+" + REQ_OPTION + ")+\\s*", Pattern.CASE_INSENSITIVE);
/** Typographic single quotes, which PowerShell treats as single-quote characters. */
private static final String SMART_SINGLE_QUOTES = "‘’‚‛";
/** Typographic double quotes, which PowerShell treats as double-quote characters. */
private static final String SMART_DOUBLE_QUOTES = "“”„";
/**
* Creates a redactor; required by {@link java.util.ServiceLoader}.
* It accepts every file whose name ends in {@code .ps1}.
*/
public PowerShellCommentRedactor() {
// Required by ServiceLoader
}
/**
* Returns the identifier of this redactor, equal to that of
* {@link PowerShellTestDiscovery#pluginId()}.
*
* @return {@code "powershell"}
*/
@Override
public String pluginId() {
return "powershell";
}
/**
* Tells whether {@code sourceFile} is a PowerShell script: its name ends in
* {@code .ps1} (case-insensitive), which includes {@code *.Tests.ps1}.
* 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(PS1_SUFFIX);
}
/**
* Replaces every comment in {@code source} with spaces, except
* {@code #requires} directives.
*
* @param sourceFile the file the text belongs to; used for diagnostics only
* @param source PowerShell 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 the comment tokens that must be blanked,
* 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<>();
PowerShellTestLexer lexer = new PowerShellTestLexer(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<Token> comments = new ArrayList<>();
for (Token token : stream.getTokens()) {
int type = token.getType();
if (type == PowerShellTestLexer.LINE_COMMENT) {
if (!isRequiresDirective(token, text, positions)) {
comments.add(token);
}
} else if (type == PowerShellTestLexer.BLOCK_COMMENT) {
comments.add(token);
} else if (type == PowerShellTestLexer.OTHER) {
rejectUnrecognisedOpener(sourceFile, token, text, positions);
} else if (type == PowerShellTestLexer.SINGLE_QUOTED) {
rejectSmartQuote(sourceFile, token, SMART_SINGLE_QUOTES);
} else if (type == PowerShellTestLexer.DOUBLE_QUOTED) {
rejectSmartQuote(sourceFile, token, SMART_DOUBLE_QUOTES);
rejectCommentInSubExpression(sourceFile, token);
} else if (type == PowerShellTestLexer.HERE_DOUBLE) {
rejectCommentInSubExpression(sourceFile, token);
}
}
return comments;
}
/** A {@code #requires} line that starts its line (after white space only) is a directive. */
private static boolean isRequiresDirective(Token token, String text, CommentBlanker positions)
throws CommentRedactionException {
if (!REQUIRES_DIRECTIVE.matcher(token.getText()).matches()) {
return false;
}
int i = positions.charIndex(token.getStartIndex()) - 1;
while (i >= 0 && (text.charAt(i) == ' ' || text.charAt(i) == '\t')) {
i--;
}
return i < 0 || text.charAt(i) == '\n' || text.charAt(i) == '\r';
}
/**
* Fails if an unrecognised character could be the start of a string, a
* quote escape or a block comment that the lexer did not understand; text
* after it may then be misclassified.
*/
private static void rejectUnrecognisedOpener(Path sourceFile, Token other, String text,
CommentBlanker positions) throws CommentRedactionException {
String ch = other.getText();
int next = positions.charIndex(other.getStopIndex() + 1);
char following = next < text.length() ? text.charAt(next) : '\0';
boolean quote = "\"".equals(ch) || "'".equals(ch)
|| (ch.length() == 1 && (SMART_SINGLE_QUOTES + SMART_DOUBLE_QUOTES).indexOf(ch.charAt(0)) >= 0);
boolean escapedOpener = "`".equals(ch) && "\"'#<".indexOf(following) >= 0;
boolean blockOpener = "<".equals(ch) && following == '#';
if (quote || escapedOpener || blockOpener) {
throw new CommentRedactionException("unterminated or unrecognised literal or comment in "
+ sourceFile + " at line " + other.getLine());
}
}
/**
* Fails if an expandable string contains a {@code $( ... )} sub-expression with a
* {@code #} or {@code <#} inside: the lexer treats the whole string as one token, so a
* real comment in the sub-expression would be neither found nor blanked. A {@code #} in
* a literal part of the string, or after the closing parenthesis, is fine. Parentheses
* are counted individually (conservative for quoted parentheses), a back-tick-escaped
* {@code $} does not open a sub-expression, and an unclosed sub-expression runs to the
* end of the token.
*/
private static void rejectCommentInSubExpression(Path sourceFile, Token token)
throws CommentRedactionException {
String body = token.getText();
int from = body.indexOf("$(");
while (from >= 0) {
if (!isBacktickEscaped(body, from)) {
int depth = 1;
for (int i = from + 2; i < body.length() && depth > 0; i++) {
char c = body.charAt(i);
if (c == OPEN_PAREN) {
depth++;
} else if (c == CLOSE_PAREN) {
depth--;
} else if (c == HASH) {
throw new CommentRedactionException("comment marker inside a $( ) sub-expression of a "
+ "string in " + sourceFile + " at line " + token.getLine());
}
}
}
from = body.indexOf("$(", from + 2);
}
}
private static boolean isBacktickEscaped(String body, int index) {
int ticks = 0;
for (int i = index - 1; i >= 0 && body.charAt(i) == '`'; i--) {
ticks++;
}
return ticks % 2 == 1;
}
/** Fails if {@code token} contains a typographic quote that PowerShell would treat as its terminator. */
private static void rejectSmartQuote(Path sourceFile, Token token, String smartQuotes)
throws CommentRedactionException {
String body = token.getText();
for (int i = 0; i < body.length(); i++) {
if (smartQuotes.indexOf(body.charAt(i)) >= 0) {
throw new CommentRedactionException("typographic quote inside a string in " + sourceFile
+ " at line " + token.getLine());
}
}
}
}