com.google.javascript.jscomp.CheckJSDoc Maven / Gradle / Ivy
Show all versions of closure-compiler-linter Show documentation
/*
* Copyright 2015 The Closure Compiler Authors.
*
* 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.javascript.jscomp;
import static com.google.common.base.Preconditions.checkState;
import com.google.common.collect.Iterables;
import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback;
import com.google.javascript.jscomp.parsing.parser.trees.Comment;
import com.google.javascript.rhino.JSDocInfo;
import com.google.javascript.rhino.JSTypeExpression;
import com.google.javascript.rhino.Node;
import com.google.javascript.rhino.Token;
import java.util.Set;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
/**
* Checks for misplaced, misused or deprecated JSDoc annotations.
*/
final class CheckJSDoc extends AbstractPostOrderCallback implements HotSwapCompilerPass {
public static final DiagnosticType MISPLACED_MSG_ANNOTATION =
DiagnosticType.disabled(
"JSC_MISPLACED_MSG_ANNOTATION",
"Misplaced message annotation. @desc, @hidden, and @meaning annotations should only "
+ "be on message nodes.\nMessage constants must be prefixed with 'MSG_'.");
public static final DiagnosticType MISPLACED_ANNOTATION =
DiagnosticType.warning("JSC_MISPLACED_ANNOTATION",
"Misplaced {0} annotation. {1}");
public static final DiagnosticType ANNOTATION_DEPRECATED =
DiagnosticType.warning("JSC_ANNOTATION_DEPRECATED",
"The {0} annotation is deprecated. {1}");
public static final DiagnosticType DISALLOWED_MEMBER_JSDOC =
DiagnosticType.warning("JSC_DISALLOWED_MEMBER_JSDOC",
"Class level JSDocs (@interface, @extends, etc.) are not allowed on class members");
static final DiagnosticType ARROW_FUNCTION_AS_CONSTRUCTOR = DiagnosticType.error(
"JSC_ARROW_FUNCTION_AS_CONSTRUCTOR",
"Arrow functions cannot be used as constructors");
static final DiagnosticType DEFAULT_PARAM_MUST_BE_MARKED_OPTIONAL = DiagnosticType.error(
"JSC_DEFAULT_PARAM_MUST_BE_MARKED_OPTIONAL",
"Inline JSDoc on default parameters must be marked as optional");
public static final DiagnosticType INVALID_NO_SIDE_EFFECT_ANNOTATION =
DiagnosticType.error(
"JSC_INVALID_NO_SIDE_EFFECT_ANNOTATION",
"@nosideeffects may only appear in externs files.");
public static final DiagnosticType INVALID_MODIFIES_ANNOTATION =
DiagnosticType.error(
"JSC_INVALID_MODIFIES_ANNOTATION", "@modifies may only appear in externs files.");
public static final DiagnosticType INVALID_DEFINE_ON_LET =
DiagnosticType.error(
"JSC_INVALID_DEFINE_ON_LET",
"variables annotated with @define may only be declared with VARs, ASSIGNs, or CONSTs");
public static final DiagnosticType MISPLACED_SUPPRESS =
DiagnosticType.warning(
"JSC_MISPLACED_SUPPRESS",
"@suppress annotation not allowed here. See"
+ " https://github.com/google/closure-compiler/wiki/@suppress-annotations");
public static final DiagnosticType JSDOC_IN_BLOCK_COMMENT =
DiagnosticType.warning(
"JSC_JSDOC_IN_BLOCK_COMMENT",
"Non-JSDoc comment has annotations. Did you mean to start it with '/**'?");
public static final DiagnosticType JSDOC_ON_RETURN =
DiagnosticType.warning(
"JSC_JSDOC_ON_RETURN", "JSDoc annotations are not supported on return.");
private static final Pattern COMMENT_PATTERN =
Pattern.compile("(/|(\n[ \t]*))\\*[ \t]*@[a-zA-Z]+[ \t\n{]");
private final AbstractCompiler compiler;
private boolean inExterns;
CheckJSDoc(AbstractCompiler compiler) {
this.compiler = compiler;
}
@Override
public void process(Node externs, Node root) {
inExterns = true;
NodeTraversal.traverse(compiler, externs, this);
inExterns = false;
NodeTraversal.traverse(compiler, root, this);
}
@Override
public void hotSwapScript(Node scriptRoot, Node originalRoot) {
NodeTraversal.traverse(compiler, scriptRoot, this);
}
/**
* Checks for block comments (e.g. starting with /*) that look like they are JsDoc, and thus
* should start with /**.
*/
private void checkJsDocInBlockComments(String fileName) {
if (!compiler.getOptions().preservesDetailedSourceInfo()) {
// Comments only available if preservesDetailedSourceInfo is true.
return;
}
for (Comment comment : compiler.getComments(fileName)) {
if (comment.type == Comment.Type.BLOCK) {
if (COMMENT_PATTERN.matcher(comment.value).find()) {
compiler.report(
JSError.make(
fileName,
comment.location.start.line + 1,
comment.location.start.column,
JSDOC_IN_BLOCK_COMMENT));
}
}
}
}
@Override
public void visit(NodeTraversal t, Node n, Node parent) {
if (n.isScript()) {
checkJsDocInBlockComments(n.getSourceFileName());
}
JSDocInfo info = n.getJSDocInfo();
validateTypeAnnotations(n, info);
validateFunctionJsDoc(n, info);
validateMsgJsDoc(n, info);
validateDeprecatedJsDoc(n, info);
validateNoCollapse(n, info);
validateClassLevelJsDoc(n, info);
validateArrowFunction(n);
validateDefaultValue(n);
validateTemplates(n, info);
validateTypedefs(n, info);
validateNoSideEffects(n, info);
validateAbstractJsDoc(n, info);
validateDefinesDeclaration(n, info);
validateSuppress(n, info);
validateImplicitCast(n, info);
validateClosurePrimitive(n, info);
validateReturnJsDoc(n, info);
}
private void validateSuppress(Node n, JSDocInfo info) {
if (info == null || info.getSuppressions().isEmpty()) {
return;
}
switch (n.getToken()) {
case FUNCTION:
case CLASS:
case VAR:
case LET:
case CONST:
case SCRIPT:
case MEMBER_FUNCTION_DEF:
case GETTER_DEF:
case SETTER_DEF:
// Suppressions are always valid here.
return;
case COMPUTED_PROP:
if (n.getLastChild().isFunction()) {
return; // Suppressions are valid on computed properties that declare functions.
}
break;
case STRING_KEY:
if (n.getParent().isObjectLit()) {
return;
}
break;
case ASSIGN:
case ASSIGN_BITOR:
case ASSIGN_BITXOR:
case ASSIGN_BITAND:
case ASSIGN_LSH:
case ASSIGN_RSH:
case ASSIGN_URSH:
case ASSIGN_ADD:
case ASSIGN_SUB:
case ASSIGN_MUL:
case ASSIGN_DIV:
case ASSIGN_MOD:
case ASSIGN_EXPONENT:
case GETPROP:
if (n.getParent().isExprResult()) {
return;
}
break;
case CALL:
// TODO(blickly): Stop ignoring no-op extraProvide suppression.
// We don't actually support extraProvide, but if we did, it would go on a CALL.
if (containsOnlySuppressionFor(info, "extraRequire")
|| containsOnlySuppressionFor(info, "extraProvide")) {
return;
}
break;
case WITH:
if (containsOnlySuppressionFor(info, "with")) {
return;
}
break;
default:
break;
}
if (containsOnlySuppressionFor(info, "missingRequire")) {
return;
}
compiler.report(JSError.make(n, MISPLACED_SUPPRESS));
}
private static boolean containsOnlySuppressionFor(JSDocInfo jsdoc, String allowedSuppression) {
Set suppressions = jsdoc.getSuppressions();
return suppressions.size() == 1
&& Iterables.getOnlyElement(suppressions).equals(allowedSuppression);
}
private void validateTypedefs(Node n, JSDocInfo info) {
if (info != null && info.hasTypedefType() && isClassDecl(n)) {
reportMisplaced(n, "typedef", "@typedef does not make sense on a class declaration.");
}
}
private void validateTemplates(Node n, JSDocInfo info) {
if (info != null
&& !info.getTemplateTypeNames().isEmpty()
&& !info.isConstructorOrInterface()
&& !isClassDecl(n)
&& !info.containsFunctionDeclaration()
&& getFunctionDecl(n) == null) {
reportMisplaced(n, "template",
"@template is only allowed in class, constructor, interface, function "
+ "or method declarations");
}
}
/**
* @return The function node associated with the function declaration associated with the
* specified node, no null if no such function exists.
*/
@Nullable
private Node getFunctionDecl(Node n) {
if (n.isFunction()) {
return n;
}
if (n.isMemberFunctionDef()) {
return n.getFirstChild();
}
if (NodeUtil.isNameDeclaration(n)
&& n.getFirstFirstChild() != null
&& n.getFirstFirstChild().isFunction()) {
return n.getFirstFirstChild();
}
if (n.isAssign() && n.getFirstChild().isQualifiedName() && n.getLastChild().isFunction()) {
return n.getLastChild();
}
if (n.isStringKey() && n.getGrandparent() != null
&& ClosureRewriteClass.isGoogDefineClass(n.getGrandparent())
&& n.getFirstChild().isFunction()) {
return n.getFirstChild();
}
if (n.isGetterDef() || n.isSetterDef()) {
return n.getFirstChild();
}
if (n.isComputedProp() && n.getLastChild().isFunction()) {
return n.getLastChild();
}
return null;
}
private boolean isClassDecl(Node n) {
return isClass(n)
|| (n.isAssign() && isClass(n.getLastChild()))
|| (NodeUtil.isNameDeclaration(n) && isNameInitializeWithClass(n.getFirstChild()))
|| isNameInitializeWithClass(n);
}
private boolean isNameInitializeWithClass(Node n) {
return n != null && n.isName() && n.hasChildren() && isClass(n.getFirstChild());
}
private boolean isClass(Node n) {
return n.isClass()
|| (n.isCall() && compiler.getCodingConvention().isClassFactoryCall(n));
}
/**
* Checks that class-level annotations like @interface/@extends are not used on member functions.
*/
private void validateClassLevelJsDoc(Node n, JSDocInfo info) {
if (info != null && n.isMemberFunctionDef()
&& hasClassLevelJsDoc(info)) {
report(n, DISALLOWED_MEMBER_JSDOC);
}
}
private void validateAbstractJsDoc(Node n, JSDocInfo info) {
if (info == null || !info.isAbstract()) {
return;
}
if (isClassDecl(n)) {
return;
}
Node functionNode = getFunctionDecl(n);
if (functionNode == null) {
// @abstract annotation on a non-function
report(
n,
MISPLACED_ANNOTATION,
"@abstract",
"only functions or non-static methods can be abstract");
return;
}
if (!info.isConstructor() && NodeUtil.getFunctionBody(functionNode).hasChildren()) {
// @abstract annotation on a function with a non-empty body
report(n, MISPLACED_ANNOTATION, "@abstract",
"function with a non-empty body cannot be abstract");
return;
}
// TODO(b/124020008): Delete this case when `goog.defineClass` is dropped.
boolean isGoogDefineClassConstructor =
n.getParent().isObjectLit()
&& (n.isMemberFunctionDef() || n.isStringKey())
&& "constructor".equals(n.getString());
if (NodeUtil.isEs6ConstructorMemberFunctionDef(n) || isGoogDefineClassConstructor) {
// @abstract annotation on an ES6 or goog.defineClass constructor
report(n, MISPLACED_ANNOTATION, "@abstract", "constructors cannot be abstract");
return;
}
if (!info.isConstructor()
&& !n.isMemberFunctionDef()
&& !n.isStringKey()
&& !n.isComputedProp()
&& !n.isGetterDef()
&& !n.isSetterDef()
&& !NodeUtil.isPrototypeMethod(functionNode)) {
// @abstract annotation on a non-method (or static method) in ES5
report(
n,
MISPLACED_ANNOTATION,
"@abstract",
"only functions or non-static methods can be abstract");
return;
}
if (n.isStaticMember()) {
// @abstract annotation on a static method in ES6
report(n, MISPLACED_ANNOTATION, "@abstract", "static methods cannot be abstract");
return;
}
}
private boolean hasClassLevelJsDoc(JSDocInfo info) {
return info.isConstructorOrInterface()
|| info.hasBaseType()
|| info.getImplementedInterfaceCount() != 0
|| info.getExtendedInterfacesCount() != 0;
}
/**
* Checks that deprecated annotations such as @expose are not present
*/
private void validateDeprecatedJsDoc(Node n, JSDocInfo info) {
if (info != null && info.isExpose()) {
report(n, ANNOTATION_DEPRECATED, "@expose",
"Use @nocollapse or @export instead.");
}
}
/**
* Warns when nocollapse annotations are present on nodes
* which are not eligible for property collapsing.
*/
private void validateNoCollapse(Node n, JSDocInfo info) {
if (n.isFromExterns()) {
if (info != null && info.isNoCollapse()) {
// @nocollapse has no effect in externs
reportMisplaced(n, "nocollapse", "This JSDoc has no effect in externs.");
}
return;
}
if (!NodeUtil.isPrototypePropertyDeclaration(n.getParent())) {
return;
}
JSDocInfo jsdoc = n.getJSDocInfo();
if (jsdoc != null && jsdoc.isNoCollapse()) {
reportMisplaced(n, "nocollapse", "This JSDoc has no effect on prototype properties.");
}
}
/**
* Checks that JSDoc intended for a function is actually attached to a
* function.
*/
private void validateFunctionJsDoc(Node n, JSDocInfo info) {
if (info == null) {
return;
}
if (info.containsFunctionDeclaration() && !info.hasType() && !isJSDocOnFunctionNode(n, info)) {
// This JSDoc should be attached to a FUNCTION node, or an assignment
// with a function as the RHS, etc.
reportMisplaced(
n,
"function",
"This JSDoc is not attached to a function node. " + "Are you missing parentheses?");
}
}
/**
* Whether this node's JSDoc may apply to a function
*
* This has some false positive cases, to allow for patterns like goog.abstractMethod.
*/
private boolean isJSDocOnFunctionNode(Node n, JSDocInfo info) {
switch (n.getToken()) {
case FUNCTION:
case GETTER_DEF:
case SETTER_DEF:
case MEMBER_FUNCTION_DEF:
case STRING_KEY:
case COMPUTED_PROP:
case EXPORT:
return true;
case GETELEM:
case GETPROP:
if (n.getFirstChild().isQualifiedName()) {
// assume qualified names may be function declarations
return true;
}
return false;
case VAR:
case LET:
case CONST:
case ASSIGN:
{
Node lhs = n.getFirstChild();
Node rhs = NodeUtil.getRValueOfLValue(lhs);
if (rhs != null && isClass(rhs) && !info.isConstructor()) {
return false;
}
// TODO(b/124081098): Check that the RHS of the assignment is a
// function. Note that it can be a FUNCTION node, but it can also be
// a call to goog.abstractMethod, goog.functions.constant, etc.
return true;
}
default:
return false;
}
}
/**
* Checks that annotations for messages ({@code @desc}, {@code @hidden},
* and {@code @meaning})
* are in the proper place, namely on names starting with MSG_ which
* indicates they should be
* extracted for translation. A later pass checks that the right side is
* a call to goog.getMsg.
*/
private void validateMsgJsDoc(Node n,
JSDocInfo info) {
if (info == null) {
return;
}
if (info.getDescription() != null || info.isHidden() || info.getMeaning() != null) {
boolean descOkay = false;
switch (n.getToken()) {
case ASSIGN:
case VAR:
case LET:
case CONST:
descOkay = isValidMsgName(n.getFirstChild());
break;
case STRING_KEY:
descOkay = isValidMsgName(n);
break;
case GETPROP:
if (n.isFromExterns() && n.isQualifiedName()) {
descOkay = isValidMsgName(n);
}
break;
default:
break;
}
if (!descOkay) {
report(n, MISPLACED_MSG_ANNOTATION);
}
}
}
/** Returns whether of not the given name is valid target for the result of goog.getMsg */
private boolean isValidMsgName(Node nameNode) {
if (nameNode.isName() || nameNode.isStringKey()) {
return nameNode.getString().startsWith("MSG_");
} else if (nameNode.isQualifiedName()) {
return nameNode.getLastChild().getString().startsWith("MSG_");
} else {
return false;
}
}
/**
* Check that JSDoc with a {@code @type} annotation is in a valid place.
*/
private void validateTypeAnnotations(Node n, JSDocInfo info) {
if (info != null && info.hasType()) {
boolean valid = false;
switch (n.getToken()) {
// Function declarations are valid
case FUNCTION:
valid = NodeUtil.isFunctionDeclaration(n);
break;
// Object literal properties, catch declarations and variable
// initializers are valid.
case NAME:
valid = isTypeAnnotationAllowedForName(n);
break;
case ARRAY_PATTERN:
case OBJECT_PATTERN:
// allow JSDoc like
// function f(/** !Object */ {x}) {}
// function f(/** !Array */ [x]) {}
valid = n.getParent().isParamList();
break;
// Casts, exports, and Object literal properties are valid.
case CAST:
case EXPORT:
case STRING_KEY:
case GETTER_DEF:
case SETTER_DEF:
valid = true;
break;
// Declarations are valid iff they only contain simple names
// /** @type {number} */ var x = 3; // ok
// /** @type {number} */ var {x} = obj; // forbidden
case VAR:
case LET:
case CONST:
valid = !NodeUtil.isDestructuringDeclaration(n);
break;
// Property assignments are valid, if at the root of an expression.
case ASSIGN: {
Node lvalue = n.getFirstChild();
valid =
n.getParent().isExprResult()
&& (lvalue.isGetProp() || lvalue.isGetElem() || lvalue.matchesName("exports"));
break;
}
case GETPROP:
valid = n.getParent().isExprResult() && n.isQualifiedName();
break;
case CALL:
valid = info.isDefine();
break;
default:
break;
}
if (!valid) {
reportMisplaced(n, "type", "Type annotations are not allowed here. "
+ "Are you missing parentheses?");
}
}
}
/**
* Is it valid to have a type annotation on the given NAME node?
*/
private boolean isTypeAnnotationAllowedForName(Node n) {
checkState(n.isName(), n);
// Only allow type annotations on nodes used as an lvalue.
if (!NodeUtil.isLValue(n)) {
return false;
}
// Don't allow JSDoc on a name in an assignment. Simple names should only have JSDoc on them
// when originally declared.
Node rootTarget = NodeUtil.getRootTarget(n);
return !NodeUtil.isLhsOfAssign(rootTarget);
}
private void reportMisplaced(Node n, String annotationName, String note) {
compiler.report(JSError.make(n, MISPLACED_ANNOTATION,
annotationName, note));
}
private void report(Node n, DiagnosticType type, String... arguments) {
compiler.report(JSError.make(n, type, arguments));
}
/**
* Check that an arrow function is not annotated with {@constructor}.
*/
private void validateArrowFunction(Node n) {
if (n.isArrowFunction()) {
JSDocInfo info = NodeUtil.getBestJSDocInfo(n);
if (info != null && info.isConstructorOrInterface()) {
report(n, ARROW_FUNCTION_AS_CONSTRUCTOR);
}
}
}
/**
* Check that a parameter with a default value is marked as optional.
* TODO(bradfordcsmith): This is redundant. We shouldn't require it.
*/
private void validateDefaultValue(Node n) {
if (n.isDefaultValue() && n.getParent().isParamList()) {
Node targetNode = n.getFirstChild();
JSDocInfo info = targetNode.getJSDocInfo();
if (info == null) {
return;
}
JSTypeExpression typeExpr = info.getType();
if (typeExpr == null) {
return;
}
Node typeNode = typeExpr.getRoot();
if (typeNode.getToken() != Token.EQUALS) {
report(typeNode, DEFAULT_PARAM_MUST_BE_MARKED_OPTIONAL);
}
}
}
/**
* Check that @nosideeeffects annotations are only present in externs.
*/
private void validateNoSideEffects(Node n, JSDocInfo info) {
// Cannot have @modifies or @nosideeffects in regular (non externs) js. Report errors.
if (info == null) {
return;
}
if (n.isFromExterns()) {
return;
}
if (info.hasSideEffectsArgumentsAnnotation() || info.modifiesThis()) {
report(n, INVALID_MODIFIES_ANNOTATION);
}
if (info.isNoSideEffects()) {
report(n, INVALID_NO_SIDE_EFFECT_ANNOTATION);
}
}
/**
* Check that a let declaration is not used with {@defines}
*/
private void validateDefinesDeclaration(Node n, JSDocInfo info) {
if (info != null && info.isDefine() && n.isLet()) {
report(n, INVALID_DEFINE_ON_LET);
}
}
/** Checks that an @implicitCast annotation is in the externs */
private void validateImplicitCast(Node n, JSDocInfo info) {
if (!inExterns && info != null && info.isImplicitCast()) {
report(n, TypeCheck.ILLEGAL_IMPLICIT_CAST);
}
}
/** Checks that a @closurePrimitive {id} is on a function */
private void validateClosurePrimitive(Node n, JSDocInfo info) {
if (info == null || !info.hasClosurePrimitiveId()) {
return;
}
if (!isJSDocOnFunctionNode(n, info)) {
report(n, MISPLACED_ANNOTATION, "closurePrimitive", "must be on a function node");
}
}
/** Checks that there are no annotations on return. */
private void validateReturnJsDoc(Node n, JSDocInfo info) {
if (!n.isReturn() || info == null) {
return;
}
// @type is handled in validateTypeAnnotations method.
if (info.containsDeclaration() && !info.hasType()) {
report(n, JSDOC_ON_RETURN);
}
}
}