001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.validation; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.GraphicsEnvironment; 007import java.io.File; 008import java.io.FileNotFoundException; 009import java.io.IOException; 010import java.nio.charset.StandardCharsets; 011import java.nio.file.Files; 012import java.nio.file.Path; 013import java.nio.file.Paths; 014import java.util.ArrayList; 015import java.util.Arrays; 016import java.util.Collection; 017import java.util.Collections; 018import java.util.EnumMap; 019import java.util.Enumeration; 020import java.util.HashMap; 021import java.util.Iterator; 022import java.util.List; 023import java.util.Map; 024import java.util.Map.Entry; 025import java.util.SortedMap; 026import java.util.TreeMap; 027import java.util.TreeSet; 028import java.util.function.Predicate; 029import java.util.regex.Pattern; 030import java.util.stream.Collectors; 031 032import javax.swing.JOptionPane; 033import javax.swing.JTree; 034import javax.swing.tree.DefaultMutableTreeNode; 035import javax.swing.tree.TreeModel; 036import javax.swing.tree.TreeNode; 037 038import org.openstreetmap.josm.data.preferences.sources.ValidatorPrefHelper; 039import org.openstreetmap.josm.data.projection.ProjectionRegistry; 040import org.openstreetmap.josm.data.validation.tests.Addresses; 041import org.openstreetmap.josm.data.validation.tests.ApiCapabilitiesTest; 042import org.openstreetmap.josm.data.validation.tests.BarriersEntrances; 043import org.openstreetmap.josm.data.validation.tests.Coastlines; 044import org.openstreetmap.josm.data.validation.tests.ConditionalKeys; 045import org.openstreetmap.josm.data.validation.tests.CrossingWays; 046import org.openstreetmap.josm.data.validation.tests.DuplicateNode; 047import org.openstreetmap.josm.data.validation.tests.DuplicateRelation; 048import org.openstreetmap.josm.data.validation.tests.DuplicateWay; 049import org.openstreetmap.josm.data.validation.tests.DuplicatedWayNodes; 050import org.openstreetmap.josm.data.validation.tests.Highways; 051import org.openstreetmap.josm.data.validation.tests.InternetTags; 052import org.openstreetmap.josm.data.validation.tests.Lanes; 053import org.openstreetmap.josm.data.validation.tests.LongSegment; 054import org.openstreetmap.josm.data.validation.tests.MapCSSTagChecker; 055import org.openstreetmap.josm.data.validation.tests.MultipolygonTest; 056import org.openstreetmap.josm.data.validation.tests.NameMismatch; 057import org.openstreetmap.josm.data.validation.tests.OpeningHourTest; 058import org.openstreetmap.josm.data.validation.tests.OverlappingWays; 059import org.openstreetmap.josm.data.validation.tests.PowerLines; 060import org.openstreetmap.josm.data.validation.tests.PublicTransportRouteTest; 061import org.openstreetmap.josm.data.validation.tests.RelationChecker; 062import org.openstreetmap.josm.data.validation.tests.RightAngleBuildingTest; 063import org.openstreetmap.josm.data.validation.tests.SelfIntersectingWay; 064import org.openstreetmap.josm.data.validation.tests.SharpAngles; 065import org.openstreetmap.josm.data.validation.tests.SimilarNamedWays; 066import org.openstreetmap.josm.data.validation.tests.TagChecker; 067import org.openstreetmap.josm.data.validation.tests.TurnrestrictionTest; 068import org.openstreetmap.josm.data.validation.tests.UnclosedWays; 069import org.openstreetmap.josm.data.validation.tests.UnconnectedWays; 070import org.openstreetmap.josm.data.validation.tests.UntaggedNode; 071import org.openstreetmap.josm.data.validation.tests.UntaggedWay; 072import org.openstreetmap.josm.data.validation.tests.WayConnectedToArea; 073import org.openstreetmap.josm.data.validation.tests.WronglyOrderedWays; 074import org.openstreetmap.josm.gui.MainApplication; 075import org.openstreetmap.josm.gui.layer.ValidatorLayer; 076import org.openstreetmap.josm.gui.preferences.projection.ProjectionPreference; 077import org.openstreetmap.josm.gui.util.GuiHelper; 078import org.openstreetmap.josm.spi.preferences.Config; 079import org.openstreetmap.josm.tools.AlphanumComparator; 080import org.openstreetmap.josm.tools.Logging; 081import org.openstreetmap.josm.tools.Utils; 082 083/** 084 * A OSM data validator. 085 * 086 * @author Francisco R. Santos <frsantos@gmail.com> 087 */ 088public final class OsmValidator { 089 090 private OsmValidator() { 091 // Hide default constructor for utilities classes 092 } 093 094 private static volatile ValidatorLayer errorLayer; 095 096 /** Grid detail, multiplier of east,north values for valuable cell sizing */ 097 private static double griddetail; 098 099 private static final SortedMap<String, String> ignoredErrors = new TreeMap<>(); 100 /** 101 * All registered tests 102 */ 103 private static final Collection<Class<? extends Test>> allTests = new ArrayList<>(); 104 private static final Map<String, Test> allTestsMap = new HashMap<>(); 105 106 /** 107 * All available tests in core 108 */ 109 @SuppressWarnings("unchecked") 110 private static final Class<Test>[] CORE_TEST_CLASSES = new Class[] { 111 /* FIXME - unique error numbers for tests aren't properly unique - ignoring will not work as expected */ 112 DuplicateNode.class, // ID 1 .. 99 113 OverlappingWays.class, // ID 101 .. 199 114 UntaggedNode.class, // ID 201 .. 299 115 UntaggedWay.class, // ID 301 .. 399 116 SelfIntersectingWay.class, // ID 401 .. 499 117 DuplicatedWayNodes.class, // ID 501 .. 599 118 CrossingWays.Ways.class, // ID 601 .. 699 119 CrossingWays.Boundaries.class, // ID 601 .. 699 120 CrossingWays.Barrier.class, // ID 601 .. 699 121 CrossingWays.SelfCrossing.class, // ID 601 .. 699 122 SimilarNamedWays.class, // ID 701 .. 799 123 Coastlines.class, // ID 901 .. 999 124 WronglyOrderedWays.class, // ID 1001 .. 1099 125 UnclosedWays.class, // ID 1101 .. 1199 126 TagChecker.class, // ID 1201 .. 1299 127 UnconnectedWays.UnconnectedHighways.class, // ID 1301 .. 1399 128 UnconnectedWays.UnconnectedRailways.class, // ID 1301 .. 1399 129 UnconnectedWays.UnconnectedWaterways.class, // ID 1301 .. 1399 130 UnconnectedWays.UnconnectedNaturalOrLanduse.class, // ID 1301 .. 1399 131 UnconnectedWays.UnconnectedPower.class, // ID 1301 .. 1399 132 DuplicateWay.class, // ID 1401 .. 1499 133 NameMismatch.class, // ID 1501 .. 1599 134 MultipolygonTest.class, // ID 1601 .. 1699 135 RelationChecker.class, // ID 1701 .. 1799 136 TurnrestrictionTest.class, // ID 1801 .. 1899 137 DuplicateRelation.class, // ID 1901 .. 1999 138 WayConnectedToArea.class, // ID 2301 .. 2399 139 PowerLines.class, // ID 2501 .. 2599 140 Addresses.class, // ID 2601 .. 2699 141 Highways.class, // ID 2701 .. 2799 142 BarriersEntrances.class, // ID 2801 .. 2899 143 OpeningHourTest.class, // 2901 .. 2999 144 MapCSSTagChecker.class, // 3000 .. 3099 145 Lanes.class, // 3100 .. 3199 146 ConditionalKeys.class, // 3200 .. 3299 147 InternetTags.class, // 3300 .. 3399 148 ApiCapabilitiesTest.class, // 3400 .. 3499 149 LongSegment.class, // 3500 .. 3599 150 PublicTransportRouteTest.class, // 3600 .. 3699 151 RightAngleBuildingTest.class, // 3700 .. 3799 152 SharpAngles.class, // 3800 .. 3899 153 }; 154 155 /** 156 * Adds a test to the list of available tests 157 * @param testClass The test class 158 */ 159 public static void addTest(Class<? extends Test> testClass) { 160 allTests.add(testClass); 161 try { 162 allTestsMap.put(testClass.getName(), testClass.getConstructor().newInstance()); 163 } catch (ReflectiveOperationException e) { 164 Logging.error(e); 165 } 166 } 167 168 static { 169 for (Class<? extends Test> testClass : CORE_TEST_CLASSES) { 170 addTest(testClass); 171 } 172 } 173 174 /** 175 * Initializes {@code OsmValidator}. 176 */ 177 public static void initialize() { 178 initializeGridDetail(); 179 loadIgnoredErrors(); 180 } 181 182 /** 183 * Returns the validator directory. 184 * 185 * @return The validator directory 186 */ 187 public static String getValidatorDir() { 188 File dir = new File(Config.getDirs().getUserDataDirectory(true), "validator"); 189 try { 190 return dir.getAbsolutePath(); 191 } catch (SecurityException e) { 192 Logging.log(Logging.LEVEL_ERROR, null, e); 193 return dir.getPath(); 194 } 195 } 196 197 private static void loadIgnoredErrors() { 198 ignoredErrors.clear(); 199 if (ValidatorPrefHelper.PREF_USE_IGNORE.get()) { 200 Config.getPref().getListOfMaps(ValidatorPrefHelper.PREF_IGNORELIST).forEach(ignoredErrors::putAll); 201 Path path = Paths.get(getValidatorDir()).resolve("ignorederrors"); 202 try { 203 if (path.toFile().exists()) { 204 try { 205 TreeSet<String> treeSet = new TreeSet<>(); 206 treeSet.addAll(Files.readAllLines(path, StandardCharsets.UTF_8)); 207 treeSet.forEach(ignore -> ignoredErrors.putIfAbsent(ignore, "")); 208 209 saveIgnoredErrors(); 210 Files.deleteIfExists(path); 211 212 } catch (FileNotFoundException e) { 213 Logging.debug(Logging.getErrorMessage(e)); 214 } catch (IOException e) { 215 Logging.error(e); 216 } 217 } 218 } catch (SecurityException e) { 219 Logging.log(Logging.LEVEL_ERROR, "Unable to load ignored errors", e); 220 } 221 } 222 } 223 224 /** 225 * Adds an ignored error 226 * @param s The ignore group / sub group name 227 * @see TestError#getIgnoreGroup() 228 * @see TestError#getIgnoreSubGroup() 229 */ 230 public static void addIgnoredError(String s) { 231 addIgnoredError(s, ""); 232 } 233 234 /** 235 * Adds an ignored error 236 * @param s The ignore group / sub group name 237 * @param description What the error actually is 238 * @see TestError#getIgnoreGroup() 239 * @see TestError#getIgnoreSubGroup() 240 */ 241 public static void addIgnoredError(String s, String description) { 242 if (description == null) description = ""; 243 ignoredErrors.put(s, description); 244 } 245 246 /** 247 * Make sure that we don't keep single entries for a "group ignore". 248 */ 249 static void cleanupIgnoredErrors() { 250 if (ignoredErrors.size() > 1) { 251 List<String> toRemove = new ArrayList<>(); 252 253 Iterator<Entry<String, String>> iter = ignoredErrors.entrySet().iterator(); 254 String lastKey = iter.next().getKey(); 255 while (iter.hasNext()) { 256 String currKey = iter.next().getKey(); 257 if (currKey.startsWith(lastKey) && sameCode(currKey, lastKey)) { 258 toRemove.add(currKey); 259 } else { 260 lastKey = currKey; 261 } 262 } 263 toRemove.forEach(ignoredErrors::remove); 264 } 265 266 Map<String, String> tmap = buildIgnore(buildJTreeList()); 267 if (!tmap.isEmpty()) { 268 ignoredErrors.clear(); 269 ignoredErrors.putAll(tmap); 270 } 271 } 272 273 private static boolean sameCode(String key1, String key2) { 274 return extractCodeFromIgnoreKey(key1).equals(extractCodeFromIgnoreKey(key2)); 275 } 276 277 /** 278 * Extract the leading digits building the code for the error key. 279 * @param key the error key 280 * @return the leading digits 281 */ 282 private static String extractCodeFromIgnoreKey(String key) { 283 int lenCode = 0; 284 285 for (int i = 0; i < key.length(); i++) { 286 if (key.charAt(i) >= '0' && key.charAt(i) <= '9') { 287 lenCode++; 288 } else { 289 break; 290 } 291 } 292 return key.substring(0, lenCode); 293 } 294 295 /** 296 * Check if a error should be ignored 297 * @param s The ignore group / sub group name 298 * @return <code>true</code> to ignore that error 299 */ 300 public static boolean hasIgnoredError(String s) { 301 return ignoredErrors.containsKey(s); 302 } 303 304 /** 305 * Get the list of all ignored errors 306 * @return The <code>Collection<String></code> of errors that are ignored 307 */ 308 public static SortedMap<String, String> getIgnoredErrors() { 309 return ignoredErrors; 310 } 311 312 /** 313 * Build a JTree with a list 314 * @return <type>list as a {@code JTree} 315 */ 316 public static JTree buildJTreeList() { 317 DefaultMutableTreeNode root = new DefaultMutableTreeNode(tr("Ignore list")); 318 final Pattern elemId1Pattern = Pattern.compile(":(r|w|n)_"); 319 final Pattern elemId2Pattern = Pattern.compile("^[0-9]+$"); 320 for (Entry<String, String> e: ignoredErrors.entrySet()) { 321 String key = e.getKey(); 322 // key starts with a code, it maybe followed by a string (eg. a MapCSS rule) and 323 // optionally with a list of one or more OSM element IDs 324 String description = e.getValue(); 325 326 ArrayList<String> ignoredElementList = new ArrayList<>(); 327 String[] osmobjects = elemId1Pattern.split(key); 328 for (int i = 1; i < osmobjects.length; i++) { 329 String osmid = osmobjects[i]; 330 if (elemId2Pattern.matcher(osmid).matches()) { 331 osmid = '_' + osmid; 332 int index = key.indexOf(osmid); 333 if (index < key.lastIndexOf(']')) continue; 334 char type = key.charAt(index - 1); 335 ignoredElementList.add(type + osmid); 336 } 337 } 338 for (String osmignore : ignoredElementList) { 339 key = key.replace(':' + osmignore, ""); 340 } 341 342 DefaultMutableTreeNode trunk; 343 DefaultMutableTreeNode branch; 344 345 if (description != null && !description.isEmpty()) { 346 trunk = inTree(root, description); 347 branch = inTree(trunk, key); 348 trunk.add(branch); 349 } else { 350 trunk = inTree(root, key); 351 branch = trunk; 352 } 353 if (!ignoredElementList.isEmpty()) { 354 String item; 355 if (ignoredElementList.size() == 1) { 356 item = ignoredElementList.iterator().next(); 357 } else { 358 // combination of two or more objects, keep them together 359 item = ignoredElementList.toString(); // [ID1, ID2, ..., IDn] 360 } 361 branch.add(new DefaultMutableTreeNode(item)); 362 } 363 root.add(trunk); 364 } 365 return new JTree(root); 366 } 367 368 private static DefaultMutableTreeNode inTree(DefaultMutableTreeNode root, String name) { 369 @SuppressWarnings("unchecked") 370 Enumeration<TreeNode> trunks = root.children(); 371 while (trunks.hasMoreElements()) { 372 TreeNode ttrunk = trunks.nextElement(); 373 if (ttrunk instanceof DefaultMutableTreeNode) { 374 DefaultMutableTreeNode trunk = (DefaultMutableTreeNode) ttrunk; 375 if (name.equals(trunk.getUserObject())) { 376 return trunk; 377 } 378 } 379 } 380 return new DefaultMutableTreeNode(name); 381 } 382 383 /** 384 * Build a {@code HashMap} from a tree of ignored errors 385 * @param tree The JTree of ignored errors 386 * @return A {@code HashMap} of the ignored errors for comparison 387 */ 388 public static Map<String, String> buildIgnore(JTree tree) { 389 TreeModel model = tree.getModel(); 390 DefaultMutableTreeNode root = (DefaultMutableTreeNode) model.getRoot(); 391 return buildIgnore(model, root); 392 } 393 394 private static Map<String, String> buildIgnore(TreeModel model, DefaultMutableTreeNode node) { 395 HashMap<String, String> rHashMap = new HashMap<>(); 396 397 for (int i = 0; i < model.getChildCount(node); i++) { 398 DefaultMutableTreeNode child = (DefaultMutableTreeNode) model.getChild(node, i); 399 if (model.getChildCount(child) == 0) { 400 // create an entry for the error list 401 String key = node.getUserObject().toString(); 402 String description; 403 404 if (!model.getRoot().equals(node)) { 405 description = ((DefaultMutableTreeNode) node.getParent()).getUserObject().toString(); 406 } else { 407 description = key; // we get here when reading old file ignorederrors 408 } 409 if (tr("Ignore list").equals(description)) 410 description = ""; 411 if (!key.matches("^[0-9]+(_.*|$)")) { 412 description = key; 413 key = ""; 414 } 415 416 String item = child.getUserObject().toString(); 417 String entry = null; 418 if (item.matches("^\\[(r|w|n)_.*")) { 419 // list of elements (produced with list.toString() method) 420 entry = key + ":" + item.substring(1, item.lastIndexOf(']')).replace(", ", ":"); 421 } else if (item.matches("^(r|w|n)_.*")) { 422 // single element 423 entry = key + ":" + item; 424 } else if (item.matches("^[0-9]+(_.*|)$")) { 425 // no element ids 426 entry = item; 427 } 428 if (entry != null) { 429 rHashMap.put(entry, description); 430 } else { 431 Logging.warn("ignored unexpected item in validator ignore list management dialog:'" + item + "'"); 432 } 433 } else { 434 rHashMap.putAll(buildIgnore(model, child)); 435 } 436 } 437 return rHashMap; 438 } 439 440 /** 441 * Reset the error list by deleting {@code validator.ignorelist} 442 */ 443 public static void resetErrorList() { 444 saveIgnoredErrors(); 445 Config.getPref().putListOfMaps(ValidatorPrefHelper.PREF_IGNORELIST, null); 446 OsmValidator.initialize(); 447 } 448 449 /** 450 * Saves the names of the ignored errors to a preference 451 */ 452 public static void saveIgnoredErrors() { 453 List<Map<String, String>> list = new ArrayList<>(); 454 cleanupIgnoredErrors(); 455 list.add(ignoredErrors); 456 int i = 0; 457 while (i < list.size()) { 458 if (list.get(i) == null || list.get(i).isEmpty()) { 459 list.remove(i); 460 continue; 461 } 462 i++; 463 } 464 if (list.isEmpty()) list = null; 465 Config.getPref().putListOfMaps(ValidatorPrefHelper.PREF_IGNORELIST, list); 466 } 467 468 /** 469 * Initializes error layer. 470 */ 471 public static synchronized void initializeErrorLayer() { 472 if (!ValidatorPrefHelper.PREF_LAYER.get()) 473 return; 474 if (errorLayer == null) { 475 errorLayer = new ValidatorLayer(); 476 MainApplication.getLayerManager().addLayer(errorLayer); 477 } 478 } 479 480 /** 481 * Resets error layer. 482 * @since 11852 483 */ 484 public static synchronized void resetErrorLayer() { 485 errorLayer = null; 486 } 487 488 /** 489 * Gets a map from simple names to all tests. 490 * @return A map of all tests, indexed and sorted by the name of their Java class 491 */ 492 public static SortedMap<String, Test> getAllTestsMap() { 493 applyPrefs(allTestsMap, false); 494 applyPrefs(allTestsMap, true); 495 return new TreeMap<>(allTestsMap); 496 } 497 498 /** 499 * Returns the instance of the given test class. 500 * @param <T> testClass type 501 * @param testClass The class of test to retrieve 502 * @return the instance of the given test class, if any, or {@code null} 503 * @since 6670 504 */ 505 @SuppressWarnings("unchecked") 506 public static <T extends Test> T getTest(Class<T> testClass) { 507 if (testClass == null) { 508 return null; 509 } 510 return (T) allTestsMap.get(testClass.getName()); 511 } 512 513 private static void applyPrefs(Map<String, Test> tests, boolean beforeUpload) { 514 for (String testName : Config.getPref().getList(beforeUpload 515 ? ValidatorPrefHelper.PREF_SKIP_TESTS_BEFORE_UPLOAD : ValidatorPrefHelper.PREF_SKIP_TESTS)) { 516 Test test = tests.get(testName); 517 if (test != null) { 518 if (beforeUpload) { 519 test.testBeforeUpload = false; 520 } else { 521 test.enabled = false; 522 } 523 } 524 } 525 } 526 527 /** 528 * Gets all tests that are possible 529 * @return The tests 530 */ 531 public static Collection<Test> getTests() { 532 return getAllTestsMap().values(); 533 } 534 535 /** 536 * Gets all tests that are run 537 * @param beforeUpload To get the ones that are run before upload 538 * @return The tests 539 */ 540 public static Collection<Test> getEnabledTests(boolean beforeUpload) { 541 Collection<Test> enabledTests = getTests(); 542 for (Test t : new ArrayList<>(enabledTests)) { 543 if (beforeUpload ? t.testBeforeUpload : t.enabled) { 544 continue; 545 } 546 enabledTests.remove(t); 547 } 548 return enabledTests; 549 } 550 551 /** 552 * Gets the list of all available test classes 553 * 554 * @return A collection of the test classes 555 */ 556 public static Collection<Class<? extends Test>> getAllAvailableTestClasses() { 557 return Collections.unmodifiableCollection(allTests); 558 } 559 560 /** 561 * Initialize grid details based on current projection system. Values based on 562 * the original value fixed for EPSG:4326 (10000) using heuristics (that is, test&error 563 * until most bugs were discovered while keeping the processing time reasonable) 564 */ 565 public static void initializeGridDetail() { 566 String code = ProjectionRegistry.getProjection().toCode(); 567 if (Arrays.asList(ProjectionPreference.wgs84.allCodes()).contains(code)) { 568 OsmValidator.griddetail = 10_000; 569 } else if (Arrays.asList(ProjectionPreference.mercator.allCodes()).contains(code)) { 570 OsmValidator.griddetail = 0.01; 571 } else if (Arrays.asList(ProjectionPreference.lambert.allCodes()).contains(code)) { 572 OsmValidator.griddetail = 0.1; 573 } else { 574 OsmValidator.griddetail = 1.0; 575 } 576 } 577 578 /** 579 * Returns grid detail, multiplier of east,north values for valuable cell sizing 580 * @return grid detail 581 * @since 11852 582 */ 583 public static double getGridDetail() { 584 return griddetail; 585 } 586 587 private static boolean testsInitialized; 588 589 /** 590 * Initializes all tests if this operations hasn't been performed already. 591 */ 592 public static synchronized void initializeTests() { 593 if (!testsInitialized) { 594 Logging.debug("Initializing validator tests"); 595 final long startTime = System.currentTimeMillis(); 596 initializeTests(getTests()); 597 testsInitialized = true; 598 if (Logging.isDebugEnabled()) { 599 final long elapsedTime = System.currentTimeMillis() - startTime; 600 Logging.debug("Initializing validator tests completed in {0}", Utils.getDurationString(elapsedTime)); 601 } 602 } 603 } 604 605 /** 606 * Initializes all tests 607 * @param allTests The tests to initialize 608 */ 609 public static void initializeTests(Collection<? extends Test> allTests) { 610 for (Test test : allTests) { 611 try { 612 if (test.enabled) { 613 test.initialize(); 614 } 615 } catch (Exception e) { // NOPMD 616 String message = tr("Error initializing test {0}:\n {1}", test.getClass().getSimpleName(), e); 617 Logging.error(message); 618 if (!GraphicsEnvironment.isHeadless()) { 619 GuiHelper.runInEDT(() -> 620 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), message, tr("Error"), JOptionPane.ERROR_MESSAGE) 621 ); 622 } 623 } 624 } 625 } 626 627 /** 628 * Groups the given collection of errors by severity, then message, then description. 629 * @param errors list of errors to group 630 * @param filterToUse optional filter 631 * @return collection of errors grouped by severity, then message, then description 632 * @since 12667 633 */ 634 public static Map<Severity, Map<String, Map<String, List<TestError>>>> getErrorsBySeverityMessageDescription( 635 Collection<TestError> errors, Predicate<? super TestError> filterToUse) { 636 return errors.stream().filter(filterToUse).collect( 637 Collectors.groupingBy(TestError::getSeverity, () -> new EnumMap<>(Severity.class), 638 Collectors.groupingBy(TestError::getMessage, () -> new TreeMap<>(AlphanumComparator.getInstance()), 639 Collectors.groupingBy(e -> e.getDescription() == null ? "" : e.getDescription(), 640 () -> new TreeMap<>(AlphanumComparator.getInstance()), 641 Collectors.toList() 642 )))); 643 } 644 645 /** 646 * For unit tests 647 */ 648 static void clearIgnoredErrors() { 649 ignoredErrors.clear(); 650 } 651}