source: josm/trunk/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerRule.java@ 18365

Last change on this file since 18365 was 18365, checked in by Don-vip, 2 years ago

see #15182 - make JOSM callable as standalone validator (patch by taylor.smock)

File size: 19.7 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.data.validation.tests;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.awt.geom.Area;
7import java.io.Reader;
8import java.io.StringReader;
9import java.util.ArrayList;
10import java.util.Collection;
11import java.util.HashMap;
12import java.util.HashSet;
13import java.util.List;
14import java.util.Map;
15import java.util.Objects;
16import java.util.Optional;
17import java.util.Set;
18import java.util.function.Consumer;
19import java.util.function.Predicate;
20import java.util.regex.Matcher;
21import java.util.regex.Pattern;
22import java.util.stream.Collectors;
23
24import org.openstreetmap.josm.command.Command;
25import org.openstreetmap.josm.command.DeleteCommand;
26import org.openstreetmap.josm.command.SequenceCommand;
27import org.openstreetmap.josm.data.osm.IPrimitive;
28import org.openstreetmap.josm.data.osm.OsmPrimitive;
29import org.openstreetmap.josm.data.osm.Tag;
30import org.openstreetmap.josm.data.osm.Way;
31import org.openstreetmap.josm.data.osm.WaySegment;
32import org.openstreetmap.josm.data.validation.Severity;
33import org.openstreetmap.josm.data.validation.Test;
34import org.openstreetmap.josm.data.validation.TestError;
35import org.openstreetmap.josm.gui.mappaint.Environment;
36import org.openstreetmap.josm.gui.mappaint.Keyword;
37import org.openstreetmap.josm.gui.mappaint.mapcss.Condition;
38import org.openstreetmap.josm.gui.mappaint.mapcss.Condition.TagCondition;
39import org.openstreetmap.josm.gui.mappaint.mapcss.Expression;
40import org.openstreetmap.josm.gui.mappaint.mapcss.Instruction;
41import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSRule;
42import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
43import org.openstreetmap.josm.gui.mappaint.mapcss.Selector;
44import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.MapCSSParser;
45import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.ParseException;
46import org.openstreetmap.josm.io.IllegalDataException;
47import org.openstreetmap.josm.tools.CheckParameterUtil;
48import org.openstreetmap.josm.tools.Logging;
49import org.openstreetmap.josm.tools.Utils;
50
51/**
52 * Tag check.
53 */
54final class MapCSSTagCheckerRule implements Predicate<OsmPrimitive> {
55 /**
56 * The selector of this {@code TagCheck}
57 */
58 final MapCSSRule rule;
59 /**
60 * Commands to apply in order to fix a matching primitive
61 */
62 final List<MapCSSTagCheckerFixCommand> fixCommands;
63 /**
64 * Tags (or arbitrary strings) of alternatives to be presented to the user
65 */
66 final List<String> alternatives;
67 /**
68 * An {@link org.openstreetmap.josm.gui.mappaint.mapcss.Instruction.AssignmentInstruction}-{@link Severity} pair.
69 * Is evaluated on the matching primitive to give the error message. Map is checked to contain exactly one element.
70 */
71 final Map<Instruction.AssignmentInstruction, Severity> errors;
72 /**
73 * MapCSS Classes to set on matching primitives
74 */
75 final Collection<String> setClassExpressions;
76 /**
77 * Denotes whether the object should be deleted for fixing it
78 */
79 boolean deletion;
80 /**
81 * A string used to group similar tests
82 */
83 String group;
84
85 MapCSSTagCheckerRule(MapCSSRule rule) {
86 this.rule = rule;
87 this.fixCommands = new ArrayList<>();
88 this.alternatives = new ArrayList<>();
89 this.errors = new HashMap<>();
90 this.setClassExpressions = new HashSet<>();
91 }
92
93 MapCSSTagCheckerRule(MapCSSTagCheckerRule check) {
94 this.rule = check.rule;
95 this.fixCommands = Utils.toUnmodifiableList(check.fixCommands);
96 this.alternatives = Utils.toUnmodifiableList(check.alternatives);
97 this.errors = Utils.toUnmodifiableMap(check.errors);
98 this.setClassExpressions = Utils.toUnmodifiableList(check.setClassExpressions);
99 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, Consumer<String> 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, Consumer<String> 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}
Note: See TracBrowser for help on using the repository browser.