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