Ticket #15182: 15182.3.patch

File 15182.3.patch, 65.9 KB (added by taylor.smock, 2 years ago)

Fix test issue (from test pollution), fix progress monitor issue

  • new file src/org/openstreetmap/josm/data/validation/ValidatorCLI.java

    diff --git a/src/org/openstreetmap/josm/data/validation/ValidatorCLI.java b/src/org/openstreetmap/josm/data/validation/ValidatorCLI.java
    new file mode 100644
    index 0000000000..2403bb38c4
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.data.validation;
     3
     4import static org.openstreetmap.josm.tools.I18n.tr;
     5import static org.openstreetmap.josm.tools.I18n.trn;
     6
     7import java.io.File;
     8import java.io.IOException;
     9import java.io.InputStream;
     10import java.io.OutputStream;
     11import java.nio.charset.StandardCharsets;
     12import java.nio.file.Files;
     13import java.nio.file.Paths;
     14import java.util.ArrayList;
     15import java.util.Arrays;
     16import java.util.Collection;
     17import java.util.Collections;
     18import java.util.HashMap;
     19import java.util.List;
     20import java.util.Locale;
     21import java.util.Map;
     22import java.util.Optional;
     23import java.util.concurrent.atomic.AtomicReference;
     24import java.util.function.Supplier;
     25import java.util.logging.Level;
     26import java.util.stream.Collectors;
     27
     28import org.apache.commons.compress.utils.FileNameUtils;
     29import org.openstreetmap.josm.actions.ExtensionFileFilter;
     30import org.openstreetmap.josm.cli.CLIModule;
     31import org.openstreetmap.josm.data.Preferences;
     32import org.openstreetmap.josm.data.osm.DataSet;
     33import org.openstreetmap.josm.data.preferences.JosmBaseDirectories;
     34import org.openstreetmap.josm.data.preferences.JosmUrls;
     35import org.openstreetmap.josm.data.projection.ProjectionRegistry;
     36import org.openstreetmap.josm.data.projection.Projections;
     37import org.openstreetmap.josm.data.validation.tests.MapCSSTagChecker;
     38import org.openstreetmap.josm.gui.MainApplication;
     39import org.openstreetmap.josm.gui.io.CustomConfigurator;
     40import org.openstreetmap.josm.gui.io.importexport.FileImporter;
     41import org.openstreetmap.josm.gui.layer.OsmDataLayer;
     42import org.openstreetmap.josm.gui.mappaint.MapPaintStyles;
     43import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
     44import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.ParseException;
     45import org.openstreetmap.josm.gui.progress.CLIProgressMonitor;
     46import org.openstreetmap.josm.gui.progress.ProgressMonitor;
     47import org.openstreetmap.josm.io.Compression;
     48import org.openstreetmap.josm.io.GeoJSONMapRouletteWriter;
     49import org.openstreetmap.josm.io.IllegalDataException;
     50import org.openstreetmap.josm.io.OsmChangeReader;
     51import org.openstreetmap.josm.spi.lifecycle.Lifecycle;
     52import org.openstreetmap.josm.spi.preferences.Config;
     53import org.openstreetmap.josm.spi.preferences.IPreferences;
     54import org.openstreetmap.josm.spi.preferences.MemoryPreferences;
     55import org.openstreetmap.josm.tools.Http1Client;
     56import org.openstreetmap.josm.tools.HttpClient;
     57import org.openstreetmap.josm.tools.I18n;
     58import org.openstreetmap.josm.tools.JosmRuntimeException;
     59import org.openstreetmap.josm.tools.Logging;
     60import org.openstreetmap.josm.tools.OptionParser;
     61import org.openstreetmap.josm.tools.Stopwatch;
     62import org.openstreetmap.josm.tools.Territories;
     63import org.openstreetmap.josm.tools.Utils;
     64
     65/**
     66 * Add a validate command to the JOSM command line interface.
     67 * @author Taylor Smock
     68 * @since xxx
     69 */
     70public class ValidatorCLI implements CLIModule {
     71    public static final ValidatorCLI INSTANCE = new ValidatorCLI();
     72
     73    /** The input file(s) */
     74    private final List<String> input = new ArrayList<>();
     75    /** The change files. input file -> list of change files */
     76    private final Map<String, List<String>> changeFiles = new HashMap<>();
     77    /** The output file(s). If {@code null}, use input filename as base (replace extension with geojson). input -> output */
     78    private final Map<String, String> output = new HashMap<>();
     79
     80    private static final Supplier<ProgressMonitor> progressMonitorFactory = CLIProgressMonitor::new;
     81
     82    /** The log level */
     83    private Level logLevel;
     84
     85    private enum Option {
     86        /** --help                                    Show the help for validate */
     87        HELP(false, 'h'),
     88        /** --input=&lt;input-file&gt;                Set the current input file */
     89        INPUT(true, 'i', OptionParser.OptionCount.MULTIPLE),
     90        /** --output=&lt;output-file&gt;              Set the output file for the current input file */
     91        OUTPUT(true, 'o', OptionParser.OptionCount.MULTIPLE),
     92        /** --change-file=&lt;change-file&gt;         Add a change file */
     93        CHANGE_FILE(true, 'c', OptionParser.OptionCount.MULTIPLE),
     94        /** --debug                                   Set logging level to debug */
     95        DEBUG(false, '*'),
     96        /** --trace                                   Set logging level to trace */
     97        TRACE(false, '*'),
     98        /** --language=&lt;language&gt;                Set the language */
     99        LANGUAGE(true, 'l'),
     100        /** --load-preferences=&lt;url-to-xml&gt;      Changes preferences according to the XML file */
     101        LOAD_PREFERENCES(true, 'p'),
     102        /** --set=&lt;key&gt;=&lt;value&gt;            Set preference key to value */
     103        SET(true, 's');
     104
     105        private final String name;
     106        private final boolean requiresArgument;
     107        private final char shortOption;
     108        private final OptionParser.OptionCount optionCount;
     109        Option(final boolean requiresArgument, final char shortOption) {
     110            this(requiresArgument, shortOption, OptionParser.OptionCount.OPTIONAL);
     111        }
     112
     113        Option(final boolean requiresArgument, final char shortOption, final OptionParser.OptionCount optionCount) {
     114            this.name = name().toLowerCase(Locale.ROOT).replace('_', '-');
     115            this.requiresArgument = requiresArgument;
     116            this.shortOption = shortOption;
     117            this.optionCount = optionCount;
     118        }
     119
     120        /**
     121         * Replies the option name
     122         * @return The option name, in lowercase
     123         */
     124        public String getName() {
     125            return this.name;
     126        }
     127
     128        /**
     129         * Get the number of times this option should be seen
     130         * @return The option count
     131         */
     132        public OptionParser.OptionCount getOptionCount() {
     133            return this.optionCount;
     134        }
     135
     136        /**
     137         * Replies the short option (single letter) associated with this option.
     138         * @return the short option or '*' if there is no short option
     139         */
     140        public char getShortOption() {
     141            return this.shortOption;
     142        }
     143
     144        /**
     145         * Determines if this option requires an argument.
     146         * @return {@code true} if this option requires an argument, {@code false} otherwise
     147         */
     148        public boolean requiresArgument() {
     149            return this.requiresArgument;
     150        }
     151
     152    }
     153
     154    @Override
     155    public String getActionKeyword() {
     156        return "validate";
     157    }
     158
     159    @Override
     160    public void processArguments(final String[] argArray) {
     161        try {
     162            // Ensure that preferences are only in memory
     163            Config.setPreferencesInstance(new MemoryPreferences());
     164            Logging.setLogLevel(Level.INFO);
     165            this.parseArguments(argArray);
     166            if (this.input.isEmpty()) {
     167                throw new IllegalArgumentException(tr("Missing argument - input data file ({0})", "--input|-i"));
     168            }
     169            this.initialize();
     170            final ProgressMonitor fileMonitor = progressMonitorFactory.get();
     171            fileMonitor.beginTask(tr("Processing files..."), this.input.size());
     172            for (String inputFile : this.input) {
     173                if (inputFile.endsWith(".validator.mapcss")) {
     174                    this.processValidatorFile(inputFile);
     175                } else if (inputFile.endsWith(".mapcss")) {
     176                    this.processMapcssFile(inputFile);
     177                } else {
     178                    this.processFile(inputFile);
     179                }
     180                fileMonitor.worked(1);
     181            }
     182            fileMonitor.finishTask();
     183        } catch (Exception e) {
     184            Logging.info(e);
     185            Lifecycle.exitJosm(true, 1);
     186        }
     187        Lifecycle.exitJosm(true, 0);
     188    }
     189
     190    /**
     191     * Process a standard mapcss file
     192     * @param inputFile The mapcss file to validate
     193     * @throws ParseException if the file does not match the mapcss syntax
     194     */
     195    private void processMapcssFile(final String inputFile) throws ParseException {
     196        final MapCSSStyleSource styleSource = new MapCSSStyleSource(new File(inputFile).toURI().getPath(), inputFile, inputFile);
     197        styleSource.loadStyleSource();
     198        if (!styleSource.getErrors().isEmpty()) {
     199            throw new ParseException(trn("{0} had {1} error", "{0} had {1} errors", styleSource.getErrors().size(),
     200                    inputFile, styleSource.getErrors().size()));
     201        } else {
     202            Logging.info(tr("{0} had no errors", inputFile));
     203        }
     204    }
     205
     206    /**
     207     * Process a validator file
     208     * @param inputFile The file to check
     209     * @throws IOException if there is a problem reading the file
     210     * @throws ParseException if the file does not match the validator mapcss syntax
     211     */
     212    private void processValidatorFile(final String inputFile) throws ParseException, IOException {
     213        // Check asserts
     214        Config.getPref().putBoolean("validator.check_assert_local_rules", true);
     215        final MapCSSTagChecker mapCSSTagChecker = new MapCSSTagChecker();
     216        final Collection<String> assertionErrors = new ArrayList<>();
     217        final MapCSSTagChecker.ParseResult result = mapCSSTagChecker.addMapCSS(new File(inputFile).toURI().getPath(),
     218                assertionErrors::add);
     219        if (!result.parseErrors.isEmpty() || !assertionErrors.isEmpty()) {
     220            for (Throwable throwable : result.parseErrors) {
     221                Logging.error(throwable);
     222            }
     223            for (String error : assertionErrors) {
     224                Logging.error(error);
     225            }
     226            throw new ParseException(trn("{0} had {1} error", "{0} had {1} errors", result.parseErrors.size() + assertionErrors.size(),
     227                    inputFile, result.parseErrors.size() + assertionErrors.size()));
     228        } else {
     229            Logging.info(tr("{0} had no errors"), inputFile);
     230        }
     231    }
     232
     233    /**
     234     * Process an OSM file
     235     * @param inputFile The input filename
     236     * @throws IllegalArgumentException If an argument is not valid
     237     * @throws IllegalDataException If there is bad data
     238     * @throws IOException If a file could not be read or written
     239     */
     240    private void processFile(final String inputFile) throws IllegalDataException, IOException {
     241        final File inputFileFile = new File(inputFile);
     242        final List<FileImporter> inputFileImporters = ExtensionFileFilter.getImporters().stream()
     243                .filter(importer -> importer.acceptFile(inputFileFile)).collect(Collectors.toList());
     244        final Stopwatch stopwatch = Stopwatch.createStarted();
     245        if (inputFileImporters.stream().noneMatch(fileImporter ->
     246                fileImporter.importDataHandleExceptions(inputFileFile, progressMonitorFactory.get()))) {
     247            throw new IOException(tr("Could not load input file: {0}", inputFile));
     248        }
     249        final String outputFile = Optional.ofNullable(this.output.get(inputFile)).orElseGet(() -> getDefaultOutputName(inputFile));
     250        final String task = tr("Validating {0}, saving output to {1}", inputFile, outputFile);
     251        OsmDataLayer dataLayer = null;
     252        try {
     253            Logging.info(task);
     254            OsmValidator.initializeTests();
     255            dataLayer = MainApplication.getLayerManager().getLayersOfType(OsmDataLayer.class)
     256                    .stream().filter(layer -> inputFileFile.equals(layer.getAssociatedFile()))
     257                    .findFirst().orElseThrow(() -> new JosmRuntimeException(tr("Could not find a layer for {0}", inputFile)));
     258            final DataSet dataSet = dataLayer.getDataSet();
     259            if (this.changeFiles.containsKey(inputFile)) {
     260                ProgressMonitor changeFilesMonitor = progressMonitorFactory.get();
     261                for (String changeFile : this.changeFiles.getOrDefault(inputFile, Collections.emptyList())) {
     262                    try (InputStream changeStream = Compression.getUncompressedFileInputStream(Paths.get(changeFile))) {
     263                        dataSet.mergeFrom(OsmChangeReader.parseDataSet(changeStream, changeFilesMonitor));
     264                    }
     265                }
     266            }
     267            Collection<Test> tests = OsmValidator.getEnabledTests(false);
     268            if (Files.isRegularFile(Paths.get(outputFile)) && !Files.deleteIfExists(Paths.get(outputFile))) {
     269                Logging.error("Could not delete {0}, attempting to append", outputFile);
     270            }
     271            GeoJSONMapRouletteWriter geoJSONMapRouletteWriter = new GeoJSONMapRouletteWriter(dataSet);
     272            try (OutputStream fileOutputStream = Files.newOutputStream(Paths.get(outputFile))) {
     273                tests.parallelStream().forEach(test -> runTest(test, geoJSONMapRouletteWriter, fileOutputStream, dataSet));
     274            }
     275        } finally {
     276            if (dataLayer != null) {
     277                MainApplication.getLayerManager().removeLayer(dataLayer);
     278            }
     279            Logging.info(stopwatch.toString(task));
     280        }
     281    }
     282
     283    /**
     284     * Get the default output name
     285     * @param inputString The input file
     286     * @return The default output name for the input file (extension stripped, ".geojson" added)
     287     */
     288    private static String getDefaultOutputName(final String inputString) {
     289        final String extension = FileNameUtils.getExtension(inputString);
     290        if (!Arrays.asList("zip", "bz", "xz", "geojson").contains(extension)) {
     291            return FileNameUtils.getBaseName(inputString) + ".geojson";
     292        } else if ("geojson".equals(extension)) {
     293            // Account for geojson input files
     294            return FileNameUtils.getBaseName(inputString) + ".validated.geojson";
     295        }
     296        return FileNameUtils.getBaseName(FileNameUtils.getBaseName(inputString)) + ".geojson";
     297    }
     298
     299    /**
     300     * Run a test
     301     * @param test The test to run
     302     * @param geoJSONMapRouletteWriter The object to use to create challenges
     303     * @param fileOutputStream The location to write data to
     304     * @param dataSet The dataset to check
     305     */
     306    private void runTest(final Test test, final GeoJSONMapRouletteWriter geoJSONMapRouletteWriter,
     307            final OutputStream fileOutputStream, DataSet dataSet) {
     308        test.startTest(progressMonitorFactory.get());
     309        test.visit(dataSet.allPrimitives());
     310        test.endTest();
     311        test.getErrors().stream().map(geoJSONMapRouletteWriter::write)
     312                .filter(Optional::isPresent).map(Optional::get)
     313                .map(jsonObject -> jsonObject.toString().getBytes(StandardCharsets.UTF_8)).forEach(bytes -> {
     314                    try {
     315                        writeToFile(fileOutputStream, bytes);
     316                    } catch (IOException e) {
     317                        throw new JosmRuntimeException(e);
     318                    }
     319                });
     320        test.clear();
     321    }
     322
     323    /**
     324     * Write to a file. Synchronized to avoid writing to the same file in different threads.
     325     *
     326     * @param fileOutputStream The file output stream to read
     327     * @param bytes The bytes to write (surrounded by RS and LF)
     328     * @throws IOException If we couldn't write to file
     329     */
     330    private synchronized void writeToFile(final OutputStream fileOutputStream, final byte[] bytes)
     331            throws IOException {
     332        // Write the ASCII Record Separator character
     333        fileOutputStream.write(0x1e);
     334        fileOutputStream.write(bytes);
     335        // Write the ASCII Line Feed character
     336        fileOutputStream.write(0x0a);
     337    }
     338
     339    /**
     340     * Initialize everything that might be needed
     341     *
     342     * Arguments may need to be parsed first.
     343     */
     344    void initialize() {
     345        Logging.setLogLevel(this.logLevel);
     346        HttpClient.setFactory(Http1Client::new);
     347        Config.setBaseDirectoriesProvider(JosmBaseDirectories.getInstance()); // for right-left-hand traffic cache file
     348        Config.setUrlsProvider(JosmUrls.getInstance());
     349        ProjectionRegistry.setProjection(Projections.getProjectionByCode("epsg:3857".toUpperCase(Locale.ROOT)));
     350
     351        Territories.initializeInternalData();
     352        OsmValidator.initialize();
     353        MapPaintStyles.readFromPreferences();
     354    }
     355
     356    /**
     357     * Parse command line arguments and do some low-level error checking.
     358     * @param argArray the arguments array
     359     */
     360    void parseArguments(String[] argArray) {
     361        Logging.setLogLevel(Level.INFO);
     362
     363        OptionParser parser = new OptionParser("JOSM validate");
     364        final AtomicReference<String> currentInput = new AtomicReference<>(null);
     365        for (Option o : Option.values()) {
     366            if (o.requiresArgument()) {
     367                parser.addArgumentParameter(o.getName(),
     368                        o.getOptionCount(),
     369                        arg -> handleOption(currentInput.get(), o, arg).ifPresent(currentInput::set));
     370            } else {
     371                parser.addFlagParameter(o.getName(), () -> handleOption(o));
     372            }
     373            if (o.getShortOption() != '*') {
     374                parser.addShortAlias(o.getName(), Character.toString(o.getShortOption()));
     375            }
     376        }
     377        parser.parseOptionsOrExit(Arrays.asList(argArray));
     378    }
     379
     380    private void handleOption(final Option option) {
     381        switch (option) {
     382        case HELP:
     383            showHelp();
     384            System.exit(0);
     385            break;
     386        case DEBUG:
     387            this.logLevel = Logging.LEVEL_DEBUG;
     388            break;
     389        case TRACE:
     390            this.logLevel = Logging.LEVEL_TRACE;
     391            break;
     392        default:
     393            throw new AssertionError("Unexpected option: " + option);
     394        }
     395    }
     396
     397    /**
     398     * Handle an option
     399     * @param currentInput The current input file, if any. May be {@code null}.
     400     * @param option The option to parse
     401     * @param argument The argument for the option
     402     * @return The new input file, if any.
     403     */
     404    private Optional<String> handleOption(final String currentInput, final Option option, final String argument) {
     405        switch (option) {
     406        case INPUT:
     407            this.input.add(argument);
     408            return Optional.of(argument);
     409        case OUTPUT:
     410            this.output.put(currentInput, argument);
     411            break;
     412        case CHANGE_FILE:
     413            this.changeFiles.computeIfAbsent(currentInput, key -> new ArrayList<>()).add(argument);
     414            break;
     415        case LANGUAGE:
     416            I18n.set(argument);
     417            break;
     418        case LOAD_PREFERENCES:
     419            final Preferences tempPreferences = new Preferences();
     420            tempPreferences.enableSaveOnPut(false);
     421            CustomConfigurator.XMLCommandProcessor config = new CustomConfigurator.XMLCommandProcessor(tempPreferences);
     422            try (InputStream is = Utils.openStream(new File(argument).toURI().toURL())) {
     423                config.openAndReadXML(is);
     424            } catch (IOException e) {
     425                throw new JosmRuntimeException(e);
     426            }
     427            final IPreferences pref = Config.getPref();
     428            if (pref instanceof MemoryPreferences) {
     429                final MemoryPreferences memoryPreferences = (MemoryPreferences) pref;
     430                tempPreferences.getAllSettings().entrySet().stream().filter(entry -> entry.getValue().isNew())
     431                        .forEach(entry -> memoryPreferences.putSetting(entry.getKey(), entry.getValue()));
     432            } else {
     433                throw new JosmRuntimeException(tr("Preferences are not the expected type"));
     434            }
     435            break;
     436        case SET:
     437
     438        default:
     439            throw new AssertionError("Unexpected option: " + option);
     440        }
     441        return Optional.empty();
     442    }
     443
     444    private static void showHelp() {
     445        System.out.println(getHelp());
     446    }
     447
     448    private static String getHelp() {
     449        final String helpPadding = "\t                          ";
     450        // CHECKSTYLE.OFF: SingleSpaceSeparator
     451        return tr("JOSM Validation command line interface") + "\n\n" +
     452                tr("Usage") + ":\n" +
     453                "\tjava -jar josm.jar validate <options>\n\n" +
     454                tr("Description") + ":\n" +
     455                tr("Validates data and saves the result to a file.") + "\n\n"+
     456                tr("Options") + ":\n" +
     457                "\t--help|-h                 " + tr("Show this help") + "\n" +
     458                "\t--input|-i <file>         " + tr("Input data file name (.osm, .validator.mapcss, .mapcss).") + '\n' +
     459                helpPadding                    + tr("OSM files can be specified multiple times. Required.") + '\n' +
     460                helpPadding                    + tr(".validator.mapcss and .mapcss files will stop processing on first error.") + '\n' +
     461                helpPadding                    + tr("Non-osm files do not use --output or --change-file") + '\n' +
     462                "\t--output|-o <file>        " + tr("Output data file name (.geojson, line-by-line delimited for MapRoulette). Optional.")
     463                                               + '\n' +
     464                "\t--change-file|-c <file>   " + tr("Change file name (.osc). Can be specified multiple times per input.") + '\n' +
     465                helpPadding                    + tr("Changes will be applied in the specified order. Optional.");
     466        // CHECKSTYLE.ON: SingleSpaceSeparator
     467    }
     468}
  • src/org/openstreetmap/josm/data/validation/tests/MapCSSTagChecker.java

    diff --git a/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagChecker.java b/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagChecker.java
    index 7db32bd554..664dac89ed 100644
    a b public class MapCSSTagChecker extends Test.TagTest {  
    253253        }
    254254    }
    255255
    256     /**
    257      * A handler for assertion error messages (for not fulfilled "assertMatch", "assertNoMatch").
    258      */
    259     @FunctionalInterface
    260     interface AssertionConsumer extends Consumer<String> {
    261     }
    262 
    263256    /**
    264257     * Adds a new MapCSS config file from the given URL.
    265258     * @param url The unique URL of the MapCSS config file
    public class MapCSSTagChecker extends Test.TagTest {  
    274267        return addMapCSS(url, checkAssertions ? Logging::warn : null);
    275268    }
    276269
    277     synchronized ParseResult addMapCSS(String url, AssertionConsumer assertionConsumer) throws ParseException, IOException {
     270    /**
     271     * Adds a new MapCSS config file from the given URL. <br />
     272     * NOTE: You should prefer {@link #addMapCSS(String)} unless you <i>need</i> to know what the assertions return.
     273     *
     274     * @param url The unique URL of the MapCSS config file
     275     * @param assertionConsumer A string consumer for error messages.
     276     * @return List of tag checks and parsing errors, or null
     277     * @throws ParseException if the config file does not match MapCSS syntax
     278     * @throws IOException if any I/O error occurs
     279     * @since xxx (public, primarily for ValidatorCLI)
     280     */
     281    public synchronized ParseResult addMapCSS(String url, Consumer<String> assertionConsumer) throws ParseException, IOException {
    278282        CheckParameterUtil.ensureParameterNotNull(url, "url");
    279283        ParseResult result;
    280284        try (CachedFile cache = new CachedFile(url);
  • src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerAsserts.java

    diff --git a/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerAsserts.java b/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerAsserts.java
    index d004882872..838868f9d5 100644
    a b import java.util.List;  
    1010import java.util.Map;
    1111import java.util.Optional;
    1212import java.util.Set;
     13import java.util.function.Consumer;
    1314import java.util.stream.Collectors;
    1415
    1516import org.openstreetmap.josm.command.Command;
    final class MapCSSTagCheckerAsserts {  
    4647     * @param assertionConsumer The handler for assertion error messages
    4748     */
    4849    static void checkAsserts(final MapCSSTagCheckerRule check, final Map<String, Boolean> assertions,
    49                              final MapCSSTagChecker.AssertionConsumer assertionConsumer) {
     50                             final Consumer<String> assertionConsumer) {
    5051        final DataSet ds = new DataSet();
    5152        Logging.debug("Check: {0}", check);
    5253        for (final Map.Entry<String, Boolean> i : assertions.entrySet()) {
  • src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerRule.java

    diff --git a/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerRule.java b/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerRule.java
    index 4e012467e8..478eb57650 100644
    a b import java.util.Map;  
    1515import java.util.Objects;
    1616import java.util.Optional;
    1717import java.util.Set;
     18import java.util.function.Consumer;
    1819import java.util.function.Predicate;
    1920import java.util.regex.Matcher;
    2021import java.util.regex.Pattern;
    import org.openstreetmap.josm.data.osm.WaySegment;  
    3132import org.openstreetmap.josm.data.validation.Severity;
    3233import org.openstreetmap.josm.data.validation.Test;
    3334import org.openstreetmap.josm.data.validation.TestError;
    34 import org.openstreetmap.josm.data.validation.tests.MapCSSTagChecker.AssertionConsumer;
    3535import org.openstreetmap.josm.gui.mappaint.Environment;
    3636import org.openstreetmap.josm.gui.mappaint.Keyword;
    3737import org.openstreetmap.josm.gui.mappaint.mapcss.Condition;
    final class MapCSSTagCheckerRule implements Predicate<OsmPrimitive> {  
    106106
    107107    private static final String POSSIBLE_THROWS = "throwError/throwWarning/throwOther";
    108108
    109     static MapCSSTagCheckerRule ofMapCSSRule(final MapCSSRule rule, AssertionConsumer assertionConsumer) throws IllegalDataException {
     109    static MapCSSTagCheckerRule ofMapCSSRule(final MapCSSRule rule, Consumer<String> assertionConsumer) throws IllegalDataException {
    110110        final MapCSSTagCheckerRule check = new MapCSSTagCheckerRule(rule);
    111111        final Map<String, Boolean> assertions = new HashMap<>();
    112112        for (Instruction i : rule.declaration.instructions) {
    final class MapCSSTagCheckerRule implements Predicate<OsmPrimitive> {  
    185185        return readMapCSS(css, null);
    186186    }
    187187
    188     static MapCSSTagChecker.ParseResult readMapCSS(Reader css, AssertionConsumer assertionConsumer) throws ParseException {
     188    static MapCSSTagChecker.ParseResult readMapCSS(Reader css, Consumer<String> assertionConsumer) throws ParseException {
    189189        CheckParameterUtil.ensureParameterNotNull(css, "css");
    190190
    191191        final MapCSSStyleSource source = new MapCSSStyleSource("");
  • src/org/openstreetmap/josm/gui/MainApplication.java

    diff --git a/src/org/openstreetmap/josm/gui/MainApplication.java b/src/org/openstreetmap/josm/gui/MainApplication.java
    index 05da963669..8aa31512fa 100644
    a b import org.openstreetmap.josm.data.projection.ProjectionRegistry;  
    9898import org.openstreetmap.josm.data.projection.datum.NTV2GridShiftFileSource;
    9999import org.openstreetmap.josm.data.projection.datum.NTV2GridShiftFileWrapper;
    100100import org.openstreetmap.josm.data.projection.datum.NTV2Proj4DirGridShiftFileSource;
     101import org.openstreetmap.josm.data.validation.ValidatorCLI;
    101102import org.openstreetmap.josm.data.validation.tests.MapCSSTagChecker;
    102103import org.openstreetmap.josm.gui.ProgramArguments.Option;
    103104import org.openstreetmap.josm.gui.SplashScreen.SplashProgressMonitor;
    public class MainApplication {  
    311312        registerCLIModule(JOSM_CLI_MODULE);
    312313        registerCLIModule(ProjectionCLI.INSTANCE);
    313314        registerCLIModule(RenderingCLI.INSTANCE);
     315        registerCLIModule(ValidatorCLI.INSTANCE);
    314316    }
    315317
    316318    /**
    public class MainApplication {  
    660662                tr("commands")+":\n"+
    661663                "\trunjosm     "+tr("launch JOSM (default, performed when no command is specified)")+'\n'+
    662664                "\trender      "+tr("render data and save the result to an image file")+'\n'+
    663                 "\tproject     "+tr("convert coordinates from one coordinate reference system to another")+"\n\n"+
     665                "\tproject     " + tr("convert coordinates from one coordinate reference system to another")+ '\n' +
     666                "\tvalidate    " + tr("validate data") + "\n\n" +
    664667                tr("For details on the {0} and {1} commands, run them with the {2} option.", "render", "project", "--help")+'\n'+
    665668                tr("The remainder of this help page documents the {0} command.", "runjosm")+"\n\n"+
    666669                tr("options")+":\n"+
  • src/org/openstreetmap/josm/gui/io/importexport/OsmImporter.java

    diff --git a/src/org/openstreetmap/josm/gui/io/importexport/OsmImporter.java b/src/org/openstreetmap/josm/gui/io/importexport/OsmImporter.java
    index d839be5963..4216843f60 100644
    a b public class OsmImporter extends FileImporter {  
    9797        final OsmImporterData data = loadLayer(in, associatedFile,
    9898                associatedFile == null ? OsmDataLayer.createNewName() : associatedFile.getName(), pm);
    9999
     100        final OsmDataLayer layer = data.getLayer();
     101        // Note: addLayer calls GuiHelper.runInEDTAndWaitWithException
     102        MainApplication.getLayerManager().addLayer(layer);
    100103        // FIXME: remove UI stuff from IO subsystem
    101104        GuiHelper.runInEDT(() -> {
    102             OsmDataLayer layer = data.getLayer();
    103             MainApplication.getLayerManager().addLayer(layer);
    104105            data.getPostLayerTask().run();
    105106            data.getLayer().onPostLoadFromFile();
    106107        });
  • src/org/openstreetmap/josm/gui/preferences/projection/CustomProjectionChoice.java

    diff --git a/src/org/openstreetmap/josm/gui/preferences/projection/CustomProjectionChoice.java b/src/org/openstreetmap/josm/gui/preferences/projection/CustomProjectionChoice.java
    index e4db57d1d0..6dfc53eb17 100644
    a b import org.openstreetmap.josm.data.projection.Projection;  
    2323import org.openstreetmap.josm.data.projection.ProjectionConfigurationException;
    2424import org.openstreetmap.josm.data.projection.Projections;
    2525import org.openstreetmap.josm.gui.ExtendedDialog;
     26import org.openstreetmap.josm.gui.tagging.ac.AutoCompTextField;
    2627import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator;
    2728import org.openstreetmap.josm.gui.widgets.HistoryComboBox;
    2829import org.openstreetmap.josm.gui.widgets.HtmlPanel;
    public class CustomProjectionChoice extends AbstractProjectionChoice implements  
    5253
    5354    private static class PreferencePanel extends JPanel {
    5455
    55         public JosmTextField input;
     56        public AutoCompTextField<String> input;
    5657        private HistoryComboBox cbInput;
    5758
    5859        PreferencePanel(String initialText, ActionListener listener) {
    public class CustomProjectionChoice extends AbstractProjectionChoice implements  
    6061        }
    6162
    6263        private void build(String initialText, final ActionListener listener) {
    63             input = new JosmTextField(30);
     64            input = new AutoCompTextField<>(30);
    6465            cbInput = new HistoryComboBox();
    6566            cbInput.setEditor(new BasicComboBoxEditor() {
    6667                @Override
  • src/org/openstreetmap/josm/gui/progress/AbstractProgressMonitor.java

    diff --git a/src/org/openstreetmap/josm/gui/progress/AbstractProgressMonitor.java b/src/org/openstreetmap/josm/gui/progress/AbstractProgressMonitor.java
    index 31e2c0b3c9..f8d2381f66 100644
    a b public abstract class AbstractProgressMonitor implements ProgressMonitor {  
    233233     * Ticks handling
    234234    ==================*/
    235235
     236    /**
     237     * Update progress message
     238     * @param value The percentage of completion (this and child progress)
     239     */
    236240    protected abstract void updateProgress(double value);
    237241
    238242    @Override
  • new file src/org/openstreetmap/josm/gui/progress/CLIProgressMonitor.java

    diff --git a/src/org/openstreetmap/josm/gui/progress/CLIProgressMonitor.java b/src/org/openstreetmap/josm/gui/progress/CLIProgressMonitor.java
    new file mode 100644
    index 0000000000..b41ab40ace
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.progress;
     3
     4import static org.openstreetmap.josm.tools.I18n.tr;
     5
     6import java.awt.Component;
     7import java.util.Optional;
     8import java.util.concurrent.TimeUnit;
     9
     10import org.openstreetmap.josm.tools.Logging;
     11import org.openstreetmap.josm.tools.Stopwatch;
     12import org.openstreetmap.josm.tools.Utils;
     13
     14/**
     15 * CLI implementation of a {@link ProgressMonitor}
     16 * @author Taylor Smock
     17 * @since xxx
     18 */
     19public class CLIProgressMonitor extends AbstractProgressMonitor {
     20    /** The current task id */
     21    private ProgressTaskId taskId;
     22    /** The current task title */
     23    private String title = "";
     24    /** The custom text (prepended with '/') */
     25    private String customText = "";
     26    /** The last time we updated the progress information */
     27    private Stopwatch lastUpdateTime;
     28    /** The start time of the monitor */
     29    private Stopwatch startTime;
     30
     31    /**
     32     * Create a new {@link CLIProgressMonitor}
     33     */
     34    public CLIProgressMonitor() {
     35        super(new CancelHandler());
     36    }
     37
     38    @Override
     39    protected void doBeginTask() {
     40        if (!Utils.isBlank(this.title)) {
     41            Logging.info(tr("Beginning task{2}: {0}{1}", this.title, this.customText,
     42                    Optional.ofNullable(this.taskId).map(ProgressTaskId::getId).map(id -> ' ' + id).orElse("")));
     43        }
     44        this.startTime = Stopwatch.createStarted();
     45        this.lastUpdateTime = this.startTime;
     46    }
     47
     48    @Override
     49    protected void doFinishTask() {
     50        Logging.info(tr("Finishing task{2}: {0}{1} ({3})", this.title, this.customText,
     51                Optional.ofNullable(this.taskId).map(ProgressTaskId::getId).map(id -> ' ' + id).orElse(""), this.startTime));
     52        this.lastUpdateTime = null;
     53    }
     54
     55    @Override
     56    protected void doSetIntermediate(boolean value) {
     57        // Do nothing for now
     58    }
     59
     60    @Override
     61    protected void doSetTitle(String title) {
     62        this.title = Optional.ofNullable(title).orElse("");
     63    }
     64
     65    @Override
     66    protected void doSetCustomText(String customText) {
     67        this.customText = Optional.ofNullable(customText).map(str -> '/' + str).orElse("");
     68    }
     69
     70    @Override
     71    protected void updateProgress(double value) {
     72        if (this.lastUpdateTime == null || this.lastUpdateTime.elapsed() > TimeUnit.SECONDS.toMillis(10)) {
     73            this.lastUpdateTime = Stopwatch.createStarted();
     74            Logging.info(tr("Progress of task{2}: {0}{1} is {3}% ({4})", this.title, this.customText,
     75                    Optional.ofNullable(this.taskId).map(ProgressTaskId::getId).map(id -> ' ' + id).orElse(""), value * 100, this.startTime));
     76        }
     77    }
     78
     79    @Override
     80    public void setProgressTaskId(ProgressTaskId taskId) {
     81        this.taskId = taskId;
     82    }
     83
     84    @Override
     85    public ProgressTaskId getProgressTaskId() {
     86        return this.taskId;
     87    }
     88
     89    @Override
     90    public Component getWindowParent() {
     91        return null;
     92    }
     93}
  • src/org/openstreetmap/josm/gui/util/GuiHelper.java

    diff --git a/src/org/openstreetmap/josm/gui/util/GuiHelper.java b/src/org/openstreetmap/josm/gui/util/GuiHelper.java
    index 89ab9f84b0..4c17334af2 100644
    a b public final class GuiHelper {  
    288288     * @since 10271
    289289     */
    290290    public static void assertCallFromEdt() {
    291         if (!SwingUtilities.isEventDispatchThread()) {
     291        if (!SwingUtilities.isEventDispatchThread() && !GraphicsEnvironment.isHeadless()) {
    292292            throw new IllegalStateException(
    293293                    "Needs to be called from the EDT thread, not from " + Thread.currentThread().getName());
    294294        }
  • new file src/org/openstreetmap/josm/io/GeoJSONMapRouletteWriter.java

    diff --git a/src/org/openstreetmap/josm/io/GeoJSONMapRouletteWriter.java b/src/org/openstreetmap/josm/io/GeoJSONMapRouletteWriter.java
    new file mode 100644
    index 0000000000..fca07f34ac
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.io;
     3
     4import java.util.Objects;
     5import java.util.Optional;
     6import java.util.stream.Stream;
     7
     8import javax.json.Json;
     9import javax.json.JsonArray;
     10import javax.json.JsonArrayBuilder;
     11import javax.json.JsonObject;
     12import javax.json.JsonObjectBuilder;
     13import javax.json.JsonValue;
     14
     15import org.openstreetmap.josm.data.osm.DataSet;
     16import org.openstreetmap.josm.data.osm.OsmPrimitive;
     17import org.openstreetmap.josm.data.osm.WaySegment;
     18import org.openstreetmap.josm.data.validation.TestError;
     19import org.openstreetmap.josm.tools.Logging;
     20
     21/**
     22 * Convert {@link TestError} to MapRoulette Tasks
     23 * @author Taylor Smock
     24 * @since xxx
     25 */
     26public class GeoJSONMapRouletteWriter extends GeoJSONWriter {
     27
     28    /**
     29     * Constructs a new {@code GeoJSONWriter}.
     30     * @param ds The originating OSM dataset
     31     */
     32    public GeoJSONMapRouletteWriter(DataSet ds) {
     33        super(ds);
     34        super.setOptions(Options.RIGHT_HAND_RULE, Options.WRITE_OSM_INFORMATION);
     35    }
     36
     37    /**
     38     * Convert a test error to a string
     39     * @param testError The test error to convert
     40     * @return The MapRoulette challenge object
     41     */
     42    public Optional<JsonObject> write(final TestError testError) {
     43        final JsonObjectBuilder jsonObjectBuilder = Json.createObjectBuilder();
     44        final JsonArrayBuilder featuresBuilder = Json.createArrayBuilder();
     45        final JsonObjectBuilder propertiesBuilder = Json.createObjectBuilder();
     46        propertiesBuilder.add("message", testError.getMessage());
     47        Optional.ofNullable(testError.getDescription()).ifPresent(description -> propertiesBuilder.add("description", description));
     48        propertiesBuilder.add("code", testError.getCode());
     49        propertiesBuilder.add("fixable", testError.isFixable());
     50        propertiesBuilder.add("severity", testError.getSeverity().toString());
     51        propertiesBuilder.add("severityInteger", testError.getSeverity().getLevel());
     52        propertiesBuilder.add("test", testError.getTester().getName());
     53        Stream.concat(testError.getPrimitives().stream(), testError.getHighlighted().stream()).distinct().map(p -> {
     54            if (p instanceof OsmPrimitive) {
     55                return p;
     56            } else if (p instanceof WaySegment) {
     57                return ((WaySegment) p).toWay();
     58            }
     59            Logging.trace("Could not convert {0} to an OsmPrimitive", p);
     60            return null;
     61        }).filter(Objects::nonNull).distinct().map(OsmPrimitive.class::cast)
     62                .forEach(primitive -> super.appendPrimitive(primitive, featuresBuilder));
     63        final JsonArray featureArray = featuresBuilder.build();
     64        final JsonArrayBuilder featuresMessageBuilder = Json.createArrayBuilder();
     65        if (featureArray.isEmpty()) {
     66            Logging.trace("Could not generate task for {0}", testError.getMessage());
     67            return Optional.empty();
     68        }
     69        JsonObject primitive = featureArray.getJsonObject(0);
     70        JsonObjectBuilder replacementPrimitive = Json.createObjectBuilder(primitive);
     71        final JsonObjectBuilder properties;
     72        if (primitive.containsKey("properties") && primitive.get("properties").getValueType() == JsonValue.ValueType.OBJECT) {
     73            properties = Json.createObjectBuilder(primitive.getJsonObject("properties"));
     74        } else {
     75            properties = Json.createObjectBuilder();
     76        }
     77        properties.addAll(propertiesBuilder);
     78        replacementPrimitive.add("properties", properties);
     79        featuresMessageBuilder.add(replacementPrimitive);
     80        for (int i = 1; i < featureArray.size(); i++) {
     81            featuresMessageBuilder.add(featureArray.get(i));
     82        }
     83        // For now, don't add any cooperativeWork objects, as JOSM should be able to find the fixes.
     84        // This should change if the ValidatorCLI can use plugins (especially those introducing external data, like
     85        // the ElevationProfile plugin (which provides elevation data)).
     86        jsonObjectBuilder.add("type", "FeatureCollection");
     87        jsonObjectBuilder.add("features", featuresMessageBuilder);
     88        return Optional.of(jsonObjectBuilder.build());
     89    }
     90}
  • src/org/openstreetmap/josm/io/GeoJSONWriter.java

    diff --git a/src/org/openstreetmap/josm/io/GeoJSONWriter.java b/src/org/openstreetmap/josm/io/GeoJSONWriter.java
    index d2eb644846..23ac2ba7a4 100644
    a b import java.io.StringWriter;  
    66import java.io.Writer;
    77import java.math.BigDecimal;
    88import java.math.RoundingMode;
     9import java.time.Instant;
     10import java.util.ArrayList;
     11import java.util.Arrays;
    912import java.util.Collection;
    1013import java.util.Collections;
     14import java.util.EnumSet;
    1115import java.util.HashSet;
    1216import java.util.Iterator;
    1317import java.util.List;
    1418import java.util.Map;
    15 import java.util.Map.Entry;
    1619import java.util.Set;
     20import java.util.stream.Collectors;
    1721import java.util.stream.Stream;
    1822
    1923import javax.json.Json;
    import org.openstreetmap.josm.data.Bounds;  
    3034import org.openstreetmap.josm.data.coor.EastNorth;
    3135import org.openstreetmap.josm.data.coor.LatLon;
    3236import org.openstreetmap.josm.data.osm.DataSet;
     37import org.openstreetmap.josm.data.osm.INode;
     38import org.openstreetmap.josm.data.osm.IWay;
    3339import org.openstreetmap.josm.data.osm.MultipolygonBuilder;
    34 import org.openstreetmap.josm.data.osm.MultipolygonBuilder.JoinedPolygon;
    3540import org.openstreetmap.josm.data.osm.Node;
    3641import org.openstreetmap.josm.data.osm.OsmPrimitive;
    3742import org.openstreetmap.josm.data.osm.Relation;
     43import org.openstreetmap.josm.data.osm.RelationMember;
    3844import org.openstreetmap.josm.data.osm.Way;
    3945import org.openstreetmap.josm.data.osm.visitor.OsmPrimitiveVisitor;
    4046import org.openstreetmap.josm.data.preferences.BooleanProperty;
    4147import org.openstreetmap.josm.data.projection.Projection;
    4248import org.openstreetmap.josm.data.projection.Projections;
    4349import org.openstreetmap.josm.gui.mappaint.ElemStyles;
     50import org.openstreetmap.josm.tools.Geometry;
    4451import org.openstreetmap.josm.tools.Logging;
    4552import org.openstreetmap.josm.tools.Pair;
     53import org.openstreetmap.josm.tools.Utils;
    4654
    4755/**
    4856 * Writes OSM data as a GeoJSON string, using JSR 353: Java API for JSON Processing (JSON-P).
    import org.openstreetmap.josm.tools.Pair;  
    5159 */
    5260public class GeoJSONWriter {
    5361
     62    enum Options {
     63        /** If using the right hand rule, we have to ensure that the "right" side is the interior of the object. */
     64        RIGHT_HAND_RULE,
     65        /** Write OSM information to the feature properties field. This tries to follow the Overpass turbo format. */
     66        WRITE_OSM_INFORMATION,
     67        /** Skip empty nodes */
     68        SKIP_EMPTY_NODES
     69    }
     70
    5471    private final DataSet data;
    55     private final Projection projection;
     72    private static final Projection projection = Projections.getProjectionByCode("EPSG:4326"); // WGS 84
    5673    private static final BooleanProperty SKIP_EMPTY_NODES = new BooleanProperty("geojson.export.skip-empty-nodes", true);
    5774    private static final BooleanProperty UNTAGGED_CLOSED_IS_POLYGON = new BooleanProperty("geojson.export.untagged-closed-is-polygon", false);
    5875    private static final Set<Way> processedMultipolygonWays = new HashSet<>();
     76    private EnumSet<Options> options = EnumSet.noneOf(Options.class);
    5977
    6078    /**
    6179     * This is used to determine that a tag should be interpreted as a json
    public class GeoJSONWriter {  
    7795     */
    7896    public GeoJSONWriter(DataSet ds) {
    7997        this.data = ds;
    80         this.projection = Projections.getProjectionByCode("EPSG:4326"); // WGS 84
     98        if (Boolean.TRUE.equals(SKIP_EMPTY_NODES.get())) {
     99            this.options.add(Options.SKIP_EMPTY_NODES);
     100        }
     101    }
     102
     103    /**
     104     * Set the options for this writer. See {@link Options}.
     105     * @param options The options to set.
     106     */
     107    void setOptions(final Options... options) {
     108        this.options.clear();
     109        this.options.addAll(Arrays.asList(options));
    81110    }
    82111
    83112    /**
    public class GeoJSONWriter {  
    117146        }
    118147    }
    119148
     149    /**
     150     * Convert a primitive to a json object
     151     */
    120152    private class GeometryPrimitiveVisitor implements OsmPrimitiveVisitor {
    121153
    122154        private final JsonObjectBuilder geomObj;
    public class GeoJSONWriter {  
    141173                    // no need to write this object again
    142174                    return;
    143175                }
    144                 final JsonArrayBuilder array = getCoorsArray(w.getNodes());
    145176                boolean writeAsPolygon = w.isClosed() && ((!w.isTagged() && UNTAGGED_CLOSED_IS_POLYGON.get())
    146177                        || ElemStyles.hasAreaElemStyle(w, false));
     178                final List<Node> nodes = w.getNodes();
     179                if (writeAsPolygon && options.contains(Options.RIGHT_HAND_RULE) && Geometry.isClockwise(nodes)) {
     180                    Collections.reverse(nodes);
     181                }
     182                final JsonArrayBuilder array = getCoorsArray(nodes);
    147183                if (writeAsPolygon) {
    148184                    geomObj.add("type", "Polygon");
    149185                    geomObj.add("coordinates", Json.createArrayBuilder().add(array));
    public class GeoJSONWriter {  
    159195            if (r == null || !r.isMultipolygon() || r.hasIncompleteMembers()) {
    160196                return;
    161197            }
    162             try {
    163                 final Pair<List<JoinedPolygon>, List<JoinedPolygon>> mp = MultipolygonBuilder.joinWays(r);
     198            if (r.isMultipolygon()) {
     199                try {
     200                    this.visitMultipolygon(r);
     201                    return;
     202                } catch (MultipolygonBuilder.JoinedPolygonCreationException ex) {
     203                    Logging.warn("GeoJSON: Failed to export multipolygon {0}, falling back to other multi geometry types", r.getUniqueId());
     204                    Logging.warn(ex);
     205                }
     206            }
     207            // These are run if (a) r is not a multipolygon or (b) r is not a well-formed multipolygon.
     208            if (r.getMemberPrimitives().stream().allMatch(IWay.class::isInstance)) {
     209                this.visitMultiLineString(r);
     210            } else if (r.getMemberPrimitives().stream().allMatch(INode.class::isInstance)) {
     211                this.visitMultiPoints(r);
     212            } else {
     213                this.visitMultiGeometry(r);
     214            }
     215        }
     216
     217        /**
     218         * Visit a multi-part geometry.
     219         * Note: Does not currently recurse down relations. RFC 7946 indicates that we
     220         * should avoid nested geometry collections. This behavior may change any time in the future!
     221         * @param r The relation to visit.
     222         */
     223        private void visitMultiGeometry(final Relation r) {
     224            final JsonArrayBuilder jsonArrayBuilder = Json.createArrayBuilder();
     225            r.getMemberPrimitives().stream().filter(p -> !(p instanceof Relation))
     226                    .map(p -> {
     227                        final JsonObjectBuilder tempGeomObj = Json.createObjectBuilder();
     228                        p.accept(new GeometryPrimitiveVisitor(tempGeomObj));
     229                        return tempGeomObj.build();
     230                    }).forEach(jsonArrayBuilder::add);
     231            geomObj.add("type", "GeometryCollection");
     232            geomObj.add("geometries", jsonArrayBuilder);
     233        }
     234
     235        /**
     236         * Visit a relation that only contains points
     237         * @param r The relation to visit
     238         */
     239        private void visitMultiPoints(final Relation r) {
     240            final JsonArrayBuilder multiPoint = Json.createArrayBuilder();
     241            r.getMembers().stream().map(RelationMember::getMember).filter(Node.class::isInstance).map(Node.class::cast)
     242                    .map(Node::getCoor).map(latLon -> getCoorArray(null, latLon))
     243                    .forEach(multiPoint::add);
     244            geomObj.add("type", "MultiPoint");
     245            geomObj.add("coordinates", multiPoint);
     246        }
     247
     248        /**
     249         * Visit a relation that is a multi line string
     250         * @param r The relation to convert
     251         */
     252        private void visitMultiLineString(final Relation r) {
     253            final JsonArrayBuilder multiLine = Json.createArrayBuilder();
     254            r.getMembers().stream().map(RelationMember::getMember).filter(Way.class::isInstance).map(Way.class::cast)
     255                    .map(Way::getNodes).map(p -> {
     256                JsonArrayBuilder array = getCoorsArray(p);
     257                LatLon ll = p.get(0).getCoor();
     258                // since first node is not duplicated as last node
     259                return ll != null ? array.add(getCoorArray(null, ll)) : array;
     260            }).forEach(multiLine::add);
     261            geomObj.add("type", "MultiLineString");
     262            geomObj.add("coordinates", multiLine);
     263            processedMultipolygonWays.addAll(r.getMemberPrimitives(Way.class));
     264        }
     265
     266        /**
     267         * Convert a multipolygon to geojson
     268         * @param r The relation to convert
     269         * @throws MultipolygonBuilder.JoinedPolygonCreationException See {@link MultipolygonBuilder#joinWays(Relation)}.
     270         * Note that if the exception is thrown, {@link #geomObj} will not have been modified.
     271         */
     272        private void visitMultipolygon(final Relation r) throws MultipolygonBuilder.JoinedPolygonCreationException {
     273                final Pair<List<MultipolygonBuilder.JoinedPolygon>, List<MultipolygonBuilder.JoinedPolygon>> mp =
     274                        MultipolygonBuilder.joinWays(r);
    164275                final JsonArrayBuilder polygon = Json.createArrayBuilder();
    165                 Stream.concat(mp.a.stream(), mp.b.stream())
     276                // Peek would theoretically be better for these two streams, but SonarLint doesn't like it.
     277                // java:S3864: "Stream.peek" should be used with caution
     278                final Stream<List<Node>> outer = mp.a.stream().map(MultipolygonBuilder.JoinedPolygon::getNodes).map(nodes -> {
     279                    final ArrayList<Node> tempNodes = new ArrayList<>(nodes);
     280                    tempNodes.add(tempNodes.get(0));
     281                    if (options.contains(Options.RIGHT_HAND_RULE) && Geometry.isClockwise(tempNodes)) {
     282                        Collections.reverse(nodes);
     283                    }
     284                    return nodes;
     285                });
     286                final Stream<List<Node>> inner = mp.b.stream().map(MultipolygonBuilder.JoinedPolygon::getNodes).map(nodes -> {
     287                    final ArrayList<Node> tempNodes = new ArrayList<>(nodes);
     288                    tempNodes.add(tempNodes.get(0));
     289                    // Note that we are checking !Geometry.isClockwise, which is different from the outer
     290                    // ring check.
     291                    if (options.contains(Options.RIGHT_HAND_RULE) && !Geometry.isClockwise(tempNodes)) {
     292                        Collections.reverse(nodes);
     293                    }
     294                    return nodes;
     295                });
     296                Stream.concat(outer, inner)
    166297                        .map(p -> {
    167                             JsonArrayBuilder array = getCoorsArray(p.getNodes());
    168                             LatLon ll = p.getNodes().get(0).getCoor();
     298                            JsonArrayBuilder array = getCoorsArray(p);
     299                            LatLon ll = p.get(0).getCoor();
    169300                            // since first node is not duplicated as last node
    170301                            return ll != null ? array.add(getCoorArray(null, ll)) : array;
    171                             })
     302                        })
    172303                        .forEach(polygon::add);
    173                 geomObj.add("type", "MultiPolygon");
    174304                final JsonArrayBuilder multiPolygon = Json.createArrayBuilder().add(polygon);
     305                geomObj.add("type", "MultiPolygon");
    175306                geomObj.add("coordinates", multiPolygon);
    176307                processedMultipolygonWays.addAll(r.getMemberPrimitives(Way.class));
    177             } catch (MultipolygonBuilder.JoinedPolygonCreationException ex) {
    178                 Logging.warn("GeoJSON: Failed to export multipolygon {0}", r.getUniqueId());
    179                 Logging.warn(ex);
    180             }
    181308        }
    182309
    183310        private JsonArrayBuilder getCoorsArray(Iterable<Node> nodes) {
    public class GeoJSONWriter {  
    204331
    205332    protected void appendPrimitive(OsmPrimitive p, JsonArrayBuilder array) {
    206333        if (p.isIncomplete() ||
    207             (SKIP_EMPTY_NODES.get() && p instanceof Node && p.getKeys().isEmpty())) {
     334            (this.options.contains(Options.SKIP_EMPTY_NODES) && p instanceof Node && p.getKeys().isEmpty())) {
    208335            return;
    209336        }
    210337
    211338        // Properties
    212339        final JsonObjectBuilder propObj = Json.createObjectBuilder();
    213         for (Entry<String, String> t : p.getKeys().entrySet()) {
    214             propObj.add(t.getKey(), convertValueToJson(t.getValue()));
     340        for (Map.Entry<String, String> t : p.getKeys().entrySet()) {
     341            // If writing OSM information, follow Overpass syntax (escape `@` with another `@`)
     342            final String key = options.contains(Options.WRITE_OSM_INFORMATION) && t.getKey().startsWith("@")
     343                    ? '@' + t.getKey() : t.getKey();
     344            propObj.add(key, convertValueToJson(t.getValue()));
     345        }
     346        if (options.contains(Options.WRITE_OSM_INFORMATION)) {
     347            // Use the same format as Overpass
     348            propObj.add("@id", p.getPrimitiveId().getType().getAPIName() + '/' + p.getUniqueId()); // type/id
     349            if (!p.isNew()) {
     350                propObj.add("@timestamp", Instant.ofEpochSecond(p.getRawTimestamp()).toString());
     351                propObj.add("@version", Integer.toString(p.getVersion()));
     352                propObj.add("@changeset", Long.toString(p.getChangesetId()));
     353            }
     354            if (p.getUser() != null) {
     355                propObj.add("@user", p.getUser().getName());
     356                propObj.add("@uid", p.getUser().getId());
     357            }
     358            if (options.contains(Options.WRITE_OSM_INFORMATION) && p.getReferrers(true).stream().anyMatch(Relation.class::isInstance)) {
     359                final JsonArrayBuilder jsonArrayBuilder = Json.createArrayBuilder();
     360                for (Relation relation : Utils.filteredCollection(p.getReferrers(), Relation.class)) {
     361                    final JsonObjectBuilder relationObject = Json.createObjectBuilder();
     362                    relationObject.add("rel", relation.getId());
     363                    Collection<RelationMember> members = relation.getMembersFor(Collections.singleton(p));
     364                    // Each role is a separate object in overpass-turbo geojson export. For now, just concat them.
     365                    relationObject.add("role",
     366                            members.stream().map(RelationMember::getRole).collect(Collectors.joining(";")));
     367                    final JsonObjectBuilder relationKeys = Json.createObjectBuilder();
     368                    // Uncertain if the @relation reltags need to be @ escaped. I don't think so, as example output
     369                    // didn't have any metadata in it.
     370                    for (Map.Entry<String, String> tag : relation.getKeys().entrySet()) {
     371                        relationKeys.add(tag.getKey(), convertValueToJson(tag.getValue()));
     372                    }
     373                    relationObject.add("reltags", relationKeys);
     374                }
     375                propObj.add("@relations", jsonArrayBuilder);
     376            }
    215377        }
    216378        final JsonObject prop = propObj.build();
    217379
  • new file test/unit/org/openstreetmap/josm/data/validation/ValidatorCLITest.java

    diff --git a/test/unit/org/openstreetmap/josm/data/validation/ValidatorCLITest.java b/test/unit/org/openstreetmap/josm/data/validation/ValidatorCLITest.java
    new file mode 100644
    index 0000000000..92466c9175
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.data.validation;
     3
     4import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
     5import static org.junit.jupiter.api.Assertions.assertEquals;
     6import static org.junit.jupiter.api.Assertions.assertTrue;
     7import static org.junit.jupiter.api.Assertions.fail;
     8
     9import java.io.ByteArrayInputStream;
     10import java.io.File;
     11import java.io.IOException;
     12import java.io.PrintWriter;
     13import java.lang.reflect.InvocationTargetException;
     14import java.nio.charset.StandardCharsets;
     15import java.nio.file.Files;
     16import java.nio.file.Path;
     17import java.nio.file.Paths;
     18import java.util.ArrayList;
     19import java.util.Collections;
     20import java.util.List;
     21import java.util.Objects;
     22import java.util.logging.Handler;
     23import java.util.logging.LogRecord;
     24import java.util.stream.Collectors;
     25import java.util.stream.Stream;
     26
     27import javax.json.Json;
     28import javax.json.JsonObject;
     29import javax.json.JsonReader;
     30import javax.swing.SwingUtilities;
     31
     32import org.junit.jupiter.api.AfterEach;
     33import org.junit.jupiter.api.BeforeEach;
     34import org.junit.jupiter.api.Test;
     35import org.junit.jupiter.api.io.TempDir;
     36import org.junit.jupiter.params.ParameterizedTest;
     37import org.junit.jupiter.params.provider.Arguments;
     38import org.junit.jupiter.params.provider.MethodSource;
     39import org.junit.jupiter.params.provider.ValueSource;
     40import org.openstreetmap.josm.TestUtils;
     41import org.openstreetmap.josm.data.coor.LatLon;
     42import org.openstreetmap.josm.data.osm.DataSet;
     43import org.openstreetmap.josm.data.osm.Node;
     44import org.openstreetmap.josm.gui.MainApplication;
     45import org.openstreetmap.josm.io.OsmWriter;
     46import org.openstreetmap.josm.io.OsmWriterFactory;
     47import org.openstreetmap.josm.spi.lifecycle.Lifecycle;
     48import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
     49import org.openstreetmap.josm.tools.Logging;
     50import org.openstreetmap.josm.tools.Utils;
     51
     52import mockit.Mock;
     53import mockit.MockUp;
     54
     55/**
     56 * Test class for {@link ValidatorCLI}
     57 * @author Taylor Smock
     58 */
     59@BasicPreferences
     60class ValidatorCLITest {
     61    @TempDir
     62    static File temporaryDirectory;
     63
     64    TestHandler handler;
     65
     66    private static void synchronizeThreads() {
     67        MainApplication.worker.execute(() -> { /* Sync worker thread */ });
     68        try {
     69            SwingUtilities.invokeAndWait(() -> { /* Sync EDT thread */ });
     70        } catch (InterruptedException e) {
     71            Thread.currentThread().interrupt();
     72            fail(e);
     73        } catch (InvocationTargetException e) {
     74            fail(e);
     75        }
     76    }
     77
     78    @BeforeEach
     79    void setup() {
     80        TestUtils.assumeWorkingJMockit();
     81        new LifecycleMock();
     82        this.handler = new TestHandler();
     83        Logging.getLogger().addHandler(this.handler);
     84    }
     85
     86    @AfterEach
     87    void tearDown() throws InterruptedException, InvocationTargetException {
     88        synchronizeThreads();
     89        Logging.getLogger().removeHandler(this.handler);
     90        this.handler.close();
     91        this.handler = null;
     92    }
     93
     94    @ParameterizedTest
     95    @ValueSource(strings = {"resources/styles/standard/elemstyles.mapcss", "resources/styles/standard/potlatch2.mapcss"})
     96    void testInternalMapcss(final String resourceLocation) {
     97        new ValidatorCLI().processArguments(new String[]{"--input", resourceLocation});
     98        assertEquals(2, this.handler.logRecordList.size());
     99        assertEquals(resourceLocation + " had no errors", this.handler.logRecordList.get(0).getMessage());
     100        assertTrue(this.handler.logRecordList.get(1).getMessage().contains("Finishing task"));
     101    }
     102
     103    static Stream<Arguments> testInternalValidatorMapcss() {
     104        return Stream.of(Objects.requireNonNull(new File("resources/data/validator").listFiles()))
     105                .filter(file -> file.getPath().endsWith(".mapcss"))
     106                .map(file -> {
     107                    // External validator mapcss files must have validator.mapcss as the extension.
     108                    final String renamedValidator = file.getName().endsWith(".validator.mapcss") ?
     109                            file.getName() : file.getName().replace(".mapcss", ".validator.mapcss");
     110                    try {
     111                        return Files.copy(file.toPath(), Paths.get(temporaryDirectory.getPath(), renamedValidator)).getFileName().toString();
     112                    } catch (IOException e) {
     113                        fail(e);
     114                    }
     115                    return null;
     116                }).map(Arguments::of);
     117    }
     118
     119    @ParameterizedTest
     120    @MethodSource
     121    void testInternalValidatorMapcss(final String resourceLocation) {
     122        final String path = Paths.get(temporaryDirectory.getPath(), resourceLocation).toString();
     123        new ValidatorCLI().processArguments(new String[]{"--input", path});
     124        assertEquals(2, this.handler.logRecordList.size(), this.handler.logRecordList.stream().map(LogRecord::getMessage).collect(
     125                Collectors.joining(",\n")));
     126        assertEquals(path + " had no errors", this.handler.logRecordList.get(0).getMessage());
     127        assertTrue(this.handler.logRecordList.get(1).getMessage().contains("Finishing task"));
     128    }
     129
     130    @Test
     131    void testBadDataTicket13165() {
     132        // Ticket #13165 was a validator non-regression test.
     133        final String dataPath = TestUtils.getRegressionDataFile(13165, "13165.osm");
     134        final String outputPath = Paths.get(temporaryDirectory.getPath(), "testBadDataTicket13165.geojson").toString();
     135        new ValidatorCLI().processArguments(new String[]{"--input", dataPath, "--output", outputPath});
     136        final File outputFile = new File(outputPath);
     137        assertTrue(outputFile.exists());
     138        synchronizeThreads();
     139        final List<JsonObject> errors = readJsonObjects(outputFile.toPath());
     140        assertEquals(3, errors.stream().map(ValidatorCLITest::getMessage).filter("Overlapping Identical Landuses"::equals).count());
     141        assertEquals(3, errors.size(), errors.stream().map(ValidatorCLITest::getMessage).collect(Collectors.joining("\n")));
     142    }
     143
     144    @Test
     145    void testBadDataPlusChangeFile() throws IOException {
     146        final ValidatorCLI validatorCLI = new ValidatorCLI();
     147        // Write test data out
     148        final String osmPath = Paths.get(temporaryDirectory.getPath(), "testBadDataPlusChangeFile.osm").toString();
     149        final String changePath = Paths.get(temporaryDirectory.getPath(), "testBadDataPlusChangeFile.osc").toString();
     150        final String errorPath = Paths.get(temporaryDirectory.getPath(), "testBadDataPlusChangeFile.geojson").toString();
     151        final DataSet dataSet = new DataSet();
     152        final Node node = new Node(LatLon.ZERO);
     153        node.setOsmId(1, 1);
     154        dataSet.addPrimitive(node);
     155        final PrintWriter printWriter = new PrintWriter(Files.newOutputStream(Paths.get(osmPath)), true);
     156        final OsmWriter writer = OsmWriterFactory.createOsmWriter(printWriter, true, "0.6");
     157        writer.write(dataSet);
     158        printWriter.flush();
     159        final PrintWriter changeWriter = new PrintWriter(Files.newOutputStream(Paths.get(changePath)), true);
     160        changeWriter.write("<osmChange version=\"0.6\" generator=\"JOSM testBadDataPlusChangeFile\">");
     161        changeWriter.write("<delete><node id=\"1\"/></delete>");
     162        changeWriter.write("</osmChange>");
     163        changeWriter.flush();
     164
     165        validatorCLI.processArguments(new String[] {"--input", osmPath, "--output", errorPath});
     166        final List<JsonObject> errors = readJsonObjects(Paths.get(errorPath));
     167        // There is already a mapped weather buoy at 0,0 (3000), and the node has no tags (201).
     168        assertEquals(2, errors.size());
     169        Files.deleteIfExists(Paths.get(errorPath));
     170
     171        validatorCLI.processArguments(new String[] {"--input", osmPath, "--change-file", changePath, "--output", errorPath});
     172        errors.clear();
     173        errors.addAll(readJsonObjects(Paths.get(errorPath)));
     174        assertEquals(0, errors.size());
     175        Files.deleteIfExists(Paths.get(errorPath));
     176    }
     177
     178    /**
     179     * Read json objects from a file
     180     * @param path The file to read
     181     * @return The json objects
     182     */
     183    private static List<JsonObject> readJsonObjects(final Path path) {
     184        if (Files.exists(path)) {
     185            final List<String> lines = assertDoesNotThrow(() -> Files.readAllLines(path));
     186            lines.replaceAll(line -> Utils.strip(line.replace((char) 0x1e, ' ')));
     187            return lines.stream().map(str -> Json.createReader(new ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8))))
     188                    .map(JsonReader::readObject).collect(Collectors.toList());
     189        }
     190        return Collections.emptyList();
     191    }
     192
     193    /**
     194     * Get the validation message from a json object
     195     * @param jsonObject The json object to parse
     196     * @return The validator message
     197     */
     198    private static String getMessage(JsonObject jsonObject) {
     199        return jsonObject.getJsonArray("features").getValuesAs(JsonObject.class)
     200                .stream().filter(feature -> feature.containsKey("properties")).map(feature -> feature.getJsonObject("properties"))
     201                .filter(properties -> properties.containsKey("message")).map(properties -> properties.getJsonString("message").getString())
     202                .collect(Collectors.joining(","));
     203    }
     204
     205    /**
     206     * This exists to avoid exiting the tests.
     207     */
     208    private static final class LifecycleMock extends MockUp<Lifecycle> {
     209        @Mock
     210        public static boolean exitJosm(boolean exit, int exitCode) {
     211            // No-op for now
     212            return true;
     213        }
     214    }
     215
     216    private static final class TestHandler extends Handler {
     217        final List<LogRecord> logRecordList = new ArrayList<>();
     218
     219        @Override
     220        public void publish(LogRecord record) {
     221            this.logRecordList.add(record);
     222        }
     223
     224        @Override
     225        public void flush() {
     226            this.logRecordList.clear();
     227        }
     228
     229        @Override
     230        public void close() throws SecurityException {
     231            this.flush();
     232        }
     233    }
     234}