blob: f901ca0db9ae161896b3722b7f6c44551af678a2 [file] [log] [blame]
/*
* Copyright 2014 Google Inc.
*
* 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.gwt.resources.converter;
import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.TreeLogger.Type;
import com.google.gwt.dev.util.TextOutput;
import com.google.gwt.resources.css.ast.Context;
import com.google.gwt.resources.css.ast.CssCharset;
import com.google.gwt.resources.css.ast.CssDef;
import com.google.gwt.resources.css.ast.CssEval;
import com.google.gwt.resources.css.ast.CssExternalSelectors;
import com.google.gwt.resources.css.ast.CssFontFace;
import com.google.gwt.resources.css.ast.CssIf;
import com.google.gwt.resources.css.ast.CssMediaRule;
import com.google.gwt.resources.css.ast.CssNoFlip;
import com.google.gwt.resources.css.ast.CssPageRule;
import com.google.gwt.resources.css.ast.CssProperty;
import com.google.gwt.resources.css.ast.CssProperty.DotPathValue;
import com.google.gwt.resources.css.ast.CssProperty.FunctionValue;
import com.google.gwt.resources.css.ast.CssProperty.Value;
import com.google.gwt.resources.css.ast.CssRule;
import com.google.gwt.resources.css.ast.CssSelector;
import com.google.gwt.resources.css.ast.CssSprite;
import com.google.gwt.resources.css.ast.CssUnknownAtRule;
import com.google.gwt.resources.css.ast.CssUrl;
import com.google.gwt.thirdparty.common.css.SourceCode;
import com.google.gwt.thirdparty.common.css.compiler.ast.GssParser;
import com.google.gwt.thirdparty.common.css.compiler.ast.GssParserException;
import com.google.gwt.thirdparty.guava.common.base.Predicate;
import com.google.gwt.thirdparty.guava.common.base.Splitter;
import com.google.gwt.thirdparty.guava.common.base.Strings;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* The GssGenerationVisitor turns a css tree into a gss string.
*/
public class GssGenerationVisitor extends ExtendedCssVisitor {
/* templates and tokens list */
private static final String AND = " && ";
private static final String CHARSET = "@charset \"%s\";";
private static final String DEF = "@def ";
private static final String ELSE = "@else ";
private static final String ELSE_IF = "@elseif (%s)";
private static final String EVAL = "eval('%s')";
private static final String EXTERNAL = "@external";
private static final String GWT_SPRITE = "gwt-sprite: \"%s\"";
private static final String IF = "@if (%s)";
private static final String IMPORTANT = " !important";
private static final String IS = "is(\"%s\", \"%s\")";
private static final String NO_FLIP = "/* @noflip */";
private static final String NOT = "!";
private static final String OR = " || ";
private static final Pattern UNESCAPE = Pattern.compile("\\\\");
private static final Pattern UNESCAPE_EXTERNAL = Pattern.compile("\\\\|@external|,|\\n|\\r");
private static final String URL = "resourceUrl(\"%s\")";
private static final String VALUE = "value('%s')";
private static final String VALUE_WITH_SUFFIX = "value('%s', '%s')";
// Used to quote font family name that contains white space(s) and aren't quoted yet.
private static Pattern NOT_QUOTED_WITH_WITHESPACE = Pattern.compile("^[^'\"].*\\s.*[^'\"]$");
// Used to sanitize the boolean conditions
private static Pattern BANG_OPERATOR = Pattern.compile("^(!+)(.*)");
// GSS impose constant names to be in uppercase. This Map will contains the mapping between
// the name of constants defined in the CSS and the corresponding name that will be used in GSS.
private final Map<String, String> cssToGssConstantMapping;
private final TextOutput out;
private final boolean lenient;
private final TreeLogger treeLogger;
private final Predicate<String> simpleBooleanConditionPredicate;
// list of external at-rules defined inside a media at-rule.
// In lenient mode, these nodes will be extracted and print outside the media at-rule.
private final List<CssExternalSelectors> wrongExternalNodes = new
ArrayList<CssExternalSelectors>();
// list of constant definition nodes defined inside a media at-rule.
// In lenient mode, these nodes will be extracted and print outside the media at-rule.
private final List<CssDef> wrongDefNodes = new ArrayList<CssDef>();
private boolean insideNoFlipNode;
private boolean needsNewLine;
private boolean needsOpenBrace;
private boolean needsComma;
private boolean insideMediaAtRule;
// used to group a sequence of @def in one block
private boolean previousNodeIsDef;
// used to group asequence of @external in one block
private boolean previousNodeIsExternal;
public GssGenerationVisitor(TextOutput out, Map<String, String> cssToGssConstantMapping,
boolean lenient, TreeLogger treeLogger, Predicate<String> simpleBooleanConditionPredicate) {
this.cssToGssConstantMapping = cssToGssConstantMapping;
this.out = out;
this.lenient = lenient;
this.treeLogger = treeLogger;
this.simpleBooleanConditionPredicate = simpleBooleanConditionPredicate;
}
public String getContent() {
return out.toString();
}
@Override
public void endVisit(CssFontFace x, Context ctx) {
closeBrace();
}
@Override
public void endVisit(CssMediaRule x, Context ctx) {
closeBrace();
insideMediaAtRule = false;
maybePrintWrongExternalNodes();
maybePrintWrongDefNodes(ctx);
}
@Override
public void endVisit(CssPageRule x, Context ctx) {
closeBrace();
}
@Override
public void endVisit(CssUnknownAtRule x, Context ctx) {
// The old CSS resource has no support for many at-rules, like animations. There is no way
// for us to parse them using the old CSS parser, so we will just output them as a string to
// the GSS stylesheet and hope that the GSS parser is okay with the rule
out.print(x.getRule());
}
@Override
public boolean visit(CssSprite x, Context ctx) {
return false;
}
@Override
public void endVisit(CssSprite x, Context ctx) {
needsComma = false;
accept(x.getSelectors());
openBrace();
out.print(String.format(GWT_SPRITE, x.getResourceFunction().getPath()));
semiColon();
accept(x.getProperties());
closeBrace();
}
@Override
public boolean visit(CssDef x, Context ctx) {
printDef(x, null, "def", false);
previousNodeIsDef = true;
previousNodeIsExternal = false;
return false;
}
@Override
public boolean visit(CssEval x, Context ctx) {
printDef(x, EVAL, "eval", false);
return false;
}
@Override
public boolean visit(CssUrl x, Context ctx) {
printDef(x, URL, "url", true);
return false;
}
@Override
public boolean visit(CssRule x, Context ctx) {
maybePrintNewLine();
needsOpenBrace = true;
needsComma = false;
needsNewLine = false;
previousNodeIsDef = false;
previousNodeIsExternal = false;
return true;
}
@Override
public void endVisit(CssRule x, Context ctx) {
// empty rule block case.
maybePrintOpenBrace();
closeBrace();
needsNewLine = true;
}
@Override
public boolean visit(CssNoFlip x, Context ctx) {
insideNoFlipNode = true;
previousNodeIsDef = false;
previousNodeIsExternal = false;
return true;
}
@Override
public boolean visit(CssExternalSelectors x, Context ctx) {
if (insideMediaAtRule) {
if (lenient) {
treeLogger.log(Type.WARN, "An external at-rule is not allowed inside a @media at-rule. " +
"The following external at-rule [" + x + "] will be moved in the upper scope");
wrongExternalNodes.add(x);
} else {
treeLogger.log(Type.ERROR, "An external at-rule is not allowed inside a @media at-rule. " +
"[" + x + "].");
throw new Css2GssConversionException("An external at-rule is not allowed inside a @media" +
" at-rule.");
}
} else {
printExternal(x);
}
return false;
}
@Override
public boolean visit(CssCharset x, Context ctx) {
out.print(String.format(CHARSET, x.getCharset()));
out.newlineOpt();
needsNewLine = true;
previousNodeIsDef = false;
previousNodeIsExternal = false;
return true;
}
private void maybePrintWrongExternalNodes() {
if (!lenient) {
return;
}
for (CssExternalSelectors external : wrongExternalNodes) {
printExternal(external);
}
wrongExternalNodes.clear();
}
private void maybePrintWrongDefNodes(Context ctx) {
if (!lenient) {
return;
}
for (CssDef def : wrongDefNodes) {
if (def instanceof CssUrl) {
visit((CssUrl) def, ctx);
} else if (def instanceof CssEval) {
visit((CssEval) def, ctx);
} else {
visit(def, ctx);
}
}
wrongDefNodes.clear();
}
private void printExternal(CssExternalSelectors x) {
boolean first = true;
for (String selector : x.getClasses()) {
String unescaped = unescapeExternalClass(selector);
if (validateExternalClass(selector) && !Strings.isNullOrEmpty(unescaped)) {
if (first) {
if (!previousNodeIsExternal) {
maybePrintNewLine();
}
out.print(EXTERNAL);
first = false;
}
out.print(" ");
boolean needQuote = selector.endsWith("*");
if (needQuote) {
out.print("'");
}
out.printOpt(unescaped);
if (needQuote) {
out.print("'");
}
}
}
if (!first) {
semiColon();
}
previousNodeIsDef = false;
previousNodeIsExternal = true;
}
private boolean validateExternalClass(String selector) {
if (selector.contains(":")) {
if (lenient) {
treeLogger.log(Type.WARN, "This invalid external selector will be skipped: " + selector);
return false;
} else {
throw new Css2GssConversionException(
"One of your external statements contains a pseudo class: " + selector);
}
}
return true;
}
@Override
public void endVisit(CssNoFlip x, Context ctx) {
insideNoFlipNode = false;
}
@Override
public boolean visit(CssProperty x, Context ctx) {
maybePrintOpenBrace();
StringBuilder propertyBuilder = new StringBuilder();
if (insideNoFlipNode) {
propertyBuilder.append(NO_FLIP);
propertyBuilder.append(' ');
}
propertyBuilder.append(x.getName());
propertyBuilder.append(": ");
String valueListCss = printValuesList(x.getValues().getValues(), false);
if ("font-family".equals(x.getName())) {
// Font family names containing whitespace should be quoted.
valueListCss = quoteFontFamilyWithWhiteSpace(valueListCss);
}
propertyBuilder.append(valueListCss);
if (x.isImportant()) {
propertyBuilder.append(IMPORTANT);
}
String cssProperty = propertyBuilder.toString();
// See if we can parse the rule using the GSS parser and thus verify that the
// rule is indeed correct CSS.
try {
new GssParser(new SourceCode(null, "body{" + cssProperty + "}")).parse();
} catch (GssParserException e) {
if (lenient) {
// print a warning message and don't print the rule.
treeLogger.log(Type.WARN, "The following rule is not valid and will be skipped: " +
cssProperty);
return false;
} else {
treeLogger.log(Type.ERROR, "The following rule is not valid. " +
cssProperty);
throw new Css2GssConversionException("Invalid css rule", e);
}
}
out.print(cssProperty);
semiColon();
return true;
}
/**
* Quotes the font family names that contains white space but aren't quoted yet. thus allowing
* usage of fonts that might be mistaken for constants. RTis is also recommended by the CSS
* specification: http://www.w3.org/TR/CSS2/fonts.html#propdef-font-family
* <p>It's important to notice that the converter doesn't manage the case where a constant is
* used inside a font family name with whitespace. The font family name will be quoted and
* won't be replaced.
* {@code
* @def myFontFamily Comic;
*
* .div {
* font-family: Arial, myFontFamily sans MS;
* }
* }
*
* will be converted to:
*
* {@code
* @def MY_FONT_FAMILY Comic;
*
* .div {
* font-family: Arial, "MY_FONT_FAMILY sans MS";
* }
* }
* @param cssProperty
*/
private String quoteFontFamilyWithWhiteSpace(String cssProperty) {
StringBuilder valueBuilder = new StringBuilder();
boolean first = true;
for (String subProperty : Splitter.on(",").trimResults().omitEmptyStrings().split(cssProperty)) {
if (first) {
first = false;
} else {
valueBuilder.append(",");
}
if (NOT_QUOTED_WITH_WITHESPACE.matcher(subProperty).matches()) {
valueBuilder.append("'" + subProperty + "'");
} else {
valueBuilder.append(subProperty);
}
}
return valueBuilder.toString();
}
@Override
public boolean visit(CssElse x, Context ctx) {
closeBrace();
out.print(ELSE);
openBrace();
needsNewLine = false;
previousNodeIsDef = false;
previousNodeIsExternal = false;
return true;
}
@Override
public boolean visit(CssElIf x, Context ctx) {
closeBrace();
openConditional(ELSE_IF, x);
return true;
}
@Override
public void endVisit(CssIf x, Context ctx) {
closeBrace();
needsNewLine = true;
}
@Override
public boolean visit(CssIf x, Context ctx) {
maybePrintNewLine();
openConditional(IF, x);
return true;
}
private void openConditional(String template, CssIf ifOrElif) {
String condition;
String runtimeCondition = extractExpression(ifOrElif);
if (runtimeCondition != null) {
if (simpleBooleanConditionPredicate.apply(runtimeCondition)) {
condition = runtimeCondition;
} else {
condition = String.format(EVAL, runtimeCondition);
}
} else {
condition = printConditionnalExpression(ifOrElif);
}
out.print(String.format(template, condition));
openBrace();
needsNewLine = false;
previousNodeIsDef = false;
previousNodeIsExternal = false;
}
private String extractExpression(CssIf ifOrElif) {
String condition = ifOrElif.getExpression();
if (condition == null) {
return null;
}
if (condition.trim().startsWith("(")) {
condition = condition.substring(1, condition.length() - 1);
}
// sanitize the expression. GSS doesn't accept more than one ! operator
Matcher m = BANG_OPERATOR.matcher(condition);
if (m.matches()) {
String bangs = m.group(1);
String replacement;
if (bangs.length() % 2 == 0) {
replacement = "";
} else {
replacement = "!";
}
condition = m.replaceFirst(replacement + "$2");
}
return condition;
}
@Override
public boolean visit(CssFontFace x, Context ctx) {
out.print("@font-face");
openBrace();
previousNodeIsDef = false;
previousNodeIsExternal = false;
return true;
}
@Override
public boolean visit(CssMediaRule x, Context ctx) {
maybePrintNewLine();
insideMediaAtRule = true;
out.print("@media");
boolean isFirst = true;
for (String m : x.getMedias()) {
if (isFirst) {
out.print(" ");
isFirst = false;
} else {
comma();
}
out.print(m);
}
spaceOpt();
out.print("{");
out.newlineOpt();
out.indentIn();
needsNewLine = false;
previousNodeIsDef = false;
previousNodeIsExternal = false;
return true;
}
@Override
public boolean visit(CssPageRule x, Context ctx) {
out.print("@page");
if (x.getPseudoPage() != null) {
out.print(" :");
out.print(x.getPseudoPage());
}
spaceOpt();
out.print("{");
out.newlineOpt();
out.indentIn();
previousNodeIsDef = false;
previousNodeIsExternal = false;
return true;
}
@Override
public boolean visit(CssSelector x, Context ctx) {
if (needsComma) {
comma(false);
}
maybePrintNewLine();
needsComma = true;
needsNewLine = true;
out.print(unescape(x.getSelector()));
return true;
}
private void printDef(CssDef def, String valueTemplate, String atRule, boolean insideUrlNode) {
if (validateDefNode(def, atRule)) {
if (!previousNodeIsDef) {
maybePrintNewLine();
}
out.print(DEF);
String name = cssToGssConstantMapping.get(def.getKey());
if (name == null) {
throw new Css2GssConversionException("unknown @" + atRule + " rule [" + def.getKey() + "]");
}
out.print(name);
out.print(' ');
String values = printValuesList(def.getValues(), insideUrlNode);
if (valueTemplate != null) {
out.print(String.format(valueTemplate, values));
} else {
out.print(values);
}
semiColon();
previousNodeIsDef = true;
needsNewLine = true;
}
}
private boolean validateDefNode(CssDef def, String atRule) {
if (insideMediaAtRule) {
if (lenient) {
treeLogger.log(Type.WARN, "A " + atRule + " is not allowed inside a @media at-rule." +
"The following " + atRule + " [" + def + "] will be moved in the upper scope");
wrongDefNodes.add(def);
return false;
} else {
treeLogger.log(Type.ERROR, "A " + atRule + " is not allowed inside a @media at-rule. ["
+ def + "]");
throw new Css2GssConversionException("A " + atRule + " is not allowed inside a @media " +
"at-rule.");
}
}
return true;
}
private void closeBrace() {
out.indentOut();
out.print('}');
out.newlineOpt();
}
private void comma() {
comma(true);
}
private void comma(boolean addSpace) {
out.print(',');
if (addSpace) {
spaceOpt();
}
}
private void openBrace() {
spaceOpt();
out.print('{');
out.newlineOpt();
out.indentIn();
}
private void semiColon() {
out.print(';');
out.newlineOpt();
}
private void spaceOpt() {
out.printOpt(' ');
}
private void maybePrintOpenBrace() {
if (needsOpenBrace) {
openBrace();
needsOpenBrace = false;
}
}
private void maybePrintNewLine() {
if (needsNewLine) {
out.newlineOpt();
}
}
private String printConditionnalExpression(CssIf x) {
if (x == null || x.getExpression() != null) {
throw new IllegalStateException();
}
StringBuilder builder = new StringBuilder();
String propertyName = x.getPropertyName();
for (String propertyValue : x.getPropertyValues()) {
if (builder.length() != 0) {
if (x.isNegated()) {
builder.append(AND);
} else {
builder.append(OR);
}
}
if (x.isNegated()) {
builder.append(NOT);
}
builder.append(String.format(IS, propertyName, propertyValue));
}
return builder.toString();
}
private String printValuesList(List<Value> values, boolean insideUrlNode) {
StringBuilder builder = new StringBuilder();
for (Value value : values) {
if (value.isSpaceRequired() && builder.length() != 0) {
builder.append(' ');
}
String expression = value.toCss();
if (value.isIdentValue() != null && cssToGssConstantMapping.containsKey(expression)) {
expression = cssToGssConstantMapping.get(expression);
} else if (value.isExpressionValue() != null) {
expression = value.getExpression();
} else if (value.isDotPathValue() != null) {
DotPathValue dotPathValue = value.isDotPathValue();
if (insideUrlNode) {
expression = dotPathValue.getPath();
} else {
if (Strings.isNullOrEmpty(dotPathValue.getSuffix())) {
expression = String.format(VALUE, dotPathValue.getPath());
} else {
expression =
String.format(VALUE_WITH_SUFFIX, dotPathValue.getPath(), dotPathValue.getSuffix());
}
}
} else if (value.isFunctionValue() != null) {
FunctionValue functionValue = value.isFunctionValue();
// process the argument list values
String arguments = printValuesList(functionValue.getValues().getValues(), insideUrlNode);
expression = unescape(functionValue.getName()) + "(" + arguments + ")";
}
// don't escape content of quoted string and don't escape isFunctionValue because the
// arguments and the name of the functions are already unescaped if needed.
if (value.isStringValue() != null || value.isFunctionValue() != null) {
builder.append(expression);
} else {
builder.append(unescape(expression));
}
}
return builder.toString();
}
private String unescape(String toEscape) {
return UNESCAPE.matcher(toEscape).replaceAll("");
}
private String unescapeExternalClass(String external) {
return UNESCAPE_EXTERNAL.matcher(external).replaceAll("");
}
}