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.io.BufferedReader; 007import java.io.IOException; 008import java.io.InputStream; 009import java.io.Reader; 010import java.io.StringReader; 011import java.text.MessageFormat; 012import java.util.ArrayList; 013import java.util.Arrays; 014import java.util.Collection; 015import java.util.Collections; 016import java.util.HashMap; 017import java.util.HashSet; 018import java.util.Iterator; 019import java.util.LinkedHashMap; 020import java.util.LinkedHashSet; 021import java.util.LinkedList; 022import java.util.List; 023import java.util.Locale; 024import java.util.Map; 025import java.util.Objects; 026import java.util.Optional; 027import java.util.Set; 028import java.util.function.Predicate; 029import java.util.regex.Matcher; 030import java.util.regex.Pattern; 031 032import org.openstreetmap.josm.Main; 033import org.openstreetmap.josm.command.ChangePropertyCommand; 034import org.openstreetmap.josm.command.ChangePropertyKeyCommand; 035import org.openstreetmap.josm.command.Command; 036import org.openstreetmap.josm.command.DeleteCommand; 037import org.openstreetmap.josm.command.SequenceCommand; 038import org.openstreetmap.josm.data.osm.DataSet; 039import org.openstreetmap.josm.data.osm.OsmPrimitive; 040import org.openstreetmap.josm.data.osm.OsmUtils; 041import org.openstreetmap.josm.data.osm.Tag; 042import org.openstreetmap.josm.data.preferences.sources.SourceEntry; 043import org.openstreetmap.josm.data.preferences.sources.ValidatorPrefHelper; 044import org.openstreetmap.josm.data.validation.OsmValidator; 045import org.openstreetmap.josm.data.validation.Severity; 046import org.openstreetmap.josm.data.validation.Test; 047import org.openstreetmap.josm.data.validation.TestError; 048import org.openstreetmap.josm.gui.mappaint.Environment; 049import org.openstreetmap.josm.gui.mappaint.Keyword; 050import org.openstreetmap.josm.gui.mappaint.MultiCascade; 051import org.openstreetmap.josm.gui.mappaint.mapcss.Condition; 052import org.openstreetmap.josm.gui.mappaint.mapcss.ConditionFactory.ClassCondition; 053import org.openstreetmap.josm.gui.mappaint.mapcss.Expression; 054import org.openstreetmap.josm.gui.mappaint.mapcss.Instruction; 055import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSRule; 056import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSRule.Declaration; 057import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource; 058import org.openstreetmap.josm.gui.mappaint.mapcss.Selector; 059import org.openstreetmap.josm.gui.mappaint.mapcss.Selector.AbstractSelector; 060import org.openstreetmap.josm.gui.mappaint.mapcss.Selector.GeneralSelector; 061import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.MapCSSParser; 062import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.ParseException; 063import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.TokenMgrError; 064import org.openstreetmap.josm.io.CachedFile; 065import org.openstreetmap.josm.io.IllegalDataException; 066import org.openstreetmap.josm.io.UTFInputStreamReader; 067import org.openstreetmap.josm.spi.preferences.Config; 068import org.openstreetmap.josm.tools.CheckParameterUtil; 069import org.openstreetmap.josm.tools.I18n; 070import org.openstreetmap.josm.tools.Logging; 071import org.openstreetmap.josm.tools.MultiMap; 072import org.openstreetmap.josm.tools.Utils; 073 074/** 075 * MapCSS-based tag checker/fixer. 076 * @since 6506 077 */ 078public class MapCSSTagChecker extends Test.TagTest { 079 080 /** 081 * A grouped MapCSSRule with multiple selectors for a single declaration. 082 * @see MapCSSRule 083 */ 084 public static class GroupedMapCSSRule { 085 /** MapCSS selectors **/ 086 public final List<Selector> selectors; 087 /** MapCSS declaration **/ 088 public final Declaration declaration; 089 090 /** 091 * Constructs a new {@code GroupedMapCSSRule}. 092 * @param selectors MapCSS selectors 093 * @param declaration MapCSS declaration 094 */ 095 public GroupedMapCSSRule(List<Selector> selectors, Declaration declaration) { 096 this.selectors = selectors; 097 this.declaration = declaration; 098 } 099 100 @Override 101 public int hashCode() { 102 return Objects.hash(selectors, declaration); 103 } 104 105 @Override 106 public boolean equals(Object obj) { 107 if (this == obj) return true; 108 if (obj == null || getClass() != obj.getClass()) return false; 109 GroupedMapCSSRule that = (GroupedMapCSSRule) obj; 110 return Objects.equals(selectors, that.selectors) && 111 Objects.equals(declaration, that.declaration); 112 } 113 114 @Override 115 public String toString() { 116 return "GroupedMapCSSRule [selectors=" + selectors + ", declaration=" + declaration + ']'; 117 } 118 } 119 120 /** 121 * The preference key for tag checker source entries. 122 * @since 6670 123 */ 124 public static final String ENTRIES_PREF_KEY = "validator." + MapCSSTagChecker.class.getName() + ".entries"; 125 126 /** 127 * Constructs a new {@code MapCSSTagChecker}. 128 */ 129 public MapCSSTagChecker() { 130 super(tr("Tag checker (MapCSS based)"), tr("This test checks for errors in tag keys and values.")); 131 } 132 133 /** 134 * Represents a fix to a validation test. The fixing {@link Command} can be obtained by {@link #createCommand(OsmPrimitive, Selector)}. 135 */ 136 @FunctionalInterface 137 interface FixCommand { 138 /** 139 * Creates the fixing {@link Command} for the given primitive. The {@code matchingSelector} is used to evaluate placeholders 140 * (cf. {@link MapCSSTagChecker.TagCheck#insertArguments(Selector, String, OsmPrimitive)}). 141 * @param p OSM primitive 142 * @param matchingSelector matching selector 143 * @return fix command 144 */ 145 Command createCommand(OsmPrimitive p, Selector matchingSelector); 146 147 /** 148 * Checks that object is either an {@link Expression} or a {@link String}. 149 * @param obj object to check 150 * @throws IllegalArgumentException if object is not an {@code Expression} or a {@code String} 151 */ 152 static void checkObject(final Object obj) { 153 CheckParameterUtil.ensureThat(obj instanceof Expression || obj instanceof String, 154 () -> "instance of Exception or String expected, but got " + obj); 155 } 156 157 /** 158 * Evaluates given object as {@link Expression} or {@link String} on the matched {@link OsmPrimitive} and {@code matchingSelector}. 159 * @param obj object to evaluate ({@link Expression} or {@link String}) 160 * @param p OSM primitive 161 * @param matchingSelector matching selector 162 * @return result string 163 */ 164 static String evaluateObject(final Object obj, final OsmPrimitive p, final Selector matchingSelector) { 165 final String s; 166 if (obj instanceof Expression) { 167 s = (String) ((Expression) obj).evaluate(new Environment(p)); 168 } else if (obj instanceof String) { 169 s = (String) obj; 170 } else { 171 return null; 172 } 173 return TagCheck.insertArguments(matchingSelector, s, p); 174 } 175 176 /** 177 * Creates a fixing command which executes a {@link ChangePropertyCommand} on the specified tag. 178 * @param obj object to evaluate ({@link Expression} or {@link String}) 179 * @return created fix command 180 */ 181 static FixCommand fixAdd(final Object obj) { 182 checkObject(obj); 183 return new FixCommand() { 184 @Override 185 public Command createCommand(OsmPrimitive p, Selector matchingSelector) { 186 final Tag tag = Tag.ofString(evaluateObject(obj, p, matchingSelector)); 187 return new ChangePropertyCommand(p, tag.getKey(), tag.getValue()); 188 } 189 190 @Override 191 public String toString() { 192 return "fixAdd: " + obj; 193 } 194 }; 195 } 196 197 /** 198 * Creates a fixing command which executes a {@link ChangePropertyCommand} to delete the specified key. 199 * @param obj object to evaluate ({@link Expression} or {@link String}) 200 * @return created fix command 201 */ 202 static FixCommand fixRemove(final Object obj) { 203 checkObject(obj); 204 return new FixCommand() { 205 @Override 206 public Command createCommand(OsmPrimitive p, Selector matchingSelector) { 207 final String key = evaluateObject(obj, p, matchingSelector); 208 return new ChangePropertyCommand(p, key, ""); 209 } 210 211 @Override 212 public String toString() { 213 return "fixRemove: " + obj; 214 } 215 }; 216 } 217 218 /** 219 * Creates a fixing command which executes a {@link ChangePropertyKeyCommand} on the specified keys. 220 * @param oldKey old key 221 * @param newKey new key 222 * @return created fix command 223 */ 224 static FixCommand fixChangeKey(final String oldKey, final String newKey) { 225 return new FixCommand() { 226 @Override 227 public Command createCommand(OsmPrimitive p, Selector matchingSelector) { 228 return new ChangePropertyKeyCommand(p, 229 TagCheck.insertArguments(matchingSelector, oldKey, p), 230 TagCheck.insertArguments(matchingSelector, newKey, p)); 231 } 232 233 @Override 234 public String toString() { 235 return "fixChangeKey: " + oldKey + " => " + newKey; 236 } 237 }; 238 } 239 } 240 241 final MultiMap<String, TagCheck> checks = new MultiMap<>(); 242 243 /** 244 * Result of {@link TagCheck#readMapCSS} 245 * @since 8936 246 */ 247 public static class ParseResult { 248 /** Checks successfully parsed */ 249 public final List<TagCheck> parseChecks; 250 /** Errors that occured during parsing */ 251 public final Collection<Throwable> parseErrors; 252 253 /** 254 * Constructs a new {@code ParseResult}. 255 * @param parseChecks Checks successfully parsed 256 * @param parseErrors Errors that occured during parsing 257 */ 258 public ParseResult(List<TagCheck> parseChecks, Collection<Throwable> parseErrors) { 259 this.parseChecks = parseChecks; 260 this.parseErrors = parseErrors; 261 } 262 } 263 264 /** 265 * Tag check. 266 */ 267 public static class TagCheck implements Predicate<OsmPrimitive> { 268 /** The selector of this {@code TagCheck} */ 269 protected final GroupedMapCSSRule rule; 270 /** Commands to apply in order to fix a matching primitive */ 271 protected final List<FixCommand> fixCommands = new ArrayList<>(); 272 /** Tags (or arbitraty strings) of alternatives to be presented to the user */ 273 protected final List<String> alternatives = new ArrayList<>(); 274 /** An {@link org.openstreetmap.josm.gui.mappaint.mapcss.Instruction.AssignmentInstruction}-{@link Severity} pair. 275 * Is evaluated on the matching primitive to give the error message. Map is checked to contain exactly one element. */ 276 protected final Map<Instruction.AssignmentInstruction, Severity> errors = new HashMap<>(); 277 /** Unit tests */ 278 protected final Map<String, Boolean> assertions = new HashMap<>(); 279 /** MapCSS Classes to set on matching primitives */ 280 protected final Set<String> setClassExpressions = new HashSet<>(); 281 /** Denotes whether the object should be deleted for fixing it */ 282 protected boolean deletion; 283 /** A string used to group similar tests */ 284 protected String group; 285 286 TagCheck(GroupedMapCSSRule rule) { 287 this.rule = rule; 288 } 289 290 private static final String POSSIBLE_THROWS = possibleThrows(); 291 292 static final String possibleThrows() { 293 StringBuilder sb = new StringBuilder(); 294 for (Severity s : Severity.values()) { 295 if (sb.length() > 0) { 296 sb.append('/'); 297 } 298 sb.append("throw") 299 .append(s.name().charAt(0)) 300 .append(s.name().substring(1).toLowerCase(Locale.ENGLISH)); 301 } 302 return sb.toString(); 303 } 304 305 static TagCheck ofMapCSSRule(final GroupedMapCSSRule rule) throws IllegalDataException { 306 final TagCheck check = new TagCheck(rule); 307 for (Instruction i : rule.declaration.instructions) { 308 if (i instanceof Instruction.AssignmentInstruction) { 309 final Instruction.AssignmentInstruction ai = (Instruction.AssignmentInstruction) i; 310 if (ai.isSetInstruction) { 311 check.setClassExpressions.add(ai.key); 312 continue; 313 } 314 try { 315 final String val = ai.val instanceof Expression 316 ? Optional.of(((Expression) ai.val).evaluate(new Environment())).map(Object::toString).orElse(null) 317 : ai.val instanceof String 318 ? (String) ai.val 319 : ai.val instanceof Keyword 320 ? ((Keyword) ai.val).val 321 : null; 322 if (ai.key.startsWith("throw")) { 323 try { 324 check.errors.put(ai, Severity.valueOf(ai.key.substring("throw".length()).toUpperCase(Locale.ENGLISH))); 325 } catch (IllegalArgumentException e) { 326 Logging.log(Logging.LEVEL_WARN, 327 "Unsupported "+ai.key+" instruction. Allowed instructions are "+POSSIBLE_THROWS+'.', e); 328 } 329 } else if ("fixAdd".equals(ai.key)) { 330 check.fixCommands.add(FixCommand.fixAdd(ai.val)); 331 } else if ("fixRemove".equals(ai.key)) { 332 CheckParameterUtil.ensureThat(!(ai.val instanceof String) || !(val != null && val.contains("=")), 333 "Unexpected '='. Please only specify the key to remove in: " + ai); 334 check.fixCommands.add(FixCommand.fixRemove(ai.val)); 335 } else if (val != null && "fixChangeKey".equals(ai.key)) { 336 CheckParameterUtil.ensureThat(val.contains("=>"), "Separate old from new key by '=>'!"); 337 final String[] x = val.split("=>", 2); 338 check.fixCommands.add(FixCommand.fixChangeKey(Tag.removeWhiteSpaces(x[0]), Tag.removeWhiteSpaces(x[1]))); 339 } else if (val != null && "fixDeleteObject".equals(ai.key)) { 340 CheckParameterUtil.ensureThat("this".equals(val), "fixDeleteObject must be followed by 'this'"); 341 check.deletion = true; 342 } else if (val != null && "suggestAlternative".equals(ai.key)) { 343 check.alternatives.add(val); 344 } else if (val != null && "assertMatch".equals(ai.key)) { 345 check.assertions.put(val, Boolean.TRUE); 346 } else if (val != null && "assertNoMatch".equals(ai.key)) { 347 check.assertions.put(val, Boolean.FALSE); 348 } else if (val != null && "group".equals(ai.key)) { 349 check.group = val; 350 } else { 351 throw new IllegalDataException("Cannot add instruction " + ai.key + ": " + ai.val + '!'); 352 } 353 } catch (IllegalArgumentException e) { 354 throw new IllegalDataException(e); 355 } 356 } 357 } 358 if (check.errors.isEmpty() && check.setClassExpressions.isEmpty()) { 359 throw new IllegalDataException( 360 "No "+POSSIBLE_THROWS+" given! You should specify a validation error message for " + rule.selectors); 361 } else if (check.errors.size() > 1) { 362 throw new IllegalDataException( 363 "More than one "+POSSIBLE_THROWS+" given! You should specify a single validation error message for " 364 + rule.selectors); 365 } 366 return check; 367 } 368 369 static ParseResult readMapCSS(Reader css) throws ParseException { 370 CheckParameterUtil.ensureParameterNotNull(css, "css"); 371 372 final MapCSSStyleSource source = new MapCSSStyleSource(""); 373 final MapCSSParser preprocessor = new MapCSSParser(css, MapCSSParser.LexicalState.PREPROCESSOR); 374 final StringReader mapcss = new StringReader(preprocessor.pp_root(source)); 375 final MapCSSParser parser = new MapCSSParser(mapcss, MapCSSParser.LexicalState.DEFAULT); 376 parser.sheet(source); 377 // Ignore "meta" rule(s) from external rules of JOSM wiki 378 removeMetaRules(source); 379 // group rules with common declaration block 380 Map<Declaration, List<Selector>> g = new LinkedHashMap<>(); 381 for (MapCSSRule rule : source.rules) { 382 if (!g.containsKey(rule.declaration)) { 383 List<Selector> sels = new ArrayList<>(); 384 sels.add(rule.selector); 385 g.put(rule.declaration, sels); 386 } else { 387 g.get(rule.declaration).add(rule.selector); 388 } 389 } 390 List<TagCheck> parseChecks = new ArrayList<>(); 391 for (Map.Entry<Declaration, List<Selector>> map : g.entrySet()) { 392 try { 393 parseChecks.add(TagCheck.ofMapCSSRule( 394 new GroupedMapCSSRule(map.getValue(), map.getKey()))); 395 } catch (IllegalDataException e) { 396 Logging.error("Cannot add MapCss rule: "+e.getMessage()); 397 source.logError(e); 398 } 399 } 400 return new ParseResult(parseChecks, source.getErrors()); 401 } 402 403 private static void removeMetaRules(MapCSSStyleSource source) { 404 for (Iterator<MapCSSRule> it = source.rules.iterator(); it.hasNext();) { 405 MapCSSRule x = it.next(); 406 if (x.selector instanceof GeneralSelector) { 407 GeneralSelector gs = (GeneralSelector) x.selector; 408 if ("meta".equals(gs.base)) { 409 it.remove(); 410 } 411 } 412 } 413 } 414 415 @Override 416 public boolean test(OsmPrimitive primitive) { 417 // Tests whether the primitive contains a deprecated tag which is represented by this MapCSSTagChecker. 418 return whichSelectorMatchesPrimitive(primitive) != null; 419 } 420 421 Selector whichSelectorMatchesPrimitive(OsmPrimitive primitive) { 422 return whichSelectorMatchesEnvironment(new Environment(primitive)); 423 } 424 425 Selector whichSelectorMatchesEnvironment(Environment env) { 426 for (Selector i : rule.selectors) { 427 env.clearSelectorMatchingInformation(); 428 if (i.matches(env)) { 429 return i; 430 } 431 } 432 return null; 433 } 434 435 /** 436 * Determines the {@code index}-th key/value/tag (depending on {@code type}) of the 437 * {@link org.openstreetmap.josm.gui.mappaint.mapcss.Selector.GeneralSelector}. 438 * @param matchingSelector matching selector 439 * @param index index 440 * @param type selector type ("key", "value" or "tag") 441 * @param p OSM primitive 442 * @return argument value, can be {@code null} 443 */ 444 static String determineArgument(Selector.GeneralSelector matchingSelector, int index, String type, OsmPrimitive p) { 445 try { 446 final Condition c = matchingSelector.getConditions().get(index); 447 final Tag tag = c instanceof Condition.ToTagConvertable 448 ? ((Condition.ToTagConvertable) c).asTag(p) 449 : null; 450 if (tag == null) { 451 return null; 452 } else if ("key".equals(type)) { 453 return tag.getKey(); 454 } else if ("value".equals(type)) { 455 return tag.getValue(); 456 } else if ("tag".equals(type)) { 457 return tag.toString(); 458 } 459 } catch (IndexOutOfBoundsException ignore) { 460 Logging.debug(ignore); 461 } 462 return null; 463 } 464 465 /** 466 * Replaces occurrences of <code>{i.key}</code>, <code>{i.value}</code>, <code>{i.tag}</code> in {@code s} by the corresponding 467 * key/value/tag of the {@code index}-th {@link Condition} of {@code matchingSelector}. 468 * @param matchingSelector matching selector 469 * @param s any string 470 * @param p OSM primitive 471 * @return string with arguments inserted 472 */ 473 static String insertArguments(Selector matchingSelector, String s, OsmPrimitive p) { 474 if (s != null && matchingSelector instanceof Selector.ChildOrParentSelector) { 475 return insertArguments(((Selector.ChildOrParentSelector) matchingSelector).right, s, p); 476 } else if (s == null || !(matchingSelector instanceof GeneralSelector)) { 477 return s; 478 } 479 final Matcher m = Pattern.compile("\\{(\\d+)\\.(key|value|tag)\\}").matcher(s); 480 final StringBuffer sb = new StringBuffer(); 481 while (m.find()) { 482 final String argument = determineArgument((Selector.GeneralSelector) matchingSelector, 483 Integer.parseInt(m.group(1)), m.group(2), p); 484 try { 485 // Perform replacement with null-safe + regex-safe handling 486 m.appendReplacement(sb, String.valueOf(argument).replace("^(", "").replace(")$", "")); 487 } catch (IndexOutOfBoundsException | IllegalArgumentException e) { 488 Logging.log(Logging.LEVEL_ERROR, tr("Unable to replace argument {0} in {1}: {2}", argument, sb, e.getMessage()), e); 489 } 490 } 491 m.appendTail(sb); 492 return sb.toString(); 493 } 494 495 /** 496 * Constructs a fix in terms of a {@link org.openstreetmap.josm.command.Command} for the {@link OsmPrimitive} 497 * if the error is fixable, or {@code null} otherwise. 498 * 499 * @param p the primitive to construct the fix for 500 * @return the fix or {@code null} 501 */ 502 Command fixPrimitive(OsmPrimitive p) { 503 if (fixCommands.isEmpty() && !deletion) { 504 return null; 505 } 506 try { 507 final Selector matchingSelector = whichSelectorMatchesPrimitive(p); 508 Collection<Command> cmds = new LinkedList<>(); 509 for (FixCommand fixCommand : fixCommands) { 510 cmds.add(fixCommand.createCommand(p, matchingSelector)); 511 } 512 if (deletion && !p.isDeleted()) { 513 cmds.add(new DeleteCommand(p)); 514 } 515 return new SequenceCommand(tr("Fix of {0}", getDescriptionForMatchingSelector(p, matchingSelector)), cmds); 516 } catch (IllegalArgumentException e) { 517 Logging.error(e); 518 return null; 519 } 520 } 521 522 /** 523 * Constructs a (localized) message for this deprecation check. 524 * @param p OSM primitive 525 * 526 * @return a message 527 */ 528 String getMessage(OsmPrimitive p) { 529 if (errors.isEmpty()) { 530 // Return something to avoid NPEs 531 return rule.declaration.toString(); 532 } else { 533 final Object val = errors.keySet().iterator().next().val; 534 return String.valueOf( 535 val instanceof Expression 536 ? ((Expression) val).evaluate(new Environment(p)) 537 : val 538 ); 539 } 540 } 541 542 /** 543 * Constructs a (localized) description for this deprecation check. 544 * @param p OSM primitive 545 * 546 * @return a description (possibly with alternative suggestions) 547 * @see #getDescriptionForMatchingSelector 548 */ 549 String getDescription(OsmPrimitive p) { 550 if (alternatives.isEmpty()) { 551 return getMessage(p); 552 } else { 553 /* I18N: {0} is the test error message and {1} is an alternative */ 554 return tr("{0}, use {1} instead", getMessage(p), Utils.join(tr(" or "), alternatives)); 555 } 556 } 557 558 /** 559 * Constructs a (localized) description for this deprecation check 560 * where any placeholders are replaced by values of the matched selector. 561 * 562 * @param matchingSelector matching selector 563 * @param p OSM primitive 564 * @return a description (possibly with alternative suggestions) 565 */ 566 String getDescriptionForMatchingSelector(OsmPrimitive p, Selector matchingSelector) { 567 return insertArguments(matchingSelector, getDescription(p), p); 568 } 569 570 Severity getSeverity() { 571 return errors.isEmpty() ? null : errors.values().iterator().next(); 572 } 573 574 @Override 575 public String toString() { 576 return getDescription(null); 577 } 578 579 /** 580 * Constructs a {@link TestError} for the given primitive, or returns null if the primitive does not give rise to an error. 581 * 582 * @param p the primitive to construct the error for 583 * @return an instance of {@link TestError}, or returns null if the primitive does not give rise to an error. 584 */ 585 TestError getErrorForPrimitive(OsmPrimitive p) { 586 final Environment env = new Environment(p); 587 return getErrorForPrimitive(p, whichSelectorMatchesEnvironment(env), env, null); 588 } 589 590 TestError getErrorForPrimitive(OsmPrimitive p, Selector matchingSelector, Environment env, Test tester) { 591 if (matchingSelector != null && !errors.isEmpty()) { 592 final Command fix = fixPrimitive(p); 593 final String description = getDescriptionForMatchingSelector(p, matchingSelector); 594 final String description1 = group == null ? description : group; 595 final String description2 = group == null ? null : description; 596 final List<OsmPrimitive> primitives; 597 if (env.child != null) { 598 primitives = Arrays.asList(p, env.child); 599 } else { 600 primitives = Collections.singletonList(p); 601 } 602 final TestError.Builder error = TestError.builder(tester, getSeverity(), 3000) 603 .messageWithManuallyTranslatedDescription(description1, description2, matchingSelector.toString()) 604 .primitives(primitives); 605 if (fix != null) { 606 return error.fix(() -> fix).build(); 607 } else { 608 return error.build(); 609 } 610 } else { 611 return null; 612 } 613 } 614 615 /** 616 * Returns the set of tagchecks on which this check depends on. 617 * @param schecks the collection of tagcheks to search in 618 * @return the set of tagchecks on which this check depends on 619 * @since 7881 620 */ 621 public Set<TagCheck> getTagCheckDependencies(Collection<TagCheck> schecks) { 622 Set<TagCheck> result = new HashSet<>(); 623 Set<String> classes = getClassesIds(); 624 if (schecks != null && !classes.isEmpty()) { 625 for (TagCheck tc : schecks) { 626 if (this.equals(tc)) { 627 continue; 628 } 629 for (String id : tc.setClassExpressions) { 630 if (classes.contains(id)) { 631 result.add(tc); 632 break; 633 } 634 } 635 } 636 } 637 return result; 638 } 639 640 /** 641 * Returns the list of ids of all MapCSS classes referenced in the rule selectors. 642 * @return the list of ids of all MapCSS classes referenced in the rule selectors 643 * @since 7881 644 */ 645 public Set<String> getClassesIds() { 646 Set<String> result = new HashSet<>(); 647 for (Selector s : rule.selectors) { 648 if (s instanceof AbstractSelector) { 649 for (Condition c : ((AbstractSelector) s).getConditions()) { 650 if (c instanceof ClassCondition) { 651 result.add(((ClassCondition) c).id); 652 } 653 } 654 } 655 } 656 return result; 657 } 658 } 659 660 static class MapCSSTagCheckerAndRule extends MapCSSTagChecker { 661 public final GroupedMapCSSRule rule; 662 663 MapCSSTagCheckerAndRule(GroupedMapCSSRule rule) { 664 this.rule = rule; 665 } 666 667 @Override 668 public synchronized boolean equals(Object obj) { 669 return super.equals(obj) 670 || (obj instanceof TagCheck && rule.equals(((TagCheck) obj).rule)) 671 || (obj instanceof GroupedMapCSSRule && rule.equals(obj)); 672 } 673 674 @Override 675 public synchronized int hashCode() { 676 return Objects.hash(super.hashCode(), rule); 677 } 678 679 @Override 680 public String toString() { 681 return "MapCSSTagCheckerAndRule [rule=" + rule + ']'; 682 } 683 } 684 685 /** 686 * Obtains all {@link TestError}s for the {@link OsmPrimitive} {@code p}. 687 * @param p The OSM primitive 688 * @param includeOtherSeverity if {@code true}, errors of severity {@link Severity#OTHER} (info) will also be returned 689 * @return all errors for the given primitive, with or without those of "info" severity 690 */ 691 public synchronized Collection<TestError> getErrorsForPrimitive(OsmPrimitive p, boolean includeOtherSeverity) { 692 return getErrorsForPrimitive(p, includeOtherSeverity, checks.values()); 693 } 694 695 private static Collection<TestError> getErrorsForPrimitive(OsmPrimitive p, boolean includeOtherSeverity, 696 Collection<Set<TagCheck>> checksCol) { 697 final List<TestError> r = new ArrayList<>(); 698 final Environment env = new Environment(p, new MultiCascade(), Environment.DEFAULT_LAYER, null); 699 for (Set<TagCheck> schecks : checksCol) { 700 for (TagCheck check : schecks) { 701 boolean ignoreError = Severity.OTHER.equals(check.getSeverity()) && !includeOtherSeverity; 702 // Do not run "information" level checks if not wanted, unless they also set a MapCSS class 703 if (ignoreError && check.setClassExpressions.isEmpty()) { 704 continue; 705 } 706 final Selector selector = check.whichSelectorMatchesEnvironment(env); 707 if (selector != null) { 708 check.rule.declaration.execute(env); 709 if (!ignoreError) { 710 final TestError error = check.getErrorForPrimitive(p, selector, env, new MapCSSTagCheckerAndRule(check.rule)); 711 if (error != null) { 712 r.add(error); 713 } 714 } 715 } 716 } 717 } 718 return r; 719 } 720 721 /** 722 * Visiting call for primitives. 723 * 724 * @param p The primitive to inspect. 725 */ 726 @Override 727 public void check(OsmPrimitive p) { 728 errors.addAll(getErrorsForPrimitive(p, ValidatorPrefHelper.PREF_OTHER.get())); 729 } 730 731 /** 732 * Adds a new MapCSS config file from the given URL. 733 * @param url The unique URL of the MapCSS config file 734 * @return List of tag checks and parsing errors, or null 735 * @throws ParseException if the config file does not match MapCSS syntax 736 * @throws IOException if any I/O error occurs 737 * @since 7275 738 */ 739 public synchronized ParseResult addMapCSS(String url) throws ParseException, IOException { 740 CheckParameterUtil.ensureParameterNotNull(url, "url"); 741 ParseResult result; 742 try (CachedFile cache = new CachedFile(url); 743 InputStream zip = cache.findZipEntryInputStream("validator.mapcss", ""); 744 InputStream s = zip != null ? zip : cache.getInputStream(); 745 Reader reader = new BufferedReader(UTFInputStreamReader.create(s))) { 746 if (zip != null) 747 I18n.addTexts(cache.getFile()); 748 result = TagCheck.readMapCSS(reader); 749 checks.remove(url); 750 checks.putAll(url, result.parseChecks); 751 // Check assertions, useful for development of local files 752 if (Config.getPref().getBoolean("validator.check_assert_local_rules", false) && Utils.isLocalUrl(url)) { 753 for (String msg : checkAsserts(result.parseChecks)) { 754 Logging.warn(msg); 755 } 756 } 757 } 758 return result; 759 } 760 761 @Override 762 public synchronized void initialize() throws Exception { 763 checks.clear(); 764 for (SourceEntry source : new ValidatorPrefHelper().get()) { 765 if (!source.active) { 766 continue; 767 } 768 String i = source.url; 769 try { 770 if (!i.startsWith("resource:")) { 771 Logging.info(tr("Adding {0} to tag checker", i)); 772 } else if (Logging.isDebugEnabled()) { 773 Logging.debug(tr("Adding {0} to tag checker", i)); 774 } 775 addMapCSS(i); 776 if (Config.getPref().getBoolean("validator.auto_reload_local_rules", true) && source.isLocal()) { 777 Main.fileWatcher.registerSource(source); 778 } 779 } catch (IOException | IllegalStateException | IllegalArgumentException ex) { 780 Logging.warn(tr("Failed to add {0} to tag checker", i)); 781 Logging.log(Logging.LEVEL_WARN, ex); 782 } catch (ParseException | TokenMgrError ex) { 783 Logging.warn(tr("Failed to add {0} to tag checker", i)); 784 Logging.warn(ex); 785 } 786 } 787 } 788 789 /** 790 * Checks that rule assertions are met for the given set of TagChecks. 791 * @param schecks The TagChecks for which assertions have to be checked 792 * @return A set of error messages, empty if all assertions are met 793 * @since 7356 794 */ 795 public Set<String> checkAsserts(final Collection<TagCheck> schecks) { 796 Set<String> assertionErrors = new LinkedHashSet<>(); 797 final DataSet ds = new DataSet(); 798 for (final TagCheck check : schecks) { 799 Logging.debug("Check: {0}", check); 800 for (final Map.Entry<String, Boolean> i : check.assertions.entrySet()) { 801 Logging.debug("- Assertion: {0}", i); 802 final OsmPrimitive p = OsmUtils.createPrimitive(i.getKey()); 803 // Build minimal ordered list of checks to run to test the assertion 804 List<Set<TagCheck>> checksToRun = new ArrayList<>(); 805 Set<TagCheck> checkDependencies = check.getTagCheckDependencies(schecks); 806 if (!checkDependencies.isEmpty()) { 807 checksToRun.add(checkDependencies); 808 } 809 checksToRun.add(Collections.singleton(check)); 810 // Add primitive to dataset to avoid DataIntegrityProblemException when evaluating selectors 811 ds.addPrimitive(p); 812 final Collection<TestError> pErrors = getErrorsForPrimitive(p, true, checksToRun); 813 Logging.debug("- Errors: {0}", pErrors); 814 @SuppressWarnings({"EqualsBetweenInconvertibleTypes", "EqualsIncompatibleType"}) 815 final boolean isError = pErrors.stream().anyMatch(e -> e.getTester().equals(check.rule)); 816 if (isError != i.getValue()) { 817 final String error = MessageFormat.format("Expecting test ''{0}'' (i.e., {1}) to {2} {3} (i.e., {4})", 818 check.getMessage(p), check.rule.selectors, i.getValue() ? "match" : "not match", i.getKey(), p.getKeys()); 819 assertionErrors.add(error); 820 } 821 ds.removePrimitive(p); 822 } 823 } 824 return assertionErrors; 825 } 826 827 @Override 828 public synchronized int hashCode() { 829 return Objects.hash(super.hashCode(), checks); 830 } 831 832 @Override 833 public synchronized boolean equals(Object obj) { 834 if (this == obj) return true; 835 if (obj == null || getClass() != obj.getClass()) return false; 836 if (!super.equals(obj)) return false; 837 MapCSSTagChecker that = (MapCSSTagChecker) obj; 838 return Objects.equals(checks, that.checks); 839 } 840 841 /** 842 * Reload tagchecker rule. 843 * @param rule tagchecker rule to reload 844 * @since 12825 845 */ 846 public static void reloadRule(SourceEntry rule) { 847 MapCSSTagChecker tagChecker = OsmValidator.getTest(MapCSSTagChecker.class); 848 if (tagChecker != null) { 849 try { 850 tagChecker.addMapCSS(rule.url); 851 } catch (IOException | ParseException | TokenMgrError e) { 852 Logging.warn(e); 853 } 854 } 855 } 856}