Index: src/org/openstreetmap/josm/data/validation/tests/Highways.java
===================================================================
--- src/org/openstreetmap/josm/data/validation/tests/Highways.java	(revision 18080)
+++ src/org/openstreetmap/josm/data/validation/tests/Highways.java	(working copy)
@@ -1,9 +1,29 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.validation.tests;
 
-import static org.openstreetmap.josm.data.validation.tests.CrossingWays.HIGHWAY;
-import static org.openstreetmap.josm.tools.I18n.tr;
+import org.openstreetmap.josm.command.ChangePropertyCommand;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.OsmUtils;
+import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.data.validation.Severity;
+import org.openstreetmap.josm.data.validation.Test;
+import org.openstreetmap.josm.data.validation.TestError;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.spi.preferences.IPreferences;
+import org.openstreetmap.josm.tools.GBC;
+import org.openstreetmap.josm.tools.Logging;
+import org.openstreetmap.josm.tools.Utils;
 
+import javax.swing.*;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.text.DecimalFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -14,16 +34,8 @@
 import java.util.Set;
 import java.util.stream.Collectors;
 
-import org.openstreetmap.josm.command.ChangePropertyCommand;
-import org.openstreetmap.josm.data.osm.Node;
-import org.openstreetmap.josm.data.osm.OsmPrimitive;
-import org.openstreetmap.josm.data.osm.OsmUtils;
-import org.openstreetmap.josm.data.osm.Way;
-import org.openstreetmap.josm.data.validation.Severity;
-import org.openstreetmap.josm.data.validation.Test;
-import org.openstreetmap.josm.data.validation.TestError;
-import org.openstreetmap.josm.tools.Logging;
-import org.openstreetmap.josm.tools.Utils;
+import static org.openstreetmap.josm.data.validation.tests.CrossingWays.HIGHWAY;
+import static org.openstreetmap.josm.tools.I18n.tr;
 
 /**
  * Test that performs semantic checks on highways.
@@ -38,9 +50,24 @@
     protected static final int SOURCE_MAXSPEED_CONTEXT_MISMATCH_VS_MAXSPEED = 2705;
     protected static final int SOURCE_MAXSPEED_CONTEXT_MISMATCH_VS_HIGHWAY = 2706;
     protected static final int SOURCE_WRONG_LINK = 2707;
+    protected static final int SOURCE_BUS_STOP_NEEDED = 2708;
 
     protected static final String SOURCE_MAXSPEED = "source:maxspeed";
+    protected static final String BUS = "bus";
+    protected static final String PUBLIC_TRANSPORT = "public_transport";
 
+    private final JFormattedTextField formattedTextField;
+    private final JLabel warningLabel;
+    private final JButton defaultButton;
+
+    private final JLabel labelForBboxExpansion = new JLabel("Enter bbox expansion constant for searching nearby Nodes (LatLon degrees)");
+    private static final String BBOX_EXPANSION_PREF_KEY = "bboxExpansionConstant";
+    private static final double EXPANSE_DISTANCE_DEFAULT = 0.00015;
+    private final IPreferences preferences = Config.getPref();
+
+    private final ActionListener actionListener;
+    private final DocumentListener documentListener;
+
     /**
      * Classified highways in order of importance
      */
@@ -73,7 +100,32 @@
      * Constructs a new {@code Highways} test.
      */
     public Highways() {
+
         super(tr("Highways"), tr("Performs semantic checks on highways."));
+        formattedTextField = new JFormattedTextField(new DecimalFormat("#.########"));
+        defaultButton = new JButton("Set Default (~15 meters)");
+        warningLabel = new JLabel("Entry must be a valid number.");
+        warningLabel.setVisible(false);
+        warningLabel.setForeground(Color.RED);
+        documentListener = new DocumentListener() {
+            @Override
+            public void insertUpdate(DocumentEvent e) {
+                warn();
+            }
+
+            @Override
+            public void removeUpdate(DocumentEvent e) {
+                warn();
+            }
+
+            @Override
+            public void changedUpdate(DocumentEvent e) {
+                //unneeded for this listener's purpose.
+            }
+        };
+        actionListener = e -> {
+            formattedTextField.setValue(EXPANSE_DISTANCE_DEFAULT);
+        };
     }
 
     @Override
@@ -90,6 +142,10 @@
                 // as maxspeed is not set on highways here but on signs, speed cameras, etc.
                 testSourceMaxspeed(n, false);
             }
+            if ((IN_DOWNLOADED_AREA.test(n) || n.isNew()) && n.hasTag(BUS, "yes") && n.hasTag(PUBLIC_TRANSPORT, "stop_position")) {
+                // Test for 17188: complain about bus stop position without nearby highway=bus_stop
+                testMissingBusStopNode(n);
+            }
         }
     }
 
@@ -243,6 +299,51 @@
         }
     }
 
+    /**
+     * Tests for bus stop ("public_transport"="stop_position"/"bus"="yes") Nodes a long a way that are missing a related nearby Node with "highway=bus_stop"
+     *
+     * @param n Node being visited
+     */
+    public void testMissingBusStopNode(Node n) {
+        int countOfNodesWithProperTags = 0;
+        double expanseDistance = EXPANSE_DISTANCE_DEFAULT;
+
+        //Approximately 15 meters depending on Lat/Lon
+        String expanseDistanceFromPrefs = preferences.get(BBOX_EXPANSION_PREF_KEY);
+        if (expanseDistanceFromPrefs != null && !expanseDistanceFromPrefs.isEmpty()) {
+            expanseDistance = Double.parseDouble(expanseDistanceFromPrefs);
+        }
+        List<Node> nearbyNodesWithinTwentyFiveMeters = getNearbyNodesWithinShortDistance(n, expanseDistance);
+        for (Node nearbyNodeWithinTwentyFiveMeters : nearbyNodesWithinTwentyFiveMeters) {
+            if (nearbyNodeWithinTwentyFiveMeters.hasTag(HIGHWAY, "bus_stop") && nearbyNodeWithinTwentyFiveMeters.hasTag(BUS, "yes")) {
+                countOfNodesWithProperTags += 1;
+            }
+        }
+        if (countOfNodesWithProperTags == 0) {
+            errors.add(TestError.builder(this, Severity.WARNING, SOURCE_BUS_STOP_NEEDED)
+                    .message(tr("Node needs a nearby related Node with tags: {0} and {1}.",
+                            "highway=bus_stop", "bus=yes"))
+                    .primitives(n)
+                    .build());
+        }
+    }
+
+    /**
+     * Gathers list of Nodes within specified approximate distance (takes double but unit is LatLon degrees) of Node n.
+     *
+     * @param n               Node being visited
+     * @param expanseDistance Distance to expand Node bounds. Units are in LatLon degrees.
+     * @return List of Nodes
+     */
+    public List<Node> getNearbyNodesWithinShortDistance(Node n, double expanseDistance) {
+        DataSet nodeDataSet = n.getDataSet();
+
+        BBox nodeBBox = n.getBBox();
+        nodeBBox.addLatLon(nodeBBox.getCenter(), expanseDistance);
+
+        return nodeDataSet.searchNodes(nodeBBox);
+    }
+
     private void handleCarWay(Node n, Way w) {
         carsWays++;
         if (!w.isFirstLastNode(n) || carsWays > 1) {
@@ -295,4 +396,50 @@
             }
         }
     }
+
+    @Override
+    public void addGui(JPanel testPanel) {
+        super.addGui(testPanel);
+        String bboxExpansionPref = preferences.get(BBOX_EXPANSION_PREF_KEY);
+        if (bboxExpansionPref != null && !bboxExpansionPref.isEmpty()) {
+            try {
+                Double.parseDouble(bboxExpansionPref);
+                formattedTextField.setValue(Double.parseDouble(bboxExpansionPref));
+            }
+            catch (Exception exception) {
+                formattedTextField.setValue(EXPANSE_DISTANCE_DEFAULT);
+            }
+        }
+        else {
+            formattedTextField.setValue(EXPANSE_DISTANCE_DEFAULT);
+        }
+        formattedTextField.setColumns(5);
+        formattedTextField.getDocument().addDocumentListener(documentListener);
+        defaultButton.addActionListener(actionListener);
+        testPanel.add(labelForBboxExpansion, GBC.eol().insets(20,0,0,0));
+        testPanel.add(formattedTextField, GBC.eol().insets(20, 0, 0, 0));
+        testPanel.add(warningLabel, GBC.eol().insets(20,0,0,0));
+        testPanel.add(defaultButton, GBC.eol().insets(20,0,0,0));
+    }
+
+    public void warn() {
+        String expansionDegrees = formattedTextField.getText();
+        if (expansionDegrees.equals(".") || expansionDegrees.equals("")) {
+            expansionDegrees = "0";
+        }
+        try {
+            Double.parseDouble(expansionDegrees);
+            warningLabel.setVisible(false);
+        }
+        catch (Exception exception) {
+            warningLabel.setVisible(true);
+        }
+    }
+
+    @Override
+    public boolean ok() {
+        //Uses most recent VALID entry for bbox expanse on OK
+        preferences.put(BBOX_EXPANSION_PREF_KEY, formattedTextField.getValue().toString());
+        return false;
+    }
 }
