001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.mappaint.mapcss;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Color;
007import java.io.ByteArrayInputStream;
008import java.io.File;
009import java.io.IOException;
010import java.io.InputStream;
011import java.lang.reflect.Field;
012import java.nio.charset.StandardCharsets;
013import java.text.MessageFormat;
014import java.util.ArrayList;
015import java.util.BitSet;
016import java.util.Collections;
017import java.util.HashMap;
018import java.util.HashSet;
019import java.util.Iterator;
020import java.util.List;
021import java.util.Locale;
022import java.util.Map;
023import java.util.Map.Entry;
024import java.util.NoSuchElementException;
025import java.util.Set;
026import java.util.concurrent.locks.ReadWriteLock;
027import java.util.concurrent.locks.ReentrantReadWriteLock;
028import java.util.zip.ZipEntry;
029import java.util.zip.ZipFile;
030
031import org.openstreetmap.josm.Main;
032import org.openstreetmap.josm.data.Version;
033import org.openstreetmap.josm.data.osm.AbstractPrimitive;
034import org.openstreetmap.josm.data.osm.AbstractPrimitive.KeyValueVisitor;
035import org.openstreetmap.josm.data.osm.Node;
036import org.openstreetmap.josm.data.osm.OsmPrimitive;
037import org.openstreetmap.josm.data.osm.Relation;
038import org.openstreetmap.josm.data.osm.Way;
039import org.openstreetmap.josm.gui.mappaint.Cascade;
040import org.openstreetmap.josm.gui.mappaint.Environment;
041import org.openstreetmap.josm.gui.mappaint.MultiCascade;
042import org.openstreetmap.josm.gui.mappaint.Range;
043import org.openstreetmap.josm.gui.mappaint.StyleKeys;
044import org.openstreetmap.josm.gui.mappaint.StyleSetting;
045import org.openstreetmap.josm.gui.mappaint.StyleSetting.BooleanStyleSetting;
046import org.openstreetmap.josm.gui.mappaint.StyleSource;
047import org.openstreetmap.josm.gui.mappaint.mapcss.Condition.KeyCondition;
048import org.openstreetmap.josm.gui.mappaint.mapcss.Condition.KeyMatchType;
049import org.openstreetmap.josm.gui.mappaint.mapcss.Condition.KeyValueCondition;
050import org.openstreetmap.josm.gui.mappaint.mapcss.Condition.Op;
051import org.openstreetmap.josm.gui.mappaint.mapcss.Condition.SimpleKeyValueCondition;
052import org.openstreetmap.josm.gui.mappaint.mapcss.Selector.ChildOrParentSelector;
053import org.openstreetmap.josm.gui.mappaint.mapcss.Selector.GeneralSelector;
054import org.openstreetmap.josm.gui.mappaint.mapcss.Selector.OptimizedGeneralSelector;
055import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.MapCSSParser;
056import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.ParseException;
057import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.TokenMgrError;
058import org.openstreetmap.josm.gui.mappaint.styleelement.LineElement;
059import org.openstreetmap.josm.gui.preferences.SourceEntry;
060import org.openstreetmap.josm.io.CachedFile;
061import org.openstreetmap.josm.tools.CheckParameterUtil;
062import org.openstreetmap.josm.tools.LanguageInfo;
063import org.openstreetmap.josm.tools.Utils;
064
065/**
066 * This is a mappaint style that is based on MapCSS rules.
067 */
068public class MapCSSStyleSource extends StyleSource {
069
070    /**
071     * The accepted MIME types sent in the HTTP Accept header.
072     * @since 6867
073     */
074    public static final String MAPCSS_STYLE_MIME_TYPES =
075            "text/x-mapcss, text/mapcss, text/css; q=0.9, text/plain; q=0.8, application/zip, application/octet-stream; q=0.5";
076
077    // all rules
078    public final List<MapCSSRule> rules = new ArrayList<>();
079    // rule indices, filtered by primitive type
080    public final MapCSSRuleIndex nodeRules = new MapCSSRuleIndex();         // nodes
081    public final MapCSSRuleIndex wayRules = new MapCSSRuleIndex();          // ways without tag area=no
082    public final MapCSSRuleIndex wayNoAreaRules = new MapCSSRuleIndex();    // ways with tag area=no
083    public final MapCSSRuleIndex relationRules = new MapCSSRuleIndex();     // relations that are not multipolygon relations
084    public final MapCSSRuleIndex multipolygonRules = new MapCSSRuleIndex(); // multipolygon relations
085    public final MapCSSRuleIndex canvasRules = new MapCSSRuleIndex();       // rules to apply canvas properties
086
087    private Color backgroundColorOverride;
088    private String css;
089    private ZipFile zipFile;
090
091    /**
092     * This lock prevents concurrent execution of {@link MapCSSRuleIndex#clear() } /
093     * {@link MapCSSRuleIndex#initIndex()} and {@link MapCSSRuleIndex#getRuleCandidates }.
094     *
095     * For efficiency reasons, these methods are synchronized higher up the
096     * stack trace.
097     */
098    public static final ReadWriteLock STYLE_SOURCE_LOCK = new ReentrantReadWriteLock();
099
100    /**
101     * Set of all supported MapCSS keys.
102     */
103    protected static final Set<String> SUPPORTED_KEYS = new HashSet<>();
104    static {
105        Field[] declaredFields = StyleKeys.class.getDeclaredFields();
106        for (Field f : declaredFields) {
107            try {
108                SUPPORTED_KEYS.add((String) f.get(null));
109                if (!f.getName().toLowerCase(Locale.ENGLISH).replace('_', '-').equals(f.get(null))) {
110                    throw new RuntimeException(f.getName());
111                }
112            } catch (IllegalArgumentException | IllegalAccessException ex) {
113                throw new RuntimeException(ex);
114            }
115        }
116        for (LineElement.LineType lt : LineElement.LineType.values()) {
117            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.COLOR);
118            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.DASHES);
119            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.DASHES_BACKGROUND_COLOR);
120            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.DASHES_BACKGROUND_OPACITY);
121            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.DASHES_OFFSET);
122            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.LINECAP);
123            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.LINEJOIN);
124            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.MITERLIMIT);
125            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.OFFSET);
126            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.OPACITY);
127            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.REAL_WIDTH);
128            SUPPORTED_KEYS.add(lt.prefix + StyleKeys.WIDTH);
129        }
130    }
131
132    /**
133     * A collection of {@link MapCSSRule}s, that are indexed by tag key and value.
134     *
135     * Speeds up the process of finding all rules that match a certain primitive.
136     *
137     * Rules with a {@link SimpleKeyValueCondition} [key=value] or rules that require a specific key to be set are
138     * indexed. Now you only need to loop the tags of a primitive to retrieve the possibly matching rules.
139     *
140     * To use this index, you need to {@link #add(MapCSSRule)} all rules to it. You then need to call
141     * {@link #initIndex()}. Afterwards, you can use {@link #getRuleCandidates(OsmPrimitive)} to get an iterator over
142     * all rules that might be applied to that primitive.
143     */
144    public static class MapCSSRuleIndex {
145        /**
146         * This is an iterator over all rules that are marked as possible in the bitset.
147         *
148         * @author Michael Zangl
149         */
150        private final class RuleCandidatesIterator implements Iterator<MapCSSRule>, KeyValueVisitor {
151            private final BitSet ruleCandidates;
152            private int next;
153
154            private RuleCandidatesIterator(BitSet ruleCandidates) {
155                this.ruleCandidates = ruleCandidates;
156            }
157
158            @Override
159            public boolean hasNext() {
160                return next >= 0 && next < rules.size();
161            }
162
163            @Override
164            public MapCSSRule next() {
165                if (!hasNext())
166                    throw new NoSuchElementException();
167                MapCSSRule rule = rules.get(next);
168                next = ruleCandidates.nextSetBit(next + 1);
169                return rule;
170            }
171
172            @Override
173            public void remove() {
174                throw new UnsupportedOperationException();
175            }
176
177            @Override
178            public void visitKeyValue(AbstractPrimitive p, String key, String value) {
179                MapCSSKeyRules v = index.get(key);
180                if (v != null) {
181                    BitSet rs = v.get(value);
182                    ruleCandidates.or(rs);
183                }
184            }
185
186            /**
187             * Call this before using the iterator.
188             */
189            public void prepare() {
190                next = ruleCandidates.nextSetBit(0);
191            }
192        }
193
194        /**
195         * This is a map of all rules that are only applied if the primitive has a given key (and possibly value)
196         *
197         * @author Michael Zangl
198         */
199        private static final class MapCSSKeyRules {
200            /**
201             * The indexes of rules that might be applied if this tag is present and the value has no special handling.
202             */
203            BitSet generalRules = new BitSet();
204
205            /**
206             * A map that sores the indexes of rules that might be applied if the key=value pair is present on this
207             * primitive. This includes all key=* rules.
208             */
209            Map<String, BitSet> specialRules = new HashMap<>();
210
211            public void addForKey(int ruleIndex) {
212                generalRules.set(ruleIndex);
213                for (BitSet r : specialRules.values()) {
214                    r.set(ruleIndex);
215                }
216            }
217
218            public void addForKeyAndValue(String value, int ruleIndex) {
219                BitSet forValue = specialRules.get(value);
220                if (forValue == null) {
221                    forValue = new BitSet();
222                    forValue.or(generalRules);
223                    specialRules.put(value.intern(), forValue);
224                }
225                forValue.set(ruleIndex);
226            }
227
228            public BitSet get(String value) {
229                BitSet forValue = specialRules.get(value);
230                if (forValue != null) return forValue; else return generalRules;
231            }
232        }
233
234        /**
235         * All rules this index is for. Once this index is built, this list is sorted.
236         */
237        private final List<MapCSSRule> rules = new ArrayList<>();
238        /**
239         * All rules that only apply when the given key is present.
240         */
241        private final Map<String, MapCSSKeyRules> index = new HashMap<>();
242        /**
243         * Rules that do not require any key to be present. Only the index in the {@link #rules} array is stored.
244         */
245        private final BitSet remaining = new BitSet();
246
247        /**
248         * Add a rule to this index. This needs to be called before {@link #initIndex()} is called.
249         * @param rule The rule to add.
250         */
251        public void add(MapCSSRule rule) {
252            rules.add(rule);
253        }
254
255        /**
256         * Initialize the index.
257         * <p>
258         * You must own the write lock of STYLE_SOURCE_LOCK when calling this method.
259         */
260        public void initIndex() {
261            Collections.sort(rules);
262            for (int ruleIndex = 0; ruleIndex < rules.size(); ruleIndex++) {
263                MapCSSRule r = rules.get(ruleIndex);
264                // find the rightmost selector, this must be a GeneralSelector
265                Selector selRightmost = r.selector;
266                while (selRightmost instanceof ChildOrParentSelector) {
267                    selRightmost = ((ChildOrParentSelector) selRightmost).right;
268                }
269                OptimizedGeneralSelector s = (OptimizedGeneralSelector) selRightmost;
270                if (s.conds == null) {
271                    remaining.set(ruleIndex);
272                    continue;
273                }
274                List<SimpleKeyValueCondition> sk = new ArrayList<>(Utils.filteredCollection(s.conds,
275                        SimpleKeyValueCondition.class));
276                if (!sk.isEmpty()) {
277                    SimpleKeyValueCondition c = sk.get(sk.size() - 1);
278                    getEntryInIndex(c.k).addForKeyAndValue(c.v, ruleIndex);
279                } else {
280                    String key = findAnyRequiredKey(s.conds);
281                    if (key != null) {
282                        getEntryInIndex(key).addForKey(ruleIndex);
283                    } else {
284                        remaining.set(ruleIndex);
285                    }
286                }
287            }
288        }
289
290        /**
291         * Search for any key that condition might depend on.
292         *
293         * @param conds The conditions to search through.
294         * @return An arbitrary key this rule depends on or <code>null</code> if there is no such key.
295         */
296        private String findAnyRequiredKey(List<Condition> conds) {
297            String key = null;
298            for (Condition c : conds) {
299                if (c instanceof KeyCondition) {
300                    KeyCondition keyCondition = (KeyCondition) c;
301                    if (!keyCondition.negateResult && conditionRequiresKeyPresence(keyCondition.matchType)) {
302                        key = keyCondition.label;
303                    }
304                } else if (c instanceof KeyValueCondition) {
305                    KeyValueCondition keyValueCondition = (KeyValueCondition) c;
306                    if (!Op.NEGATED_OPS.contains(keyValueCondition.op)) {
307                        key = keyValueCondition.k;
308                    }
309                }
310            }
311            return key;
312        }
313
314        private static boolean conditionRequiresKeyPresence(KeyMatchType matchType) {
315            return matchType != KeyMatchType.REGEX;
316        }
317
318        private MapCSSKeyRules getEntryInIndex(String key) {
319            MapCSSKeyRules rulesWithMatchingKey = index.get(key);
320            if (rulesWithMatchingKey == null) {
321                rulesWithMatchingKey = new MapCSSKeyRules();
322                index.put(key.intern(), rulesWithMatchingKey);
323            }
324            return rulesWithMatchingKey;
325        }
326
327        /**
328         * Get a subset of all rules that might match the primitive. Rules not included in the result are guaranteed to
329         * not match this primitive.
330         * <p>
331         * You must have a read lock of STYLE_SOURCE_LOCK when calling this method.
332         *
333         * @param osm the primitive to match
334         * @return An iterator over possible rules in the right order.
335         */
336        public Iterator<MapCSSRule> getRuleCandidates(OsmPrimitive osm) {
337            final BitSet ruleCandidates = new BitSet(rules.size());
338            ruleCandidates.or(remaining);
339
340            final RuleCandidatesIterator candidatesIterator = new RuleCandidatesIterator(ruleCandidates);
341            osm.visitKeys(candidatesIterator);
342            candidatesIterator.prepare();
343            return candidatesIterator;
344        }
345
346        /**
347         * Clear the index.
348         * <p>
349         * You must own the write lock STYLE_SOURCE_LOCK when calling this method.
350         */
351        public void clear() {
352            rules.clear();
353            index.clear();
354            remaining.clear();
355        }
356    }
357
358    /**
359     * Constructs a new, active {@link MapCSSStyleSource}.
360     * @param url URL that {@link org.openstreetmap.josm.io.CachedFile} understands
361     * @param name The name for this StyleSource
362     * @param shortdescription The title for that source.
363     */
364    public MapCSSStyleSource(String url, String name, String shortdescription) {
365        super(url, name, shortdescription);
366    }
367
368    /**
369     * Constructs a new {@link MapCSSStyleSource}
370     * @param entry The entry to copy the data (url, name, ...) from.
371     */
372    public MapCSSStyleSource(SourceEntry entry) {
373        super(entry);
374    }
375
376    /**
377     * <p>Creates a new style source from the MapCSS styles supplied in
378     * {@code css}</p>
379     *
380     * @param css the MapCSS style declaration. Must not be null.
381     * @throws IllegalArgumentException if {@code css} is null
382     */
383    public MapCSSStyleSource(String css) {
384        super(null, null, null);
385        CheckParameterUtil.ensureParameterNotNull(css);
386        this.css = css;
387    }
388
389    @Override
390    public void loadStyleSource() {
391        STYLE_SOURCE_LOCK.writeLock().lock();
392        try {
393            init();
394            rules.clear();
395            nodeRules.clear();
396            wayRules.clear();
397            wayNoAreaRules.clear();
398            relationRules.clear();
399            multipolygonRules.clear();
400            canvasRules.clear();
401            try (InputStream in = getSourceInputStream()) {
402                try {
403                    // evaluate @media { ... } blocks
404                    MapCSSParser preprocessor = new MapCSSParser(in, "UTF-8", MapCSSParser.LexicalState.PREPROCESSOR);
405                    String mapcss = preprocessor.pp_root(this);
406
407                    // do the actual mapcss parsing
408                    InputStream in2 = new ByteArrayInputStream(mapcss.getBytes(StandardCharsets.UTF_8));
409                    MapCSSParser parser = new MapCSSParser(in2, "UTF-8", MapCSSParser.LexicalState.DEFAULT);
410                    parser.sheet(this);
411
412                    loadMeta();
413                    loadCanvas();
414                    loadSettings();
415                } finally {
416                    closeSourceInputStream(in);
417                }
418            } catch (IOException e) {
419                Main.warn(tr("Failed to load Mappaint styles from ''{0}''. Exception was: {1}", url, e.toString()));
420                Main.error(e);
421                logError(e);
422            } catch (TokenMgrError e) {
423                Main.warn(tr("Failed to parse Mappaint styles from ''{0}''. Error was: {1}", url, e.getMessage()));
424                Main.error(e);
425                logError(e);
426            } catch (ParseException e) {
427                Main.warn(tr("Failed to parse Mappaint styles from ''{0}''. Error was: {1}", url, e.getMessage()));
428                Main.error(e);
429                logError(new ParseException(e.getMessage())); // allow e to be garbage collected, it links to the entire token stream
430            }
431            // optimization: filter rules for different primitive types
432            for (MapCSSRule r: rules) {
433                // find the rightmost selector, this must be a GeneralSelector
434                Selector selRightmost = r.selector;
435                while (selRightmost instanceof ChildOrParentSelector) {
436                    selRightmost = ((ChildOrParentSelector) selRightmost).right;
437                }
438                MapCSSRule optRule = new MapCSSRule(r.selector.optimizedBaseCheck(), r.declaration);
439                final String base = ((GeneralSelector) selRightmost).getBase();
440                switch (base) {
441                    case "node":
442                        nodeRules.add(optRule);
443                        break;
444                    case "way":
445                        wayNoAreaRules.add(optRule);
446                        wayRules.add(optRule);
447                        break;
448                    case "area":
449                        wayRules.add(optRule);
450                        multipolygonRules.add(optRule);
451                        break;
452                    case "relation":
453                        relationRules.add(optRule);
454                        multipolygonRules.add(optRule);
455                        break;
456                    case "*":
457                        nodeRules.add(optRule);
458                        wayRules.add(optRule);
459                        wayNoAreaRules.add(optRule);
460                        relationRules.add(optRule);
461                        multipolygonRules.add(optRule);
462                        break;
463                    case "canvas":
464                        canvasRules.add(r);
465                        break;
466                    case "meta":
467                    case "setting":
468                        break;
469                    default:
470                        final RuntimeException e = new RuntimeException(MessageFormat.format("Unknown MapCSS base selector {0}", base));
471                        Main.warn(tr("Failed to parse Mappaint styles from ''{0}''. Error was: {1}", url, e.getMessage()));
472                        Main.error(e);
473                        logError(e);
474                }
475            }
476            nodeRules.initIndex();
477            wayRules.initIndex();
478            wayNoAreaRules.initIndex();
479            relationRules.initIndex();
480            multipolygonRules.initIndex();
481            canvasRules.initIndex();
482        } finally {
483            STYLE_SOURCE_LOCK.writeLock().unlock();
484        }
485    }
486
487    @Override
488    public InputStream getSourceInputStream() throws IOException {
489        if (css != null) {
490            return new ByteArrayInputStream(css.getBytes(StandardCharsets.UTF_8));
491        }
492        CachedFile cf = getCachedFile();
493        if (isZip) {
494            File file = cf.getFile();
495            zipFile = new ZipFile(file, StandardCharsets.UTF_8);
496            zipIcons = file;
497            ZipEntry zipEntry = zipFile.getEntry(zipEntryPath);
498            return zipFile.getInputStream(zipEntry);
499        } else {
500            zipFile = null;
501            zipIcons = null;
502            return cf.getInputStream();
503        }
504    }
505
506    @Override
507    @SuppressWarnings("resource")
508    public CachedFile getCachedFile() throws IOException {
509        return new CachedFile(url).setHttpAccept(MAPCSS_STYLE_MIME_TYPES); // NOSONAR
510    }
511
512    @Override
513    public void closeSourceInputStream(InputStream is) {
514        super.closeSourceInputStream(is);
515        if (isZip) {
516            Utils.close(zipFile);
517        }
518    }
519
520    /**
521     * load meta info from a selector "meta"
522     */
523    private void loadMeta() {
524        Cascade c = constructSpecial("meta");
525        String pTitle = c.get("title", null, String.class);
526        if (title == null) {
527            title = pTitle;
528        }
529        String pIcon = c.get("icon", null, String.class);
530        if (icon == null) {
531            icon = pIcon;
532        }
533    }
534
535    private void loadCanvas() {
536        Cascade c = constructSpecial("canvas");
537        backgroundColorOverride = c.get("fill-color", null, Color.class);
538    }
539
540    private void loadSettings() {
541        settings.clear();
542        settingValues.clear();
543        MultiCascade mc = new MultiCascade();
544        Node n = new Node();
545        String code = LanguageInfo.getJOSMLocaleCode();
546        n.put("lang", code);
547        // create a fake environment to read the meta data block
548        Environment env = new Environment(n, mc, "default", this);
549
550        for (MapCSSRule r : rules) {
551            if (r.selector instanceof GeneralSelector) {
552                GeneralSelector gs = (GeneralSelector) r.selector;
553                if ("setting".equals(gs.getBase())) {
554                    if (!gs.matchesConditions(env)) {
555                        continue;
556                    }
557                    env.layer = null;
558                    env.layer = gs.getSubpart().getId(env);
559                    r.execute(env);
560                }
561            }
562        }
563        for (Entry<String, Cascade> e : mc.getLayers()) {
564            if ("default".equals(e.getKey())) {
565                Main.warn("setting requires layer identifier e.g. 'setting::my_setting {...}'");
566                continue;
567            }
568            Cascade c = e.getValue();
569            String type = c.get("type", null, String.class);
570            StyleSetting set = null;
571            if ("boolean".equals(type)) {
572                set = BooleanStyleSetting.create(c, this, e.getKey());
573            } else {
574                Main.warn("Unkown setting type: "+type);
575            }
576            if (set != null) {
577                settings.add(set);
578                settingValues.put(e.getKey(), set.getValue());
579            }
580        }
581    }
582
583    private Cascade constructSpecial(String type) {
584
585        MultiCascade mc = new MultiCascade();
586        Node n = new Node();
587        String code = LanguageInfo.getJOSMLocaleCode();
588        n.put("lang", code);
589        // create a fake environment to read the meta data block
590        Environment env = new Environment(n, mc, "default", this);
591
592        for (MapCSSRule r : rules) {
593            if (r.selector instanceof GeneralSelector) {
594                GeneralSelector gs = (GeneralSelector) r.selector;
595                if (gs.getBase().equals(type)) {
596                    if (!gs.matchesConditions(env)) {
597                        continue;
598                    }
599                    r.execute(env);
600                }
601            }
602        }
603        return mc.getCascade("default");
604    }
605
606    @Override
607    public Color getBackgroundColorOverride() {
608        return backgroundColorOverride;
609    }
610
611    @Override
612    public void apply(MultiCascade mc, OsmPrimitive osm, double scale, boolean pretendWayIsClosed) {
613        Environment env = new Environment(osm, mc, null, this);
614        MapCSSRuleIndex matchingRuleIndex;
615        if (osm instanceof Node) {
616            matchingRuleIndex = nodeRules;
617        } else if (osm instanceof Way) {
618            if (osm.isKeyFalse("area")) {
619                matchingRuleIndex = wayNoAreaRules;
620            } else {
621                matchingRuleIndex = wayRules;
622            }
623        } else {
624            if (((Relation) osm).isMultipolygon()) {
625                matchingRuleIndex = multipolygonRules;
626            } else if (osm.hasKey("#canvas")) {
627                matchingRuleIndex = canvasRules;
628            } else {
629                matchingRuleIndex = relationRules;
630            }
631        }
632
633        // the declaration indices are sorted, so it suffices to save the
634        // last used index
635        int lastDeclUsed = -1;
636
637        Iterator<MapCSSRule> candidates = matchingRuleIndex.getRuleCandidates(osm);
638        while (candidates.hasNext()) {
639            MapCSSRule r = candidates.next();
640            env.clearSelectorMatchingInformation();
641            env.layer = null;
642            String sub = env.layer = r.selector.getSubpart().getId(env);
643            if (r.selector.matches(env)) { // as side effect env.parent will be set (if s is a child selector)
644                Selector s = r.selector;
645                if (s.getRange().contains(scale)) {
646                    mc.range = Range.cut(mc.range, s.getRange());
647                } else {
648                    mc.range = mc.range.reduceAround(scale, s.getRange());
649                    continue;
650                }
651
652                if (r.declaration.idx == lastDeclUsed)
653                    continue; // don't apply one declaration more than once
654                lastDeclUsed = r.declaration.idx;
655                if ("*".equals(sub)) {
656                    for (Entry<String, Cascade> entry : mc.getLayers()) {
657                        env.layer = entry.getKey();
658                        if ("*".equals(env.layer)) {
659                            continue;
660                        }
661                        r.execute(env);
662                    }
663                }
664                env.layer = sub;
665                r.execute(env);
666            }
667        }
668    }
669
670    public boolean evalSupportsDeclCondition(String feature, Object val) {
671        if (feature == null) return false;
672        if (SUPPORTED_KEYS.contains(feature)) return true;
673        switch (feature) {
674            case "user-agent":
675            {
676                String s = Cascade.convertTo(val, String.class);
677                return "josm".equals(s);
678            }
679            case "min-josm-version":
680            {
681                Float v = Cascade.convertTo(val, Float.class);
682                return v != null && Math.round(v) <= Version.getInstance().getVersion();
683            }
684            case "max-josm-version":
685            {
686                Float v = Cascade.convertTo(val, Float.class);
687                return v != null && Math.round(v) >= Version.getInstance().getVersion();
688            }
689            default:
690                return false;
691        }
692    }
693
694    @Override
695    public String toString() {
696        return Utils.join("\n", rules);
697    }
698}