001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.validation.tests;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.geom.Area;
007import java.io.Reader;
008import java.io.StringReader;
009import java.util.ArrayList;
010import java.util.Collection;
011import java.util.HashMap;
012import java.util.HashSet;
013import java.util.List;
014import java.util.Map;
015import java.util.Objects;
016import java.util.Optional;
017import java.util.Set;
018import java.util.function.Predicate;
019import java.util.regex.Matcher;
020import java.util.regex.Pattern;
021import java.util.stream.Collectors;
022
023import org.openstreetmap.josm.command.Command;
024import org.openstreetmap.josm.command.DeleteCommand;
025import org.openstreetmap.josm.command.SequenceCommand;
026import org.openstreetmap.josm.data.osm.IPrimitive;
027import org.openstreetmap.josm.data.osm.OsmPrimitive;
028import org.openstreetmap.josm.data.osm.Tag;
029import org.openstreetmap.josm.data.osm.Way;
030import org.openstreetmap.josm.data.osm.WaySegment;
031import org.openstreetmap.josm.data.validation.Severity;
032import org.openstreetmap.josm.data.validation.Test;
033import org.openstreetmap.josm.data.validation.TestError;
034import org.openstreetmap.josm.data.validation.tests.MapCSSTagChecker.AssertionConsumer;
035import org.openstreetmap.josm.gui.mappaint.Environment;
036import org.openstreetmap.josm.gui.mappaint.Keyword;
037import org.openstreetmap.josm.gui.mappaint.mapcss.Condition;
038import org.openstreetmap.josm.gui.mappaint.mapcss.Condition.TagCondition;
039import org.openstreetmap.josm.gui.mappaint.mapcss.Expression;
040import org.openstreetmap.josm.gui.mappaint.mapcss.Instruction;
041import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSRule;
042import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
043import org.openstreetmap.josm.gui.mappaint.mapcss.Selector;
044import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.MapCSSParser;
045import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.ParseException;
046import org.openstreetmap.josm.io.IllegalDataException;
047import org.openstreetmap.josm.tools.CheckParameterUtil;
048import org.openstreetmap.josm.tools.Logging;
049import org.openstreetmap.josm.tools.Utils;
050
051/**
052 * Tag check.
053 */
054final class MapCSSTagCheckerRule implements Predicate<OsmPrimitive> {
055    /**
056     * The selector of this {@code TagCheck}
057     */
058    final MapCSSRule rule;
059    /**
060     * Commands to apply in order to fix a matching primitive
061     */
062    final List<MapCSSTagCheckerFixCommand> fixCommands;
063    /**
064     * Tags (or arbitrary strings) of alternatives to be presented to the user
065     */
066    final List<String> alternatives;
067    /**
068     * An {@link org.openstreetmap.josm.gui.mappaint.mapcss.Instruction.AssignmentInstruction}-{@link Severity} pair.
069     * Is evaluated on the matching primitive to give the error message. Map is checked to contain exactly one element.
070     */
071    final Map<Instruction.AssignmentInstruction, Severity> errors;
072    /**
073     * MapCSS Classes to set on matching primitives
074     */
075    final Collection<String> setClassExpressions;
076    /**
077     * Denotes whether the object should be deleted for fixing it
078     */
079    boolean deletion;
080    /**
081     * A string used to group similar tests
082     */
083    String group;
084
085    MapCSSTagCheckerRule(MapCSSRule rule) {
086        this.rule = rule;
087        this.fixCommands = new ArrayList<>();
088        this.alternatives = new ArrayList<>();
089        this.errors = new HashMap<>();
090        this.setClassExpressions = new HashSet<>();
091    }
092
093    MapCSSTagCheckerRule(MapCSSTagCheckerRule check) {
094        this.rule = check.rule;
095        this.fixCommands = Utils.toUnmodifiableList(check.fixCommands);
096        this.alternatives = Utils.toUnmodifiableList(check.alternatives);
097        this.errors = Utils.toUnmodifiableMap(check.errors);
098        this.setClassExpressions = Utils.toUnmodifiableList(check.setClassExpressions);
099        this.deletion = check.deletion;
100        this.group = check.group;
101    }
102
103    MapCSSTagCheckerRule toImmutable() {
104        return new MapCSSTagCheckerRule(this);
105    }
106
107    private static final String POSSIBLE_THROWS = "throwError/throwWarning/throwOther";
108
109    static MapCSSTagCheckerRule ofMapCSSRule(final MapCSSRule rule, AssertionConsumer assertionConsumer) throws IllegalDataException {
110        final MapCSSTagCheckerRule check = new MapCSSTagCheckerRule(rule);
111        final Map<String, Boolean> assertions = new HashMap<>();
112        for (Instruction i : rule.declaration.instructions) {
113            if (i instanceof Instruction.AssignmentInstruction) {
114                final Instruction.AssignmentInstruction ai = (Instruction.AssignmentInstruction) i;
115                if (ai.isSetInstruction) {
116                    check.setClassExpressions.add(ai.key);
117                    continue;
118                }
119                try {
120                    final String val = ai.val instanceof Expression
121                            ? Optional.ofNullable(((Expression) ai.val).evaluate(new Environment()))
122                            .map(Object::toString).map(String::intern).orElse(null)
123                            : ai.val instanceof String
124                            ? (String) ai.val
125                            : ai.val instanceof Keyword
126                            ? ((Keyword) ai.val).val
127                            : null;
128                    if ("throwError".equals(ai.key)) {
129                        check.errors.put(ai, Severity.ERROR);
130                    } else if ("throwWarning".equals(ai.key)) {
131                        check.errors.put(ai, Severity.WARNING);
132                    } else if ("throwOther".equals(ai.key)) {
133                        check.errors.put(ai, Severity.OTHER);
134                    } else if (ai.key.startsWith("throw")) {
135                        Logging.log(Logging.LEVEL_WARN,
136                                "Unsupported " + ai.key + " instruction. Allowed instructions are " + POSSIBLE_THROWS + '.', null);
137                    } else if ("fixAdd".equals(ai.key)) {
138                        check.fixCommands.add(MapCSSTagCheckerFixCommand.fixAdd(ai.val));
139                    } else if ("fixRemove".equals(ai.key)) {
140                        CheckParameterUtil.ensureThat(!(ai.val instanceof String) || !(val != null && val.contains("=")),
141                                "Unexpected '='. Please only specify the key to remove in: " + ai);
142                        check.fixCommands.add(MapCSSTagCheckerFixCommand.fixRemove(ai.val));
143                    } else if (val != null && "fixChangeKey".equals(ai.key)) {
144                        CheckParameterUtil.ensureThat(val.contains("=>"), "Separate old from new key by '=>'!");
145                        final String[] x = val.split("=>", 2);
146                        final String oldKey = Utils.removeWhiteSpaces(x[0]);
147                        final String newKey = Utils.removeWhiteSpaces(x[1]);
148                        check.fixCommands.add(MapCSSTagCheckerFixCommand.fixChangeKey(oldKey, newKey));
149                    } else if (val != null && "fixDeleteObject".equals(ai.key)) {
150                        CheckParameterUtil.ensureThat("this".equals(val), "fixDeleteObject must be followed by 'this'");
151                        check.deletion = true;
152                    } else if (val != null && "suggestAlternative".equals(ai.key)) {
153                        check.alternatives.add(val);
154                    } else if (val != null && "assertMatch".equals(ai.key)) {
155                        assertions.put(val, Boolean.TRUE);
156                    } else if (val != null && "assertNoMatch".equals(ai.key)) {
157                        assertions.put(val, Boolean.FALSE);
158                    } else if (val != null && "group".equals(ai.key)) {
159                        check.group = val;
160                    } else if (ai.key.startsWith("-")) {
161                        Logging.debug("Ignoring extension instruction: " + ai.key + ": " + ai.val);
162                    } else {
163                        throw new IllegalDataException("Cannot add instruction " + ai.key + ": " + ai.val + '!');
164                    }
165                } catch (IllegalArgumentException e) {
166                    throw new IllegalDataException(e);
167                }
168            }
169        }
170        if (check.errors.isEmpty() && check.setClassExpressions.isEmpty()) {
171            throw new IllegalDataException(
172                    "No " + POSSIBLE_THROWS + " given! You should specify a validation error message for " + rule.selectors);
173        } else if (check.errors.size() > 1) {
174            throw new IllegalDataException(
175                    "More than one " + POSSIBLE_THROWS + " given! You should specify a single validation error message for "
176                            + rule.selectors);
177        }
178        if (assertionConsumer != null) {
179            MapCSSTagCheckerAsserts.checkAsserts(check, assertions, assertionConsumer);
180        }
181        return check.toImmutable();
182    }
183
184    static MapCSSTagChecker.ParseResult readMapCSS(Reader css) throws ParseException {
185        return readMapCSS(css, null);
186    }
187
188    static MapCSSTagChecker.ParseResult readMapCSS(Reader css, AssertionConsumer assertionConsumer) throws ParseException {
189        CheckParameterUtil.ensureParameterNotNull(css, "css");
190
191        final MapCSSStyleSource source = new MapCSSStyleSource("");
192        final MapCSSParser preprocessor = new MapCSSParser(css, MapCSSParser.LexicalState.PREPROCESSOR);
193        try (StringReader mapcss = new StringReader(preprocessor.pp_root(source))) {
194            new MapCSSParser(mapcss, MapCSSParser.LexicalState.DEFAULT).sheet(source);
195        }
196        // Ignore "meta" rule(s) from external rules of JOSM wiki
197        source.removeMetaRules();
198        List<MapCSSTagCheckerRule> parseChecks = new ArrayList<>();
199        for (MapCSSRule rule : source.rules) {
200            try {
201                parseChecks.add(MapCSSTagCheckerRule.ofMapCSSRule(rule, assertionConsumer));
202            } catch (IllegalDataException e) {
203                Logging.error("Cannot add MapCSS rule: " + e.getMessage());
204                source.logError(e);
205            }
206        }
207        return new MapCSSTagChecker.ParseResult(parseChecks, source.getErrors());
208    }
209
210    @Override
211    public boolean test(OsmPrimitive primitive) {
212        // Tests whether the primitive contains a deprecated tag which is represented by this MapCSSTagChecker.
213        return whichSelectorMatchesPrimitive(primitive) != null;
214    }
215
216    Selector whichSelectorMatchesPrimitive(OsmPrimitive primitive) {
217        return whichSelectorMatchesEnvironment(new Environment(primitive));
218    }
219
220    Selector whichSelectorMatchesEnvironment(Environment env) {
221        return rule.selectors.stream()
222                .filter(i -> i.matches(env.clearSelectorMatchingInformation()))
223                .findFirst()
224                .orElse(null);
225    }
226
227    /**
228     * Determines the {@code index}-th key/value/tag (depending on {@code type}) of the
229     * {@link org.openstreetmap.josm.gui.mappaint.mapcss.Selector.GeneralSelector}.
230     *
231     * @param matchingSelector matching selector
232     * @param index            index
233     * @param type             selector type ("key", "value" or "tag")
234     * @param p                OSM primitive
235     * @return argument value, can be {@code null}
236     */
237    static String determineArgument(Selector.GeneralSelector matchingSelector, int index, String type, OsmPrimitive p) {
238        try {
239            final Condition c = matchingSelector.getConditions().get(index);
240            final Tag tag = c instanceof TagCondition
241                    ? ((TagCondition) c).asTag(p)
242                    : null;
243            if (tag == null) {
244                return null;
245            } else if ("key".equals(type)) {
246                return tag.getKey();
247            } else if ("value".equals(type)) {
248                return tag.getValue();
249            } else if ("tag".equals(type)) {
250                return tag.toString();
251            }
252        } catch (IndexOutOfBoundsException ignore) {
253            Logging.debug(ignore);
254        }
255        return null;
256    }
257
258    /**
259     * Replaces occurrences of <code>{i.key}</code>, <code>{i.value}</code>, <code>{i.tag}</code> in {@code s} by the corresponding
260     * key/value/tag of the {@code index}-th {@link Condition} of {@code matchingSelector}.
261     *
262     * @param matchingSelector matching selector
263     * @param s                any string
264     * @param p                OSM primitive
265     * @return string with arguments inserted
266     */
267    static String insertArguments(Selector matchingSelector, String s, OsmPrimitive p) {
268        if (s != null && matchingSelector instanceof Selector.ChildOrParentSelector) {
269            return insertArguments(((Selector.ChildOrParentSelector) matchingSelector).right, s, p);
270        } else if (s == null || !(matchingSelector instanceof Selector.GeneralSelector)) {
271            return s;
272        }
273        final Matcher m = Pattern.compile("\\{(\\d+)\\.(key|value|tag)\\}").matcher(s);
274        final StringBuffer sb = new StringBuffer();
275        while (m.find()) {
276            final String argument = determineArgument((Selector.GeneralSelector) matchingSelector,
277                    Integer.parseInt(m.group(1)), m.group(2), p);
278            try {
279                // Perform replacement with null-safe + regex-safe handling
280                m.appendReplacement(sb, String.valueOf(argument).replace("^(", "").replace(")$", ""));
281            } catch (IndexOutOfBoundsException | IllegalArgumentException e) {
282                Logging.log(Logging.LEVEL_ERROR, tr("Unable to replace argument {0} in {1}: {2}", argument, sb, e.getMessage()), e);
283            }
284        }
285        m.appendTail(sb);
286        return sb.toString();
287    }
288
289    /**
290     * Constructs a fix in terms of a {@link org.openstreetmap.josm.command.Command} for the {@link OsmPrimitive}
291     * if the error is fixable, or {@code null} otherwise.
292     *
293     * @param p the primitive to construct the fix for
294     * @return the fix or {@code null}
295     */
296    Command fixPrimitive(OsmPrimitive p) {
297        if (p.getDataSet() == null || (fixCommands.isEmpty() && !deletion)) {
298            return null;
299        }
300        try {
301            final Selector matchingSelector = whichSelectorMatchesPrimitive(p);
302            Collection<Command> cmds = fixCommands.stream()
303                    .map(fixCommand -> fixCommand.createCommand(p, matchingSelector))
304                    .filter(Objects::nonNull)
305                    .collect(Collectors.toList());
306            if (deletion && !p.isDeleted()) {
307                cmds.add(new DeleteCommand(p));
308            }
309            return cmds.isEmpty() ? null
310                    : new SequenceCommand(tr("Fix of {0}", getDescriptionForMatchingSelector(p, matchingSelector)), cmds);
311        } catch (IllegalArgumentException e) {
312            Logging.error(e);
313            return null;
314        }
315    }
316
317    /**
318     * Constructs a (localized) message for this deprecation check.
319     *
320     * @param p OSM primitive
321     * @return a message
322     */
323    String getMessage(OsmPrimitive p) {
324        if (errors.isEmpty()) {
325            // Return something to avoid NPEs
326            return rule.declaration.toString();
327        } else {
328            final Object val = errors.keySet().iterator().next().val;
329            return String.valueOf(
330                    val instanceof Expression
331                            ? ((Expression) val).evaluate(new Environment(p))
332                            : val
333            );
334        }
335    }
336
337    /**
338     * Constructs a (localized) description for this deprecation check.
339     *
340     * @param p OSM primitive
341     * @return a description (possibly with alternative suggestions)
342     * @see #getDescriptionForMatchingSelector
343     */
344    String getDescription(OsmPrimitive p) {
345        if (alternatives.isEmpty()) {
346            return getMessage(p);
347        } else {
348            /* I18N: {0} is the test error message and {1} is an alternative */
349            return tr("{0}, use {1} instead", getMessage(p), String.join(tr(" or "), alternatives));
350        }
351    }
352
353    /**
354     * Constructs a (localized) description for this deprecation check
355     * where any placeholders are replaced by values of the matched selector.
356     *
357     * @param matchingSelector matching selector
358     * @param p                OSM primitive
359     * @return a description (possibly with alternative suggestions)
360     */
361    String getDescriptionForMatchingSelector(OsmPrimitive p, Selector matchingSelector) {
362        return insertArguments(matchingSelector, getDescription(p), p);
363    }
364
365    Severity getSeverity() {
366        return errors.isEmpty() ? null : errors.values().iterator().next();
367    }
368
369    @Override
370    public String toString() {
371        return getDescription(null);
372    }
373
374    /**
375     * Constructs a {@link TestError} for the given primitive, or returns null if the primitive does not give rise to an error.
376     *
377     * @param p                the primitive to construct the error for
378     * @param matchingSelector the matching selector (e.g., obtained via {@link #whichSelectorMatchesPrimitive})
379     * @param env              the environment
380     * @param tester           the tester
381     * @return an instance of {@link TestError}, or returns null if the primitive does not give rise to an error.
382     */
383    List<TestError> getErrorsForPrimitive(OsmPrimitive p, Selector matchingSelector, Environment env, Test tester) {
384        List<TestError> res = new ArrayList<>();
385        if (matchingSelector != null && !errors.isEmpty()) {
386            final Command fix = fixPrimitive(p);
387            final String description = getDescriptionForMatchingSelector(p, matchingSelector);
388            final String description1 = group == null ? description : group;
389            final String description2 = group == null ? null : description;
390            final String selector = matchingSelector.toString();
391            TestError.Builder errorBuilder = TestError.builder(tester, getSeverity(), 3000)
392                    .messageWithManuallyTranslatedDescription(description1, description2, selector);
393            if (fix != null) {
394                errorBuilder.fix(() -> fix);
395            }
396            if (env.child instanceof OsmPrimitive) {
397                res.add(errorBuilder.primitives(p, (OsmPrimitive) env.child).build());
398            } else if (env.children != null) {
399                for (IPrimitive c : env.children) {
400                    if (c instanceof OsmPrimitive) {
401                        errorBuilder = TestError.builder(tester, getSeverity(), 3000)
402                                .messageWithManuallyTranslatedDescription(description1, description2, selector);
403                        if (fix != null) {
404                            errorBuilder.fix(() -> fix);
405                        }
406                        // check if we have special information about highlighted objects */
407                        boolean hiliteFound = false;
408                        if (env.intersections != null) {
409                            Area is = env.intersections.get(c);
410                            if (is != null) {
411                                errorBuilder.highlight(is);
412                                hiliteFound = true;
413                            }
414                        }
415                        if (env.crossingWaysMap != null && !hiliteFound) {
416                            Map<List<Way>, List<WaySegment>> is = env.crossingWaysMap.get(c);
417                            if (is != null) {
418                                Set<WaySegment> toHilite = new HashSet<>();
419                                for (List<WaySegment> wsList : is.values()) {
420                                    toHilite.addAll(wsList);
421                                }
422                                errorBuilder.highlightWaySegments(toHilite);
423                            }
424                        }
425                        res.add(errorBuilder.primitives(p, (OsmPrimitive) c).build());
426                    }
427                }
428            } else {
429                res.add(errorBuilder.primitives(p).build());
430            }
431        }
432        return res;
433    }
434
435}