/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2014, Open Source Geospatial Foundation (OSGeo)
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotools.styling.css;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.geotools.filter.text.cql2.CQLException;
import org.geotools.filter.text.ecql.ECQL;
import org.geotools.styling.css.Value.Literal;
import org.geotools.styling.css.selector.Data;
import org.geotools.styling.css.selector.Id;
import org.geotools.styling.css.selector.PseudoClass;
import org.geotools.styling.css.selector.ScaleRange;
import org.geotools.styling.css.selector.Selector;
import org.geotools.styling.css.selector.TypeName;
import org.opengis.filter.Filter;
import org.opengis.filter.expression.Expression;
import org.parboiled.Action;
import org.parboiled.BaseParser;
import org.parboiled.Context;
import org.parboiled.Parboiled;
import org.parboiled.Rule;
import org.parboiled.annotations.BuildParseTree;
import org.parboiled.annotations.SuppressNode;
import org.parboiled.annotations.SuppressSubnodes;
import org.parboiled.parserunners.ParseRunner;
import org.parboiled.parserunners.ReportingParseRunner;
import org.parboiled.support.ParsingResult;
import org.parboiled.support.ValueStack;
/**
* Parser for the cartographic CSS. In order to parse a CSS either get a parser using the
* {@link #getInstance()} method, or directly call {@link #parse(String)}
*
* @author Andrea Aime - GeoSolutions
*/
@BuildParseTree
public class CssParser extends BaseParser<Object> {
static CssParser INSTANCE;
static final Object MARKER = new Object();
/**
* Quick key/value storage
*/
static final class KeyValue {
String key;
Value value;
public KeyValue(String key, Value value) {
this.key = key;
this.value = value;
}
}
static final class Prefix {
String prefix;
public Prefix(java.lang.String prefix) {
super();
this.prefix = prefix;
}
}
/**
* Allows Parboiled to do its magic, while disallowing normal users from instantiating this
* class
*/
protected CssParser() {
}
/**
* Returns the single instance of the CSS parser. The CSSParser should not be instantiated
* directly, Parboiled needs to do it instead.
*
* @return
*/
public static CssParser getInstance() {
// we need to lazily create it, otherwise Parboiled won't be able to instrument the class
if (INSTANCE == null) {
INSTANCE = Parboiled.createParser(CssParser.class);
}
return INSTANCE;
}
/**
* Turns the CSS provided into a {@link Stylesheet} object, will throw a
* {@link CSSParseException} in case of syntax errors
*
* @return
* @throws IOException
*/
public static Stylesheet parse(String css) throws CSSParseException {
CssParser parser = getInstance();
ParseRunner<Stylesheet> runner = new ReportingParseRunner<Stylesheet>(parser.StyleSheet());
ParsingResult<Stylesheet> result = runner.run(css);
if (result.hasErrors()) {
throw new CSSParseException(result.parseErrors);
}
Stylesheet ss = result.parseTreeRoot.getValue();
return ss;
}
Rule StyleSheet() {
return Sequence(ZeroOrMore(Directive(), OptionalWhiteSpace()), OneOrMore(CssRule()),
WhiteSpaceOrIgnoredComment(), EOI, push(new Stylesheet(popAll(CssRule.class),
popAll((Directive.class)))));
}
Rule Directive() {
return Sequence("@", Identifier(), push(match()), WhiteSpace(), String(), Ch(';'), swap(),
push(new Directive((String) pop(), ((Literal) pop()).toLiteral())));
}
Rule CssRule() {
return Sequence(WhiteSpaceOrComment(), Selector(), OptionalWhiteSpace(),//
'{', OptionalWhiteSpace(), //
RuleContents(), WhiteSpaceOrIgnoredComment(), '}', new Action() {
@Override
public boolean run(Context ctx) {
List contents = (List) pop();
Selector selector = (Selector) pop();
String comment = null;
if (!ctx.getValueStack().isEmpty() && peek() instanceof String) {
comment = (String) pop();
comment = comment.trim();
// get rid of the extra comments between rules
while (!ctx.getValueStack().isEmpty() && peek() instanceof String) {
pop();
}
}
final Stream stream = contents.stream();
Map<Boolean, List> splitContents = (Map<Boolean, List>) stream.collect(Collectors.partitioningBy(x -> x instanceof CssRule));
List<Property> properties = splitContents.get(Boolean.FALSE);
List<CssRule> subRules = splitContents.get(Boolean.TRUE);
final CssRule rule = new CssRule(selector, properties, comment);
rule.nestedRules = subRules;
push(rule);
return true;
}
});
}
Rule RuleContents() {
return Sequence(
FirstOf(CssRule(), Property()),
ZeroOrMore(Sequence(WhitespaceOrIgnoredComment(), ';',
OptionalWhiteSpace(), FirstOf(CssRule(), Property()))), Optional(';'),
push(popAll(Property.class, CssRule.class)));
}
Rule Selector() {
return FirstOf(OrSelector(), AndSelector(), BasicSelector());
}
Rule BasicSelector() {
return FirstOf(CatchAllSelector(), ECQLSelector(), MinScaleSelector(), MaxScaleSelector(),
IdSelector(), PseudoClassSelector(), NumberedPseudoClassSelector(),
TypenameSelector());
}
Rule AndSelector() {
return Sequence(BasicSelector(), OptionalWhiteSpace(),
FirstOf(AndSelector(), BasicSelector()), //
swap() && push(Selector.and((Selector) pop(), (Selector) pop(), null)));
}
Rule OrSelector() {
return Sequence(FirstOf(AndSelector(), BasicSelector()), OptionalWhiteSpace(), ',',
OptionalWhiteSpace(), Selector(), //
swap() && push(Selector.or((Selector) pop(), (Selector) pop(), null)));
}
@SuppressSubnodes
Rule PseudoClassSelector() {
return Sequence(':', ClassName(), push(PseudoClass.newPseudoClass((String) pop())));
}
@SuppressSubnodes
Rule NumberedPseudoClassSelector() {
return Sequence(
":nth-",
ClassName(),
'(',
Number(),
push(match()),
')',
swap()
&& push(PseudoClass.newPseudoClass((String) pop(),
Integer.valueOf((String) pop()))));
}
Rule ClassName() {
return Sequence(FirstOf("mark", "stroke", "fill", "symbol", "shield"), push(match()));
}
@SuppressSubnodes
Rule TypenameSelector() {
return Sequence(QualifiedIdentifier(), push(new TypeName(
match())));
}
Rule QualifiedIdentifier() {
return Sequence(Identifier(), Optional(':', Identifier()));
}
@SuppressSubnodes
Rule IdSelector() {
return Sequence(
'#',
Sequence(Identifier(), Optional(':', Identifier()),
Optional('.', Sequence(TestNot(AnyOf("\"'[]")), ANY))),
push(new Id(match())));
}
Rule CatchAllSelector() {
return Sequence('*', push(Selector.ACCEPT));
}
Rule MaxScaleSelector() {
return Sequence("[", OptionalWhiteSpace(), "@scale", OptionalWhiteSpace(),
FirstOf("<=", "<"), OptionalWhiteSpace(), Number(), push(new ScaleRange(0, true,
Double.valueOf(match()), false)), //
OptionalWhiteSpace(), "]");
}
Rule MinScaleSelector() {
return Sequence(
"[",
OptionalWhiteSpace(),
"@scale",
OptionalWhiteSpace(),
FirstOf(">=", ">"),
OptionalWhiteSpace(),
Number(),
push(new ScaleRange(Double.valueOf(match()), true, Double.POSITIVE_INFINITY, true)), //
OptionalWhiteSpace(), "]");
}
Rule WhitespaceOrIgnoredComment() {
return ZeroOrMore(FirstOf(WhiteSpace(), IgnoredComment()));
}
Rule Property() {
return Sequence(WhiteSpaceOrIgnoredComment(), Identifier(),
push(match()),
OptionalWhiteSpace(),
Colon(),
OptionalWhiteSpace(), //
Sequence(Value(), OptionalWhiteSpace(),
ZeroOrMore(',', OptionalWhiteSpace(), Value())), //
push(popAll(Value.class)) && swap()
&& push(new Property(pop(String.class), pop(List.class))));
}
Rule KeyValue() {
return Sequence(Identifier(), push(match()),
OptionalWhiteSpace(),
Colon(),
OptionalWhiteSpace(),
Value(),
swap()
&& push(new KeyValue(pop(String.class), pop(Value.class))));
}
@SuppressNode
Rule Colon() {
return Ch(':');
}
Rule Value() {
return FirstOf(MultiValue(), SimpleValue());
}
Rule SimpleValue() {
return FirstOf(URLFunction(), TransformFunction(), Function(), Color(), NamedColor(), Measure(),
ValueIdentifier(), MixedExpression());
}
Rule MixedExpression() {
return Sequence(push(MARKER), OneOrMore(FirstOf(ECQLExpression(), String())), new Action() {
@Override
public boolean run(Context ctx) {
Object value = pop();
List<Expression> expressions = new ArrayList<>();
Object firstValue = null;
while (value != MARKER) {
firstValue = value;
if (value instanceof Value) {
expressions.add(((Value) value).toExpression());
}
value = pop();
}
if (expressions.size() == 0) {
return false;
} else if (expressions.size() == 1) {
push(firstValue);
} else {
Collections.reverse(expressions);
org.opengis.filter.expression.Function function = Data.FF.function(
"Concatenate", expressions.toArray(new Expression[expressions.size()]));
push(new Value.Expression(function));
}
return true;
}
});
}
Rule MultiValue() {
return Sequence(push(MARKER), SimpleValue(), OneOrMore(WhiteSpace(), SimpleValue()),
push(new Value.MultiValue(popAll(Value.class))));
}
Rule Function() {
return Sequence(Identifier(), push(match()), '(', Value(),
ZeroOrMore(OptionalWhiteSpace(), ',', OptionalWhiteSpace(), Value()), ')',
push(buildFunction(popAll(Value.class), (String) pop())));
}
Value.Function buildFunction(List<Value> values, String name) {
return new Value.Function(name, values);
}
Rule TransformFunction() {
return Sequence(QualifiedIdentifier(), push(new Prefix(match())), '(', Optional(OptionalWhiteSpace(), KeyValue()),
ZeroOrMore(OptionalWhiteSpace(), ',', OptionalWhiteSpace(), KeyValue()), ')',
push(buildTransformFunction(popAll(KeyValue.class), pop(Prefix.class))));
}
Value.TransformFunction buildTransformFunction(List<KeyValue> values, Prefix name) {
Map<String, Value> parameters = new LinkedHashMap<>();
for (KeyValue keyValue : values) {
parameters.put(keyValue.key, keyValue.value);
}
return new Value.TransformFunction(name.prefix, parameters);
}
Rule URLFunction() {
return Sequence("url", OptionalWhiteSpace(), "(", OptionalWhiteSpace(), URL(),
OptionalWhiteSpace(), ")", push(new Value.Function("url", (Value) pop())));
}
/**
* Very relaxed URL matcher, as we need to match also relative urls
*
* @return
*/
Rule URL() {
return FirstOf(QuotedURL(), SimpleURL());
}
Rule SimpleURL() {
return Sequence(OneOrMore(FirstOf(Alphanumeric(), AnyOf("-._]:/?#[]@|$&'*+,;="))),
push(new Value.Literal(match())));
}
Rule QuotedURL() {
// same as simple url, but with ' surrounding it, and not within the url itlsef
return Sequence(
"'",
Sequence(OneOrMore(FirstOf(Alphanumeric(), AnyOf("-._]:/?#[]@|$&*+,;="))),
push(new Value.Literal(match()))), "'");
}
Rule ValueIdentifier() {
return Sequence(Identifier(), push(new Value.Literal(match())));
}
Rule String() {
return FirstOf(
Sequence('\'', ZeroOrMore(Sequence(TestNot(AnyOf("'\\")), ANY)),
push(new Value.Literal(match())), '\''),
Sequence('"', ZeroOrMore(Sequence(TestNot(AnyOf("\"\\")), ANY)),
push(new Value.Literal(match())), '"'));
}
Rule Measure() {
return Sequence(
Sequence(
Number(),
Optional(FirstOf(String("px"), String("m"), String("ft"), String("%"),
String("deg")))), push(new Value.Literal(match())));
}
Rule ECQLExpression() {
return ECQL(new Action() {
@Override
public boolean run(Context ctx) {
String expression = match();
try {
org.opengis.filter.expression.Expression e = ECQL.toExpression(expression);
ctx.getValueStack().push(new Value.Expression(e));
return true;
} catch (CQLException e) {
return false;
}
}
});
}
Rule ECQLSelector() {
return ECQL(new Action() {
@Override
public boolean run(Context ctx) {
String expression = match();
try {
Filter f = ECQL.toFilter(expression);
ctx.getValueStack().push(new Data(f));
return true;
} catch (CQLException e) {
return false;
}
}
});
}
Rule ECQL(Action parserChecker) {
return Sequence(
'[',
OneOrMore(FirstOf(SingleQuotedString(), DoubleQuotedString(),
Sequence(TestNot(AnyOf("\"'[]")), ANY))), //
parserChecker, ']');
}
Rule DoubleQuotedString() {
return Sequence('"', ZeroOrMore(Sequence(TestNot(AnyOf("\r\n\"\\")), ANY)), '"');
}
Rule SingleQuotedString() {
return Sequence('\'', ZeroOrMore(Sequence(TestNot(AnyOf("\r\n'\\")), ANY)), '\'');
}
Rule IntegralNumber() {
return OneOrMore(Digit());
}
Rule Number() {
return Sequence(Optional(AnyOf("-+")), OneOrMore(Digit()),
Optional('.', ZeroOrMore(Digit())));
}
@SuppressSubnodes
Rule Color() {
return Sequence(
Sequence(
'#',
FirstOf(Sequence(Hex(), Hex(), Hex(), Hex(), Hex(), Hex()),
Sequence(Hex(), Hex(), Hex()))), push(new Value.Literal(
toHexColor(match()))));
}
String toHexColor(String hex) {
if (hex.length() == 7) {
return hex;
} else {
char r = hex.charAt(1);
char g = hex.charAt(2);
char b = hex.charAt(3);
return "#" + r + r + g + g + b + b;
}
}
@SuppressSubnodes
Rule NamedColor() {
String[] colorNames = new String[Value.COLORS_TO_HEX.size()];
int i = 0;
for (String name : Value.COLORS_TO_HEX.keySet()) {
colorNames[i++] = name;
}
// make sure the longer words come before the shorter ones (yellowgreen before yellow)
Arrays.sort(colorNames, Collections.reverseOrder());
Rule[] insensitiveColorNames = new Rule[colorNames.length];
for (int j = 0; j < colorNames.length; j++) {
insensitiveColorNames[j] = IgnoreCase(colorNames[j]);
}
return Sequence(FirstOf(insensitiveColorNames), new Action() {
@Override
public boolean run(Context ctx) {
String hex = Value.COLORS_TO_HEX.get(match().toLowerCase());
push(new Value.Literal(hex));
return true;
}
});
}
@SuppressNode
Rule Identifier() {
return Sequence(Optional('-'), NameStart(), ZeroOrMore(NameCharacter()));
}
// Rule QualifiedIdentifier() {
// return Sequence(Optional(Identifier(), push(new Prefix(match())), ':'), Identifier(),
// new Action() {
// @Override
// public boolean run(Context ctx) {
// String name = (java.lang.String) pop();
// if(peek() instanceof Prefix) {
// Prefix prefix = (Prefix) pop();
// name = prefix.prefix + ":" + name;
// }
//
// push(name);
// }
// });
// }
@SuppressNode
Rule NameStart() {
return FirstOf('_', Alpha());
}
@SuppressNode
Rule NameCharacter() {
return FirstOf(AnyOf("-_"), Alphanumeric());
}
@SuppressNode
Rule Hex() {
return FirstOf(CharRange('a', 'f'), CharRange('A', 'F'), Digit());
}
@SuppressNode
Rule Digit() {
return CharRange('0', '9');
}
@SuppressNode
Rule Alphanumeric() {
return FirstOf(Alpha(), Digit());
}
@SuppressNode
Rule Alpha() {
return FirstOf(CharRange('a', 'z'), CharRange('A', 'Z'));
}
Rule IgnoredComment() {
return Sequence("/*", ZeroOrMore(TestNot("*/"), ANY), "*/");
}
Rule RuleComment() {
return Sequence("/*", ZeroOrMore(TestNot("*/"), ANY), push(match()), "*/");
}
@SuppressNode
Rule WhiteSpaceOrIgnoredComment() {
return ZeroOrMore(FirstOf(IgnoredComment(), WhiteSpace()));
}
@SuppressNode
Rule WhiteSpaceOrComment() {
return ZeroOrMore(FirstOf(RuleComment(), WhiteSpace()));
}
@SuppressNode
Rule OptionalWhiteSpace() {
return ZeroOrMore(AnyOf(" \r\t\f\n"));
}
@SuppressNode
Rule WhiteSpace() {
return OneOrMore(AnyOf(" \r\t\f\n"));
}
/**
* We redefine the rule creation for string literals to automatically match trailing whitespace
* if the string literal ends with a space character, this way we don't have to insert extra
* whitespace() rules after each character or string literal
*/
@Override
protected Rule fromStringLiteral(String string) {
return string.matches("\\s+$") ? Sequence(String(string.substring(0, string.length() - 1)),
OptionalWhiteSpace()) : String(string);
}
<T> T pop(Class<T> clazz) {
return (T) pop();
}
<T> List<T> popAll(Class... classes) {
ValueStack<Object> valueStack = getContext().getValueStack();
List<T> result = new ArrayList<T>();
while (!valueStack.isEmpty() && isInstance(classes, valueStack.peek())) {
result.add((T) valueStack.pop());
}
if (!valueStack.isEmpty() && valueStack.peek() == MARKER) {
valueStack.pop();
}
Collections.reverse(result);
return result;
}
private boolean isInstance(Class[] classes, Object peek) {
for (int i = 0; i < classes.length; i++) {
if(classes[i].isInstance(peek)) {
return true;
}
}
return false;
}
}