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