com.google.javascript.jscomp.ReplaceCssNames Maven / Gradle / Ivy
Show all versions of closure-compiler-unshaded Show documentation
/*
* Copyright 2009 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.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import com.google.common.base.Joiner;
import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback;
import com.google.javascript.jscomp.base.format.SimpleFormat;
import com.google.javascript.rhino.IR;
import com.google.javascript.rhino.Node;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.jspecify.nullness.Nullable;
/**
* ReplaceCssNames replaces occurrences of goog.getCssName('foo') with a shorter version from the
* passed in renaming map. There are two styles of operation: for 'BY_WHOLE' we look up the whole
* string in the renaming map. For 'BY_PART', all the class name's components, separated by '-', are
* renamed individually and then recombined.
*
* Given the renaming map:
*
*
* {
* once: 'a',
* upon: 'b',
* atime: 'c',
* long: 'd',
* time: 'e',
* ago: 'f'
* }
*
*
* The following outputs are expected with the 'BY_PART' renaming style:
*
*
* goog.getCssName('once') -> 'a'
* goog.getCssName('once-upon-atime') -> 'a-b-c'
*
* var baseClass = goog.getCssName('long-time');
* el.className = goog.getCssName(baseClass, 'ago');
* ->
* var baseClass = 'd-e';
* el.className = baseClass + '-f';
*
*
* However if we have the following renaming map with the 'BY_WHOLE' renaming style:
*
*
* {
* once: 'a',
* upon-atime: 'b',
* long-time: 'c',
* ago: 'd'
* }
*
*
* Then we would expect:
*
*
* goog.getCssName('once') -> 'a'
*
* var baseClass = goog.getCssName('long-time');
* el.className = goog.getCssName(baseClass, 'ago');
* ->
* var baseClass = 'c';
* el.className = baseClass + '-d';
*
*
* In addition, the CSS names before replacement can optionally be gathered.
*/
class ReplaceCssNames implements CompilerPass {
// This is used only for qualified name comparison, so it's OK to build it with IR instead of
// AstFactory. Type information isn't important here.
static final Node GET_CSS_NAME_FUNCTION = IR.getprop(IR.name("goog"), "getCssName");
static final DiagnosticType INVALID_NUM_ARGUMENTS_ERROR =
DiagnosticType.error(
"JSC_GETCSSNAME_NUM_ARGS",
"goog.getCssName called with \"{0}\" arguments, expected 1 or 2.");
static final DiagnosticType STRING_LITERAL_EXPECTED_ERROR =
DiagnosticType.error(
"JSC_GETCSSNAME_STRING_LITERAL_EXPECTED",
"goog.getCssName called with invalid argument, string literal "
+ "expected. Was \"{0}\".");
static final DiagnosticType UNEXPECTED_STRING_LITERAL_ERROR =
DiagnosticType.error(
"JSC_GETCSSNAME_UNEXPECTED_STRING_LITERAL",
"goog.getCssName called with invalid arguments, string literal "
+ "passed as first of two arguments. Did you mean "
+ "goog.getCssName(\"{0}-{1}\")?");
static final DiagnosticType NESTED_CALL_ERROR =
DiagnosticType.error(
"JSC_GETCSSNAME_NESTED_CALL", "goog.getCssName: nested call is not allowed.");
static final DiagnosticType UNKNOWN_SYMBOL_WARNING =
DiagnosticType.warning(
"JSC_GETCSSNAME_UNKNOWN_CSS_SYMBOL",
"goog.getCssName called with unrecognized symbol \"{0}\" in class " + "\"{1}\".");
static final DiagnosticType UNEXPECTED_SASS_GENERATED_CSS_TS_ERROR =
DiagnosticType.error(
"JSC_UNEXPECTED_SASS_GENERATED_CSS_TS",
"@sass_generated_css_ts JSDoc annotation is only allowed on .css.closure.js files.");
static final DiagnosticType UNKNOWN_SYMBOL_ERROR =
DiagnosticType.error(
"JSC_UNKNOWN_CSS_SYMBOL_IN_CLASSES_OBJECT",
"Symbol was not defined \"{0}\" in classes object.");
static final DiagnosticType INVALID_USE_OF_CLASSES_OBJECT_ERROR =
DiagnosticType.error(
"JSC_INVALID_USE_OF_CLASSES_OBJECT",
"invalid use of generated classes object. Only accessing its members is allowed.");
private final AbstractCompiler compiler;
private final AstFactory astFactory;
private final CssNameCollector cssNameCollector;
private final Map cssNamesBySymbol;
private final Set classesObjectsQualifiedNames;
private @Nullable CssRenamingMap symbolMap;
private final Set skiplist;
/** Called for each class name seen when replacing. */
@FunctionalInterface
public interface CssNameCollector {
void add(String className);
}
ReplaceCssNames(
AbstractCompiler compiler,
@Nullable CssRenamingMap symbolMap,
CssNameCollector cssNameCollector,
@Nullable Set skiplist) {
this.compiler = compiler;
this.astFactory = compiler.createAstFactory();
this.symbolMap = symbolMap;
this.cssNameCollector = cssNameCollector;
this.skiplist = skiplist;
this.cssNamesBySymbol = new LinkedHashMap<>();
this.classesObjectsQualifiedNames = new LinkedHashSet<>();
}
@Override
public void process(Node externs, Node root) {
NodeTraversal.traverse(compiler, root, new FindSetCssNameTraversal());
List getCssNameInstances = gatherGetCssNameInstances(root);
for (GetCssNameInstance getCssNameInstance : getCssNameInstances) {
String cssClassName = getCssNameInstance.getCssClassName();
if (getCssNameInstance.isCssClosureFileClassesMember()) {
cssNamesBySymbol.put(getCssNameInstance.getCssClosureClassesMemberName(), cssClassName);
classesObjectsQualifiedNames.add(getCssNameInstance.getCssClosureClassesQualifiedName());
} else {
cssNameCollector.add(cssClassName);
}
getCssNameInstance.replaceWithExpression();
}
NodeTraversal.traverse(compiler, root, new CountCssNamesBySymbol());
}
/**
* Create a list of objects representing all the `goog.getCssName()` calls in the AST.
*
* The elements in this list will be in depth-first left-to-right order. So, if you perform
* actions on them in this order you'll affect nested calls before the containing calls. e.g.
*
*
* goog.getCssName(goog.getCssName('me-first'), 'me-second')
*
*/
private List gatherGetCssNameInstances(Node root) {
GatherCssNamesTraversal gatherCssNamesTraversal = new GatherCssNamesTraversal();
NodeTraversal.traverse(compiler, root, gatherCssNamesTraversal);
return gatherCssNamesTraversal.listOfCssNameInstances;
}
// This is used only for qualified name comparison, so it's OK to build it with IR instead of
// AstFactory. Type information isn't important here.
private static final Node GOOG_SET_CSS_NAME_MAPPING =
IR.getprop(IR.name("goog"), "setCssNameMapping");
private class FindSetCssNameTraversal extends AbstractPostOrderCallback {
@Override
public void visit(NodeTraversal t, Node n, Node parent) {
if (!n.isCall() || !parent.isExprResult()) {
return;
}
Node callee = n.getFirstChild();
if (!callee.matchesQualifiedName(GOOG_SET_CSS_NAME_MAPPING)) {
return;
}
CssRenamingMap cssRenamingMap =
ProcessClosurePrimitives.processSetCssNameMapping(compiler, n, parent);
symbolMap = cssRenamingMap;
compiler.reportChangeToEnclosingScope(parent);
parent.detach();
}
}
private class GatherCssNamesTraversal implements NodeTraversal.Callback {
final List listOfCssNameInstances = new ArrayList<>();
final TraversalState traversalState = new TraversalState();
@Override
public boolean shouldTraverse(NodeTraversal t, Node n, Node parent) {
SassGeneratedCssTsExpert sassGeneratedCssTsExpert = createSassGeneratedCssTsExpert(n);
if (sassGeneratedCssTsExpert.sassGeneratedCssTsValidationError != null) {
compiler.report(sassGeneratedCssTsExpert.sassGeneratedCssTsValidationError);
// Skip all nodes that are descendants of this one, since we found an invalid
// @sassGeneratedCssTs JSDoc annotation, and are in an invalid state.
return false;
}
if (n.isScript()) {
traversalState.inSassGeneratedCssTsScript =
sassGeneratedCssTsExpert.hasSassGeneratedCssTsJsDoc;
traversalState.cssClosureClassesQualifiedName = null;
} else if (traversalState.inSassGeneratedCssTsScript
&& sassGeneratedCssTsExpert.isCssClosureClassesAssignment) {
traversalState.cssClosureClassesQualifiedName =
sassGeneratedCssTsExpert.cssClosureClassesQualifiedName;
}
return true;
}
@Override
public void visit(NodeTraversal t, Node n, Node parent) {
if (isGetCssNameCall(n)) {
GetCssNameInstance getCssNameInstance =
createGetCssNameInstance(n, traversalState.cssClosureClassesQualifiedName);
if (getCssNameInstance.isValid()) {
listOfCssNameInstances.add(getCssNameInstance);
} else {
compiler.report(getCssNameInstance.getValidationError());
}
}
}
public SassGeneratedCssTsExpert createSassGeneratedCssTsExpert(Node n) {
boolean hasSassGeneratedCssTsJsDoc =
n.getJSDocInfo() != null && n.getJSDocInfo().isSassGeneratedCssTs();
JSError sassGeneratedCssTsValidationError = null;
boolean isInvalidSassGeneratedCssTsJsDoc = !n.isScript() || isNotInCssClosureFile(n);
if (hasSassGeneratedCssTsJsDoc && isInvalidSassGeneratedCssTsJsDoc) {
sassGeneratedCssTsValidationError = JSError.make(n, UNEXPECTED_SASS_GENERATED_CSS_TS_ERROR);
}
Node assignmentTarget = n.getFirstChild();
boolean isCssClosureClassesAssignment =
n.isAssign()
&& assignmentTarget.isGetProp()
&& assignmentTarget.getString().equals("classes");
String cssClosureClassesQualifiedName = null;
if (isCssClosureClassesAssignment) {
cssClosureClassesQualifiedName = assignmentTarget.getQualifiedName();
}
return new SassGeneratedCssTsExpert(
hasSassGeneratedCssTsJsDoc,
sassGeneratedCssTsValidationError,
isCssClosureClassesAssignment,
cssClosureClassesQualifiedName);
}
private class SassGeneratedCssTsExpert {
public final boolean hasSassGeneratedCssTsJsDoc;
public final JSError sassGeneratedCssTsValidationError;
public final boolean isCssClosureClassesAssignment;
public final String cssClosureClassesQualifiedName;
public SassGeneratedCssTsExpert(
boolean hasSassGeneratedCssTsJsDoc,
JSError sassGeneratedCssTsValidationError,
boolean isCssClosureClassesAssignment,
String cssClosureClassesQualifiedName) {
this.hasSassGeneratedCssTsJsDoc = hasSassGeneratedCssTsJsDoc;
this.sassGeneratedCssTsValidationError = sassGeneratedCssTsValidationError;
this.isCssClosureClassesAssignment = isCssClosureClassesAssignment;
this.cssClosureClassesQualifiedName = cssClosureClassesQualifiedName;
}
}
private class TraversalState {
public boolean inSassGeneratedCssTsScript;
public String cssClosureClassesQualifiedName;
}
}
private boolean isNotInCssClosureFile(Node node) {
String sourceFileName = node.getSourceFileName();
return sourceFileName == null || !sourceFileName.endsWith(".css.closure.js");
}
private boolean isGetCssNameCall(Node node) {
return node.isCall() && node.getFirstChild().matchesQualifiedName(GET_CSS_NAME_FUNCTION);
}
private GetCssNameInstance createGetCssNameInstance(
Node n, String cssClosureClassesQualifiedName) {
JSError validationError = validateGetCssNameCall(n);
return new GetCssNameInstance(n, validationError, cssClosureClassesQualifiedName);
}
/** Represents a `goog.getCssName()` call. */
private class GetCssNameInstance {
/**
* The CALL node representing `goog.getCssName()`.
*
* We shouldn't store final pointers to any of the children of this node, because they might
* be changed between creation of this object and performance of other methods.
*/
final Node callNode;
/** Non-null if the shape of the function call was invalid, when this object was created. */
final JSError validationError;
/**
* The qualified name for the classes object if we're in a Sass-generated .css.ts file, or null
* otherwise. For example "module$exports$foo.classes".
*/
final String cssClosureClassesQualifiedName;
GetCssNameInstance(
Node callNode, JSError validationError, String cssClosureClassesQualifiedName) {
this.callNode = callNode;
this.validationError = validationError;
this.cssClosureClassesQualifiedName = cssClosureClassesQualifiedName;
}
boolean isValid() {
return validationError == null;
}
JSError getValidationError() {
checkNotNull(validationError, "No validation error found: %s", callNode);
return validationError;
}
/** Replace this `goog.getCssName()` call in the AST with a string typed expression. */
void replaceWithExpression() {
checkState(isValid(), "not a valid goog.getCssName() call: %s", callNode);
checkNotNull(callNode.getParent(), "already replaced: %s", callNode);
final int childCount = callNode.getChildCount();
switch (childCount) {
case 2:
replaceWithConvertedSingleArg();
break;
case 3:
replaceWithConcatenatedArgs();
break;
default:
throw new IllegalStateException(
SimpleFormat.format("invalid number of children: %s for: %s", childCount, callNode));
}
}
private void replaceWithConvertedSingleArg() {
// `goog.getCssName('some-literal')` -> `'mapped-literal'`
final Node stringLitArg = checkNotNull(callNode.getSecondChild(), callNode);
checkState(stringLitArg.isStringLit(), "not a string literal: %s", stringLitArg);
stringLitArg.detach();
processStringNode(stringLitArg);
callNode.replaceWith(stringLitArg);
compiler.reportChangeToEnclosingScope(stringLitArg);
}
private void replaceWithConcatenatedArgs() {
// `goog.getCssName(someExpr, 'some-literal')` -> `someExpr + '-mapped-literal'`
final Node firstArg = checkNotNull(callNode.getSecondChild(), callNode);
final Node secondArg = checkNotNull(firstArg.getNext(), firstArg);
firstArg.detach();
secondArg.detach();
processStringNode(secondArg);
secondArg.setString("-" + secondArg.getString());
final Node replacement = astFactory.createAdd(firstArg, secondArg).srcref(callNode);
callNode.replaceWith(replacement);
compiler.reportChangeToEnclosingScope(replacement);
}
boolean isCssClosureFileClassesMember() {
return cssClosureClassesQualifiedName != null;
}
@Nullable String getCssClosureClassesQualifiedName() {
checkNotNull(
cssClosureClassesQualifiedName, "Not a css closure file classes object: %s", callNode);
return cssClosureClassesQualifiedName;
}
@Nullable String getCssClosureClassesMemberName() {
checkNotNull(
cssClosureClassesQualifiedName, "Not a css closure file classes object: %s", callNode);
String memberName = callNode.getParent().getString();
return cssClosureClassesQualifiedName + "." + memberName;
}
@Nullable String getCssClassName() {
checkState(isValid(), "not a valid goog.getCssName() call: %s", callNode);
checkNotNull(callNode.getParent(), "already replaced: %s", callNode);
final int childCount = callNode.getChildCount();
final Node firstArg = checkNotNull(callNode.getSecondChild(), callNode);
switch (childCount) {
case 2:
checkState(firstArg.isStringLit(), "not a string literal: %s", firstArg);
return firstArg.getString();
case 3:
final Node secondArg = checkNotNull(firstArg.getNext(), firstArg);
checkState(secondArg.isStringLit(), "not a string literal: %s", secondArg);
return secondArg.getString();
default:
throw new IllegalStateException(
SimpleFormat.format("invalid number of children: %s for: %s", childCount, callNode));
}
}
}
private @Nullable JSError validateGetCssNameCall(Node callNode) {
int childCount = callNode.getChildCount();
switch (childCount) {
case 2:
return validateSingleArgGetCssNameCall(callNode);
case 3:
return validateTwoArgGetCssNameCall(callNode);
default:
return JSError.make(callNode, INVALID_NUM_ARGUMENTS_ERROR, String.valueOf(childCount));
}
}
private @Nullable JSError validateSingleArgGetCssNameCall(Node callNode) {
// `goog.getCssName('css-name')`
final Node stringLiteralArg = checkNotNull(callNode.getLastChild());
if (!stringLiteralArg.isStringLit()) {
return JSError.make(
callNode, STRING_LITERAL_EXPECTED_ERROR, stringLiteralArg.getToken().toString());
}
return null;
}
private @Nullable JSError validateTwoArgGetCssNameCall(Node callNode) {
// `goog.getCssName(BASE_CSS_NAME, 'css-suffix')`
// The first child is the callee, so the first argument is the second child
final Node firstArg = checkNotNull(callNode.getSecondChild());
final Node secondArg = checkNotNull(firstArg.getNext());
if (!secondArg.isStringLit()) {
return JSError.make(callNode, STRING_LITERAL_EXPECTED_ERROR, secondArg.getToken().toString());
}
if (firstArg.isStringLit()) {
return JSError.make(
callNode, UNEXPECTED_STRING_LITERAL_ERROR, firstArg.getString(), secondArg.getString());
}
if (isGetCssNameCall(firstArg)) {
// Disallow `goog.getCssName(goog.getCssName('n1'), 'n2')`.
// Disallowing this is a bit arbitrary and is done for historical reasons.
// The 2-argument form results in more JS code shipped to the browser and
// forces users to have camelCase CSS names, which contravenes normal CSS style.
// Since we'd like to stop supporting the 2-argument form, we don't want to
// start allowing any form of it that was previously an error.
return JSError.make(firstArg, NESTED_CALL_ERROR);
}
return null;
}
/**
* Processes a string argument to goog.getCssName(). The string will be renamed based off the
* symbol map. If there is no map or any part of the name can't be renamed, a warning is reported
* to the compiler and the node is left unchanged.
*
*
If the type is unexpected then an error is reported to the compiler.
*
* @param n The string node to process.
*/
private void processStringNode(Node n) {
String name = n.getString();
if (skiplist != null && skiplist.contains(name)) {
// We apply the skiplist before splitting on dashes, and not after.
// External substitution maps should do the same.
return;
}
String[] parts = name.split("-");
if (symbolMap != null) {
String replacement = null;
switch (symbolMap.getStyle()) {
case BY_WHOLE:
replacement = symbolMap.get(name);
if (replacement == null) {
compiler.report(JSError.make(n, UNKNOWN_SYMBOL_WARNING, name, name));
return;
}
break;
case BY_PART:
String[] replaced = new String[parts.length];
for (int i = 0; i < parts.length; i++) {
String part = symbolMap.get(parts[i]);
if (part == null) {
// If we can't encode all parts, don't encode any of it.
compiler.report(JSError.make(n, UNKNOWN_SYMBOL_WARNING, parts[i], name));
return;
}
replaced[i] = part;
}
replacement = Joiner.on("-").join(replaced);
break;
}
n.setString(replacement);
}
}
private class CountCssNamesBySymbol implements NodeTraversal.Callback {
@Override
public boolean shouldTraverse(NodeTraversal t, Node n, Node parent) {
if (n.isScript()) {
// Only descend into source files NOT named `*.css.closure.js`
return isNotInCssClosureFile(n);
} else {
return true; // descend into every other node
}
}
@Override
public void visit(NodeTraversal t, Node n, Node parent) {
String classesObjectQualifiedName = n.getQualifiedName();
if (classesObjectQualifiedName == null
|| !classesObjectsQualifiedNames.contains(classesObjectQualifiedName)) {
return;
}
Node classNameNode = n.getParent();
if (!n.isGetProp() || !classNameNode.isGetProp()) {
compiler.report(JSError.make(n, INVALID_USE_OF_CLASSES_OBJECT_ERROR));
return;
}
String classQualifiedName = classNameNode.getQualifiedName();
if (classQualifiedName == null || !cssNamesBySymbol.containsKey(classQualifiedName)) {
compiler.report(JSError.make(n, UNKNOWN_SYMBOL_ERROR, classNameNode.getString()));
return;
}
String className = cssNamesBySymbol.get(classQualifiedName);
cssNameCollector.add(className);
}
}
}