blob: 73cbf3c69e9c3d1ec991949726153a017e5408c8 [file] [log] [blame]
package org.chromium.devtools.jsdoc.checks;
import com.google.common.base.Preconditions;
import com.google.javascript.rhino.Node;
import com.google.javascript.rhino.Token;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class FunctionReceiverChecker extends ContextTrackingChecker {
private static final Set<String> FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT =
new HashSet<>();
private static final String SUPPRESSION_HINT = "This check can be suppressed using "
+ "@suppressReceiverCheck annotation on function declaration.";
static {
// Array.prototype methods.
FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("every");
FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("filter");
FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("forEach");
FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("map");
FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("some");
}
private final Map<String, FunctionRecord> nestedFunctionsByName = new HashMap<>();
private final Map<String, Set<CallSite>> callSitesByFunctionName = new HashMap<>();
private final Map<String, Set<SymbolicArgument>> symbolicArgumentsByName = new HashMap<>();
private final Set<FunctionRecord> functionsRequiringThisAnnotation = new HashSet<>();
@Override
void enterNode(Node node) {
switch (node.getToken()) {
case CALL:
handleCall(node);
break;
case FUNCTION: {
handleFunction(node);
break;
}
case THIS: {
handleThis();
break;
}
default:
break;
}
}
private void handleCall(Node functionCall) {
Preconditions.checkState(functionCall.isCall());
String[] callParts = getContext().getNodeText(functionCall.getFirstChild()).split("\\.");
String firstPart = callParts[0];
List<Node> argumentNodes = AstUtil.getArguments(functionCall);
List<String> actualArguments = argumentsForCall(argumentNodes);
int partCount = callParts.length;
String functionName = callParts[partCount - 1];
saveSymbolicArguments(functionName, argumentNodes, actualArguments);
boolean isBindCall = partCount > 1 && "bind".equals(functionName);
if (isBindCall && partCount == 3 && "this".equals(firstPart)
&& !(actualArguments.size() > 0 && "this".equals(actualArguments.get(0)))) {
reportErrorAtNodeStart(
functionCall, "Member function can only be bound to 'this' as the receiver");
return;
}
if (partCount > 2 || "this".equals(firstPart)) {
return;
}
boolean hasReceiver = isBindCall && isReceiverSpecified(actualArguments);
hasReceiver |= (partCount == 2)
&& ("call".equals(functionName) || "apply".equals(functionName))
&& isReceiverSpecified(actualArguments);
getOrCreateSetByKey(callSitesByFunctionName, firstPart)
.add(new CallSite(hasReceiver, functionCall));
}
private void handleFunction(Node node) {
Preconditions.checkState(node.isFunction());
FunctionRecord function = getState().getCurrentFunctionRecord();
if (function == null) {
return;
}
if (function.isTopLevelFunction()) {
symbolicArgumentsByName.clear();
} else {
Node nameNode = AstUtil.getFunctionNameNode(node);
if (nameNode == null) {
return;
}
nestedFunctionsByName.put(getContext().getNodeText(nameNode), function);
}
}
private void handleThis() {
FunctionRecord function = getState().getCurrentFunctionRecord();
while (function != null && function.functionNode.isArrowFunction()) {
function = function.enclosingFunctionRecord;
}
if (function == null) {
return;
}
if (!function.isTopLevelFunction() && !function.isConstructor()) {
functionsRequiringThisAnnotation.add(function);
}
}
private List<String> argumentsForCall(List<Node> argumentNodes) {
int argumentCount = argumentNodes.size();
List<String> arguments = new ArrayList<>(argumentCount);
for (Node argumentNode : argumentNodes) {
arguments.add(getContext().getNodeText(argumentNode));
}
return arguments;
}
private void saveSymbolicArguments(
String functionName, List<Node> argumentNodes, List<String> arguments) {
int argumentCount = arguments.size();
CheckedReceiverPresence receiverPresence = CheckedReceiverPresence.MISSING;
if (FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.contains(functionName)) {
if (argumentCount >= 2) {
receiverPresence = CheckedReceiverPresence.PRESENT;
}
} else if ("addEventListener".equals(functionName)
|| "removeEventListener".equals(functionName)) {
String receiverArgument = argumentCount < 3 ? "" : arguments.get(2);
switch (receiverArgument) {
case "":
case "true":
case "false":
receiverPresence = CheckedReceiverPresence.MISSING;
break;
case "this":
receiverPresence = CheckedReceiverPresence.PRESENT;
break;
default:
receiverPresence = CheckedReceiverPresence.IGNORE;
}
}
for (int i = 0; i < argumentCount; ++i) {
String argumentText = arguments.get(i);
getOrCreateSetByKey(symbolicArgumentsByName, argumentText)
.add(new SymbolicArgument(receiverPresence, argumentNodes.get(i)));
}
}
private static <K, T> Set<T> getOrCreateSetByKey(Map<K, Set<T>> map, K key) {
Set<T> set = map.get(key);
if (set == null) {
set = new HashSet<>();
map.put(key, set);
}
return set;
}
private boolean isReceiverSpecified(List<String> arguments) {
return arguments.size() > 0 && !"null".equals(arguments.get(0));
}
@Override
void leaveNode(Node node) {
if (node.getToken() != Token.FUNCTION) {
return;
}
ContextTrackingState state = getState();
FunctionRecord function = state.getCurrentFunctionRecord();
if (function == null) {
return;
}
checkThisAnnotation(function);
// The nested function checks are only run when leaving a top-level function.
if (!function.isTopLevelFunction()) {
return;
}
for (FunctionRecord record : nestedFunctionsByName.values()) {
processFunctionUsesAsArgument(record, symbolicArgumentsByName.get(record.name));
processFunctionCallSites(record, callSitesByFunctionName.get(record.name));
}
nestedFunctionsByName.clear();
callSitesByFunctionName.clear();
symbolicArgumentsByName.clear();
}
private void checkThisAnnotation(FunctionRecord function) {
Node functionNameNode = AstUtil.getFunctionNameNode(function.functionNode);
if (functionNameNode == null && function.info == null) {
// Do not check anonymous functions without a JSDoc.
return;
}
int errorTargetOffset = functionNameNode == null
? (function.info == null ? function.functionNode.getSourceOffset()
: function.info.getOriginalCommentPosition())
: functionNameNode.getSourceOffset();
boolean hasThisAnnotation = function.hasThisAnnotation();
if (hasThisAnnotation == functionReferencesThis(function)) {
return;
}
if (hasThisAnnotation) {
if (!function.isTopLevelFunction()) {
reportErrorAtOffset(errorTargetOffset,
"@this annotation found for function not referencing 'this'");
}
return;
} else {
reportErrorAtOffset(errorTargetOffset,
"@this annotation is required for functions referencing 'this'");
}
}
private boolean functionReferencesThis(FunctionRecord function) {
return functionsRequiringThisAnnotation.contains(function);
}
private void processFunctionCallSites(FunctionRecord function, Set<CallSite> callSites) {
if (callSites == null) {
return;
}
boolean functionReferencesThis = functionReferencesThis(function);
for (CallSite callSite : callSites) {
if (functionReferencesThis == callSite.hasReceiver || function.isConstructor()) {
continue;
}
if (callSite.hasReceiver) {
reportErrorAtNodeStart(callSite.callNode,
"Receiver specified for a function not referencing 'this'");
} else {
reportErrorAtNodeStart(callSite.callNode,
"Receiver not specified for a function referencing 'this'");
}
}
}
private void processFunctionUsesAsArgument(
FunctionRecord function, Set<SymbolicArgument> argumentUses) {
if (argumentUses == null || function.suppressesReceiverCheck()) {
return;
}
boolean referencesThis = functionReferencesThis(function);
for (SymbolicArgument argument : argumentUses) {
if (argument.receiverPresence == CheckedReceiverPresence.IGNORE) {
continue;
}
boolean receiverProvided = argument.receiverPresence == CheckedReceiverPresence.PRESENT;
if (referencesThis == receiverProvided) {
continue;
}
if (referencesThis) {
reportErrorAtNodeStart(
argument.node, "Function referencing 'this' used as argument without "
+ "a receiver. " + SUPPRESSION_HINT);
} else {
reportErrorAtNodeStart(
argument.node, "Function not referencing 'this' used as argument with "
+ "a receiver. " + SUPPRESSION_HINT);
}
}
}
private static enum CheckedReceiverPresence { PRESENT, MISSING, IGNORE }
private static class SymbolicArgument {
CheckedReceiverPresence receiverPresence;
Node node;
public SymbolicArgument(CheckedReceiverPresence receiverPresence, Node node) {
this.receiverPresence = receiverPresence;
this.node = node;
}
}
private static class CallSite {
boolean hasReceiver;
Node callNode;
public CallSite(boolean hasReceiver, Node callNode) {
this.hasReceiver = hasReceiver;
this.callNode = callNode;
}
}
}