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&lt;String&gt;</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 &lt;type&gt;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&amp;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}