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}