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[] {// NOPMD
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    /**
169     * Removes a test from the list of available tests. This will not remove
170     * core tests.
171     *
172     * @param testClass The test class
173     * @return {@code true} if the test was removed (see {@link Collection#remove})
174     * @since 15603
175     */
176    public static boolean removeTest(Class<? extends Test> testClass) {
177        boolean removed = false;
178        if (!Arrays.asList(CORE_TEST_CLASSES).contains(testClass)) {
179            removed = allTests.remove(testClass);
180            allTestsMap.remove(testClass.getName());
181        }
182        return removed;
183    }
184
185    static {
186        for (Class<? extends Test> testClass : CORE_TEST_CLASSES) {
187            addTest(testClass);
188        }
189    }
190
191    /**
192     * Initializes {@code OsmValidator}.
193     */
194    public static void initialize() {
195        initializeGridDetail();
196        loadIgnoredErrors();
197    }
198
199    /**
200     * Returns the validator directory.
201     *
202     * @return The validator directory
203     */
204    public static String getValidatorDir() {
205        File dir = new File(Config.getDirs().getUserDataDirectory(true), "validator");
206        try {
207            return dir.getAbsolutePath();
208        } catch (SecurityException e) {
209            Logging.log(Logging.LEVEL_ERROR, null, e);
210            return dir.getPath();
211        }
212    }
213
214    private static void loadIgnoredErrors() {
215        ignoredErrors.clear();
216        if (ValidatorPrefHelper.PREF_USE_IGNORE.get()) {
217            Config.getPref().getListOfMaps(ValidatorPrefHelper.PREF_IGNORELIST).forEach(ignoredErrors::putAll);
218            Path path = Paths.get(getValidatorDir()).resolve("ignorederrors");
219            try {
220                if (path.toFile().exists()) {
221                    try {
222                        TreeSet<String> treeSet = new TreeSet<>();
223                        treeSet.addAll(Files.readAllLines(path, StandardCharsets.UTF_8));
224                        treeSet.forEach(ignore -> ignoredErrors.putIfAbsent(ignore, ""));
225
226                        saveIgnoredErrors();
227                        Files.deleteIfExists(path);
228
229                    } catch (FileNotFoundException e) {
230                        Logging.debug(Logging.getErrorMessage(e));
231                    } catch (IOException e) {
232                        Logging.error(e);
233                    }
234                }
235            } catch (SecurityException e) {
236                Logging.log(Logging.LEVEL_ERROR, "Unable to load ignored errors", e);
237            }
238        }
239    }
240
241    /**
242     * Adds an ignored error
243     * @param s The ignore group / sub group name
244     * @see TestError#getIgnoreGroup()
245     * @see TestError#getIgnoreSubGroup()
246     */
247    public static void addIgnoredError(String s) {
248        addIgnoredError(s, "");
249    }
250
251    /**
252     * Adds an ignored error
253     * @param s The ignore group / sub group name
254     * @param description What the error actually is
255     * @see TestError#getIgnoreGroup()
256     * @see TestError#getIgnoreSubGroup()
257     */
258    public static void addIgnoredError(String s, String description) {
259        if (description == null) description = "";
260        ignoredErrors.put(s, description);
261    }
262
263    /**
264     *  Make sure that we don't keep single entries for a "group ignore".
265     */
266    static void cleanupIgnoredErrors() {
267        if (ignoredErrors.size() > 1) {
268            List<String> toRemove = new ArrayList<>();
269
270            Iterator<Entry<String, String>> iter = ignoredErrors.entrySet().iterator();
271            String lastKey = iter.next().getKey();
272            while (iter.hasNext()) {
273                String currKey = iter.next().getKey();
274                if (currKey.startsWith(lastKey) && sameCode(currKey, lastKey)) {
275                    toRemove.add(currKey);
276                } else {
277                    lastKey = currKey;
278                }
279            }
280            toRemove.forEach(ignoredErrors::remove);
281        }
282
283        Map<String, String> tmap = buildIgnore(buildJTreeList());
284        if (!tmap.isEmpty()) {
285            ignoredErrors.clear();
286            ignoredErrors.putAll(tmap);
287        }
288    }
289
290    private static boolean sameCode(String key1, String key2) {
291        return extractCodeFromIgnoreKey(key1).equals(extractCodeFromIgnoreKey(key2));
292    }
293
294    /**
295     * Extract the leading digits building the code for the error key.
296     * @param key the error key
297     * @return the leading digits
298     */
299    private static String extractCodeFromIgnoreKey(String key) {
300        int lenCode = 0;
301
302        for (int i = 0; i < key.length(); i++) {
303            if (key.charAt(i) >= '0' && key.charAt(i) <= '9') {
304                lenCode++;
305            } else {
306                break;
307            }
308        }
309        return key.substring(0, lenCode);
310    }
311
312    /**
313     * Check if a error should be ignored
314     * @param s The ignore group / sub group name
315     * @return <code>true</code> to ignore that error
316     */
317    public static boolean hasIgnoredError(String s) {
318        return ignoredErrors.containsKey(s);
319    }
320
321    /**
322     * Get the list of all ignored errors
323     * @return The <code>Collection&lt;String&gt;</code> of errors that are ignored
324     */
325    public static SortedMap<String, String> getIgnoredErrors() {
326        return ignoredErrors;
327    }
328
329    /**
330     * Build a JTree with a list
331     * @return &lt;type&gt;list as a {@code JTree}
332     */
333    public static JTree buildJTreeList() {
334        DefaultMutableTreeNode root = new DefaultMutableTreeNode(tr("Ignore list"));
335        final Pattern elemId1Pattern = Pattern.compile(":(r|w|n)_");
336        final Pattern elemId2Pattern = Pattern.compile("^[0-9]+$");
337        for (Entry<String, String> e: ignoredErrors.entrySet()) {
338            String key = e.getKey();
339            // key starts with a code, it maybe followed by a string (eg. a MapCSS rule) and
340            // optionally with a list of one or more OSM element IDs
341            String description = e.getValue();
342
343            ArrayList<String> ignoredElementList = new ArrayList<>();
344            String[] osmobjects = elemId1Pattern.split(key);
345            for (int i = 1; i < osmobjects.length; i++) {
346                String osmid = osmobjects[i];
347                if (elemId2Pattern.matcher(osmid).matches()) {
348                    osmid = '_' + osmid;
349                    int index = key.indexOf(osmid);
350                    if (index < key.lastIndexOf(']')) continue;
351                    char type = key.charAt(index - 1);
352                    ignoredElementList.add(type + osmid);
353                }
354            }
355            for (String osmignore : ignoredElementList) {
356                key = key.replace(':' + osmignore, "");
357            }
358
359            DefaultMutableTreeNode trunk;
360            DefaultMutableTreeNode branch;
361
362            if (description != null && !description.isEmpty()) {
363                trunk = inTree(root, description);
364                branch = inTree(trunk, key);
365                trunk.add(branch);
366            } else {
367                trunk = inTree(root, key);
368                branch = trunk;
369            }
370            if (!ignoredElementList.isEmpty()) {
371                String item;
372                if (ignoredElementList.size() == 1) {
373                    item = ignoredElementList.iterator().next();
374                } else {
375                    // combination of two or more objects, keep them together
376                    item = ignoredElementList.toString(); // [ID1, ID2, ..., IDn]
377                }
378                branch.add(new DefaultMutableTreeNode(item));
379            }
380            root.add(trunk);
381        }
382        return new JTree(root);
383    }
384
385    private static DefaultMutableTreeNode inTree(DefaultMutableTreeNode root, String name) {
386        @SuppressWarnings("unchecked")
387        Enumeration<TreeNode> trunks = root.children();
388        while (trunks.hasMoreElements()) {
389            TreeNode ttrunk = trunks.nextElement();
390            if (ttrunk instanceof DefaultMutableTreeNode) {
391                DefaultMutableTreeNode trunk = (DefaultMutableTreeNode) ttrunk;
392                if (name.equals(trunk.getUserObject())) {
393                    return trunk;
394                }
395            }
396        }
397        return new DefaultMutableTreeNode(name);
398    }
399
400    /**
401     * Build a {@code HashMap} from a tree of ignored errors
402     * @param tree The JTree of ignored errors
403     * @return A {@code HashMap} of the ignored errors for comparison
404     */
405    public static Map<String, String> buildIgnore(JTree tree) {
406        TreeModel model = tree.getModel();
407        DefaultMutableTreeNode root = (DefaultMutableTreeNode) model.getRoot();
408        return buildIgnore(model, root);
409    }
410
411    private static Map<String, String> buildIgnore(TreeModel model, DefaultMutableTreeNode node) {
412        HashMap<String, String> rHashMap = new HashMap<>();
413
414        for (int i = 0; i < model.getChildCount(node); i++) {
415            DefaultMutableTreeNode child = (DefaultMutableTreeNode) model.getChild(node, i);
416            if (model.getChildCount(child) == 0) {
417                // create an entry for the error list
418                String key = node.getUserObject().toString();
419                String description;
420
421                if (!model.getRoot().equals(node)) {
422                    description = ((DefaultMutableTreeNode) node.getParent()).getUserObject().toString();
423                } else {
424                    description = key; // we get here when reading old file ignorederrors
425                }
426                if (tr("Ignore list").equals(description))
427                    description = "";
428                if (!key.matches("^[0-9]+(_.*|$)")) {
429                    description = key;
430                    key = "";
431                }
432
433                String item = child.getUserObject().toString();
434                String entry = null;
435                if (item.matches("^\\[(r|w|n)_.*")) {
436                    // list of elements (produced with list.toString() method)
437                    entry = key + ":" + item.substring(1, item.lastIndexOf(']')).replace(", ", ":");
438                } else if (item.matches("^(r|w|n)_.*")) {
439                    // single element
440                    entry = key + ":" + item;
441                } else if (item.matches("^[0-9]+(_.*|)$")) {
442                    // no element ids
443                    entry = item;
444                }
445                if (entry != null) {
446                    rHashMap.put(entry, description);
447                } else {
448                    Logging.warn("ignored unexpected item in validator ignore list management dialog:'" + item + "'");
449                }
450            } else {
451                rHashMap.putAll(buildIgnore(model, child));
452            }
453        }
454        return rHashMap;
455    }
456
457    /**
458     * Reset the error list by deleting {@code validator.ignorelist}
459     */
460    public static void resetErrorList() {
461        saveIgnoredErrors();
462        Config.getPref().putListOfMaps(ValidatorPrefHelper.PREF_IGNORELIST, null);
463        OsmValidator.initialize();
464    }
465
466    /**
467     * Saves the names of the ignored errors to a preference
468     */
469    public static void saveIgnoredErrors() {
470        List<Map<String, String>> list = new ArrayList<>();
471        cleanupIgnoredErrors();
472        list.add(ignoredErrors);
473        int i = 0;
474        while (i < list.size()) {
475            if (list.get(i) == null || list.get(i).isEmpty()) {
476                list.remove(i);
477                continue;
478            }
479            i++;
480        }
481        if (list.isEmpty()) list = null;
482        Config.getPref().putListOfMaps(ValidatorPrefHelper.PREF_IGNORELIST, list);
483    }
484
485    /**
486     * Initializes error layer.
487     */
488    public static synchronized void initializeErrorLayer() {
489        if (!ValidatorPrefHelper.PREF_LAYER.get())
490            return;
491        if (errorLayer == null) {
492            errorLayer = new ValidatorLayer();
493            MainApplication.getLayerManager().addLayer(errorLayer);
494        }
495    }
496
497    /**
498     * Resets error layer.
499     * @since 11852
500     */
501    public static synchronized void resetErrorLayer() {
502        errorLayer = null;
503    }
504
505    /**
506     * Gets a map from simple names to all tests.
507     * @return A map of all tests, indexed and sorted by the name of their Java class
508     */
509    public static SortedMap<String, Test> getAllTestsMap() {
510        applyPrefs(allTestsMap, false);
511        applyPrefs(allTestsMap, true);
512        return new TreeMap<>(allTestsMap);
513    }
514
515    /**
516     * Returns the instance of the given test class.
517     * @param <T> testClass type
518     * @param testClass The class of test to retrieve
519     * @return the instance of the given test class, if any, or {@code null}
520     * @since 6670
521     */
522    @SuppressWarnings("unchecked")
523    public static <T extends Test> T getTest(Class<T> testClass) {
524        if (testClass == null) {
525            return null;
526        }
527        return (T) allTestsMap.get(testClass.getName());
528    }
529
530    private static void applyPrefs(Map<String, Test> tests, boolean beforeUpload) {
531        for (String testName : Config.getPref().getList(beforeUpload
532        ? ValidatorPrefHelper.PREF_SKIP_TESTS_BEFORE_UPLOAD : ValidatorPrefHelper.PREF_SKIP_TESTS)) {
533            Test test = tests.get(testName);
534            if (test != null) {
535                if (beforeUpload) {
536                    test.testBeforeUpload = false;
537                } else {
538                    test.enabled = false;
539                }
540            }
541        }
542    }
543
544    /**
545     * Gets all tests that are possible
546     * @return The tests
547     */
548    public static Collection<Test> getTests() {
549        return getAllTestsMap().values();
550    }
551
552    /**
553     * Gets all tests that are run
554     * @param beforeUpload To get the ones that are run before upload
555     * @return The tests
556     */
557    public static Collection<Test> getEnabledTests(boolean beforeUpload) {
558        Collection<Test> enabledTests = getTests();
559        for (Test t : new ArrayList<>(enabledTests)) {
560            if (beforeUpload ? t.testBeforeUpload : t.enabled) {
561                continue;
562            }
563            enabledTests.remove(t);
564        }
565        return enabledTests;
566    }
567
568    /**
569     * Gets the list of all available test classes
570     *
571     * @return A collection of the test classes
572     */
573    public static Collection<Class<? extends Test>> getAllAvailableTestClasses() {
574        return Collections.unmodifiableCollection(allTests);
575    }
576
577    /**
578     * Initialize grid details based on current projection system. Values based on
579     * the original value fixed for EPSG:4326 (10000) using heuristics (that is, test&amp;error
580     * until most bugs were discovered while keeping the processing time reasonable)
581     */
582    public static void initializeGridDetail() {
583        String code = ProjectionRegistry.getProjection().toCode();
584        if (Arrays.asList(ProjectionPreference.wgs84.allCodes()).contains(code)) {
585            OsmValidator.griddetail = 10_000;
586        } else if (Arrays.asList(ProjectionPreference.mercator.allCodes()).contains(code)) {
587            OsmValidator.griddetail = 0.01;
588        } else if (Arrays.asList(ProjectionPreference.lambert.allCodes()).contains(code)) {
589            OsmValidator.griddetail = 0.1;
590        } else {
591            OsmValidator.griddetail = 1.0;
592        }
593    }
594
595    /**
596     * Returns grid detail, multiplier of east,north values for valuable cell sizing
597     * @return grid detail
598     * @since 11852
599     */
600    public static double getGridDetail() {
601        return griddetail;
602    }
603
604    private static boolean testsInitialized;
605
606    /**
607     * Initializes all tests if this operations hasn't been performed already.
608     */
609    public static synchronized void initializeTests() {
610        if (!testsInitialized) {
611            Logging.debug("Initializing validator tests");
612            final long startTime = System.currentTimeMillis();
613            initializeTests(getTests());
614            testsInitialized = true;
615            if (Logging.isDebugEnabled()) {
616                final long elapsedTime = System.currentTimeMillis() - startTime;
617                Logging.debug("Initializing validator tests completed in {0}", Utils.getDurationString(elapsedTime));
618            }
619        }
620    }
621
622    /**
623     * Initializes all tests
624     * @param allTests The tests to initialize
625     */
626    public static void initializeTests(Collection<? extends Test> allTests) {
627        for (Test test : allTests) {
628            try {
629                if (test.enabled) {
630                    test.initialize();
631                }
632            } catch (Exception e) { // NOPMD
633                String message = tr("Error initializing test {0}:\n {1}", test.getClass().getSimpleName(), e);
634                Logging.error(message);
635                if (!GraphicsEnvironment.isHeadless()) {
636                    GuiHelper.runInEDT(() ->
637                        JOptionPane.showMessageDialog(MainApplication.getMainFrame(), message, tr("Error"), JOptionPane.ERROR_MESSAGE)
638                    );
639                }
640            }
641        }
642    }
643
644    /**
645     * Groups the given collection of errors by severity, then message, then description.
646     * @param errors list of errors to group
647     * @param filterToUse optional filter
648     * @return collection of errors grouped by severity, then message, then description
649     * @since 12667
650     */
651    public static Map<Severity, Map<String, Map<String, List<TestError>>>> getErrorsBySeverityMessageDescription(
652            Collection<TestError> errors, Predicate<? super TestError> filterToUse) {
653        return errors.stream().filter(filterToUse).collect(
654                Collectors.groupingBy(TestError::getSeverity, () -> new EnumMap<>(Severity.class),
655                        Collectors.groupingBy(TestError::getMessage, () -> new TreeMap<>(AlphanumComparator.getInstance()),
656                                Collectors.groupingBy(e -> e.getDescription() == null ? "" : e.getDescription(),
657                                        () -> new TreeMap<>(AlphanumComparator.getInstance()),
658                                        Collectors.toList()
659                                ))));
660    }
661
662    /**
663     * For unit tests
664     */
665    static void clearIgnoredErrors() {
666        ignoredErrors.clear();
667    }
668}