1package org.chromium.devtools.jsdoc.checks;
2
3import com.google.javascript.rhino.head.Token;
4import com.google.javascript.rhino.head.ast.AstNode;
5import com.google.javascript.rhino.head.ast.FunctionCall;
6import com.google.javascript.rhino.head.ast.FunctionNode;
7
8import java.util.ArrayList;
9import java.util.HashMap;
10import java.util.HashSet;
11import java.util.List;
12import java.util.Map;
13import java.util.Set;
14
15public final class FunctionReceiverChecker extends ContextTrackingChecker {
16
17    private static final Set<String> FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT =
18            new HashSet<>();
19    private static final String SUPPRESSION_HINT = "This check can be suppressed using "
20            + "@suppressReceiverCheck annotation on function declaration.";
21    static {
22        // Array.prototype methods.
23        FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("every");
24        FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("filter");
25        FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("forEach");
26        FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("map");
27        FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.add("some");
28    }
29
30    private final Map<String, FunctionRecord> nestedFunctionsByName = new HashMap<>();
31    private final Map<String, Set<CallSite>> callSitesByFunctionName = new HashMap<>();
32    private final Map<String, Set<SymbolicArgument>> symbolicArgumentsByName = new HashMap<>();
33    private final Set<FunctionRecord> functionsRequiringThisAnnotation = new HashSet<>();
34
35    @Override
36    void enterNode(AstNode node) {
37        switch (node.getType()) {
38        case Token.CALL:
39            handleCall((FunctionCall) node);
40            break;
41        case Token.FUNCTION: {
42            handleFunction((FunctionNode) node);
43            break;
44        }
45        case Token.THIS: {
46            handleThis();
47            break;
48        }
49        default:
50            break;
51        }
52    }
53
54    private void handleCall(FunctionCall functionCall) {
55        String[] callParts = getContext().getNodeText(functionCall.getTarget()).split("\\.");
56        String firstPart = callParts[0];
57        List<AstNode> argumentNodes = functionCall.getArguments();
58        List<String> actualArguments = argumentsForCall(argumentNodes);
59        int partCount = callParts.length;
60        String functionName = callParts[partCount - 1];
61
62        saveSymbolicArguments(functionName, argumentNodes, actualArguments);
63
64        boolean isBindCall = partCount > 1 && "bind".equals(functionName);
65        if (isBindCall && partCount == 3 && "this".equals(firstPart) &&
66            !(actualArguments.size() > 0 && "this".equals(actualArguments.get(0)))) {
67                reportErrorAtNodeStart(functionCall,
68                        "Member function can only be bound to 'this' as the receiver");
69                return;
70        }
71        if (partCount > 2 || "this".equals(firstPart)) {
72            return;
73        }
74        boolean hasReceiver = isBindCall && isReceiverSpecified(actualArguments);
75        hasReceiver |= (partCount == 2) &&
76                ("call".equals(functionName) || "apply".equals(functionName)) &&
77                isReceiverSpecified(actualArguments);
78        getOrCreateSetByKey(callSitesByFunctionName, firstPart)
79                .add(new CallSite(hasReceiver, functionCall));
80    }
81
82
83    private void handleFunction(FunctionNode node) {
84        FunctionRecord function = getState().getCurrentFunctionRecord();
85        if (function == null) {
86            return;
87        }
88        if (function.isTopLevelFunction()) {
89            symbolicArgumentsByName.clear();
90        } else {
91            AstNode nameNode = AstUtil.getFunctionNameNode(node);
92            if (nameNode == null) {
93                return;
94            }
95            nestedFunctionsByName.put(getContext().getNodeText(nameNode), function);
96        }
97    }
98
99    private void handleThis() {
100        FunctionRecord function = getState().getCurrentFunctionRecord();
101        if (function == null) {
102            return;
103        }
104        if (!function.isTopLevelFunction() && !function.isConstructor) {
105            functionsRequiringThisAnnotation.add(function);
106        }
107    }
108
109    private List<String> argumentsForCall(List<AstNode> argumentNodes) {
110        int argumentCount = argumentNodes.size();
111        List<String> arguments = new ArrayList<>(argumentCount);
112        for (AstNode argumentNode : argumentNodes) {
113            arguments.add(getContext().getNodeText(argumentNode));
114        }
115        return arguments;
116    }
117
118    private void saveSymbolicArguments(
119            String functionName, List<AstNode> argumentNodes, List<String> arguments) {
120        int argumentCount = arguments.size();
121        CheckedReceiverPresence receiverPresence = CheckedReceiverPresence.MISSING;
122        if (FUNCTIONS_WITH_CALLBACK_RECEIVER_AS_SECOND_ARGUMENT.contains(functionName)) {
123            if (argumentCount >= 2) {
124                receiverPresence = CheckedReceiverPresence.PRESENT;
125            }
126        } else if ("addEventListener".equals(functionName) ||
127                "removeEventListener".equals(functionName)) {
128            String receiverArgument = argumentCount < 3 ? "" : arguments.get(2);
129            switch (receiverArgument) {
130            case "":
131            case "true":
132            case "false":
133                receiverPresence = CheckedReceiverPresence.MISSING;
134                break;
135            case "this":
136                receiverPresence = CheckedReceiverPresence.PRESENT;
137                break;
138            default:
139                receiverPresence = CheckedReceiverPresence.IGNORE;
140            }
141        }
142
143        for (int i = 0; i < argumentCount; ++i) {
144            String argumentText = arguments.get(i);
145            getOrCreateSetByKey(symbolicArgumentsByName, argumentText).add(
146                    new SymbolicArgument(receiverPresence, argumentNodes.get(i)));
147        }
148    }
149
150    private static <K, T> Set<T> getOrCreateSetByKey(Map<K, Set<T>> map, K key) {
151        Set<T> set = map.get(key);
152        if (set == null) {
153            set = new HashSet<>();
154            map.put(key, set);
155        }
156        return set;
157    }
158
159    private boolean isReceiverSpecified(List<String> arguments) {
160        return arguments.size() > 0 && !"null".equals(arguments.get(0));
161    }
162
163    @Override
164    void leaveNode(AstNode node) {
165        if (node.getType() != Token.FUNCTION) {
166            return;
167        }
168
169        ContextTrackingState state = getState();
170        FunctionRecord function = state.getCurrentFunctionRecord();
171        if (function == null) {
172            return;
173        }
174        checkThisAnnotation(function);
175
176        // The nested function checks are only run when leaving a top-level function.
177        if (!function.isTopLevelFunction()) {
178            return;
179        }
180
181        for (FunctionRecord record : nestedFunctionsByName.values()) {
182            processFunctionUsesAsArgument(record, symbolicArgumentsByName.get(record.name));
183            processFunctionCallSites(record, callSitesByFunctionName.get(record.name));
184        }
185
186        nestedFunctionsByName.clear();
187        callSitesByFunctionName.clear();
188        symbolicArgumentsByName.clear();
189    }
190
191    private void checkThisAnnotation(FunctionRecord function) {
192        AstNode functionNameNode = AstUtil.getFunctionNameNode(function.functionNode);
193        if (functionNameNode == null && function.jsDocNode == null) {
194            // Do not check anonymous functions without a JSDoc.
195            return;
196        }
197        AstNode errorTargetNode =
198                functionNameNode == null ? function.jsDocNode : functionNameNode;
199        if (errorTargetNode == null) {
200            errorTargetNode = function.functionNode;
201        }
202        boolean hasThisAnnotation = hasAnnotationTag(function.jsDocNode, "this");
203        if (hasThisAnnotation == functionReferencesThis(function)) {
204            return;
205        }
206        if (hasThisAnnotation) {
207            if (!function.isTopLevelFunction()) {
208                reportErrorAtNodeStart(
209                        errorTargetNode,
210                        "@this annotation found for function not referencing 'this'");
211            }
212            return;
213        } else {
214            reportErrorAtNodeStart(
215                    errorTargetNode,
216                    "@this annotation is required for functions referencing 'this'");
217        }
218    }
219
220    private boolean functionReferencesThis(FunctionRecord function) {
221        return functionsRequiringThisAnnotation.contains(function);
222    }
223
224    private void processFunctionCallSites(FunctionRecord function, Set<CallSite> callSites) {
225        if (callSites == null) {
226            return;
227        }
228        boolean functionReferencesThis = functionReferencesThis(function);
229        for (CallSite callSite : callSites) {
230            if (functionReferencesThis == callSite.hasReceiver || function.isConstructor) {
231                continue;
232            }
233            if (callSite.hasReceiver) {
234                reportErrorAtNodeStart(callSite.callNode,
235                        "Receiver specified for a function not referencing 'this'");
236            } else {
237                reportErrorAtNodeStart(callSite.callNode,
238                        "Receiver not specified for a function referencing 'this'");
239            }
240        }
241    }
242
243    private void processFunctionUsesAsArgument(
244            FunctionRecord function, Set<SymbolicArgument> argumentUses) {
245        if (argumentUses == null ||
246                hasAnnotationTag(function.jsDocNode, "suppressReceiverCheck")) {
247            return;
248        }
249
250        boolean referencesThis = functionReferencesThis(function);
251        for (SymbolicArgument argument : argumentUses) {
252            if (argument.receiverPresence == CheckedReceiverPresence.IGNORE) {
253                continue;
254            }
255            boolean receiverProvided =
256                    argument.receiverPresence == CheckedReceiverPresence.PRESENT;
257            if (referencesThis == receiverProvided) {
258                continue;
259            }
260            if (referencesThis) {
261                reportErrorAtNodeStart(argument.node,
262                        "Function referencing 'this' used as argument without " +
263                         "a receiver. " + SUPPRESSION_HINT);
264            } else {
265                reportErrorAtNodeStart(argument.node,
266                        "Function not referencing 'this' used as argument with " +
267                         "a receiver. " + SUPPRESSION_HINT);
268            }
269        }
270    }
271
272    private static enum CheckedReceiverPresence {
273        PRESENT,
274        MISSING,
275        IGNORE
276    }
277
278    private static class SymbolicArgument {
279        CheckedReceiverPresence receiverPresence;
280        AstNode node;
281
282        public SymbolicArgument(CheckedReceiverPresence receiverPresence, AstNode node) {
283            this.receiverPresence = receiverPresence;
284            this.node = node;
285        }
286    }
287
288    private static class CallSite {
289        boolean hasReceiver;
290        FunctionCall callNode;
291
292        public CallSite(boolean hasReceiver, FunctionCall callNode) {
293            this.hasReceiver = hasReceiver;
294            this.callNode = callNode;
295        }
296    }
297}
298