com.google.javascript.jscomp.lint.CheckRequiresSorted Maven / Gradle / Ivy
Show all versions of closure-compiler-unshaded Show documentation
/*
* Copyright 2019 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.lint;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.google.auto.value.AutoValue;
import com.google.common.base.Preconditions;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ComparisonChain;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Multimap;
import com.google.errorprone.annotations.Immutable;
import com.google.javascript.jscomp.DiagnosticType;
import com.google.javascript.jscomp.NodeTraversal;
import com.google.javascript.jscomp.NodeUtil;
import com.google.javascript.rhino.JSDocInfo;
import com.google.javascript.rhino.Node;
import com.google.javascript.rhino.QualifiedName;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.jspecify.nullness.Nullable;
/**
* Checks that Closure import statements (goog.require, goog.requireType, and goog.forwardDeclare)
* are sorted and deduplicated, exposing the necessary information to produce a suggested fix.
*/
public final class CheckRequiresSorted implements NodeTraversal.Callback {
public static final DiagnosticType REQUIRES_NOT_SORTED =
DiagnosticType.warning(
"JSC_REQUIRES_NOT_SORTED",
"goog.require() and goog.requireType() statements are not in recommended format."
+ " The correct order is:\n\n{0}\n");
/** Operation modes. */
public enum Mode {
/** Collect information to determine whether a fix is required, but do not report a warning. */
COLLECT_ONLY,
/** Additionally report a warning. */
COLLECT_AND_REPORT
};
/** Primitives that may be called in an import statement. */
enum ImportPrimitive {
REQUIRE("goog.require"),
REQUIRE_TYPE("goog.requireType"),
FORWARD_DECLARE("goog.forwardDeclare");
private final String name;
private ImportPrimitive(String name) {
this.name = name;
}
/** Returns the primitive with the given name. */
static ImportPrimitive fromName(String name) {
for (ImportPrimitive primitive : values()) {
if (primitive.name.equals(name)) {
return primitive;
}
}
throw new IllegalArgumentException("Invalid primitive name " + name);
}
static final ImportPrimitive WEAKEST = FORWARD_DECLARE;
/**
* Returns the stronger of two primitives.
*
* `goog.require` is stronger than `goog.requireType`, which is stronger than
* `goog.forwardDeclare`.
*/
static @Nullable ImportPrimitive stronger(ImportPrimitive p1, ImportPrimitive p2) {
return p1.ordinal() < p2.ordinal() ? p1 : p2;
}
@Override
public String toString() {
return name;
}
}
/**
* One of the bindings of a destructuring pattern.
*
*
{@code exportedName} and {@code localName} are equal in the case where the binding does not
* explicitly specify a local name.
*/
@AutoValue
@Immutable
abstract static class DestructuringBinding implements Comparable {
abstract String exportedName();
abstract String localName();
abstract boolean isShorthandProperty();
static DestructuringBinding of(
String exportedName, String localName, boolean isShorthandProperty) {
checkArgument(!isShorthandProperty || exportedName.equals(localName));
return new AutoValue_CheckRequiresSorted_DestructuringBinding(
exportedName, localName, isShorthandProperty);
}
/** Compares two bindings according to the style guide sort order. */
@Override
public int compareTo(DestructuringBinding other) {
return ComparisonChain.start()
.compare(this.exportedName(), other.exportedName())
.compare(this.localName(), other.localName())
.result();
}
/**
* Returns true if the destructuring binding is not canonical.
*
* For example:
*
`{Foo}` is canonical
* `{Foo: Bar}` is canonical
* `{Foo: Foo}` is not canonical
*/
private boolean isCanonical() {
return !this.exportedName().equals(this.localName()) || this.isShorthandProperty();
}
/**
* Canonicalizes the destructuring to a shorthand property when applicable.
*
* In practice, `{Foo: Foo}` gets simplified to `{Foo}`.
*/
public DestructuringBinding canonicalizeShorthandProperties() {
return this.isCanonical()
? this
: DestructuringBinding.of(
this.exportedName(), this.localName(), /* isShorthandProperty= */ true);
}
}
/**
* An import statement, which may have been merged from several import statements for the same
* namespace in the original code.
*
*
An import statement has exactly one of three shapes:
*
*
* - Standalone: has no LHS, as in `goog.require('namespace')`.
*
- Aliasing: has an LHS with an alias, as in `const alias = goog.require('namespace')`.
*
- Destructuring: has an LHS with a destructuring pattern, as in `const {name: localName} =
* goog.require('namespace')`.
*
*/
@AutoValue
abstract static class ImportStatement implements Comparable {
/** Returns the nodes this import statement was merged from, in source order. */
abstract ImmutableList nodes();
/** Returns the import primitive being called. */
abstract ImportPrimitive primitive();
/** Returns the namespace being imported. */
abstract String namespace();
/** Returns the alias for an aliasing import, or null if the import isn't aliasing. */
abstract @Nullable String alias();
/**
* Returns the destructures for a destructuring import in source order, or null if the import
* isn't destructuring.
*
* If the import is destructuring but the pattern is empty, the value is non-null but empty.
*/
abstract @Nullable ImmutableList destructures();
/** Creates a new import statement. */
static ImportStatement of(
ImmutableList nodes,
ImportPrimitive primitive,
String namespace,
@Nullable String alias,
@Nullable ImmutableList destructures) {
Preconditions.checkArgument(
alias == null || destructures == null,
"Import statement cannot be simultaneously aliasing and destructuring");
return new AutoValue_CheckRequiresSorted_ImportStatement(
nodes, primitive, namespace, alias, destructures);
}
/** Returns whether the import is standalone. */
boolean isStandalone() {
return !isAliasing() && !isDestructuring();
}
/** Returns whether the import is aliasing. */
boolean isAliasing() {
return alias() != null;
}
/** Returns whether the import is destructuring. */
boolean isDestructuring() {
return destructures() != null;
}
/**
* Returns an import statement identical to the current one, except for its primitive, which is
* upgraded to the given one if stronger.
*/
ImportStatement upgrade(ImportPrimitive otherPrimitive) {
if (ImportPrimitive.stronger(primitive(), otherPrimitive) != primitive()) {
return new AutoValue_CheckRequiresSorted_ImportStatement(
nodes(), otherPrimitive, namespace(), alias(), destructures());
}
return this;
}
private String formatWithoutDoc() {
StringBuilder sb = new StringBuilder();
if (!isStandalone()) {
sb.append("const ");
}
if (alias() != null) {
sb.append(alias());
}
if (destructures() != null) {
sb.append("{");
boolean first = true;
for (DestructuringBinding binding : destructures()) {
String exportedName = binding.exportedName();
String localName = binding.localName();
if (first) {
first = false;
} else {
sb.append(", ");
}
sb.append(exportedName);
if (!exportedName.equals(localName)) {
sb.append(": ");
sb.append(localName);
}
}
sb.append("}");
}
if (!isStandalone()) {
sb.append(" = ");
}
sb.append(primitive());
sb.append("('");
sb.append(namespace());
sb.append("');");
return sb.toString();
}
/** Formats the import statement into code. */
public String format() {
StringBuilder sb = new StringBuilder();
for (Node node : nodes()) {
String comment = node.getNonJSDocCommentString();
if (!comment.isEmpty()) {
sb.append(node.getNonJSDocCommentString()).append("\n");
}
JSDocInfo jsDoc = NodeUtil.getBestJSDocInfo(node);
if (jsDoc != null) {
sb.append(jsDoc.getOriginalCommentString()).append("\n");
}
}
sb.append(formatWithoutDoc());
return sb.toString();
}
/** Compares two import statements according to the style guide sort order. */
@Override
public int compareTo(ImportStatement other) {
return this.formatWithoutDoc().compareTo(other.formatWithoutDoc());
}
}
private static final QualifiedName GOOG_REQUIRE = QualifiedName.of("goog.require");
private static final QualifiedName GOOG_REQUIRETYPE = QualifiedName.of("goog.requireType");
private static final QualifiedName GOOG_FORWARDDECLARE = QualifiedName.of("goog.forwardDeclare");
private final Mode mode;
// Maps each namespace into the existing import statements for that namespace.
// Use an ArrayListMultimap so that values for a key are iterated in a deterministic order.
private final Multimap importsByNamespace = ArrayListMultimap.create();
// The import statements in the order they appear.
private final List originalImports = new ArrayList<>();
// The import statements in canonical order.
private @Nullable Node firstNode = null;
private @Nullable Node lastNode = null;
private boolean finished = false;
private boolean needsFix = false;
private @Nullable String replacement = null;
public CheckRequiresSorted(Mode mode) {
this.mode = mode;
}
/** Returns the node for the first recognized import statement. */
public Node getFirstNode() {
return firstNode;
}
/** Returns the node for the last recognized import statement. */
public Node getLastNode() {
return lastNode;
}
/** Returns a textual replacement yielding a canonical version of the imports. */
public String getReplacement() {
return replacement;
}
/**
* Returns whether the imports need to be fixed, i.e., whether they are *not* already canonical.
*/
public boolean needsFix() {
return needsFix;
}
@Override
public final boolean shouldTraverse(NodeTraversal t, Node n, Node parent) {
// Traverse top-level statements until a block of contiguous requires is found.
return !finished
&& (parent == null || parent.isRoot() || parent.isScript() || parent.isModuleBody());
}
@Override
public final void visit(NodeTraversal t, Node n, Node parent) {
if (n.isScript()) {
checkCanonical(t);
return;
}
Node callNode = null;
if (n.isExprResult()) {
callNode = n.getFirstChild();
} else if (NodeUtil.isNameDeclaration(n)) {
callNode = n.getFirstChild().getLastChild();
}
if (callNode != null && isValidImportCall(callNode)) {
ImportStatement stmt = parseImport(callNode);
originalImports.add(stmt);
importsByNamespace.put(stmt.namespace(), stmt);
if (firstNode == null) {
firstNode = lastNode = n;
} else {
lastNode = n;
}
} else if (!importsByNamespace.isEmpty()) {
finished = true;
}
}
private static boolean isValidImportCall(Node n) {
return n.isCall()
&& n.hasTwoChildren()
&& (GOOG_REQUIRE.matches(n.getFirstChild())
|| GOOG_REQUIRETYPE.matches(n.getFirstChild())
|| GOOG_FORWARDDECLARE.matches(n.getFirstChild()))
&& n.getSecondChild().isStringLit();
}
private static ImportStatement parseImport(Node callNode) {
ImportPrimitive primitive =
ImportPrimitive.fromName(callNode.getFirstChild().getQualifiedName());
String namespace = callNode.getSecondChild().getString();
Node parent = callNode.getParent();
if (parent.isExprResult()) {
// goog.require('a');
return ImportStatement.of(
ImmutableList.of(parent),
primitive,
namespace,
/* alias= */ null,
/* destructures= */ null);
}
Node grandparent = parent.getParent();
if (parent.isName()) {
// const a = goog.require('a');
String alias = parent.getString();
return ImportStatement.of(
ImmutableList.of(grandparent), primitive, namespace, alias, /* destructures= */ null);
}
// const {a: b, c} = goog.require('a');
ImmutableList.Builder destructures = ImmutableList.builder();
for (Node name = parent.getFirstFirstChild(); name != null; name = name.getNext()) {
String exportedName = name.getString();
String localName = name.getFirstChild().getString();
// {a: a} and {a: b} both yield false
// {a} yields true
boolean isShorthandProperty = name.isShorthandProperty();
destructures.add(DestructuringBinding.of(exportedName, localName, isShorthandProperty));
}
return ImportStatement.of(
ImmutableList.of(grandparent),
primitive,
namespace,
/* alias= */ null,
destructures.build());
}
private void checkCanonical(NodeTraversal t) {
@Nullable List canonicalImports = canonicalizeImports(importsByNamespace);
if (!originalImports.equals(canonicalImports)) {
needsFix = true;
replacement =
String.join("\n", Iterables.transform(canonicalImports, ImportStatement::format));
if (mode == Mode.COLLECT_AND_REPORT) {
t.report(firstNode, REQUIRES_NOT_SORTED, replacement);
}
}
}
/**
* Canonicalizes a list of import statements by deduplicating and merging imports for the same
* namespace, and sorting the result.
*/
private static List canonicalizeImports(
Multimap importsByNamespace) {
List canonicalImports = new ArrayList<>();
for (String namespace : importsByNamespace.keySet()) {
Collection allImports = importsByNamespace.get(namespace);
// Find the strongest primitive across all existing imports. Every emitted import for this
// namespace will use this primitive. This makes the logic simpler and cannot change runtime
// behavior, but may produce spurious changes when multiple aliasing imports of differing
// strength exist (which are already in violation of the style guide).
ImportPrimitive strongestPrimitive =
allImports.stream()
.map(ImportStatement::primitive)
.reduce(ImportPrimitive.WEAKEST, ImportPrimitive::stronger);
// Emit each aliasing import separately, as deduplicating them would require code references
// to be rewritten.
boolean hasAliasing = false;
for (ImportStatement stmt : Iterables.filter(allImports, ImportStatement::isAliasing)) {
canonicalImports.add(stmt.upgrade(strongestPrimitive));
hasAliasing = true;
}
// Emit a single destructuring import with a non-empty pattern, merged from the existing
// destructuring imports.
boolean hasDestructuring = false;
ImmutableList destructuringNodes =
allImports.stream()
.filter(ImportStatement::isDestructuring)
.flatMap(i -> i.nodes().stream())
.collect(toImmutableList());
ImmutableList destructures =
allImports.stream()
.filter(ImportStatement::isDestructuring)
.flatMap(i -> i.destructures().stream())
.map(DestructuringBinding::canonicalizeShorthandProperties)
.distinct()
.sorted()
.collect(toImmutableList());
if (!destructures.isEmpty()) {
canonicalImports.add(
ImportStatement.of(
destructuringNodes,
strongestPrimitive,
namespace,
/* alias= */ null,
destructures));
hasDestructuring = true;
}
// Emit a standalone import unless an aliasing or destructuring one already exists.
if (!hasAliasing && !hasDestructuring) {
ImmutableList standaloneNodes =
allImports.stream()
.filter(ImportStatement::isStandalone)
.flatMap(i -> i.nodes().stream())
.collect(toImmutableList());
canonicalImports.add(
ImportStatement.of(
standaloneNodes,
strongestPrimitive,
namespace,
/* alias= */ null,
/* destructures= */ null));
}
}
// Sorting by natural order yields the correct result due to the implementation of
// ImportStatement#compareTo.
Collections.sort(canonicalImports);
return canonicalImports;
}
}