/* * Copyright 2017 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.errorprone.util; import static com.google.common.collect.ImmutableList.toImmutableList; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.CharMatcher; import com.google.common.collect.ImmutableList; import com.google.errorprone.VisitorState; import com.sun.source.tree.BlockTree; import com.sun.source.tree.ClassTree; import com.sun.source.tree.ExpressionTree; import com.sun.source.tree.MethodInvocationTree; import com.sun.source.tree.NewClassTree; import com.sun.source.tree.Tree; import com.sun.source.util.TreePath; import com.sun.tools.javac.parser.Tokens.Comment; import com.sun.tools.javac.parser.Tokens.TokenKind; import com.sun.tools.javac.tree.JCTree; import com.sun.tools.javac.util.Position.LineMap; import java.util.Iterator; import java.util.List; import java.util.Optional; /** * Utilities for attaching comments to relevant AST nodes * * @author andrewrice@google.com (Andrew Rice) */ public class Comments { /** * Attach comments to nodes on arguments of constructor calls. Calls such as {@code new Test( * param1 /* c1 */, /* c2 */ param2)} will attach the comment c1 to {@code param1} and the * comment c2 to {@code param2}. * * <p>Warning: this is expensive to compute as it involves re-tokenizing the source for this node. * * <p>Currently this method will only tokenize the source code of the method call itself. However, * the source positions in the returned {@code Comment} objects are adjusted so that they are * relative to the whole file. */ public static ImmutableList<Commented<ExpressionTree>> findCommentsForArguments( NewClassTree newClassTree, VisitorState state) { int startPosition = ((JCTree) newClassTree).getStartPosition(); return findCommentsForArguments( newClassTree, newClassTree.getArguments(), startPosition, state); } /** * Attach comments to nodes on arguments of method calls. Calls such as {@code test(param1 /* c1 * */, /* c2 */ param2)} will attach the comment c1 to {@code param1} and the comment c2 * to {@code param2}. * * <p>Warning: this is expensive to compute as it involves re-tokenizing the source for this node * * <p>Currently this method will only tokenize the source code of the method call itself. However, * the source positions in the returned {@code Comment} objects are adjusted so that they are * relative to the whole file. */ public static ImmutableList<Commented<ExpressionTree>> findCommentsForArguments( MethodInvocationTree methodInvocationTree, VisitorState state) { int startPosition = state.getEndPosition(methodInvocationTree.getMethodSelect()); return findCommentsForArguments( methodInvocationTree, methodInvocationTree.getArguments(), startPosition, state); } /** * Extract the text body from a comment. * * <p>This currently includes asterisks that start lines in the body of block comments. Do not * rely on this behaviour. * * <p>TODO(andrewrice) Update this method to handle block comments properly if we find the need */ public static String getTextFromComment(Comment comment) { switch (comment.getStyle()) { case BLOCK: return comment.getText().replaceAll("^\\s*/\\*\\s*(.*?)\\s*\\*/\\s*", "$1"); case LINE: return comment.getText().replaceAll("^\\s*//\\s*", ""); default: return comment.getText(); } } private static ImmutableList<Commented<ExpressionTree>> findCommentsForArguments( Tree tree, List<? extends ExpressionTree> arguments, int startPosition, VisitorState state) { if (arguments.isEmpty()) { return ImmutableList.of(); } CharSequence sourceCode = state.getSourceCode(); Optional<Integer> endPosition = computeEndPosition(tree, sourceCode, state); if (!endPosition.isPresent()) { return noComments(arguments); } CharSequence source = sourceCode.subSequence(startPosition, endPosition.get()); if (CharMatcher.is('/').matchesNoneOf(source)) { return noComments(arguments); } // The token position of the end of the method invocation int invocationEnd = state.getEndPosition(tree) - startPosition; ErrorProneTokens errorProneTokens = new ErrorProneTokens(source.toString(), state.context); ImmutableList<ErrorProneToken> tokens = errorProneTokens.getTokens(); LineMap lineMap = errorProneTokens.getLineMap(); ArgumentTracker argumentTracker = new ArgumentTracker(arguments, startPosition, state, lineMap); TokenTracker tokenTracker = new TokenTracker(lineMap); argumentTracker.advance(); tokenLoop: for (ErrorProneToken token : tokens) { tokenTracker.advance(token); if (tokenTracker.atStartOfLine() && !tokenTracker.wasPreviousLineEmpty()) { for (Comment c : token.comments()) { if (tokenTracker.isCommentOnPreviousLine(c) && token.pos() <= argumentTracker.currentArgumentStartPosition && argumentTracker.isPreviousArgumentOnPreviousLine()) { // token was on the previous line so therefore we should add it to the previous comment // unless the previous argument was not on the the previous line with it argumentTracker.addCommentToPreviousArgument(c); } else { // if the comment comes after the end of the invocation and its not on the same line // as the final argument then we need to ignore it if (c.getSourcePos(0) <= invocationEnd || lineMap.getLineNumber(c.getSourcePos(0)) <= lineMap.getLineNumber(argumentTracker.currentArgumentEndPosition)) { argumentTracker.addCommentToCurrentArgument(c); } } } } else { argumentTracker.addAllCommentsToCurrentArgument(token.comments()); } if (token.pos() >= argumentTracker.currentArgumentEndPosition) { // We are between arguments so wait for a (lexed) comma to delimit them if (token.kind() == TokenKind.COMMA) { if (!argumentTracker.hasMoreArguments()) { break tokenLoop; } argumentTracker.advance(); } } } return argumentTracker.build(); } private static ImmutableList noComments(List<? extends ExpressionTree> arguments) { return arguments .stream() .map(a -> Commented.builder().setTree(a).build()) .collect(toImmutableList()); } /** * Finds the end position of this MethodInvocationTree. This is complicated by the fact that * sometimes a comment will fall outside of the bounds of the tree. * * <p>For example: * * <pre> * test(arg1, // comment1 * arg2); // comment2 * int i; * </pre> * * In this case {@code comment2} lies beyond the end of the invocation tree. In order to get the * comment we need the tokenizer to lex the token which follows the invocation ({@code int} in the * example). * * <p>We over-approximate this end position by looking for the next node in the AST and using the * end position of this node. * * <p>As a heuristic we first scan for any {@code /} characters on the same line as the end of the * method invocation. If we don't find any then we use the end of the method invocation as the end * position. * * @return the end position of the tree or Optional.empty if we are unable to calculate it */ @VisibleForTesting static Optional<Integer> computeEndPosition( Tree methodInvocationTree, CharSequence sourceCode, VisitorState state) { int invocationEnd = state.getEndPosition(methodInvocationTree); if (invocationEnd == -1) { return Optional.empty(); } // Finding a good end position is expensive so first check whether we have any comment at // the end of our line. If we don't then we can just use the end of the methodInvocationTree int nextNewLine = CharMatcher.is('\n').indexIn(sourceCode, invocationEnd); if (nextNewLine == -1) { return Optional.of(invocationEnd); } if (CharMatcher.is('/').matchesNoneOf(sourceCode.subSequence(invocationEnd, nextNewLine))) { return Optional.of(invocationEnd); } int nextNodeEnd = state.getEndPosition(getNextNodeOrParent(methodInvocationTree, state)); if (nextNodeEnd == -1) { return Optional.of(invocationEnd); } return Optional.of(nextNodeEnd); } /** * Find the node which (approximately) follows this one in the tree. This works by walking upwards * to find enclosing block (or class) and then looking for the node after the subtree we walked. * If our subtree is the last of the block then we return the node for the block instead, if we * can't find a suitable block we return the parent node. */ private static Tree getNextNodeOrParent(Tree current, VisitorState state) { Tree predecessorNode = current; TreePath enclosingPath = state.getPath(); while (enclosingPath != null && !(enclosingPath.getLeaf() instanceof BlockTree) && !(enclosingPath.getLeaf() instanceof ClassTree)) { predecessorNode = enclosingPath.getLeaf(); enclosingPath = enclosingPath.getParentPath(); } if (enclosingPath == null) { return state.getPath().getParentPath().getLeaf(); } Tree parent = enclosingPath.getLeaf(); if (parent instanceof BlockTree) { return after(predecessorNode, ((BlockTree) parent).getStatements(), parent); } else if (parent instanceof ClassTree) { return after(predecessorNode, ((ClassTree) parent).getMembers(), parent); } return parent; } /** * Find the element in the iterable following the target * * @param target is the element to search for * @param iterable is the iterable to search * @param defaultValue will be returned if there is no item following the searched for item * @return the item following {@code target} or {@code defaultvalue} if not found */ private static <T> T after(T target, Iterable<? extends T> iterable, T defaultValue) { Iterator<? extends T> iterator = iterable.iterator(); while (iterator.hasNext()) { if (iterator.next().equals(target)) { break; } } if (iterator.hasNext()) { return iterator.next(); } return defaultValue; } /** This class is used to keep track of state between lines of code when consuming tokens */ private static class TokenTracker { private final LineMap lineMap; private int tokensOnCurrentLine = 0; private int currentLineNumber = -1; private boolean previousLineEmpty = true; TokenTracker(LineMap lineMap) { this.lineMap = lineMap; } void advance(ErrorProneToken token) { int line = lineMap.getLineNumber(token.pos()); if (line != currentLineNumber) { currentLineNumber = line; previousLineEmpty = tokensOnCurrentLine == 0; tokensOnCurrentLine = 0; } else { tokensOnCurrentLine++; } } boolean isCommentOnPreviousLine(Comment c) { int tokenLine = lineMap.getLineNumber(c.getSourcePos(0)); return tokenLine == currentLineNumber - 1; } boolean atStartOfLine() { return tokensOnCurrentLine == 0; } boolean wasPreviousLineEmpty() { return previousLineEmpty; } } /** * This class is used to keep track of the arguments we are processing. It keeps a window of the * current and previous argument as builders so that more comments can be added to them as we find * them. When we advance everything is shuffled down: the builder for the previous argument is * built and put in the final results list, the builder for the current argument is moved to * previous and a new builder is made for the next argument. We also track the positions of the * current and previous argument so that we know whether a comment occurred before or after it */ private static class ArgumentTracker { private final VisitorState state; private final Iterator<? extends ExpressionTree> argumentsIterator; private final int offset; private final LineMap lineMap; private Commented.Builder<ExpressionTree> currentCommentedResultBuilder = null; private Commented.Builder<ExpressionTree> previousCommentedResultBuilder = null; private final ImmutableList.Builder<Commented<ExpressionTree>> resultBuilder = ImmutableList.builder(); private int currentArgumentStartPosition = -1; private int currentArgumentEndPosition = -1; private int previousArgumentEndPosition = -1; ArgumentTracker( Iterable<? extends ExpressionTree> arguments, int offset, VisitorState state, LineMap lineMap) { this.state = state; this.offset = offset; this.argumentsIterator = arguments.iterator(); this.lineMap = lineMap; } void advance() { ExpressionTree nextArgument = argumentsIterator.next(); currentArgumentEndPosition = state.getEndPosition(nextArgument) - offset; previousArgumentEndPosition = currentArgumentStartPosition; currentArgumentStartPosition = ((JCTree) nextArgument).getStartPosition() - offset; if (previousCommentedResultBuilder != null) { resultBuilder.add(previousCommentedResultBuilder.build()); } previousCommentedResultBuilder = currentCommentedResultBuilder; currentCommentedResultBuilder = Commented.builder().setTree(nextArgument); } /** Returns the final result. The object should not be used after calling this method */ ImmutableList<Commented<ExpressionTree>> build() { if (previousCommentedResultBuilder != null) { resultBuilder.add(previousCommentedResultBuilder.build()); } if (currentCommentedResultBuilder != null) { resultBuilder.add(currentCommentedResultBuilder.build()); } return resultBuilder.build(); } boolean isPreviousArgumentOnPreviousLine() { return lineMap.getLineNumber(previousArgumentEndPosition) == lineMap.getLineNumber(currentArgumentStartPosition) - 1; } void addCommentToPreviousArgument(Comment c) { previousCommentedResultBuilder.addComment(c, previousArgumentEndPosition, offset); } void addCommentToCurrentArgument(Comment c) { currentCommentedResultBuilder.addComment(c, currentArgumentStartPosition, offset); } void addAllCommentsToCurrentArgument(Iterable<Comment> comments) { currentCommentedResultBuilder.addAllComment(comments, currentArgumentStartPosition, offset); } public boolean hasMoreArguments() { return argumentsIterator.hasNext(); } } }